Skip to content

ENG-2139 Map the Obsidian source relation to source on push - #1381

Open
sid597 wants to merge 3 commits into
mainfrom
eng-2139-map-the-obsidian-source-relation-to-source-on-push
Open

ENG-2139 Map the Obsidian source relation to source on push#1381
sid597 wants to merge 3 commits into
mainfrom
eng-2139-map-the-obsidian-source-relation-to-source-on-push

Conversation

@sid597

@sid597 sid597 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Reviewer brief

  • Result: an Obsidian node pushed to Supabase now carries local_reference_content.sourceDocument when it has a relation to a node of the Source type. The value is the Source's importedFromRid when it was imported, else its nodeInstanceId. With several such relations, the earliest by created (then id) wins. With none, the key is omitted. Full sync re-upserts every node whose stored slot differs from the one its relations call for, so a relation added, accepted, or removed reaches the database at the next full sync or publish, and existing vaults backfill on their first full sync.
  • Review focus: apps/obsidian/src/utils/sourceSlot.ts. indexSourceSlotValues does one pass over relations.json and returns Record<nodeInstanceId, sourceDocumentId>; the node converter reads one entry. findStaleSourceSlotNodeIds compares that map with the stored slot, read through the full-sync probe that already backfills core_title (buildChangedNodesFromNodes), by local id. The Source type is matched by name, as Roam does in apps/roam/src/utils/sourceSlot.ts (ENG-2128 Express source as a sourceDocument slot/reference of Evidence #1329). Two inline comments carry decisions for the group: where SOURCE_SLOT should live, and the silent -2 when a value does not resolve.
  • Risk or follow-up:
    • publishNewRelation syncs relations only, so a relation added between two published nodes reaches the slot at the next full sync or publish, not at creation.
    • A Source whose concept the database cannot resolve (created while sync was off, or imported and later unshared) fails the node's whole concept row with -2 on every full sync until it resolves, so that node's title stops syncing too. Nothing surfaces: the client reads only error from upsert_concepts. Relation instances already carry this failure; a ticket for reading the negative returns into PostHog follows. See the inline comment on the converter.
    • No tests. apps/obsidian has no unit-test runner; Use vault fallback while Datacore initializes #1258 and ENG-1910–ENG-1925 v0 content model: canonical ATJSON storage #1366 each add one. The Done When line "Tests cover zero, one, and multiple matching relations" is not met here and goes with the harness question to ENG-2143.
    • Read side is out of scope: dbToCrossAppConverters.ts emits no instance slots, and sharedNodes.ts:208 builds the cross-space slot RID without the note subtype Obsidian stores in importedFromRid. ENG-2140 and ENG-2142.
    • Obsidian schemas write no roles, so Obsidian Evidence keeps arity 0 while Roam Evidence declares roles: ["sourceDocument"]. Slot-schema work is out of scope per the ticket.

Verification

  • eslint --max-warnings 0, prettier --check, tsc --noEmit --skipLibCheck on the three files: clean for this change. The two eslint warnings and 20 tsc errors in apps/obsidian are present on main at untouched lines.
  • turbo run test:unit: 32 files pass (roam 25, database 5, content-model 2).
  • pnpm ci:validate is red locally on main as well (obsidian, ui, website type errors from local type resolution). CI on main is green at ef37b20.
  • Runtime: not yet driven. See the Loom section.

Loom video

eng-2139-20260907.mp4

Scope check

  • Ran $scope-check against ENG-2139 and the final diff.
  • Scope beyond Done When: None. The full-sync comparison is what makes "a node with no matching source relation omits the source value" and "repeated pushes produce the same single source value" hold after a relation changes.

Local delegated full review

  • Ran a comprehensive review of the entire final diff in a subagent with a fresh context. Use $dg-delegated-full-review when no other full-review workflow is available.

@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown

ENG-2139

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
discourse-graph Skipped Skipped Sep 2, 2026 10:22am UTC

Request Review

@supabase

supabase Bot commented Sep 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T09:51:27.038510Z d6bae46 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread apps/obsidian/src/utils/syncDgNodesToSupabase.ts

@sid597 sid597 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Inline notes. Three are marked "Decision for the group".

// that type. Relation endpoints are stored as a nodeInstanceId, as this vault's RID for
// it, or as an imported node's origin RID, so lookups go through an index of all three.

export const SOURCE_SLOT = "sourceDocument";

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

SOURCE_SLOT is also defined in apps/roam/src/utils/sourceSlot.ts:12, and crossAppConverters.ts:92 and sharedNodes.ts:212 depend on the same key. Its one home is packages/database/src/crossAppContracts.ts. Hoisting touches roam and database, so this PR stays inside apps/obsidian.

Decision for the group: hoist here, or ticket the hoist and merge with the duplicate?

return index;
};

const isSourceNodeType = (nodeType: DiscourseNode | undefined): boolean =>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Name match follows Roam's sourceNodeType (#1329). Obsidian node type ids are generated at load (constants.ts:36), so the name is the only stable handle. A vault that renames the Source type gets no slot until slots become a node type setting.

const byCreatedThenId = (a: RelationInstance, b: RelationInstance): number =>
a.created - b.created || a.id.localeCompare(b.id);

const sourceDocumentIdOf = (node: DiscourseNodeInVault): string => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

importedFromRid first, nodeInstanceId otherwise: the rule relationInstanceToLocalConcept already uses for relation endpoints. rid_or_local_id_to_concept_db_id resolves either form.

sourceDocumentNode: DiscourseNodeInVault;
};

