@gmickel/gno 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +19 -4
  2. package/assets/skill/SKILL.md +46 -15
  3. package/package.json +1 -1
  4. package/spec/cli.md +100 -0
  5. package/spec/db/schema.sql +170 -0
  6. package/spec/mcp.md +22 -0
  7. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  8. package/spec/output-schemas/changes.schema.json +280 -0
  9. package/spec/output-schemas/document-diff.schema.json +185 -0
  10. package/spec/output-schemas/impact.schema.json +122 -0
  11. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  12. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  13. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  14. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  15. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  16. package/src/cli/commands/changes.ts +160 -0
  17. package/src/cli/commands/context-saved.ts +189 -0
  18. package/src/cli/options.ts +8 -0
  19. package/src/cli/program.ts +195 -0
  20. package/src/core/capsule-registry.ts +279 -0
  21. package/src/core/capsule-reverification-scheduler.ts +218 -0
  22. package/src/core/capsule-reverification.ts +289 -0
  23. package/src/core/change-diff.ts +182 -0
  24. package/src/core/change-journal.ts +228 -0
  25. package/src/core/knowledge-delta.ts +395 -0
  26. package/src/core/knowledge-impact.ts +202 -0
  27. package/src/ingestion/sync.ts +214 -165
  28. package/src/mcp/tools/changes.ts +80 -0
  29. package/src/mcp/tools/index.ts +29 -0
  30. package/src/sdk/client.ts +42 -0
  31. package/src/sdk/index.ts +7 -0
  32. package/src/sdk/types.ts +22 -0
  33. package/src/serve/doc-events.ts +12 -1
  34. package/src/serve/resident-runtime.ts +22 -0
  35. package/src/serve/routes/api.ts +13 -0
  36. package/src/serve/routes/changes.ts +102 -0
  37. package/src/serve/server.ts +34 -0
  38. package/src/serve/watch-service.ts +9 -0
  39. package/src/store/index.ts +21 -0
  40. package/src/store/migrations/015-document-change-journal.ts +85 -0
  41. package/src/store/migrations/016-saved-capsules.ts +131 -0
  42. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  43. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  44. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  45. package/src/store/migrations/index.ts +10 -0
  46. package/src/store/sqlite/adapter.ts +291 -7
  47. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  48. package/src/store/sqlite/change-journal-store.ts +473 -0
  49. package/src/store/types.ts +262 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Bounded, cycle-safe inbound dependency impact analysis.
