@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.
- package/README.md +19 -4
- package/assets/skill/SKILL.md +46 -15
- package/package.json +1 -1
- package/spec/cli.md +100 -0
- package/spec/db/schema.sql +170 -0
- package/spec/mcp.md +22 -0
- package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
- package/spec/output-schemas/changes.schema.json +280 -0
- package/spec/output-schemas/document-diff.schema.json +185 -0
- package/spec/output-schemas/impact.schema.json +122 -0
- package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
- package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
- package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
- package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
- package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
- package/src/cli/commands/changes.ts +160 -0
- package/src/cli/commands/context-saved.ts +189 -0
- package/src/cli/options.ts +8 -0
- package/src/cli/program.ts +195 -0
- package/src/core/capsule-registry.ts +279 -0
- package/src/core/capsule-reverification-scheduler.ts +218 -0
- package/src/core/capsule-reverification.ts +289 -0
- package/src/core/change-diff.ts +182 -0
- package/src/core/change-journal.ts +228 -0
- package/src/core/knowledge-delta.ts +395 -0
- package/src/core/knowledge-impact.ts +202 -0
- package/src/ingestion/sync.ts +214 -165
- package/src/mcp/tools/changes.ts +80 -0
- package/src/mcp/tools/index.ts +29 -0
- package/src/sdk/client.ts +42 -0
- package/src/sdk/index.ts +7 -0
- package/src/sdk/types.ts +22 -0
- package/src/serve/doc-events.ts +12 -1
- package/src/serve/resident-runtime.ts +22 -0
- package/src/serve/routes/api.ts +13 -0
- package/src/serve/routes/changes.ts +102 -0
- package/src/serve/server.ts +34 -0
- package/src/serve/watch-service.ts +9 -0
- package/src/store/index.ts +21 -0
- package/src/store/migrations/015-document-change-journal.ts +85 -0
- package/src/store/migrations/016-saved-capsules.ts +131 -0
- package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
- package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
- package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
- package/src/store/migrations/index.ts +10 -0
- package/src/store/sqlite/adapter.ts +291 -7
- package/src/store/sqlite/capsule-registry-store.ts +534 -0
- package/src/store/sqlite/change-journal-store.ts +473 -0
- 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
|
+
}
|
package/src/ingestion/sync.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
-
|
|
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
|
-
|
|
838
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
*/
|