Skip to content

feat(properties): relation property type — link notes, files, tasks and events - #942

Merged
h4yfans merged 38 commits into
mainfrom
relational-properties
Aug 4, 2026
Merged

feat(properties): relation property type — link notes, files, tasks and events#942
h4yfans merged 38 commits into
mainfrom
relational-properties

Conversation

@h4yfans

@h4yfans h4yfans commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a relation property type: a note property whose value references other Notes, Files, Tasks, or Events. Values are arrays of memry://<kind>/<id> URIs, so renaming a target requires zero writes to referencing notes — display titles resolve live from the index.

Steps 1 and 2 of the design (docs/superpowers/specs/2026-08-03-relational-properties-design.md). Dot-path references ({{john_doe.father.email}}) and rollups/query views are specified there but not built here; rollups are gated on definition sync.

Why

Beta feedback, 2026-07-22:

I wish I could link to other Notes (maybe even Files, Tasks, Events, etc.) inside of Properties. […] I always wanted Obsidian-like-Apps to not only be notes apps, but also fully relational Databases…

What you get

  • Relation property type — pick a target from a search popover grouped by Notes & Files / Tasks / Events; values render as chips with live-resolved titles. Deleted targets render in a muted state and the value is kept, never auto-scrubbed.
  • Reverse references — "Referenced by" entries in the backlinks panel, labelled with the property name. A source that references a note both by wikilink and by relation shows two distinct entries.
  • Graph edges — note→note relation edges, drawn thinner than wikilink edges.
  • Folder view — read-only relation chips, batch-resolved per page of rows.

Design decisions worth reviewing

  • ID-truth, never titles. Title-based values would need a rename fan-out across N referencing notes, and under whole-record LWW on note properties that fan-out is a cross-device data-loss vector.
  • No sync-protocol change. Values ride the existing note payload properties record. No new SyncItemType, no data-DB schema change. The only migration is 0020, index-DB only, purely additive (CREATE TABLE + CREATE INDEX).
  • property_refs is index-DB only and rebuildable, like note_links. Populated inside setNoteProperties, the single choke point both the indexer and the sync-pull path already call.
  • PropertyDefinitionSchema is untouched. relation carries no config, so it follows text/number/url. This keeps .memry/properties.md format-stable — important, because an unparseable definitions file makes the loader discard every definition in it.
  • Property type is resolved structurally, not from the stored definition. An array of memry:// URIs is a relation regardless of what the definition row says. This is load-bearing: a new relation property's first write is an empty [], which infers text and would otherwise pin the definition to text forever, flattening the array to a JSON string in the vault file on the next property edit. Applied on both the projector path and the sync-update path.

Verification

Green: lint, typecheck (except pre-existing schemas.ts TS1117, confirmed identical at merge base), 12824 tests, check:architecture, check:contracts, ipc:check, docs:impact --strict, docs:build.

Runtime-verified: two-device round trip through a real two-Electron-process harness against a real sync-server — the second device resolved chips with zero definition sync, proving the inference path. Index rebuild verified: deleting the index DB repopulates property_refs, and backlinks and graph edges return.

Not verified — worth doing before merge: the GUI click-through. Add a Relation property, pick a target, reopen the note, then edit a different property, and confirm the chips, property_refs rows, and graph edge all survive. The Critical bug above lived exactly in that sequence; it is now covered by seam tests, but not by a real UI run.

Known limitation, not introduced here

On a client that has typed a relation property as text, any push of that note ships the value as a flattened JSON string — a body edit, tag change, or rename is enough, since both push builders serialize properties from the index DB rather than the file. This is a pre-existing class rather than something relation introduces: property definitions never sync, so an existing multiselect array created on one device flattens identically on another. The spec documents it accurately. The real fix is that the renderer rebuilds the whole property record from a lossy cache, and fixing that closes multiselect at the same time.