export const indexSourceSlotValues = ({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No schema gate. Roam writes the slot only for node types whose format has {source}. Obsidian formats have no placeholder, so any node with a relation to a Source gets the slot, and the earliest relation wins across relation types.

Nothing downstream reads the gate: arity and is_relation derive from the schema's roles, and sharedNodes.ts reads the keys. Obsidian schemas write no roles, which the ticket puts out of scope, so Obsidian Evidence stays arity 0 while Roam Evidence is 1. Flagging the asymmetry in case it matters for the read side.

const nodesByEndpoint = indexNodesByEndpoint({ nodes, localSpaceUri });
const earliestByNodeId: Record<string, SourceCandidate> = {};
for (const relation of relations) {
if (relation.tentative === false) continue;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Imported relations not yet accepted (tentative === false) do not define a node's source. Same filter the relation batch applies in convertDgToSupabaseConcepts.

schema_represented_by_local_id: nodeTypeId as string,
is_schema: false,
literal_content,
// A value the database cannot resolve to a concept fails this row's upsert (-2).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

upsert_concepts resolves this value with rid_or_local_id_to_concept_db_id. When that returns NULL, the row's reference_content becomes NULL, the insert fails, and the function returns -2 for the row. The client reads only error, so the node's title update is dropped with no signal.

Triggers: the Source was created while sync was off and the Evidence syncs alone; or an imported Source whose origin space later unshares it (the lookup runs under RLS). Relation instances already have this failure mode.

Decision for the group: keep as is and ticket reading the negative returns into PostHog, or check existence before writing the slot, as ENG-2141 plans for the Roam publish path?

Comment thread apps/obsidian/src/utils/syncDgNodesToSupabase.ts Outdated
Comment thread apps/obsidian/src/utils/syncDgNodesToSupabase.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d6bae46ad8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/obsidian/src/utils/syncDgNodesToSupabase.ts

@sid597 sid597 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Notes on the full-sync comparison added in 63a964a.

// A relation added, accepted, or removed changes no note, so the stored slot is compared
// with the wanted one on full sync. The comparison is by local id: the database stores
// the Source's concept id, and an imported Source is wanted by its origin RID.
export const findStaleSourceSlotNodeIds = ({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Compared by local id. The database stores the Source's concept id; the concepts_of_relation embed gives that concept's source_local_id; an imported Source is wanted by its origin RID, reduced to its local part. A Source whose origin concept is hidden (unshared) yields no match, so that node re-queues on every full sync and its row fails with -2 until the concept resolves. Same failure the converter comment names, now repeated per full sync rather than once.

supabaseClient
.from("my_concepts")
.select(CORE_TITLE_PROBE_SELECT)
.select(`${CORE_TITLE_PROBE_SELECT}, ${SOURCE_SLOT_PROBE_SELECT}`)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Same probe ENG-2155 uses to backfill core_title, extended with the slot columns, so the comparison adds no query. The embed is the one sharedNodes.ts uses on the read side.

missingCoreTitleIds =
partitionByCoreTitle(existingConceptIds).missingCoreTitleIds;
if (sourceSlotByNodeId)
staleSourceSlotIds = findStaleSourceSlotNodeIds({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Stale nodes join changedNodes with changeTypes: []. createNodeContentEntries returns early for them, so only the concept row rewrites; no content or embeddings.

return { changedNodes };
};

const indexSourceSlots = async ({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Built once here so full sync can compare, then passed on to convertDgToSupabaseConcepts. The incremental path has no allNodes at this point, so that function keeps an inline fallback.

@sid597 sid597 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Notes on a0160ac.

return { spaceId: concept.space_id, localId: concept.source_local_id };
};

const storedMatchesWanted = ({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The stored slot is compared by which side of the space boundary it resolves to, plus local id. A local Source must resolve to a concept in this space; an imported Source, wanted by its origin RID, to a concept in another space. asSimpleLocalId in dbToCrossAppConverters.ts makes the same distinction on the read side. Two foreign spaces sharing a local id would still compare equal; local ids are uuidv7 or Roam uids, so that case is not guarded.

relationInstancesData ?? (await loadRelations(plugin));
const relationInstances = Object.values(relationInstancesData.relations);
sourceSlotByNodeId =
sourceSlotByNodeId ??

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The index built here keys nodes by all three endpoint forms because an imported Source is stored as its RID. allNodesById above stays keyed by bare nodeInstanceId: relationInstanceToLocalConcept and ensurePublishedRelationsAccuracy look up bare ids and drop RID endpoints today, and widening them changes relation push, which this ticket does not own.

@sid597

sid597 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Decision: when the slot reaches the database

Question: a node concept is rewritten only when its own file changes, so a source relation added, accepted, or removed never reached sourceDocument. Raised by the pre-PR review, Devin, and Codex.

Options:

  1. Watermark on relation timestamps. About 15 lines. Covers additions only.
  2. Compare the stored slot with the wanted one on full sync, reusing the core_title backfill probe. Covers add, accept, remove, retarget, and the backfill of existing vaults.
  3. Defer to a follow-up ticket and merge the mapping alone.

Decision (sid, 2026-09-02): option 2, in this PR. Three independent reviewers flagged the same gap, and the Done When line "a node with no matching source relation omits the source value" is false after a removal without it.

Result: commits 63a964a and a0160ac. Known limit: publishNewRelation syncs relations only, so a relation added between two published nodes reaches the slot at the next full sync or publish.

Still open for the group, in inline threads: where SOURCE_SLOT lives (sourceSlot.ts, line 16) and the silent -2 on an unresolvable value (conceptConversion.ts, line 194).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant