ENG-1977 Add schema import data layer for Obsidian - #1264
Conversation
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
3af9adf to
f364d5a
Compare
23c69c1 to
d417d43
Compare
d417d43 to
bc0e07d
Compare
f364d5a to
09bb0e7
Compare
bc0e07d to
34254ad
Compare
5119839 to
cfff9bf
Compare
34254ad to
9073b20
Compare
cfff9bf to
3820532
Compare
9073b20 to
26d1ad2
Compare
26d1ad2 to
6b684dc
Compare
b0abbd4 to
2a39a08
Compare
PR size/scope checkThis PR is over our review-size guideline.
Please split this into smaller PRs unless there is a clear reason the changes need to land together. If keeping it as one PR, please add a brief justification covering:
|
…types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lySchemaImportSelection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The remote-space import and the schema-file import both had to answer "does this incoming type already exist locally?", and answered it differently: the Supabase path compared names and labels case-sensitively while specImport lowercased them. The same vault reached through the two paths would dedupe differently. Extracts schemaMatching.ts with the id-then-name/label fallback, the triple identity check, and buildSchemaRid — which pins the "schema" RID subtype so both paths emit byte-identical RIDs. Node instance and relation instance RIDs keep their own "note"/"relation" subtypes and are untouched. Matching is now case-insensitive on both paths. This is a behavior change to the Supabase import: importing a "Claim" type into a vault holding "claim" now reuses the local type instead of creating a near-duplicate. specImport sets importedFromRid from the file's vaultId, so schema imported from a file and content imported from that same vault via Supabase resolve to the same RID. Like the Supabase path, this records the immediate source vault rather than preserving an older origin. Also fixes a duplicate-triple hole the case-insensitive matching widens: the apply loop guarded against triples that existed at plan time but not against ones created earlier in the same run, so two schema node types collapsing onto one local type produced duplicate triples. The check now runs against live settings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
31a387c to
83e0548
Compare
2a39a08 to
2f98215
Compare
A schema file holding both "Event" and "event" created two node types that matching then treats as one, because the existing-check only compared against the vault's types as they were before the import. Same hole for relation types by label. Fixed in the planner rather than at apply time: the known-set grows as types are planned, so the second type resolves to the first the same way it would resolve to a pre-existing local type. Keeping it in the planner means nodeTypeIdMapping stays correct — skipping the duplicate at apply time would leave discourse relations pointing at an id that was never created. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re: size/scope checkCurrent: +532 −44 across 4 files — within the file-count preference, over the 400-line limit. Single problem: make an exported schema file importable into a vault, resolving incoming ids against what already exists there. Why the changes are coupled: That said, a clean split does exist and I don't want to overstate the coupling:
Splitting this way would actually improve review quality: the behavior change to the shipped Supabase import currently sits underneath 400+ lines of new file, which is exactly where it's easiest to miss. The cost is one more PR in an already 5-deep stack and a re-stack of #1265. Happy to do the split if a reviewer prefers it — say the word. |
Provisional exists so schema arriving from a Supabase space can be reviewed before it takes effect. A file import is different: the user chose the file and hand-picked the items, so there is nothing left to review. The importedFromRid is still recorded for provenance. The Supabase import path (importRelations.ts) is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cally Existing items were silently skipped, so a user who already had a Claim node type could never take the imported format, template or color onto it. applySchemaImportSelection now accepts an optional merge plan and applies only the fields it names. An absent or empty plan keeps every local value, so import stays non-destructive unless the caller opts a field in. The plan is keyed by schema-file id rather than local id: buildSchemaImportMatchPlan deliberately collapses schema types that collide by normalized name, so two schema ids can share one local id and a local-keyed map would drop one of them. name and label are excluded from the mergeable sets. Matching is id-first, so an id match carrying a different name reads as a rename, but renaming a type does not retag pages already tagged with it — the vault would silently split. SchemaImportMatchPlan moves to schemaMatching.ts so the apply path and the field-diff path can both depend on it without a circular import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The create path only keeps an imported template reference when the template will actually exist — imported in this run, or already in the vault. The merge path copied imported.template unguarded, so ticking template for a node type whose template is neither selected nor local left it pointing at a file that does not exist. Both paths now share resolveTemplateReference so they cannot drift apart, and merge warns when a ticked reference is dropped rather than silently ignoring an explicit choice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nt fields Two changes, both making merge non-destructive. Templates: overwriteTemplateFile clobbered the local file. Instead reuse createTemplateFileWithUniqueName — the same helper the Supabase import path uses — so the imported version lands as "Claim (from their-vault)" and the node type is repointed at that copy. The user keeps both and can switch back by editing the node type. resolveTemplateReference now keys off what actually landed rather than what was selected, so a failed creation leaves no dangling reference. Version skew: a field the file has no value for is not an instruction to clear the local one. An export from an older plugin simply lacks fields it never knew about, and offering those as changes turned version skew into silent deletion. The diff now skips absent imported values, so merge only ever adds or overwrites. Fields the local item has never set are still offered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The diff already skipped a field the imported file has no value for, so that an older export lacking a field could not read as an instruction to clear the local one. It tested only `undefined`, but settings persist a cleared optional field as "" while omitting one that was never set, so the two spellings of empty compared as unequal. The visible effect was a node type identical to the local one being offered for merge, with both sides of the row rendering as "empty". Against a real testVault2 export this dropped the items offered from 7 to 4, all four carrying genuine differences. Applies the same guard to relation types, which had no check at all: an export lacking `complement` would have offered to blank it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each surviving comment is one line and explains something the code cannot: why name/label are unmergeable, why the diff is keyed by schema-file id, and why an empty imported value is not a request to clear. Restatements of the code are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same pass as the previous commit, applied to the two files this change introduces. Each surviving comment is one line and explains something the code cannot: why the known-types list grows during planning, why a template reference is resolved from what landed, why file imports are accepted rather than provisional, and why triples are checked against live settings. importNodes, importRelations and templates are left alone — this change adds no comments to them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The apply path mapped a triple's source, destination and relation type ids but never checked the targets existed before writing it, so a selection whose triples outlive the types they reference would have written unresolvable ids into settings. Not reachable through the import modal, which force-selects a triple's endpoints and refuses to deselect them. This is the layer that owns settings integrity though, and the same function already warns and skips in eight analogous cases, so triples were the inconsistent one. Reported by Graphite AI review on #1264. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| let discourseRelationsCreated = 0; | ||
| for (const relation of schemaFile.discourseRelations) { | ||
| if (!selectedRelationIds.has(relation.id)) { | ||
| continue; | ||
| } | ||
|
|
||
| const mappedSourceId = | ||
| matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; | ||
| const mappedDestinationId = | ||
| matchPlan.nodeTypeIdMapping.get(relation.destinationId) ?? | ||
| relation.destinationId; | ||
| const mappedRelationTypeId = | ||
| matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? | ||
| relation.relationshipTypeId; | ||
|
|
||
| // Checked against live settings, not the plan: two file relations can map to one triple after collapsing. | ||
| const alreadyPresent = findExistingTriple({ | ||
| discourseRelations: plugin.settings.discourseRelations, | ||
| sourceId: mappedSourceId, | ||
| destinationId: mappedDestinationId, | ||
| relationshipTypeId: mappedRelationTypeId, | ||
| }); | ||
| if (alreadyPresent) { | ||
| continue; | ||
| } | ||
|
|
||
| // The selection UI keeps a triple's endpoints selected, but this layer owns settings integrity, so a dangling triple is refused rather than written. | ||
| if ( | ||
| !hasNodeType(mappedSourceId) || | ||
| !hasNodeType(mappedDestinationId) || | ||
| !hasRelationType(mappedRelationTypeId) | ||
| ) { | ||
| const sourceName = | ||
| schemaNodeTypesById.get(relation.sourceId)?.name ?? relation.sourceId; | ||
| const destinationName = | ||
| schemaNodeTypesById.get(relation.destinationId)?.name ?? | ||
| relation.destinationId; | ||
| const relationLabel = | ||
| schemaRelationTypesById.get(relation.relationshipTypeId)?.label ?? | ||
| relation.relationshipTypeId; | ||
| onWarning( | ||
| `Relation "${sourceName} ${relationLabel} ${destinationName}" skipped: it references a type that is not in this vault.`, | ||
| ); | ||
| continue; | ||
| } | ||
|
|
||
| const newRelation: DiscourseRelation = { | ||
| ...relation, | ||
| id: uuidv7(), | ||
| sourceId: mappedSourceId, | ||
| destinationId: mappedDestinationId, | ||
| relationshipTypeId: mappedRelationTypeId, | ||
| importedFromRid: buildSchemaRid({ | ||
| spaceUri: sourceSpaceUri, | ||
| localId: relation.id, | ||
| }), | ||
| status: "accepted", | ||
| modified: Date.now(), | ||
| }; | ||
| plugin.settings.discourseRelations = [ | ||
| ...plugin.settings.discourseRelations, | ||
| newRelation, | ||
| ]; | ||
| discourseRelationsCreated += 1; | ||
| } |
There was a problem hiding this comment.
Performance bug: O(N²) array copying in loop causes exponential slowdown
The discourse relations loop repeatedly creates new arrays by spreading plugin.settings.discourseRelations on each iteration:
plugin.settings.discourseRelations = [
...plugin.settings.discourseRelations,
newRelation,
];For N relations, this creates N arrays with progressively larger sizes (1, 2, 3... N items), resulting in O(N²) memory allocations and copies. With 1000+ relations, this will freeze the application.
Fix: Collect relations in a temporary array first:
const newRelations: DiscourseRelation[] = [];
for (const relation of schemaFile.discourseRelations) {
// ... existing logic ...
newRelations.push(newRelation);
}
plugin.settings.discourseRelations = [
...plugin.settings.discourseRelations,
...newRelations,
];The same pattern exists at lines 477 (nodeTypes) and 531-533 (relationTypes) but those are less likely to hit performance issues due to smaller counts.
| let discourseRelationsCreated = 0; | |
| for (const relation of schemaFile.discourseRelations) { | |
| if (!selectedRelationIds.has(relation.id)) { | |
| continue; | |
| } | |
| const mappedSourceId = | |
| matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; | |
| const mappedDestinationId = | |
| matchPlan.nodeTypeIdMapping.get(relation.destinationId) ?? | |
| relation.destinationId; | |
| const mappedRelationTypeId = | |
| matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? | |
| relation.relationshipTypeId; | |
| // Checked against live settings, not the plan: two file relations can map to one triple after collapsing. | |
| const alreadyPresent = findExistingTriple({ | |
| discourseRelations: plugin.settings.discourseRelations, | |
| sourceId: mappedSourceId, | |
| destinationId: mappedDestinationId, | |
| relationshipTypeId: mappedRelationTypeId, | |
| }); | |
| if (alreadyPresent) { | |
| continue; | |
| } | |
| // The selection UI keeps a triple's endpoints selected, but this layer owns settings integrity, so a dangling triple is refused rather than written. | |
| if ( | |
| !hasNodeType(mappedSourceId) || | |
| !hasNodeType(mappedDestinationId) || | |
| !hasRelationType(mappedRelationTypeId) | |
| ) { | |
| const sourceName = | |
| schemaNodeTypesById.get(relation.sourceId)?.name ?? relation.sourceId; | |
| const destinationName = | |
| schemaNodeTypesById.get(relation.destinationId)?.name ?? | |
| relation.destinationId; | |
| const relationLabel = | |
| schemaRelationTypesById.get(relation.relationshipTypeId)?.label ?? | |
| relation.relationshipTypeId; | |
| onWarning( | |
| `Relation "${sourceName} ${relationLabel} ${destinationName}" skipped: it references a type that is not in this vault.`, | |
| ); | |
| continue; | |
| } | |
| const newRelation: DiscourseRelation = { | |
| ...relation, | |
| id: uuidv7(), | |
| sourceId: mappedSourceId, | |
| destinationId: mappedDestinationId, | |
| relationshipTypeId: mappedRelationTypeId, | |
| importedFromRid: buildSchemaRid({ | |
| spaceUri: sourceSpaceUri, | |
| localId: relation.id, | |
| }), | |
| status: "accepted", | |
| modified: Date.now(), | |
| }; | |
| plugin.settings.discourseRelations = [ | |
| ...plugin.settings.discourseRelations, | |
| newRelation, | |
| ]; | |
| discourseRelationsCreated += 1; | |
| } | |
| const newDiscourseRelations: DiscourseRelation[] = []; | |
| for (const relation of schemaFile.discourseRelations) { | |
| if (!selectedRelationIds.has(relation.id)) { | |
| continue; | |
| } | |
| const mappedSourceId = | |
| matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; | |
| const mappedDestinationId = | |
| matchPlan.nodeTypeIdMapping.get(relation.destinationId) ?? | |
| relation.destinationId; | |
| const mappedRelationTypeId = | |
| matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? | |
| relation.relationshipTypeId; | |
| // Checked against live settings, not the plan: two file relations can map to one triple after collapsing. | |
| const alreadyPresent = findExistingTriple({ | |
| discourseRelations: plugin.settings.discourseRelations, | |
| sourceId: mappedSourceId, | |
| destinationId: mappedDestinationId, | |
| relationshipTypeId: mappedRelationTypeId, | |
| }); | |
| if (alreadyPresent) { | |
| continue; | |
| } | |
| // The selection UI keeps a triple's endpoints selected, but this layer owns settings integrity, so a dangling triple is refused rather than written. | |
| if ( | |
| !hasNodeType(mappedSourceId) || | |
| !hasNodeType(mappedDestinationId) || | |
| !hasRelationType(mappedRelationTypeId) | |
| ) { | |
| const sourceName = | |
| schemaNodeTypesById.get(relation.sourceId)?.name ?? relation.sourceId; | |
| const destinationName = | |
| schemaNodeTypesById.get(relation.destinationId)?.name ?? | |
| relation.destinationId; | |
| const relationLabel = | |
| schemaRelationTypesById.get(relation.relationshipTypeId)?.label ?? | |
| relation.relationshipTypeId; | |
| onWarning( | |
| `Relation "${sourceName} ${relationLabel} ${destinationName}" skipped: it references a type that is not in this vault.`, | |
| ); | |
| continue; | |
| } | |
| const newRelation: DiscourseRelation = { | |
| ...relation, | |
| id: uuidv7(), | |
| sourceId: mappedSourceId, | |
| destinationId: mappedDestinationId, | |
| relationshipTypeId: mappedRelationTypeId, | |
| importedFromRid: buildSchemaRid({ | |
| spaceUri: sourceSpaceUri, | |
| localId: relation.id, | |
| }), | |
| status: "accepted", | |
| modified: Date.now(), | |
| }; | |
| newDiscourseRelations.push(newRelation); | |
| discourseRelationsCreated += 1; | |
| } | |
| plugin.settings.discourseRelations = [ | |
| ...plugin.settings.discourseRelations, | |
| ...newDiscourseRelations, | |
| ]; | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
…e file Export copies plugin.settings.relationTypes verbatim, but the import contract required color to be one of TLDRAW_COLOR_NAMES. Vaults created before tldraw color names hold hex, so every relation type failed validation and the whole file was refused with "schema file is incompatible with this version of the plugin". The apply path already called toTldrawColor on this value, so validation was stricter than the code consuming it. Validation now runs the same coercion: hex maps to its palette name, anything unrecognised or absent falls back to black. The schema annotations widen to ZodType<T, ZodTypeDef, unknown> because parsing starts from unknown once a field is coerced — input and output are no longer the same shape. Fixes ENG-2177. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
https://www.loom.com/share/7771ec3f58e749599a5bf406debdb101
Summary
Adds the import data layer — file parsing, match planning, and apply logic. The UI modal is in the next PR.
specImport.tsbuildSchemaImportMatchPlan— pure, no side effects. Maps every id in the schema file to a local id, id match first and name/label fallback second, and records which items already exist locally.applySchemaImportSelection— creates new relation types and relations asprovisional, skips existing matches, preserves a node type's template reference when that template is selected for import or already exists locally.parseDgSchemaFile(inspecValidation.ts) does full Zod validation againstdgSchemaFileSchema, including aversionliteral guard.schemaMatching.ts— matching primitives shared with the Supabase import path (see below).Planning is deliberately separated from applying. The Supabase import path interleaves them —
mapNodeTypeIdToLocalandmapRelationTypeToLocalboth find-or-create inline, so side effects happen during what reads like a lookup. Keeping the plan pure means the preview in #1265 can show exact counts without touching settings.Shared matching with the Supabase import path
Both import paths have to answer "does this incoming type already exist locally?" — and they answered it differently. The Supabase path compared names and labels case-sensitively;
specImportlowercased them. The same vault reached through the two paths would dedupe differently.schemaMatching.tsnow holds the shared primitives:findLocalNodeTypeMatchmapNodeTypeIdToLocal,buildSchemaImportMatchPlanfindLocalRelationTypeMatchmapRelationTypeToLocal,buildSchemaImportMatchPlanfindExistingTriplefindOrCreateTriple,buildSchemaImportMatchPlanbuildSchemaRidmapNodeTypeIdToLocal,mapRelationTypeToLocal,applySchemaImportSelectionbuildSchemaRidpins the"schema"RID subtype in one place so the two paths cannot drift. Node instance ("note") and relation instance ("relation") RIDs are a different concern and are untouched.Only the finder half of
findOrCreateTriplewas extracted, not the create half — it callsplugin.saveSettings()per triple, whileapplySchemaImportSelectionbatches a single save at the end. Sharing the whole function would have turned one write into N.Name and label matching is now case-insensitive on both paths. Importing a
Claimnode type into a vault that already holdsclaimnow reuses the local type instead of creating a near-duplicate. This is a change to an already-shipped code path and is the one thing in this PR worth reviewing on its own merits.importedFromRidprovenanceapplySchemaImportSelectionsetsimportedFromRidon created node types, relation types, and relations, derived from the file'svaultId(added in #1263) asorn:obsidian.schema:<appId>/<localId>.This is byte-identical to what the Supabase path produces for the same vault and local id, so schema imported from a file and content imported from that same vault over Supabase resolve to the same RID.
Like the Supabase path, this records the immediate source vault. If vault B exports a type it originally imported from vault C, the RID points at B, not C — consistent with existing behavior, but it means RIDs are one hop of provenance, not a full chain.
Bug fixed along the way
The apply loop guarded discourse relations against triples that existed at plan time, but not against triples created earlier in the same run. Two schema node types collapsing onto one local type therefore produced duplicate triples. The guard now runs against live settings.
This was latent before this PR, but case-insensitive matching widens the window:
Claimandclaimin one schema file now collapse onto a single local type where previously they did not.Stack
PR 4 of 5 for FEE-840. Stacks on #1263.
Test plan
Verified:
pnpm --filter @discourse-graphs/obsidian check-typespassespnpm --filter @discourse-graphs/obsidian lint— 0 errors (75 pre-existing warnings, none in touched files)ridToSpaceUriAndLocalId(buildSchemaRid(...))recovers the originalspaceUri/localIdpairfindLocalNodeTypeMatch/findLocalRelationTypeMatch— id match beats a differing name; name/label fallback folds case and trims whitespace; genuinely new items returnundefinedfindExistingTriple— matches on endpoints while ignoring the triple's own id; reversed endpoints and a different relation type both correctly miss