@gmickel/gno 1.9.0 → 1.10.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 (36) hide show
  1. package/assets/skill/SKILL.md +28 -16
  2. package/assets/skill/cli-reference.md +30 -0
  3. package/assets/skill/examples.md +20 -0
  4. package/assets/skill/mcp-reference.md +7 -1
  5. package/package.json +1 -1
  6. package/src/cli/commands/graph.ts +89 -2
  7. package/src/cli/commands/links.ts +237 -54
  8. package/src/cli/commands/query.ts +212 -0
  9. package/src/cli/commands/ref-parser.ts +8 -103
  10. package/src/cli/options.ts +4 -0
  11. package/src/cli/program.ts +176 -9
  12. package/src/config/content-types.ts +14 -0
  13. package/src/core/graph-query.ts +137 -0
  14. package/src/core/graph-resolver.ts +117 -0
  15. package/src/core/ref-parser.ts +145 -0
  16. package/src/ingestion/frontmatter.ts +95 -2
  17. package/src/ingestion/sync.ts +281 -1
  18. package/src/mcp/tools/get.ts +1 -1
  19. package/src/mcp/tools/index.ts +89 -1
  20. package/src/mcp/tools/links.ts +83 -1
  21. package/src/mcp/tools/multi-get.ts +1 -1
  22. package/src/mcp/tools/query.ts +207 -0
  23. package/src/pipeline/diagnose.ts +302 -0
  24. package/src/pipeline/filters.ts +119 -0
  25. package/src/pipeline/hybrid.ts +90 -17
  26. package/src/pipeline/types.ts +32 -0
  27. package/src/publish/export-service.ts +1 -1
  28. package/src/sdk/documents.ts +2 -2
  29. package/src/serve/routes/api.ts +194 -0
  30. package/src/serve/routes/graph.ts +173 -1
  31. package/src/serve/server.ts +24 -1
  32. package/src/store/migrations/010-typed-edges.ts +67 -0
  33. package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
  34. package/src/store/migrations/index.ts +4 -0
  35. package/src/store/sqlite/adapter.ts +826 -102
  36. package/src/store/types.ts +141 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Shared SQL expressions for graph link target resolution.