h4yfans added 30 commits August 3, 2026 16:57
Resolves memry://<kind>/<id> relation property URIs to display data
(title, existence) for chip rendering. Spans both databases: notes
from note_cache (index db), tasks and events from tasks/calendar_events
(data db). Groups URIs by kind and issues at most one inArray query
per kind regardless of input size. calendar_external_events is never
queried per spec. Malformed/missing refs come back exists:false
instead of throwing.
Renders relation property arrays as live-resolved chips via
properties:resolveRefs (Task 7's batch resolver), one chip per URI with
a kind icon and title resolved fresh on every mount/value change — no
title is ever stored on the value. Dangling refs (exists: false) render
as a distinct "deleted" chip and are kept in the value; only an
explicit remove click writes, producing a new array via onChange
without mutating the input. Dispatches from PropertyRow next to the
multiselect branch. Read side only — picker lands in Task 10.
Write side of relation properties: a "+" trigger on RelationEditor opens
an embedded Popover (tag-icon-chip pattern) hosting RelationPicker, a
grouped search (Notes & Files / Tasks / Events) that appends a
formatRelationUri() result to the value on select.

Reuses searchService.quick + calendarService.searchEvents, the same
channels the canvas add-card picker uses, rather than adding IPC. Unlike
that picker, notes are not restricted to markdown so a relation can also
point at a filed file. Duplicate URIs are blocked in RelationEditor before
onChange ever fires, since property_refs would reject the row anyway.

Also opts the new debounced search hook out of
react-you-might-not-need-an-effect/no-adjust-state-on-prop-change, the
same override already granted to use-canvas-add-search.ts for this exact
fetch-driven pattern.
The app strips the default focus ring globally (*:focus-visible {
outline: none } in assets/main.css), so the "+" add-relation trigger's
focus:outline-none left keyboard users with zero visual indicator when
tabbed to it — WCAG 2.4.7. Matches the ring-1/ring-ring convention already
used on comparable icon triggers in this area (BacklinkCard,
comment-attachments) rather than inventing a new treatment.

Pinned with a test asserting the className carries a focus-visible:
treatment — a proxy for the real visual check, since jsdom doesn't
compute Tailwind styles.
Read-only compact chips for relation properties in the folder table and
grouped table: existing refs show icon + title, dangling refs render
muted like the note-side RelationEditor. No picker, no remove control,
no click-to-edit — EditablePropertyCell bypasses its edit affordance
for type 'relation' unconditionally so a click never falls through to
the generic text editor and overwrites the URI array.

Batches concurrent resolveRefs calls made within the same tick (e.g. a
virtualized table mounting many relation cells in one commit) into a
single IPC round trip via a module-scoped microtask coalescer, instead
of one resolveRefs call per cell.

Widens property-cell.tsx's PropertyType union with 'relation', which
forced the two exhaustive Record<PropertyType, AppIcon> column-icon
maps in grouped-table.tsx and folder-table-view.tsx to add an icon
(Link2, matching the note-side property type icon).
RelationCell now memoizes its parsed uris off the raw value prop
instead of receiving a fresh .map(String) array on every render of the
PropertyValueDisplay dispatcher. Its resolve effect depends on that
memoized array, so a re-render that leaves the underlying relation
value unchanged (e.g. every visible cell re-rendering on a folder
search keystroke via highlightQuery) no longer re-fires the effect and
re-issues a resolveRefs call for data that never changed.

Also fixes packages/contracts/src/property-types.test.ts, which never
picked up RELATION: 'relation' when it was added to PropertyTypes on
this branch.
getIncomingReferences unions wiki-link backlinks with note-targeted
property_refs rows, tagging property-sourced entries with
via: { kind: 'property', propertyName }. A note referenced both by
wikilink and relation property gets two distinct, labeled backlink
entries. Dangling property_refs rows (source note deleted from the
rebuildable index-DB cache) are filtered out before they reach the
panel.

getNoteLinks (vault/notes-queries.ts) now calls getIncomingReferences
and threads `via` through to the Backlink IPC shape. The backlinks
panel renders property-sourced rows as "<property> → <source title>".
…sources

A source note that references the target both via [[wikilink]] and via a
relation property now produces two Backlink entries with the same
sourceId (by design — see prior commit). Both page transforms
(note.tsx, journal.tsx) used sourceId as the React list key
(BacklinksSection.tsx renders key={backlink.id}), so the two sibling
BacklinkCards collided on key.

Confirmed empirically (not just by inspection): reproducing the
colliding-key shape and forcing a sort-triggered re-render swapped the
two cards' isExpanded state — collapsing one card visibly expanded the
wrong one. noteId (used for navigation/selection via
handleBacklinkClick) is untouched; only id (used solely as the list
key) is now suffixed with the property name when via is set, keeping
it unique per reference rather than per source note.
…mula

Round 1 fixed the React key collision by computing id inline in both
note.tsx and journal.tsx, but nothing exercised those lines directly:
note.test.tsx and journal.test.tsx both stub out BacklinksSection
entirely, and the new BacklinksSection.test.tsx only proves the
component isolates state given already-unique ids.

Extract the formula into backlinkId(sourceId, via) in
components/note/backlinks/types.ts, unit-test it directly, and have
both pages call it instead of duplicating the ternary. Also unmock the
real backlinkId export in both page test files (importOriginal) and
add one test per page that renders two backlinks sharing a sourceId
(one via a wikilink, one via a relation property) and asserts the
computed ids differ — this exercises the actual transform lines, not
just the extracted function.

Verified by temporarily reverting each page's `id: backlinkId(...)`
line back to `id: bl.sourceId` and confirming its guard test fails
with the exact duplicate-id assertion (plus React's own duplicate-key
warning), then restoring and confirming green.
Surface note-to-note relation property refs as graph edges. Widens
GraphEdge['type'] to include 'relation' and keeps the hand-maintained
preload declaration in sync. Task/event refs and refs to notes absent
from note_cache are excluded, mirroring the existing wikilink guard.

Renderer edge styling (color) is intentionally left untouched pending
a design decision - see task-13-report.md.
Revised design ruling after the dashed-edge approach was found
disproportionate (sigma is WebGL-only, no built-in dash program, no
prior custom EdgeProgram in this codebase): relation edges keep the
wikilink grey but render at size 1.25 vs wikilink's 2, distinct from
the unregistered EDGE_SIZES fallback of 1 so the treatment is pinned
by a real assertion, not the fallback.
A relation added through the UI was persisted as `text` and its value later
flattened to a JSON string in the vault file.

`usePropertySection` seeds a new Relation with `getDefaultValueForType`, which
is `[]`, and no type crosses `properties:set`. `isRelationValue([])` is false,
so the projector inferred `text` and `ensurePropertyDefinition` wrote that
definition row. `getPropertyType` then returned the stored type forever without
re-inferring, so `deserializeValue(value, 'text')` handed back a raw string: on
reopen the row rendered as literal text, and the next property edit round-tripped
that string into YAML, deleting the `property_refs` rows, the graph edge and the
backlink.

`getPropertyType` now prefers `relation` whenever the value is structurally an
array of `memry://` URIs. Read-time only: the definitions table is a derived
cache of `.memry/properties.md`, and `PropertyDefinitionSchema` has no relation
member, so persisting one would make that file fail to parse and drop every
definition in it. Deriving from the value also self-heals notes already damaged,
as long as the YAML array survived.

Tests reproduce the real sequence — default-value write, then populated write,
then an edit to a different property — and `property-ref-queries.test.ts` now
passes the production `getPropertyType` wiring instead of raw `inferPropertyType`,
which had let it stay green with the defect fully present.
`main.css` clears the global `*:focus-visible` outline, so a keyboard user
tabbing across relation chips saw no indication of which remove button was
focused (WCAG 2.4.7). Same treatment the add trigger fifteen lines below
already carries.
Main demonstrably returns `type: 'relation'`, but the unions declared in
`preload/index.d.ts` and `packages/rpc/src/notes.ts` stopped at `'rating'`.
Any `Record<PropertyType, X>` or exhaustive switch built on either would
compile clean while silently omitting relation. Four other copies of this
union were already widened; these two were left factually false.
…ippets

`getNoteLinks` ran the `[[target]]` context scan for every incoming ref with no
check on `ref.via`. A source that both wiki-links a note and points at it from a
relation property produced two cards, and the property card carried the wiki
link's snippets and a mention count that belonged entirely to the wiki link —
the same excerpts shown twice, under a label that did not produce them.

Context extraction is now skipped when `ref.via` is set. With no mentions to
reveal, `BacklinkCard` also stops rendering the expand chevron, which previously
toggled nothing.
The shared add-property popup listed Relation, and `template-editor.tsx` then
mapped it to `'text'` after the fact — the user picked Relation and got a plain
text box with no indication anything had changed. Same silent-downgrade class
as the definition-row defect.

`InfoSection`/`AddPropertyPopup` take an `excludeTypes` list, and the template
editor excludes `relation`. Template storage is unchanged; supporting relations
there is a separate feature.
The spec claimed relation values "round-trip unmodified back through that
client's pushes" on an older build. Half true, and the wrong half was stated as
settled. Passive sync is lossless — pull writes YAML verbatim, push re-reads it
from the file, so the index-DB copy never reaches the wire. But the moment a
user edits any property on that note in a released build, `use-properties.ts`
rebuilds the record from index-DB values where the array has already been
flattened to a JSON string, and that string is written to YAML and pushed.

Recorded alongside: this is a pre-existing class, not a new failure mode —
property definitions never sync, so an existing multiselect array flattens
identically on another device. The "verification item before release" now names
writing rather than rendering as the risk. Added the gap the spec never asked
about: on the originating device the first write is `[]`, which no inference can
type.

The user guide said relation chips are clickable and open the target. They are
not; the claim is replaced with what the chips actually do.
h4yfans added 3 commits August 4, 2026 12:53
The structural override added to `getPropertyType` covers the note projector —
local edits, externally-authored notes, re-indexing, sync creates. It does not
cover sync updates. `setNoteProperties` has exactly two production callers, and
the second is `noteHandler.applyUpsert`, whose own `getType` closure was still
"canonical definition wins, else infer".

That path is reachable and self-propagating. A relation's first write is
`father: []`; whichever device indexes or receives it pins the canonical
definition to `text`, and neither `vault/note-sync.ts` nor this closure ever
corrects an existing row. Every later sync update then typed the populated value
as `text`, `deserializeValue(value, 'text')` returned the raw JSON string, and
because both push builders serialize properties from the index DB — not from the
file — that device shipped the flattened string to every other device on its
next push. A body edit, tag change or rename was enough; no property edit
required. The file `applyUpsert` writes is passed to `markWritebackIgnored`,
which the watcher honours, so the fixed projector never re-typed it.

The closure moves to `resolveSyncPropertyType` so a test can exercise the exact
function production uses against real databases rather than a re-implementation.
It short-circuits on `isRelationValue` and deliberately skips
`saveCanonicalPropertyDefinition` on that branch: `PropertyDefinitionSchema` has
no `relation` member, so nothing may write that type into a definition store.
The mutation run shows why that skip is load-bearing — without the guard,
inference derives `relation` from the populated array and persists it.
The focus-visible ring on the chip remove buttons had no test, so a className
regression would have been silent. Mirrors the existing add-trigger assertion,
and covers both chip states — they branch for hover styling and could drift.
The previous correction replaced one false claim with another. It said push
"re-reads it from the file with extractProperties(frontmatter)" and that "the
index-DB copy never reaches the wire" — that conflated two files sharing a
basename. `vault/note-sync.ts` does call `extractProperties`, but for indexing.
The push builders are `sync/note-sync.ts:95` and
`sync/item-handlers/note-handler-sync-helpers.ts:63`, and both build properties
from `propsToRecord(getNoteProperties(indexDb, ...))`. Only content and tags come
from disk.

The truth is worse than what was written. On an old client the relation is
already a flattened string in the index DB the moment it is pulled, so any push
of that note ships it — body edit, tag change, rename, dirty-recovery reseed.
"Lossless" holds only while that device never pushes the note at all. A property
edit is the fastest route, not the only one. The verification item now says to
test an ordinary edit rather than a property edit.

Also records both structural overrides under the empty-default bullet, and why
the sync-update one is not optional cleanup.
Copilot AI lite review requested due to automatic review settings August 4, 2026 10:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

# Conflicts:
#	apps/desktop/src/renderer/src/pages/template-editor.tsx
#	packages/i18n/src/locales/en/notes.json
Copilot AI review requested due to automatic review settings August 4, 2026 10:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added dependencies documentation Improvements or additions to documentation enhancement New feature or request test labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

React Doctor found 1 new issue in 1 file · 1 error · score 82 / 100 (Needs work) · 1 fixed · vs main

Errors

Reviewed by React Doctor for commit 7a8d74d. See inline comments for fixes.

h4yfans added 2 commits August 4, 2026 20:18
Chips need a note's own emoji to render it in place of the kind icon, and
navigation needs two ids the resolver did not carry: a task's project (to
scope the tasks list) and an event's start (the calendar cannot focus an
event until its range has moved to the date that contains it).

