Skip to content

Design Decisions

This page documents significant design decisions made during TypeMD’s development. Each entry explains what was decided, why, and what alternatives were considered. Understanding these decisions helps contributors make consistent choices when extending the system.

Decision: Relations are stored in both YAML frontmatter and the SQLite relations table.

Frontmatter is the source of truth for file portability — objects are self-contained Markdown files that can be version-controlled, synced, and edited manually. The database provides fast reverse lookups and relational queries that would be expensive to compute from files.

When the two stores drift (e.g., after manual file edits), opening the vault rebuilds the database from frontmatter.

Alternatives considered:

  • Database-only storage — rejected because it violates the local-first, files-are-truth principle
  • Frontmatter-only with file scanning for reverse lookups — rejected due to O(n) scan cost on every query

Single-value relations (e.g., author) overwrite on re-link. Multiple-value relations (e.g., books) append and reject duplicates. This matches user expectations: “set the author” vs. “add a book.”

Bidirectional relations require both sides to declare their inverse property in the type schema. When linking A→B, the system automatically writes the inverse B→A. This keeps both files consistent without requiring users to manually maintain both sides.

Decision: Wiki-links ([[type/name-ulid]]) are stored in a dedicated wikilinks table, separate from schema-defined relations.

Relations are structured (defined in type schemas with typed targets and cardinality). Wiki-links are freeform (written inline in markdown body, any object can link to any other). Keeping them separate avoids conflating two fundamentally different linking mechanisms.

Wiki-link targets use full object IDs including the ULID suffix (e.g., [[person/bob-01kk3gqm8zrrbjjwkx90f727y6]]). This simplifies target resolution to a direct lookup — no ambiguity, no fuzzy matching needed.

Trade-off: The syntax is verbose. This trades usability for implementation simplicity and correctness. Future improvements like auto-complete can reduce the friction without changing the underlying format.

Decision: System properties (name, description, created_at, updated_at) are defined in a []SystemProperty slice in core/system_property.go. Helper functions like IsSystemProperty() and SystemPropertyNames() derive all behavior from this registry.

Before the registry, name was the only system property and its handling was scattered across multiple files as special-case logic. Adding created_at and updated_at (and eventually more) would have multiplied these special cases.

Slices preserve insertion order, which matters for frontmatter output ordering (namedescriptioncreated_atupdated_at → schema properties). A map would require a separate ordering mechanism.

Each system property has different auto-set behavior (name from slug or template, created_at from time.Now(), updated_at on every save, description is user-authored). Encoding these as callbacks in the registry adds abstraction without reducing complexity. Instead, NewObject and SaveObject handle the setting logic directly, while the registry handles identification and validation.

Timestamps use time.Now().Format(time.RFC3339), which includes the local timezone offset. UTC was considered but rejected — in a local-first tool, human-readable local times are more useful. The string is stored as-is in YAML frontmatter to avoid round-trip formatting issues with time.Time values.

Decision: name is stored in Object.Properties["name"] rather than as a dedicated struct field.

Keeping name in the properties map means it flows through existing frontmatter read/write paths naturally — no schema changes to the SQLite properties JSON column, no special handling in frontmatter parsing. GetName() reads from the map with a fallback to DisplayName() (derived from filename) for backward compatibility.

Alternative rejected: A dedicated Object.Name struct field would require parallel storage, special handling in frontmatter parsing, and database schema changes.

Existing objects without a name property are backfilled during Vault.Sync() using the display name derived from the filename. This piggybacks on the existing sync mechanism — zero extra user steps.

Decision: Name templates are defined as - name: name entries in the type schema’s properties array, with only the template field permitted.

properties:
- name: name
template: "Journal {{ date:YYYY-MM-DD }}"
- name: content
type: string

The properties array is the natural place for property configuration. A top-level name_template field was considered but rejected — it would establish a precedent of scattering property config across the schema.

NewObject() evaluates the template when the name argument is empty, writing the result as a static string to the name property. The template string is not stored in the object. This is simple, predictable, and allows users to edit the name afterward.

Templates use {{ date:YYYY-MM-DD }} syntax instead of Go’s reference time format (2006-01-02). The template engine converts tokens internally:

TokenGo equivalentExample
YYYY20062026
MM0103
DD0214
HH1509
mm0430
ss0500

Property type system: explicit types over auto-detection

Section titled “Property type system: explicit types over auto-detection”

Decision: date and datetime are separate property types rather than a single auto-detecting type.

A date property always stores YYYY-MM-DD; a datetime always stores YYYY-MM-DDTHH:MM:SS. Users declare intent in the schema. ISO 8601 strings are naturally sortable in SQLite.

Alternative rejected: A single date type that accepts both formats — makes validation and display inconsistent.

select and multi_select types use options: [{value: x, label: X}] instead of the old values: [x] format. The label field enables display names that differ from stored values (e.g., value: in-progress, label: In Progress).

Alternative rejected: Parallel values and labels arrays — error-prone coupling.

Properties are stored as a JSON blob in objects.properties. A typed object_properties table was considered but deferred — it is only valuable when paired with query syntax that can exploit it (e.g., rating>4, date ranges). Building it now would force premature decisions about multi-select storage with no consumers.

Shared properties: single-level use references

Section titled “Shared properties: single-level use references”

Decision: Each shared property lives in its own file under properties/<name>.yaml, with the property name derived from the filename. Type schemas reference them with use: <name>, allowing only pin, emoji, and description as overrides.

properties/due_date.yaml
type: date
emoji: 📅
# types/task/schema.yaml
properties:
- use: due_date
pin: 1

Resolution happens in LoadType() — after parsing, each use entry is replaced with a fully resolved Property copied from the shared definition with overrides applied. Downstream code never sees use entries.

Alternative rejected: ref keyword — avoided to prevent confusion with JSON Schema $ref. No inheritance or multi-level composition — use is a single-level lookup with no recursion possible.

TUI session state: object ID, not cursor index

Section titled “TUI session state: object ID, not cursor index”

Decision: The TUI persists selectedObjectID (e.g., book/clean-code-01jqr...) rather than a cursor index.

Object IDs are stable across sessions even when objects are added or deleted. A cursor index of 3 could point to a completely different object after changes.

When the saved object no longer exists, the TUI falls back to the first object in the same type group, then to the overall first object. This keeps the user in the same “neighborhood” of their vault.

State is written to .typemd/tui-state.yaml only when the user quits — not continuously. A crash loses state, which is acceptable for a convenience feature. The simpler approach avoids filesystem overhead.

If the state file is missing, corrupt, or contains invalid data, the TUI silently falls back to default startup behavior. State persistence is a convenience feature, not critical — users should never be blocked by a broken state file.