3
+ */
4
+
5
+ import type { DocEdgeRow, DocumentRow, StorePort } from "../store/types";
6
+ import type {
7
+ KnowledgeDeltaServiceResult,
8
+ KnowledgeDocument,
9
+ } from "./knowledge-delta";
10
+
11
+ import { resolveDocRef } from "./ref-parser";
12
+
13
+ export interface KnowledgeImpactEvidenceStep {
14
+ source: Pick<KnowledgeDocument, "id" | "uri">;
15
+ target: Pick<KnowledgeDocument, "id" | "uri">;
16
+ edgeType: string;
17
+ relationType: string;
18
+ confidence: DocEdgeRow["confidence"];
19
+ edgeSource: DocEdgeRow["edgeSource"];
20
+ }
21
+
22
+ export interface KnowledgeImpactResult {
23
+ schemaVersion: "1.0";
24
+ root: KnowledgeDocument;
25
+ impacted: Array<{
26
+ document: KnowledgeDocument;
27
+ depth: number;
28
+ evidencePath: KnowledgeImpactEvidenceStep[];
29
+ }>;
30
+ meta: {
31
+ maxDepth: number;
32
+ maxNodes: number;
33
+ maxEdges: number;
34
+ frontierLimit: number;
35
+ visitedLimit: number;
36
+ returnedNodes: number;
37
+ returnedEdges: number;
38
+ truncated: boolean;
39
+ warnings: string[];
40
+ };
41
+ }
42
+
43
+ export interface KnowledgeImpactInput {
44
+ maxDepth?: number;
45
+ maxNodes?: number;
46
+ maxEdges?: number;
47
+ frontierLimit?: number;
48
+ visitedLimit?: number;
49
+ }
50
+
51
+ const document = (row: DocumentRow): KnowledgeDocument => ({
52
+ id: row.docid,
53
+ uri: row.uri,
54
+ title: row.title,
55
+ collection: row.collection,
56
+ relPath: row.relPath,
57
+ });
58
+
59
+ const bounded = (
60
+ name: string,
61
+ value: number | undefined,
62
+ fallback: number,
63
+ maximum: number
64
+ ): number | { error: string } => {
65
+ const resolved = value ?? fallback;
66
+ return Number.isSafeInteger(resolved) && resolved >= 1 && resolved <= maximum
67
+ ? resolved
68
+ : { error: `${name} must be between 1 and ${maximum}` };
69
+ };
70
+
71
+ const edgeStep = (edge: DocEdgeRow): KnowledgeImpactEvidenceStep => ({
72
+ source: { id: edge.sourceDocid, uri: edge.sourceUri },
73
+ target: { id: edge.targetDocid, uri: edge.targetUri },
74
+ edgeType: edge.edgeType,
75
+ relationType: edge.relationType,
76
+ confidence: edge.confidence,
77
+ edgeSource: edge.edgeSource,
78
+ });
79
+
80
+ export async function analyzeKnowledgeImpact(
81
+ store: StorePort,
82
+ ref: string,
83
+ input: KnowledgeImpactInput = {}
84
+ ): Promise<KnowledgeDeltaServiceResult<KnowledgeImpactResult>> {
85
+ if (!ref.trim() || ref.length > 4096) {
86
+ return {
87
+ success: false,
88
+ error: "ref must be between 1 and 4096 characters",
89
+ isValidation: true,
90
+ };
91
+ }
92
+ const caps = {
93
+ maxDepth: bounded("maxDepth", input.maxDepth, 3, 6),
94
+ maxNodes: bounded("maxNodes", input.maxNodes, 100, 1000),
95
+ maxEdges: bounded("maxEdges", input.maxEdges, 250, 5000),
96
+ frontierLimit: bounded("frontierLimit", input.frontierLimit, 100, 1000),
97
+ visitedLimit: bounded("visitedLimit", input.visitedLimit, 500, 5000),
98
+ };
99
+ const invalid = Object.values(caps).find(
100
+ (value): value is { error: string } => typeof value === "object"
101
+ );
102
+ if (invalid) {
103
+ return { success: false, error: invalid.error, isValidation: true };
104
+ }
105
+ const values = caps as Record<keyof typeof caps, number>;
106
+ const resolved = await resolveDocRef(store, ref);
107
+ if ("error" in resolved) {
108
+ return {
109
+ success: false,
110
+ error: resolved.error,
111
+ isValidation: resolved.isValidation,
112
+ };
113
+ }
114
+ if (!resolved.doc.active) {
115
+ return {
116
+ success: false,
117
+ error: `Document is inactive: ${ref}`,
118
+ isValidation: true,
119
+ };
120
+ }
121
+ const traversal = await store.queryGraphTraversal(resolved.doc.id, {
122
+ direction: "in",
123
+ maxDepth: values.maxDepth,
124
+ maxNodes: values.maxNodes,
125
+ frontierLimit: values.frontierLimit,
126
+ visitedLimit: values.visitedLimit,
127
+ });
128
+ if (!traversal.ok) {
129
+ return { success: false, error: traversal.error.message };
130
+ }
131
+ const sortedEdges = [...traversal.value.edges]
132
+ .sort(
133
+ (a, b) =>
134
+ a.depth - b.depth ||
135
+ a.edge.edgeType.localeCompare(b.edge.edgeType) ||
136
+ a.edge.sourceUri.localeCompare(b.edge.sourceUri) ||
137
+ a.edge.targetUri.localeCompare(b.edge.targetUri)
138
+ )
139
+ .slice(0, values.maxEdges);
140
+ const incoming = new Map<string, DocEdgeRow[]>();
141
+ for (const { edge } of sortedEdges) {
142
+ const entries = incoming.get(edge.targetDocid) ?? [];
143
+ entries.push(edge);
144
+ incoming.set(edge.targetDocid, entries);
145
+ }
146
+ const paths = new Map<string, KnowledgeImpactEvidenceStep[]>([
147
+ [resolved.doc.docid, []],
148
+ ]);
149
+ const queue = [resolved.doc.docid];
150
+ for (let index = 0; index < queue.length; index += 1) {
151
+ const targetId = queue[index]!;
152
+ const targetPath = paths.get(targetId) ?? [];
153
+ for (const edge of incoming.get(targetId) ?? []) {
154
+ if (paths.has(edge.sourceDocid)) continue;
155
+ paths.set(edge.sourceDocid, [edgeStep(edge), ...targetPath]);
156
+ queue.push(edge.sourceDocid);
157
+ }
158
+ }
159
+ const nodesById = new Map(
160
+ traversal.value.nodes.map(({ doc: row }) => [row.docid, document(row)])
161
+ );
162
+ const impacted = [...paths.entries()]
163
+ .filter(([id]) => id !== resolved.doc.docid)
164
+ .map(([id, evidencePath]) => ({
165
+ document: nodesById.get(id)!,
166
+ depth: evidencePath.length,
167
+ evidencePath,
168
+ }))
169
+ .filter((item) => item.document)
170
+ .sort(
171
+ (a, b) =>
172
+ a.depth - b.depth || a.document.uri.localeCompare(b.document.uri)
173
+ );
174
+ const warnings = [...traversal.value.warnings];
175
+ if (traversal.value.edges.length > values.maxEdges) {
176
+ warnings.push("maxEdges reached");
177
+ }
178
+ const usedEdges = new Set(
179
+ impacted.flatMap(({ evidencePath }) =>
180
+ evidencePath.map(
181
+ (step) =>
182
+ `${step.source.id}\u0000${step.target.id}\u0000${step.edgeType}`
183
+ )
184
+ )
185
+ );
186
+ return {
187
+ success: true,
188
+ data: {
189
+ schemaVersion: "1.0",
190
+ root: document(resolved.doc),
191
+ impacted,
192
+ meta: {
193
+ ...values,
194
+ returnedNodes: impacted.length + 1,
195
+ returnedEdges: usedEdges.size,
196
+ truncated:
197
+ traversal.value.truncated || warnings.includes("maxEdges reached"),
198
+ warnings: [...new Set(warnings)],
199
+ },
200
+ },
201
+ };
202
+ }
@@ -40,6 +40,13 @@ import {
40
40
  getDefaultPipeline,
41
41
  } from "../converters/pipeline";