All three come from columns already on the tables the resolver queries, so
this stays at three queries with no new joins.
…rget

Chips render the target note's own emoji instead of the generic kind icon,
and clicking one opens what it points at. One hook serves both surfaces so
the note property row and the folder-view cell navigate identically.

Each kind rides a viewState contract the destination already honours:
notes/files open a tab keyed by entityId; tasks send openTaskId plus
selectedProjectId, the pair journal-day-panel already uses; events send
focusCalendarEventId, focusDate and focusedAt together, because the
calendar's first effect needs the date to move its range before its second
effect can find the event, and the token is what makes a repeat click work.

The chip becoming a button forced the remove control out of it — a button
inside a button is invalid markup — so the two are now siblings. Dangling
refs stay inert text, and in the folder view the click is stopped from
reaching the surrounding row.
Copilot AI review requested due to automatic review settings August 4, 2026 17:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

h4yfans added 2 commits August 4, 2026 21:24
The calendar's anchorDate is a local YYYY-MM-DD string. parseLocalDate
splits it on '-' and feeds the parts to new Date(y, m, d), so the full ISO
timestamp this was passing produced Number('30T12:00:00.000Z') = NaN, an
Invalid Date, and the range memo threw RangeError: Invalid time value —
taking the whole calendar tab down through the error boundary.