3
+ *
4
+ * These helpers preserve getGraph's existing wiki matching order so future
5
+ * typed-edge projection/backfill can reuse the exact resolver.
6
+ *
7
+ * @module src/core/graph-resolver
8
+ */
9
+
10
+ const suffixMatch = (targetExpr: string, valueExpr: string): string =>
11
+ `(substr(${targetExpr}, -length(${valueExpr})) = ${valueExpr}
12
+ AND (length(${targetExpr}) = length(${valueExpr})
13
+ OR substr(${targetExpr}, -length(${valueExpr}) - 1, 1) = '/'))`;
14
+
15
+ const wikiTitleExpr = (alias: string): string => `lower(trim(${alias}.title))`;
16
+ const wikiRelPathExpr = (alias: string): string => `lower(${alias}.rel_path)`;
17
+
18
+ function wikiTargetExpressions(targetRefExpr: string): {
19
+ targetBaseExpr: string;
20
+ targetMdExpr: string;
21
+ } {
22
+ const targetBaseExpr = `CASE
23
+ WHEN ${targetRefExpr} LIKE '%.md' THEN substr(${targetRefExpr}, 1, length(${targetRefExpr}) - 3)
24
+ ELSE ${targetRefExpr}
25
+ END`;
26
+ return { targetBaseExpr, targetMdExpr: `${targetBaseExpr} || '.md'` };
27
+ }
28
+
29
+ export function buildWikiMatchExpression(
30
+ alias: string,
31
+ targetRefExpr: string
32
+ ): string {
33
+ const titleExpr = wikiTitleExpr(alias);
34
+ const relExpr = wikiRelPathExpr(alias);
35
+ const { targetBaseExpr, targetMdExpr } = wikiTargetExpressions(targetRefExpr);
36
+ return `(
37
+ ${titleExpr} = ${targetBaseExpr}
38
+ OR ${titleExpr} = ${targetMdExpr}
39
+ OR ${suffixMatch(targetBaseExpr, titleExpr)}
40
+ OR ${suffixMatch(targetMdExpr, `${titleExpr} || '.md'`)}
41
+ OR ${relExpr} = ${targetBaseExpr}
42
+ OR ${relExpr} = ${targetMdExpr}
43
+ OR ${suffixMatch(relExpr, targetMdExpr)}
44
+ OR ${suffixMatch(relExpr, targetBaseExpr)}
45
+ OR ${suffixMatch(targetMdExpr, relExpr)}
46
+ OR ${suffixMatch(targetBaseExpr, relExpr)}
47
+ )`;
48
+ }
49
+
50
+ export function buildWikiOrderExpression(
51
+ alias: string,
52
+ targetRefExpr: string
53
+ ): string {
54
+ const titleExpr = wikiTitleExpr(alias);
55
+ const relExpr = wikiRelPathExpr(alias);
56
+ const { targetBaseExpr, targetMdExpr } = wikiTargetExpressions(targetRefExpr);
57
+ return `CASE
58
+ WHEN ${titleExpr} = ${targetBaseExpr} THEN 1
59
+ WHEN ${titleExpr} = ${targetMdExpr} THEN 2
60
+ WHEN ${suffixMatch(targetBaseExpr, titleExpr)} THEN 3
61
+ WHEN ${suffixMatch(targetMdExpr, `${titleExpr} || '.md'`)} THEN 4
62
+ WHEN ${relExpr} = ${targetBaseExpr} THEN 5
63
+ WHEN ${relExpr} = ${targetMdExpr} THEN 6
64
+ WHEN ${suffixMatch(relExpr, targetMdExpr)} THEN 7
65
+ WHEN ${suffixMatch(relExpr, targetBaseExpr)} THEN 8
66
+ WHEN ${suffixMatch(targetMdExpr, relExpr)} THEN 9
67
+ WHEN ${suffixMatch(targetBaseExpr, relExpr)} THEN 10
68
+ ELSE 11
69
+ END`;
70
+ }
71
+
72
+ export function buildWikiBestMatchSubquery(
73
+ collectionExpr: string,
74
+ targetRefExpr: string
75
+ ): string {
76
+ return `
77
+ SELECT t.id FROM documents t
78
+ WHERE t.active = 1
79
+ AND t.collection = ${collectionExpr}
80
+ AND ${buildWikiMatchExpression("t", targetRefExpr)}
81
+ AND ${buildWikiOrderExpression("t", targetRefExpr)} = (
82
+ SELECT MIN(${buildWikiOrderExpression("t2", targetRefExpr)}) FROM documents t2
83
+ WHERE t2.active = 1
84
+ AND t2.collection = ${collectionExpr}
85
+ AND ${buildWikiMatchExpression("t2", targetRefExpr)}
86
+ )
87
+ ORDER BY t.id LIMIT 1
88
+ `;
89
+ }
90
+
91
+ export function buildWikiBestRankSubquery(
92
+ collectionExpr: string,
93
+ targetRefExpr: string
94
+ ): string {
95
+ return `
96
+ SELECT MIN(${buildWikiOrderExpression("t", targetRefExpr)}) FROM documents t
97
+ WHERE t.active = 1
98
+ AND t.collection = ${collectionExpr}
99
+ AND ${buildWikiMatchExpression("t", targetRefExpr)}
100
+ `;
101
+ }
102
+
103
+ export function buildWikiBestRankMatchCountSubquery(
104
+ collectionExpr: string,
105
+ targetRefExpr: string
106
+ ): string {
107
+ return `
108
+ SELECT COUNT(*) FROM documents t
109
+ WHERE t.active = 1
110
+ AND t.collection = ${collectionExpr}
111
+ AND ${buildWikiMatchExpression("t", targetRefExpr)}
112
+ AND ${buildWikiOrderExpression("t", targetRefExpr)} = (${buildWikiBestRankSubquery(
113
+ collectionExpr,
114
+ targetRefExpr
115
+ )})
116
+ `;
117
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Reference parser and resolver for document refs.
3
+ * Pure parsing helpers stay store-free; resolution depends only on StorePort.
4
+ *
5
+ * @module src/core/ref-parser
6
+ */
7
+
8
+ import type { DocumentRow, StorePort } from "../store/types";
9
+
10
+ export type RefType = "docid" | "uri" | "collPath";
11
+
12
+ export interface ParsedRef {
13
+ type: RefType;
14
+ /** Normalized ref (without :line suffix) */
15
+ value: string;
16
+ /** For collPath type */
17
+ collection?: string;
18
+ /** For collPath type */
19
+ relPath?: string;
20
+ /** Parsed :line suffix (1-indexed) */
21
+ line?: number;
22
+ }
23
+
24
+ export type ParseRefResult = ParsedRef | { error: string };
25
+
26
+ export type ResolvedDocRef =
27
+ | { doc: DocumentRow }
28
+ | { error: string; isValidation: boolean };
29
+
30
+ const DOCID_PATTERN = /^#[a-f0-9]{6,}$/;
31
+ const LINE_SUFFIX_PATTERN = /:(\d+)$/;
32
+ const GLOB_PATTERN = /[*?[\]]/;
33
+
34
+ /**
35
+ * Parse a single ref string.
36
+ * - Docid: starts with # (no :line suffix allowed)
37
+ * - URI: starts with gno:// (optional :N suffix)
38
+ * - Else: collection/path (optional :N suffix)
39
+ */
40
+ export function parseRef(ref: string): ParseRefResult {
41
+ if (ref.startsWith("#")) {
42
+ if (ref.includes(":")) {
43
+ return { error: "Docid refs cannot have :line suffix" };
44
+ }
45
+ if (!DOCID_PATTERN.test(ref)) {
46
+ return { error: `Invalid docid format: ${ref}` };
47
+ }
48
+ return { type: "docid", value: ref };
49
+ }
50
+
51
+ let line: number | undefined;
52
+ let baseRef = ref;
53
+ const lineMatch = ref.match(LINE_SUFFIX_PATTERN);
54
+ if (lineMatch?.[1]) {
55
+ const parsed = Number.parseInt(lineMatch[1], 10);
56
+ if (!Number.isInteger(parsed) || parsed < 1) {
57
+ return { error: `Invalid line suffix (must be >= 1): ${ref}` };
58
+ }
59
+ line = parsed;
60
+ baseRef = ref.slice(0, -lineMatch[0].length);
61
+ }
62
+
63
+ if (baseRef.startsWith("gno://")) {
64
+ return { type: "uri", value: baseRef, line };
65
+ }
66
+
67
+ const slashIdx = baseRef.indexOf("/");
68
+ if (slashIdx === -1) {
69
+ return { error: `Invalid ref format (missing /): ${ref}` };
70
+ }
71
+ const collection = baseRef.slice(0, slashIdx);
72
+ const relPath = baseRef.slice(slashIdx + 1);
73
+
74
+ return { type: "collPath", value: baseRef, collection, relPath, line };
75
+ }
76
+
77
+ /**
78
+ * Resolve a document reference against the store.
79
+ */
80
+ export async function resolveDocRef(
81
+ store: StorePort,
82
+ docRef: string
83
+ ): Promise<ResolvedDocRef> {
84
+ const parsed = parseRef(docRef);
85
+
86
+ if ("error" in parsed) {
87
+ return { error: parsed.error, isValidation: true };
88
+ }
89
+
90
+ let doc: DocumentRow | null = null;
91
+
92
+ switch (parsed.type) {
93
+ case "docid": {
94
+ const result = await store.getDocumentByDocid(parsed.value);
95
+ if (result.ok && result.value) {
96
+ doc = result.value;
97
+ }
98
+ break;
99
+ }
100
+ case "uri": {
101
+ const result = await store.getDocumentByUri(parsed.value);
102
+ if (result.ok && result.value) {
103
+ doc = result.value;
104
+ }
105
+ break;
106
+ }
107
+ case "collPath": {
108
+ const uri = `gno://${parsed.collection}/${parsed.relPath}`;
109
+ const result = await store.getDocumentByUri(uri);
110
+ if (result.ok && result.value) {
111
+ doc = result.value;
112
+ }
113
+ break;
114
+ }
115
+ }
116
+
117
+ if (!doc) {
118
+ return { error: `Document not found: ${docRef}`, isValidation: true };
119
+ }
120
+
121
+ return { doc };
122
+ }
123
+
124
+ /**
125
+ * Split comma-separated refs. Does NOT expand globs.
126
+ */
127
+ export function splitRefs(refs: string[]): string[] {
128
+ const result: string[] = [];
129
+ for (const r of refs) {
130
+ for (const part of r.split(",")) {
131
+ const trimmed = part.trim();
132
+ if (trimmed) {
133
+ result.push(trimmed);
134
+ }
135
+ }
136
+ }
137
+ return result;
138
+ }
139
+
140
+ /**
141
+ * Check if a ref contains glob characters.
142
+ */
143
+ export function isGlobPattern(ref: string): boolean {
144
+ return GLOB_PATTERN.test(ref);
145
+ }
@@ -170,11 +170,95 @@ function parseBlockArrayValue(lines: string[], startIdx: number): string[] {
170
170
  return items;
171
171
  }
172
172
 
173
+ function countIndent(line: string): number {
174
+ let indent = 0;
175
+ for (const char of line) {
176
+ if (char === " ") {
177
+ indent += 1;
178
+ } else if (char === "\t") {
179
+ indent += 2;
180
+ } else {
181
+ break;
182
+ }
183
+ }
184
+ return indent;
185
+ }
186
+
187
+ function parseNestedMapValue(
188
+ lines: string[],
189
+ startIdx: number
190
+ ): Record<string, string[]> {
191
+ const result: Record<string, string[]> = {};
192
+
193
+ for (let i = startIdx + 1; i < lines.length; i++) {
194
+ const line = lines[i];
195
+ if (line === undefined) break;
196
+ if (line.trim().length === 0) continue;
197
+ if (!line.startsWith(" ") && !line.startsWith("\t")) {
198
+ break;
199
+ }
200
+
201
+ const keyIndent = countIndent(line);
202
+ const trimmed = line.trim();
203
+ const colonIdx = trimmed.indexOf(":");
204
+ if (colonIdx <= 0) {
205
+ continue;
206
+ }
207
+
208
+ const key = trimmed.slice(0, colonIdx).trim();
209
+ const rawValue = trimmed.slice(colonIdx + 1).trim();
210
+ const values: string[] = [];
211
+
212
+ const inlineArray = parseInlineArrayValue(rawValue);
213
+ if (inlineArray) {
214
+ values.push(...inlineArray);
215
+ } else if (rawValue.length > 0) {
216
+ values.push(unquoteScalar(rawValue));
217
+ } else {
218
+ for (let j = i + 1; j < lines.length; j++) {
219
+ const childLine = lines[j];
220
+ if (childLine === undefined) break;
221
+ if (childLine.trim().length === 0) continue;
222
+ const childIndent = countIndent(childLine);
223
+ if (childIndent <= keyIndent) {
224
+ break;
225
+ }
226
+ const itemMatch = YAML_ARRAY_ITEM_REGEX.exec(childLine);
227
+ if (itemMatch?.[1]) {
228
+ const item = unquoteScalar(itemMatch[1]);
229
+ if (item.length > 0) {
230
+ values.push(item);
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ if (key.length > 0 && values.length > 0) {
237
+ result[key] = values;
238
+ }
239
+ }
240
+
241
+ return result;
242
+ }
243
+
244
+ function startsNestedMap(lines: string[], startIdx: number): boolean {
245
+ for (let i = startIdx + 1; i < lines.length; i++) {
246
+ const line = lines[i];
247
+ if (line === undefined) break;
248
+ if (line.trim().length === 0) continue;
249
+ if (!line.startsWith(" ") && !line.startsWith("\t")) {
250
+ return false;
251
+ }
252
+ return line.trim().indexOf(":") > 0;
253
+ }
254
+ return false;
255
+ }
256
+
173
257
  function parseMetadataValue(
174
258
  value: string,
175
259
  lines: string[],
176
260
  startIdx: number
177
- ): string | string[] | undefined {
261
+ ): string | string[] | Record<string, string[]> | undefined {
178
262
  const trimmed = value.trim();
179
263
  const inlineArray = parseInlineArrayValue(trimmed);
180
264
  if (inlineArray) {
@@ -182,8 +266,14 @@ function parseMetadataValue(
182
266
  }
183
267
 
184
268
  if (trimmed.length === 0) {
269
+ if (startsNestedMap(lines, startIdx)) {
270
+ const nestedMap = parseNestedMapValue(lines, startIdx);
271
+ return Object.keys(nestedMap).length > 0 ? nestedMap : undefined;
272
+ }
185
273
  const blockArray = parseBlockArrayValue(lines, startIdx);
186
- return blockArray.length > 0 ? blockArray : undefined;
274
+ if (blockArray.length > 0) {
275
+ return blockArray;
276
+ }
187
277
  }
188
278
 
189
279
  return unquoteScalar(trimmed);
@@ -222,6 +312,9 @@ export function parseFrontmatter(source: string): FrontmatterResult {
222
312
  for (let i = 0; i < lines.length; i++) {
223
313
  const line = lines[i];
224
314
  if (line === undefined) continue;
315
+ if (line.startsWith(" ") || line.startsWith("\t")) {
316
+ continue;
317
+ }
225
318
 
226
319
  // Logseq format: tags:: value
227
320
  const logseqMatch = LOGSEQ_TAGS_REGEX.exec(line);
@@ -14,6 +14,7 @@ import type { NormalizedContentTypeRule } from "../config";
14
14
  import type { Collection } from "../config/types";
15
15
  import type {
16
16
  ChunkInput,
17
+ DocEdgeInput,
17
18
  DocLinkInput,
18
19
  DocumentRow,
19
20
  IngestErrorInput,
@@ -43,6 +44,7 @@ import {
43
44
  normalizeMarkdownPath,
44
45
  normalizeWikiName,
45
46
  parseLinks,
47
+ parseTargetParts,
46
48
  } from "../core/links";
47
49
  import { normalizeTag, validateTag } from "../core/tags";
48
50
  import { defaultChunker } from "./chunker";
@@ -70,8 +72,130 @@ const MAX_CONCURRENCY = 16;
70
72
  * Increment when ingestion adds new derived data (tags, metadata, etc.)
71
73
  * Documents with ingestVersion < INGEST_VERSION will be re-processed.
72
74
  */
73
- export const INGEST_VERSION = 5;
75
+ export const INGEST_VERSION = 6;
74
76
  const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT = fingerprintContentTypeRules([]);
77
+ const RELATION_EDGE_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/;
78
+
79
+ type RelationMap = Record<string, string[]>;
80
+
81
+ function isRelationMap(value: unknown): value is RelationMap {
82
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
83
+ return false;
84
+ }
85
+
86
+ return Object.values(value).every(
87
+ (targets) =>
88
+ Array.isArray(targets) &&
89
+ targets.every((target) => typeof target === "string")
90
+ );
91
+ }
92
+
93
+ function normalizeRelationTarget(raw: string): string {
94
+ const trimmed = raw.trim();
95
+ if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
96
+ return trimmed.slice(2, -2).split("|")[0]?.trim() ?? "";
97
+ }
98
+ return trimmed;
99
+ }
100
+
101
+ function normalizeRelationEdgeType(raw: string): string {
102
+ return raw
103
+ .trim()
104
+ .toLowerCase()
105
+ .replace(/[-\s]+/g, "_");
106
+ }
107
+
108
+ function findDocByWikiRef(
109
+ docs: DocumentRow[],
110
+ targetRef: string,
111
+ collection?: string
112
+ ): DocumentRow | undefined {
113
+ const normalized = normalizeWikiName(targetRef);
114
+ const candidates = collection
115
+ ? docs.filter((doc) => doc.collection === collection)
116
+ : docs;
117
+
118
+ return candidates.find((doc) => {
119
+ const title = doc.title ?? doc.relPath.split("/").pop() ?? doc.relPath;
120
+ const relStem = doc.relPath.replace(/\.[^/.]+$/, "");
121
+ return (
122
+ normalizeWikiName(title) === normalized ||
123
+ normalizeWikiName(doc.relPath) === normalized ||
124
+ normalizeWikiName(relStem) === normalized
125
+ );
126
+ });
127
+ }
128
+
129
+ function resolveRelationTarget(
130
+ docs: DocumentRow[],
131
+ sourceDoc: DocumentRow,
132
+ rawTarget: string
133
+ ): DocumentRow | undefined {
134
+ const target = normalizeRelationTarget(rawTarget);
135
+ if (!target) {
136
+ return undefined;
137
+ }
138
+
139
+ if (target.startsWith("#")) {
140
+ return docs.find((doc) => doc.docid === target);
141
+ }
142
+
143
+ if (target.startsWith("gno://")) {
144
+ return docs.find((doc) => doc.uri === target);
145
+ }
146
+
147
+ const parts = parseTargetParts(target);
148
+ const targetCollection = parts.collection;
149
+ const targetRef = parts.ref;
150
+ if (!targetRef) {
151
+ return undefined;
152
+ }
153
+
154
+ if (targetCollection) {
155
+ const exact = docs.find(
156
+ (doc) => doc.collection === targetCollection && doc.relPath === targetRef
157
+ );
158
+ return exact ?? findDocByWikiRef(docs, targetRef, targetCollection);
159
+ }
160
+
161
+ const sameCollectionPath = normalizeMarkdownPath(
162
+ targetRef,
163
+ sourceDoc.relPath
164
+ );
165
+ if (sameCollectionPath) {
166
+ const exact = docs.find(
167
+ (doc) =>
168
+ doc.collection === sourceDoc.collection &&
169
+ doc.relPath === sameCollectionPath
170
+ );
171
+ if (exact) {
172
+ return exact;
173
+ }
174
+ }
175
+
176
+ const explicitCollPath = docs.find(
177
+ (doc) => `${doc.collection}/${doc.relPath}` === targetRef
178
+ );
179
+ if (explicitCollPath) {
180
+ return explicitCollPath;
181
+ }
182
+
183
+ return (
184
+ findDocByWikiRef(docs, targetRef, sourceDoc.collection) ??
185
+ findDocByWikiRef(docs, targetRef)
186
+ );
187
+ }
188
+
189
+ function getPrimaryGraphHint(
190
+ contentType: string | null | undefined,
191
+ rules: NormalizedContentTypeRule[]
192
+ ): string | undefined {
193
+ if (!contentType) {
194
+ return undefined;
195
+ }
196
+ const rule = rules.find((candidate) => candidate.id === contentType);
197
+ return rule?.graphHints?.[0];
198
+ }
75
199
 
76
200
  /**
77
201
  * Decide whether to process a file or skip it.
@@ -658,6 +782,7 @@ export class SyncService {
658
782
  converterVersion: artifact.meta.converterVersion,
659
783
  languageHint: artifact.languageHint ?? collection.languageHint,
660
784
  contentType: extractedMetadata.contentType,
785
+ contentTypeSource: extractedMetadata.contentTypeSource,
661
786
  categories: extractedMetadata.categories,
662
787
  author: extractedMetadata.author,
663
788
  frontmatterDate: extractedMetadata.frontmatterDate,
@@ -892,9 +1017,160 @@ export class SyncService {
892
1017
  results.push(result);
893
1018
  }
894
1019
 
1020
+ await this.projectTypedEdges(collection, store, options);
1021
+
895
1022
  return results;
896
1023
  }
897
1024
 
1025
+ private async projectTypedEdges(
1026
+ collection: Collection,
1027
+ store: StorePort,
1028
+ options: SyncOptions
1029
+ ): Promise<Array<{ relPath: string; code: string; message: string }>> {
1030
+ const errors: Array<{ relPath: string; code: string; message: string }> =
1031
+ [];
1032
+
1033
+ const backfillResult = await store.backfillDocEdges();
1034
+ if (!backfillResult.ok) {
1035
+ return [
1036
+ {
1037
+ relPath: "(typed edge backfill)",
1038
+ code: backfillResult.error.code,
1039
+ message: backfillResult.error.message,
1040
+ },
1041
+ ];
1042
+ }
1043
+
1044
+ const docsResult = await store.listDocuments();
1045
+ if (!docsResult.ok) {
1046
+ return [
1047
+ {
1048
+ relPath: "(typed edge projection)",
1049
+ code: docsResult.error.code,
1050
+ message: docsResult.error.message,
1051
+ },
1052
+ ];
1053
+ }
1054
+
1055
+ const activeDocs = docsResult.value.filter((doc) => doc.active);
1056
+
1057
+ for (const doc of activeDocs) {
1058
+ if (!doc.mirrorHash) {
1059
+ continue;
1060
+ }
1061
+
1062
+ const contentResult = await store.getContent(doc.mirrorHash);
1063
+ if (!contentResult.ok || contentResult.value === null) {
1064
+ continue;
1065
+ }
1066
+
1067
+ const relationsValue = parseFrontmatter(contentResult.value).metadata
1068
+ .relations;
1069
+ const relationEdges: DocEdgeInput[] = [];
1070
+
1071
+ if (isRelationMap(relationsValue)) {
1072
+ for (const [rawEdgeType, targets] of Object.entries(relationsValue)) {
1073
+ const edgeType = normalizeRelationEdgeType(rawEdgeType);
1074
+ if (!RELATION_EDGE_TYPE_PATTERN.test(edgeType)) {
1075
+ continue;
1076
+ }
1077
+ for (const target of targets) {
1078
+ const targetDoc = resolveRelationTarget(activeDocs, doc, target);
1079
+ if (targetDoc) {
1080
+ relationEdges.push({
1081
+ targetDocId: targetDoc.id,
1082
+ edgeType,
1083
+ confidence: "manual",
1084
+ });
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ const relationTargetIds = new Set(
1090
+ relationEdges.map((edge) => edge.targetDocId)
1091
+ );
1092
+
1093
+ const relationsResult = await store.setDocEdges(
1094
+ doc.id,
1095
+ relationEdges,
1096
+ "frontmatter-relation"
1097
+ );
1098
+ if (!relationsResult.ok) {
1099
+ errors.push({
1100
+ relPath: doc.relPath,
1101
+ code: relationsResult.error.code,
1102
+ message: relationsResult.error.message,
1103
+ });
1104
+ }
1105
+
1106
+ const primaryHint = getPrimaryGraphHint(
1107
+ doc.contentType,
1108
+ options.contentTypeRules ?? []
1109
+ );
1110
+ if (!primaryHint || !RELATION_EDGE_TYPE_PATTERN.test(primaryHint)) {
1111
+ continue;
1112
+ }
1113
+
1114
+ const linksResult = await store.getLinksForDoc(doc.id);
1115
+ if (!linksResult.ok) {
1116
+ errors.push({
1117
+ relPath: doc.relPath,
1118
+ code: linksResult.error.code,
1119
+ message: linksResult.error.message,
1120
+ });
1121
+ continue;
1122
+ }
1123
+
1124
+ const wikiEdges: DocEdgeInput[] = [];
1125
+ const markdownEdges: DocEdgeInput[] = [];
1126
+ for (const link of linksResult.value) {
1127
+ const targetRef =
1128
+ link.linkType === "markdown"
1129
+ ? `${doc.collection}/${link.targetRefNorm}`
1130
+ : link.targetCollection
1131
+ ? `${link.targetCollection}:${link.targetRef}`
1132
+ : link.targetRefNorm;
1133
+ const targetDoc = resolveRelationTarget(activeDocs, doc, targetRef);
1134
+ if (!targetDoc || relationTargetIds.has(targetDoc.id)) {
1135
+ continue;
1136
+ }
1137
+ const edge = {
1138
+ targetDocId: targetDoc.id,
1139
+ edgeType: primaryHint,
1140
+ confidence: "configured" as const,
1141
+ };
1142
+ if (link.linkType === "wiki") {
1143
+ wikiEdges.push(edge);
1144
+ } else {
1145
+ markdownEdges.push(edge);
1146
+ }
1147
+ }
1148
+
1149
+ const wikiResult = await store.setDocEdges(doc.id, wikiEdges, "wikilink");
1150
+ if (!wikiResult.ok) {
1151
+ errors.push({
1152
+ relPath: doc.relPath,
1153
+ code: wikiResult.error.code,
1154
+ message: wikiResult.error.message,
1155
+ });
1156
+ }
1157
+ const markdownResult = await store.setDocEdges(
1158
+ doc.id,
1159
+ markdownEdges,
1160
+ "markdown-link"
1161
+ );
1162
+ if (!markdownResult.ok) {
1163
+ errors.push({
1164
+ relPath: doc.relPath,
1165
+ code: markdownResult.error.code,
1166
+ message: markdownResult.error.message,
1167
+ });
1168
+ }
1169
+ }
1170
+
1171
+ return errors;
1172
+ }
1173
+
898
1174
  /**
899
1175
  * Sync a single collection.
900
1176
  */
@@ -1121,6 +1397,10 @@ export class SyncService {
1121
1397
  }
1122
1398
  }
1123
1399
 
1400
+ errors.push(
1401
+ ...(await this.projectTypedEdges(collection, store, syncOptions))
1402
+ );
1403
+
1124
1404
  return {
1125
1405
  collection: collection.name,
1126
1406
  filesProcessed: entries.length,