42
42
  import { DEFAULT_LIMITS } from "../converters/types";
43
+ import {
44
+ diffDocumentStructure,
45
+ extractDocumentStructure,
46
+ isRelationMap,
47
+ normalizeRelationEdgeType,
48
+ normalizeRelationTarget,
49
+ } from "../core/change-diff";
43
50
  import {
44
51
  normalizeMarkdownPath,
45
52
  normalizeWikiName,
@@ -83,35 +90,6 @@ const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
83
90
  "UNSUPPORTED",
84
91
  ]);
85
92
 
86
- type RelationMap = Record<string, string[]>;
87
-
88
- function isRelationMap(value: unknown): value is RelationMap {
89
- if (!value || typeof value !== "object" || Array.isArray(value)) {
90
- return false;
91
- }
92
-
93
- return Object.values(value).every(
94
- (targets) =>
95
- Array.isArray(targets) &&
96
- targets.every((target) => typeof target === "string")
97
- );
98
- }
99
-
100
- function normalizeRelationTarget(raw: string): string {
101
- const trimmed = raw.trim();
102
- if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
103
- return trimmed.slice(2, -2).split("|")[0]?.trim() ?? "";
104
- }
105
- return trimmed;
106
- }
107
-
108
- function normalizeRelationEdgeType(raw: string): string {
109
- return raw
110
- .trim()
111
- .toLowerCase()
112
- .replace(/[-\s]+/g, "_");
113
- }
114
-
115
93
  function findDocByWikiRef(
116
94
  docs: DocumentRow[],
117
95
  targetRef: string,
@@ -747,7 +725,24 @@ export class SyncService {
747
725
  // Log but continue - error recording is best-effort
748
726
  }
749
727
 
750
- // Upsert document with error info, explicitly clear mirrorHash
728
+ const hadRetrievableEvidence = Boolean(existing?.mirrorHash);
729
+ const structureDelta = hadRetrievableEvidence
730
+ ? diffDocumentStructure(
731
+ await this.readPreviousStructure(store, existing),
732
+ {
733
+ headings: [],
734
+ links: [],
735
+ typedEdges: [],
736
+ dates: {},
737
+ }
738
+ ).delta
739
+ : undefined;
740
+
741
+ // Upsert document with error info, explicitly clear mirrorHash. This
742
+ // transition is journaled only when retrievable evidence previously
743
+ // existed; initial/repeated error placeholders are not evidence
744
+ // creates. Real evidence disappearance stays in the same transaction
745
+ // so saved Capsule freshness checks cannot miss it.
751
746
  const upsertResult = await store.upsertDocument({
752
747
  collection: collection.name,
753
748
  relPath: entry.relPath,
@@ -761,6 +756,7 @@ export class SyncService {
761
756
  lastErrorMessage: convertResult.error.message,
762
757
  ingestVersion: INGEST_VERSION,
763
758
  contentTypeRulesFingerprint,
759
+ changeJournal: structureDelta ? { structureDelta } : false,
764
760
  // mirrorHash intentionally omitted (will be null)
765
761
  });
766
762
 
@@ -788,155 +784,186 @@ export class SyncService {
788
784
  mime.ext,
789
785
  contentTypeRules
790
786
  );
791
-
792
- // 7. Upsert document - EXPLICITLY clear error fields on success
793
- const docidResult = await store.upsertDocument({
794
- collection: collection.name,
795
- relPath: entry.relPath,
796
- sourceHash,
797
- sourceMime: mime.mime,
798
- sourceExt: mime.ext,
799
- sourceSize,
800
- sourceMtime,
801
- sourceCtime,
802
- title: artifact.title,
803
- mirrorHash: artifact.mirrorHash,
804
- converterId: artifact.meta.converterId,
805
- converterVersion: artifact.meta.converterVersion,
806
- languageHint: artifact.languageHint ?? collection.languageHint,
807
- contentType: extractedMetadata.contentType,
808
- contentTypeSource: extractedMetadata.contentTypeSource,
809
- categories: extractedMetadata.categories,
810
- author: extractedMetadata.author,
811
- frontmatterDate: extractedMetadata.frontmatterDate,
812
- dateFields: extractedMetadata.dateFields,
813
- contentTypeRulesFingerprint,
814
- // Clear error fields on success (requires store to handle undefined → null)
815
- lastErrorCode: undefined,
816
- lastErrorMessage: undefined,
817
- ingestVersion: INGEST_VERSION,
818
- });
819
-
820
- const { id: docId, docid } = mustOk(docidResult, "upsertDocument", {
821
- collection: collection.name,
822
- relPath: entry.relPath,
823
- });
824
-
825
- // 8. Upsert content (content-addressed dedupe) - CHECKED
826
- const contentResult = await store.upsertContent(
827
- artifact.mirrorHash,
828
- artifact.markdown
787
+ const previousStructure = await this.readPreviousStructure(
788
+ store,
789
+ existing
829
790
  );
830
- mustOk(contentResult, "upsertContent", {
831
- mirrorHash: artifact.mirrorHash,
832
- });
833
-
834
- // 9. Chunk content
835
- const chunks = this.chunker.chunk(
791
+ const nextStructure = extractDocumentStructure(
836
792
  artifact.markdown,
837
- DEFAULT_CHUNK_PARAMS,
838
- artifact.languageHint ?? collection.languageHint,
839
- entry.relPath
793
+ entry.relPath,
794
+ extractedMetadata.dateFields
840
795
  );
796
+ const structureDelta = diffDocumentStructure(
797
+ previousStructure,
798
+ nextStructure
799
+ ).delta;
800
+
801
+ const persistSuccessfulFile = async (): Promise<FileSyncResult> => {
802
+ // 7. Upsert document - EXPLICITLY clear error fields on success
803
+ const docidResult = await store.upsertDocument({
804
+ collection: collection.name,
805
+ relPath: entry.relPath,
806
+ sourceHash,
807
+ sourceMime: mime.mime,
808
+ sourceExt: mime.ext,
809
+ sourceSize,
810
+ sourceMtime,
811
+ sourceCtime,
812
+ title: artifact.title,
813
+ mirrorHash: artifact.mirrorHash,
814
+ converterId: artifact.meta.converterId,
815
+ converterVersion: artifact.meta.converterVersion,
816
+ languageHint: artifact.languageHint ?? collection.languageHint,
817
+ contentType: extractedMetadata.contentType,
818
+ contentTypeSource: extractedMetadata.contentTypeSource,
819
+ categories: extractedMetadata.categories,
820
+ author: extractedMetadata.author,
821
+ frontmatterDate: extractedMetadata.frontmatterDate,
822
+ dateFields: extractedMetadata.dateFields,
823
+ contentTypeRulesFingerprint,
824
+ // Clear error fields on success (requires store to handle undefined → null)
825
+ lastErrorCode: undefined,
826
+ lastErrorMessage: undefined,
827
+ ingestVersion: INGEST_VERSION,
828
+ changeJournal: { structureDelta },
829
+ });
841
830
 
842
- // 10. Convert to ChunkInput for store
843
- const chunkInputs: ChunkInput[] = chunks.map((c) => ({
844
- seq: c.seq,
845
- pos: c.pos,
846
- text: c.text,
847
- startLine: c.startLine,
848
- endLine: c.endLine,
849
- language: c.language ?? undefined,
850
- tokenCount: c.tokenCount ?? undefined,
851
- }));
852
-
853
- // 11. Upsert chunks - CHECKED
854
- const chunksResult = await store.upsertChunks(
855
- artifact.mirrorHash,
856
- chunkInputs
857
- );
858
- mustOk(chunksResult, "upsertChunks", {
859
- mirrorHash: artifact.mirrorHash,
860
- chunkCount: chunkInputs.length,
861
- });
831
+ const { id: docId, docid } = mustOk(docidResult, "upsertDocument", {
832
+ collection: collection.name,
833
+ relPath: entry.relPath,
834
+ });
862
835
 
863
- // 12. Rebuild FTS for this hash - CHECKED
864
- const ftsResult = await store.rebuildFtsForHash(artifact.mirrorHash);
865
- mustOk(ftsResult, "rebuildFtsForHash", {
866
- mirrorHash: artifact.mirrorHash,
867
- });
836
+ // 8. Upsert content (content-addressed dedupe) - CHECKED
837
+ const contentResult = await store.upsertContent(
838
+ artifact.mirrorHash,
839
+ artifact.markdown
840
+ );
841
+ mustOk(contentResult, "upsertContent", {
842
+ mirrorHash: artifact.mirrorHash,
843
+ });
868
844
 
869
- // 13. Extract and store tags from frontmatter and body hashtags
870
- // Always call setDocTags to clear removed tags on re-sync
871
- const extractedTags = extractTags(artifact.markdown);
872
- const tagsResult = await store.setDocTags(
873
- docId,
874
- extractedTags,
875
- "frontmatter"
876
- );
877
- mustOk(tagsResult, "setDocTags", {
878
- docId,
879
- tagCount: extractedTags.length,
880
- });
845
+ // 9. Chunk content
846
+ const chunks = this.chunker.chunk(
847
+ artifact.markdown,
848
+ DEFAULT_CHUNK_PARAMS,
849
+ artifact.languageHint ?? collection.languageHint,
850
+ entry.relPath
851
+ );
881
852
 
882
- // 14. Extract and store links (wiki and markdown links)
883
- const excludedRanges = getExcludedRanges(artifact.markdown);
884
- const lineOffsets = buildLineOffsets(artifact.markdown);
885
- const parsedLinks = parseLinks(
886
- artifact.markdown,
887
- lineOffsets,
888
- excludedRanges
889
- );
853
+ // 10. Convert to ChunkInput for store
854
+ const chunkInputs: ChunkInput[] = chunks.map((c) => ({
855
+ seq: c.seq,
856
+ pos: c.pos,
857
+ text: c.text,
858
+ startLine: c.startLine,
859
+ endLine: c.endLine,
860
+ language: c.language ?? undefined,
861
+ tokenCount: c.tokenCount ?? undefined,
862
+ }));
863
+
864
+ // 11. Upsert chunks - CHECKED
865
+ const chunksResult = await store.upsertChunks(
866
+ artifact.mirrorHash,
867
+ chunkInputs
868
+ );
869
+ mustOk(chunksResult, "upsertChunks", {
870
+ mirrorHash: artifact.mirrorHash,
871
+ chunkCount: chunkInputs.length,
872
+ });
890
873
 
891
- const linkInputs: DocLinkInput[] = [];
892
- for (const link of parsedLinks) {
893
- // Compute target_ref_norm based on link type
894
- let targetRefNorm: string;
895
- if (link.kind === "wiki") {
896
- targetRefNorm = normalizeWikiName(link.targetRef);
897
- } else {
898
- // Markdown links with collection prefix are not supported
899
- // (use wiki links for cross-collection references)
900
- if (link.targetCollection) {
901
- continue;
902
- }
903
- const resolved = normalizeMarkdownPath(link.targetRef, entry.relPath);
904
- if (!resolved) {
905
- // Link escapes collection root - skip silently
906
- continue;
874
+ // 12. Rebuild FTS for this hash - CHECKED
875
+ const ftsResult = await store.rebuildFtsForHash(artifact.mirrorHash);
876
+ mustOk(ftsResult, "rebuildFtsForHash", {
877
+ mirrorHash: artifact.mirrorHash,
878
+ });
879
+
880
+ // 13. Extract and store tags from frontmatter and body hashtags
881
+ // Always call setDocTags to clear removed tags on re-sync
882
+ const extractedTags = extractTags(artifact.markdown);
883
+ const tagsResult = await store.setDocTags(
884
+ docId,
885
+ extractedTags,
886
+ "frontmatter"
887
+ );
888
+ mustOk(tagsResult, "setDocTags", {
889
+ docId,
890
+ tagCount: extractedTags.length,
891
+ });
892
+
893
+ // 14. Extract and store links (wiki and markdown links)
894
+ const excludedRanges = getExcludedRanges(artifact.markdown);
895
+ const lineOffsets = buildLineOffsets(artifact.markdown);
896
+ const parsedLinks = parseLinks(
897
+ artifact.markdown,
898
+ lineOffsets,
899
+ excludedRanges
900
+ );
901
+
902
+ const linkInputs: DocLinkInput[] = [];
903
+ for (const link of parsedLinks) {
904
+ // Compute target_ref_norm based on link type
905
+ let targetRefNorm: string;
906
+ if (link.kind === "wiki") {
907
+ targetRefNorm = normalizeWikiName(link.targetRef);
908
+ } else {
909
+ // Markdown links with collection prefix are not supported
910
+ // (use wiki links for cross-collection references)
911
+ if (link.targetCollection) {
912
+ continue;
913
+ }
914
+ const resolved = normalizeMarkdownPath(
915
+ link.targetRef,
916
+ entry.relPath
917
+ );
918
+ if (!resolved) {
919
+ // Link escapes collection root - skip silently
920
+ continue;
921
+ }
922
+ targetRefNorm = resolved;
907
923
  }
908
- targetRefNorm = resolved;
924
+
925
+ linkInputs.push({
926
+ targetRef: link.targetRef,
927
+ targetRefNorm,
928
+ targetAnchor: link.targetAnchor,
929
+ targetCollection: link.targetCollection,
930
+ linkType: link.kind,
931
+ linkText: link.displayText,
932
+ startLine: link.startLine,
933
+ startCol: link.startCol,
934
+ endLine: link.endLine,
935
+ endCol: link.endCol,
936
+ });
909
937
  }
910
938
 
911
- linkInputs.push({
912
- targetRef: link.targetRef,
913
- targetRefNorm,
914
- targetAnchor: link.targetAnchor,
915
- targetCollection: link.targetCollection,
916
- linkType: link.kind,
917
- linkText: link.displayText,
918
- startLine: link.startLine,
919
- startCol: link.startCol,
920
- endLine: link.endLine,
921
- endCol: link.endCol,
939
+ const linksResult = await store.setDocLinks(
940
+ docId,
941
+ linkInputs,
942
+ "parsed"
943
+ );
944
+ mustOk(linksResult, "setDocLinks", {
945
+ docId,
946
+ linkCount: linkInputs.length,
922
947
  });
923
- }
924
948
 
925
- const linksResult = await store.setDocLinks(docId, linkInputs, "parsed");
926
- mustOk(linksResult, "setDocLinks", {
927
- docId,
928
- linkCount: linkInputs.length,
929
- });
930
-
931
- const status = existing ? "updated" : "added";
932
- return {
933
- relPath: entry.relPath,
934
- status,
935
- docid,
936
- mirrorHash: artifact.mirrorHash,
937
- contentType: extractedMetadata.contentType,
938
- contentTypeSource: extractedMetadata.contentTypeSource,
949
+ const status = existing ? "updated" : "added";
950
+ return {
951
+ relPath: entry.relPath,
952
+ status,
953
+ docid,
954
+ mirrorHash: artifact.mirrorHash,
955
+ contentType: extractedMetadata.contentType,
956
+ contentTypeSource: extractedMetadata.contentTypeSource,
957
+ };
939
958
  };
959
+ if (!store.withTransaction) {
960
+ return await persistSuccessfulFile();
961
+ }
962
+ const persisted = await store.withTransaction(persistSuccessfulFile);
963
+ return mustOk(persisted, "persistSuccessfulFile", {
964
+ collection: collection.name,
965
+ relPath: entry.relPath,
966
+ });
940
967
  } catch (error) {
941
968
  const message = error instanceof Error ? error.message : "Unknown error";
942
969
  // Distinguish store errors from other internal errors
@@ -977,6 +1004,7 @@ export class SyncService {
977
1004
  existingResult.value.sourceMtime,
978
1005
  lastErrorCode: code,
979
1006
  lastErrorMessage: message,
1007
+ changeJournal: false,
980
1008
  });
981
1009
  }
982
1010
  } catch {
@@ -992,6 +1020,27 @@ export class SyncService {
992
1020
  }
993
1021
  }
994
1022
 
1023
+ private async readPreviousStructure(
1024
+ store: StorePort,
1025
+ existing: DocumentRow | null
1026
+ ): Promise<ReturnType<typeof extractDocumentStructure> | null | undefined> {
1027
+ if (!existing) return null;
1028
+ if (!existing.mirrorHash) return undefined;
1029
+
1030
+ const content = await store.getContent(existing.mirrorHash);
1031
+ if (!content.ok) {
1032
+ throw new Error(`Store operation failed: ${content.error.message}`, {
1033
+ cause: content.error,
1034
+ });
1035
+ }
1036
+ if (content.value === null) return undefined;
1037
+ return extractDocumentStructure(
1038
+ content.value,
1039
+ existing.relPath,
1040
+ existing.dateFields
1041
+ );
1042
+ }
1043
+
995
1044
  /**
996
1045
  * Sync a specific set of files within a collection.
997
1046
  */