toLocalDateKey converts through a real Date so an event late in the day
lands on the local day the user sees it on, not its UTC day. An unparseable
instant now omits focusDate entirely, which opens the calendar unfocused
instead of crashing it.

The previous test asserted the raw ISO value, so it encoded the bug rather
than the contract the calendar actually requires.
# Conflicts:
#	apps/desktop/src/main/database/queries/notes/property-queries.ts
#	apps/desktop/src/main/database/queries/notes/query-helpers.ts
#	apps/desktop/src/main/sync/item-handlers/note-handler.ts
#	apps/desktop/src/renderer/src/components/folder-view/folder-table-view.tsx
#	apps/desktop/src/renderer/src/components/folder-view/grouped-table.tsx
#	apps/desktop/src/renderer/src/components/folder-view/property-cell.tsx
#	apps/desktop/src/renderer/src/components/note/ghost-affordance-row.tsx
#	apps/desktop/src/renderer/src/components/note/info-section/AddPropertyPopup.tsx
#	apps/desktop/src/renderer/src/components/note/info-section/InfoSection.tsx
#	apps/desktop/src/renderer/src/components/note/info-section/PropertyRow.test.tsx
#	apps/desktop/src/renderer/src/components/note/info-section/PropertyRow.tsx
#	apps/desktop/src/renderer/src/components/note/info-section/editors/index.ts
#	apps/desktop/src/renderer/src/components/note/info-section/types.ts
#	apps/desktop/src/renderer/src/lib/property-utils.ts
#	apps/docs/src/user-guide/notes/properties-tags.md
#	packages/contracts/src/property-types.test.ts
#	packages/contracts/src/property-types.ts
#	packages/i18n/src/locales/en/notes.json
#	packages/rpc/src/notes.ts
Copilot AI review requested due to automatic review settings August 4, 2026 18:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@h4yfans
h4yfans marked this pull request as ready for review August 4, 2026 18:47
@h4yfans
h4yfans merged commit 3692d68 into main Aug 4, 2026
15 of 16 checks passed
@h4yfans
h4yfans deleted the relational-properties branch August 4, 2026 18:47
<button
key={item.id}
type="button"
role="option"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/role-has-required-aria-props (error)

Screen reader users can't tell the state of this option without its required ARIA props, so add aria-selected.

Fix → Add every required aria-* attribute so assistive tech can expose the role's state correctly.

Docs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies documentation Improvements or additions to documentation enhancement New feature or request test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants