@gmickel/gno 1.8.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 (62) hide show
  1. package/README.md +6 -2
  2. package/assets/skill/SKILL.md +41 -16
  3. package/assets/skill/cli-reference.md +39 -0
  4. package/assets/skill/examples.md +41 -0
  5. package/assets/skill/mcp-reference.md +13 -1
  6. package/package.json +1 -1
  7. package/src/cli/commands/capture.ts +9 -6
  8. package/src/cli/commands/graph.ts +89 -2
  9. package/src/cli/commands/index-cmd.ts +17 -6
  10. package/src/cli/commands/links.ts +237 -54
  11. package/src/cli/commands/query.ts +212 -0
  12. package/src/cli/commands/ref-parser.ts +8 -103
  13. package/src/cli/commands/shared.ts +17 -1
  14. package/src/cli/commands/update.ts +17 -6
  15. package/src/cli/options.ts +4 -0
  16. package/src/cli/program.ts +176 -9
  17. package/src/config/content-types.ts +154 -0
  18. package/src/config/defaults.ts +1 -0
  19. package/src/config/index.ts +14 -0
  20. package/src/config/loader.ts +11 -2
  21. package/src/config/types.ts +37 -1
  22. package/src/core/config-mutation.ts +14 -2
  23. package/src/core/graph-query.ts +137 -0
  24. package/src/core/graph-resolver.ts +117 -0
  25. package/src/core/note-presets.ts +61 -5
  26. package/src/core/ref-parser.ts +145 -0
  27. package/src/ingestion/frontmatter.ts +170 -2
  28. package/src/ingestion/index.ts +2 -0
  29. package/src/ingestion/sync-options.ts +29 -0
  30. package/src/ingestion/sync.ts +385 -17
  31. package/src/ingestion/types.ts +14 -0
  32. package/src/mcp/tools/add-collection.ts +8 -5
  33. package/src/mcp/tools/capture.ts +5 -2
  34. package/src/mcp/tools/get.ts +1 -1
  35. package/src/mcp/tools/index-cmd.ts +13 -7
  36. package/src/mcp/tools/index.ts +97 -10
  37. package/src/mcp/tools/links.ts +83 -1
  38. package/src/mcp/tools/multi-get.ts +1 -1
  39. package/src/mcp/tools/query.ts +207 -0
  40. package/src/mcp/tools/sync.ts +12 -6
  41. package/src/mcp/tools/workspace-write.ts +16 -10
  42. package/src/pipeline/diagnose.ts +302 -0
  43. package/src/pipeline/filters.ts +119 -0
  44. package/src/pipeline/hybrid.ts +92 -17
  45. package/src/pipeline/search.ts +2 -0
  46. package/src/pipeline/types.ts +34 -0
  47. package/src/pipeline/vsearch.ts +4 -0
  48. package/src/publish/export-service.ts +1 -1
  49. package/src/sdk/client.ts +51 -24
  50. package/src/sdk/documents.ts +2 -2
  51. package/src/serve/background-runtime.ts +18 -4
  52. package/src/serve/config-sync.ts +9 -2
  53. package/src/serve/routes/api.ts +244 -24
  54. package/src/serve/routes/graph.ts +173 -1
  55. package/src/serve/server.ts +24 -1
  56. package/src/serve/watch-service.ts +12 -2
  57. package/src/store/migrations/009-content-type-rule-fingerprint.ts +36 -0
  58. package/src/store/migrations/010-typed-edges.ts +67 -0
  59. package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
  60. package/src/store/migrations/index.ts +16 -1
  61. package/src/store/sqlite/adapter.ts +853 -114
  62. package/src/store/types.ts +147 -0
@@ -35,6 +35,10 @@ import {
35
35
  removeCollection,
36
36
  updateCollection,
37
37
  } from "../../collection";
38
+ import {
39
+ fingerprintContentTypeRules,
40
+ normalizeContentTypes,
41
+ } from "../../config";
38
42
  import {
39
43
  buildCaptureReceipt,
40
44
  listCaptureDiskRelPaths,
@@ -82,7 +86,11 @@ import {
82
86
  validateTag,
83
87
  } from "../../core/tags";
84
88
  import { validateRelPath } from "../../core/validation";
85
- import { defaultSyncService, type SyncResult } from "../../ingestion";
89
+ import {
90
+ defaultSyncService,
91
+ type SyncResult,
92
+ withContentTypeRules,
93
+ } from "../../ingestion";
86
94
  import { updateFrontmatterTags } from "../../ingestion/frontmatter";
87
95
  import {
88
96
  getCollectionEffectiveModels,
@@ -96,6 +104,7 @@ import {
96
104
  generateGroundedAnswer,
97
105
  processAnswerResult,
98
106
  } from "../../pipeline/answer";
107
+ import { diagnoseQueryTarget } from "../../pipeline/diagnose";
99
108
  import { searchHybrid } from "../../pipeline/hybrid";
100
109
  import { validateQueryModes } from "../../pipeline/query-modes";
101
110
  import { searchBm25 } from "../../pipeline/search";
@@ -188,6 +197,10 @@ export interface QueryRequestBody {
188
197
  tagsAny?: string;
189
198
  }
190
199
 
200
+ export interface QueryDiagnoseRequestBody extends QueryRequestBody {
201
+ target?: string;
202
+ }
203
+
191
204
  export interface AskRequestBody {
192
205
  query: string;
193
206
  limit?: number;
@@ -919,10 +932,17 @@ export async function handleCreateCollection(
919
932
  return errorResponse("RUNTIME", "Collection not found after add", 500);
920
933
  }
921
934
  const jobResult = startJob("add", async (): Promise<SyncResult> => {
922
- const result = await defaultSyncService.syncCollection(collection, store, {
923
- gitPull: body.gitPull,
924
- runUpdateCmd: true,
925
- });
935
+ const result = await defaultSyncService.syncCollection(
936
+ collection,
937
+ store,
938
+ withContentTypeRules(
939
+ {
940
+ gitPull: body.gitPull,
941
+ runUpdateCmd: true,
942
+ },
943
+ syncResult.config
944
+ )
945
+ );
926
946
  if (result.filesAdded > 0 || result.filesUpdated > 0) {
927
947
  if (ctxHolder.scheduler) {
928
948
  await ctxHolder.scheduler.triggerNow();
@@ -1214,10 +1234,17 @@ export async function handleSync(
1214
1234
 
1215
1235
  // Start background sync job
1216
1236
  const jobResult = startJob("sync", async (): Promise<SyncResult> => {
1217
- const result = await defaultSyncService.syncAll(collections, store, {
1218
- gitPull: body.gitPull,
1219
- runUpdateCmd: true,
1220
- });
1237
+ const result = await defaultSyncService.syncAll(
1238
+ collections,
1239
+ store,
1240
+ withContentTypeRules(
1241
+ {
1242
+ gitPull: body.gitPull,
1243
+ runUpdateCmd: true,
1244
+ },
1245
+ ctxHolder.config
1246
+ )
1247
+ );
1221
1248
  if (result.totalFilesAdded > 0 || result.totalFilesUpdated > 0) {
1222
1249
  if (ctxHolder.scheduler) {
1223
1250
  await ctxHolder.scheduler.triggerNow();
@@ -1843,9 +1870,11 @@ export async function handleRenameDoc(
1843
1870
  const nextUri = `gno://${collection.name}/${nextRelPath}`;
1844
1871
  let warning: string | undefined;
1845
1872
  try {
1846
- await syncCollection(collection, store, {
1847
- runUpdateCmd: false,
1848
- });
1873
+ await syncCollection(
1874
+ collection,
1875
+ store,
1876
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
1877
+ );
1849
1878
  } catch {
1850
1879
  warning =
1851
1880
  "File renamed on disk, but index refresh failed. Run Update All to reconcile the workspace.";
@@ -2063,9 +2092,11 @@ export async function handleTrashDoc(
2063
2092
  }
2064
2093
  let warning: string | undefined;
2065
2094
  try {
2066
- await syncCollection(collection, store, {
2067
- runUpdateCmd: false,
2068
- });
2095
+ await syncCollection(
2096
+ collection,
2097
+ store,
2098
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2099
+ );
2069
2100
  } catch {
2070
2101
  warning =
2071
2102
  "File moved to Trash, but index refresh failed. Run Update All to reconcile the workspace.";
@@ -2184,9 +2215,11 @@ export async function handleMoveDoc(
2184
2215
  await renameFilePath(fullPath, nextFullPath);
2185
2216
  let warning: string | undefined;
2186
2217
  try {
2187
- await defaultSyncService.syncCollection(collection, store, {
2188
- runUpdateCmd: false,
2189
- });
2218
+ await defaultSyncService.syncCollection(
2219
+ collection,
2220
+ store,
2221
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2222
+ );
2190
2223
  } catch {
2191
2224
  warning =
2192
2225
  "File moved on disk, but index refresh failed. Run Update All to reconcile the workspace.";
@@ -2305,9 +2338,11 @@ export async function handleDuplicateDoc(
2305
2338
  await copyFilePath(fullPath, nextFullPath);
2306
2339
  let warning: string | undefined;
2307
2340
  try {
2308
- await defaultSyncService.syncCollection(collection, store, {
2309
- runUpdateCmd: false,
2310
- });
2341
+ await defaultSyncService.syncCollection(
2342
+ collection,
2343
+ store,
2344
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2345
+ );
2311
2346
  } catch {
2312
2347
  warning =
2313
2348
  "File duplicated on disk, but index refresh failed. Run Update All to reconcile the workspace.";
@@ -2636,7 +2671,7 @@ export async function handleUpdateDoc(
2636
2671
  const result = await defaultSyncService.syncCollection(
2637
2672
  collection,
2638
2673
  store,
2639
- { runUpdateCmd: false }
2674
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2640
2675
  );
2641
2676
  // Notify scheduler after sync completes
2642
2677
  ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
@@ -2904,7 +2939,7 @@ export async function handleCreateCapture(
2904
2939
  const result = await defaultSyncService.syncCollection(
2905
2940
  collection,
2906
2941
  store,
2907
- { runUpdateCmd: false }
2942
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2908
2943
  );
2909
2944
  ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
2910
2945
  ctxHolder.eventBus?.emit({
@@ -3140,7 +3175,7 @@ export async function handleCreateDoc(
3140
3175
  const result = await defaultSyncService.syncCollection(
3141
3176
  collection,
3142
3177
  store,
3143
- { runUpdateCmd: false }
3178
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
3144
3179
  );
3145
3180
  // Notify scheduler after sync completes (use gnoUri as docid placeholder)
3146
3181
  // The sync will create a proper docid, but we don't have it here yet
@@ -3491,6 +3526,191 @@ export async function handleQuery(
3491
3526
  return jsonResponse(result.value);
3492
3527
  }
3493
3528
 
3529
+ /**
3530
+ * POST /api/query/diagnose
3531
+ * Body: { query, target, limit?, minScore?, collection?, lang?, queryModes?, noExpand?, noRerank?, graph? }
3532
+ * Returns targeted retrieval diagnostics for one document.
3533
+ */
3534
+ export async function handleQueryDiagnose(
3535
+ ctx: ServerContext,
3536
+ req: Request
3537
+ ): Promise<Response> {
3538
+ let body: QueryDiagnoseRequestBody;
3539
+ try {
3540
+ body = (await req.json()) as QueryDiagnoseRequestBody;
3541
+ } catch {
3542
+ return errorResponse("VALIDATION", "Invalid JSON body");
3543
+ }
3544
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
3545
+ return errorResponse("VALIDATION", "JSON body must be an object");
3546
+ }
3547
+
3548
+ if (!body.query || typeof body.query !== "string") {
3549
+ return errorResponse("VALIDATION", "Missing or invalid query");
3550
+ }
3551
+ if (!body.target || typeof body.target !== "string") {
3552
+ return errorResponse("VALIDATION", "Missing or invalid target");
3553
+ }
3554
+
3555
+ const rawQuery = body.query.trim();
3556
+ const target = body.target.trim();
3557
+ if (!rawQuery) {
3558
+ return errorResponse("VALIDATION", "Query cannot be empty");
3559
+ }
3560
+ if (!target) {
3561
+ return errorResponse("VALIDATION", "target cannot be empty");
3562
+ }
3563
+
3564
+ if (
3565
+ body.limit !== undefined &&
3566
+ (typeof body.limit !== "number" || body.limit < 1)
3567
+ ) {
3568
+ return errorResponse("VALIDATION", "limit must be a positive integer");
3569
+ }
3570
+ if (
3571
+ body.minScore !== undefined &&
3572
+ (typeof body.minScore !== "number" ||
3573
+ body.minScore < 0 ||
3574
+ body.minScore > 1)
3575
+ ) {
3576
+ return errorResponse(
3577
+ "VALIDATION",
3578
+ "minScore must be a number between 0 and 1"
3579
+ );
3580
+ }
3581
+ if (body.since !== undefined && typeof body.since !== "string") {
3582
+ return errorResponse("VALIDATION", "since must be a string");
3583
+ }
3584
+ if (body.until !== undefined && typeof body.until !== "string") {
3585
+ return errorResponse("VALIDATION", "until must be a string");
3586
+ }
3587
+ if (body.intent !== undefined && typeof body.intent !== "string") {
3588
+ return errorResponse("VALIDATION", "intent must be a string");
3589
+ }
3590
+ if (body.exclude !== undefined && typeof body.exclude !== "string") {
3591
+ return errorResponse(
3592
+ "VALIDATION",
3593
+ "exclude must be a comma-separated string"
3594
+ );
3595
+ }
3596
+ if (
3597
+ body.candidateLimit !== undefined &&
3598
+ (typeof body.candidateLimit !== "number" || body.candidateLimit < 1)
3599
+ ) {
3600
+ return errorResponse(
3601
+ "VALIDATION",
3602
+ "candidateLimit must be a positive integer"
3603
+ );
3604
+ }
3605
+ if (body.category !== undefined && typeof body.category !== "string") {
3606
+ return errorResponse(
3607
+ "VALIDATION",
3608
+ "category must be a comma-separated string"
3609
+ );
3610
+ }
3611
+ if (body.author !== undefined && typeof body.author !== "string") {
3612
+ return errorResponse("VALIDATION", "author must be a string");
3613
+ }
3614
+
3615
+ const { queryModes, error: queryModesError } = parseQueryModesInput(
3616
+ body.queryModes
3617
+ );
3618
+ if (queryModesError) {
3619
+ return queryModesError;
3620
+ }
3621
+
3622
+ const {
3623
+ query,
3624
+ queryModes: normalizedQueryModes,
3625
+ error: structuredQueryError,
3626
+ } = normalizeStructuredQueryBody(rawQuery, queryModes);
3627
+ if (structuredQueryError) {
3628
+ return structuredQueryError;
3629
+ }
3630
+ const normalizedQuery = query ?? rawQuery;
3631
+
3632
+ let tagsAll: string[] | undefined;
3633
+ let tagsAny: string[] | undefined;
3634
+
3635
+ if (body.tagsAll) {
3636
+ try {
3637
+ tagsAll = parseAndValidateTagFilter(body.tagsAll);
3638
+ } catch (e) {
3639
+ return errorResponse(
3640
+ "VALIDATION",
3641
+ e instanceof Error ? e.message : "Invalid tagsAll"
3642
+ );
3643
+ }
3644
+ }
3645
+
3646
+ if (body.tagsAny) {
3647
+ try {
3648
+ tagsAny = parseAndValidateTagFilter(body.tagsAny);
3649
+ } catch (e) {
3650
+ return errorResponse(
3651
+ "VALIDATION",
3652
+ e instanceof Error ? e.message : "Invalid tagsAny"
3653
+ );
3654
+ }
3655
+ }
3656
+
3657
+ const categories = body.category
3658
+ ? parseCommaSeparatedValues(body.category)
3659
+ : undefined;
3660
+ const exclude = body.exclude
3661
+ ? parseCommaSeparatedValues(body.exclude)
3662
+ : undefined;
3663
+ const author = body.author?.trim() || undefined;
3664
+ const contentTypeRules = normalizeContentTypes(
3665
+ ctx.config.contentTypes ?? []
3666
+ ).rules;
3667
+
3668
+ const result = await diagnoseQueryTarget(
3669
+ {
3670
+ store: ctx.store,
3671
+ config: ctx.config,
3672
+ vectorIndex: ctx.vectorIndex,
3673
+ embedPort: ctx.embedPort,
3674
+ expandPort: ctx.expandPort,
3675
+ rerankPort: ctx.rerankPort,
3676
+ },
3677
+ normalizedQuery,
3678
+ {
3679
+ target,
3680
+ limit: Math.min(body.limit ?? 20, 50),
3681
+ minScore: body.minScore,
3682
+ collection: body.collection,
3683
+ lang: body.lang,
3684
+ intent: body.intent?.trim() || undefined,
3685
+ candidateLimit:
3686
+ body.candidateLimit !== undefined
3687
+ ? Math.min(body.candidateLimit, 100)
3688
+ : undefined,
3689
+ exclude,
3690
+ queryModes: normalizedQueryModes,
3691
+ noExpand: body.noExpand,
3692
+ noRerank: body.noRerank,
3693
+ graph: body.graph === true,
3694
+ noGraph: body.noGraph,
3695
+ tagsAll,
3696
+ tagsAny,
3697
+ since: body.since,
3698
+ until: body.until,
3699
+ categories,
3700
+ author,
3701
+ contentTypeRules,
3702
+ contentTypeRulesFingerprint:
3703
+ fingerprintContentTypeRules(contentTypeRules),
3704
+ }
3705
+ );
3706
+
3707
+ if (!result.ok) {
3708
+ return errorResponse("RUNTIME", result.error.message, 500);
3709
+ }
3710
+
3711
+ return jsonResponse(result.value);
3712
+ }
3713
+
3494
3714
  /**
3495
3715
  * POST /api/ask
3496
3716
  * Body: { query, limit?, collection?, lang?, maxAnswerTokens? }
@@ -4,8 +4,17 @@
4
4
  * @module src/serve/routes/graph
5
5
  */
6
6
 
7
+ import type { Config } from "../../config/types";
7
8
  import type { SqliteAdapter } from "../../store/sqlite/adapter";
8
- import type { GetGraphOptions, GraphResult } from "../../store/types";
9
+ import type {
10
+ GetGraphOptions,
11
+ GraphQueryDirection,
12
+ GraphQueryResult,
13
+ GraphResult,
14
+ } from "../../store/types";
15
+
16
+ import { normalizeContentTypes } from "../../config";
17
+ import { diagnoseGraphQuery } from "../../core/graph-query";
9
18
 
10
19
  // ─────────────────────────────────────────────────────────────────────────────
11
20
  // Types
@@ -13,6 +22,21 @@ import type { GetGraphOptions, GraphResult } from "../../store/types";
13
22
 
14
23
  export interface GraphResponse extends GraphResult {}
15
24
 
25
+ export interface GraphQueryResponse extends GraphQueryResult {}
26
+
27
+ interface GraphQueryRequestBody {
28
+ doc?: string;
29
+ root?: string;
30
+ direction?: GraphQueryDirection;
31
+ edgeType?: string;
32
+ relation?: string;
33
+ maxDepth?: number;
34
+ depth?: number;
35
+ maxNodes?: number;
36
+ frontierLimit?: number;
37
+ visitedLimit?: number;
38
+ }
39
+
16
40
  // ─────────────────────────────────────────────────────────────────────────────
17
41
  // Helpers
18
42
  // ─────────────────────────────────────────────────────────────────────────────
@@ -67,6 +91,29 @@ function parseThreshold(
67
91
  return { ok: true, value: parsed };
68
92
  }
69
93
 
94
+ function parseBodyPositiveInt(
95
+ name: string,
96
+ value: number | undefined,
97
+ defaultValue: number,
98
+ min: number,
99
+ max: number
100
+ ): ParseResult {
101
+ if (value === undefined) return { ok: true, value: defaultValue };
102
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
103
+ return {
104
+ ok: false,
105
+ message: `${name} must be an integer between ${min} and ${max}`,
106
+ };
107
+ }
108
+ if (value < min || value > max) {
109
+ return {
110
+ ok: false,
111
+ message: `${name} must be between ${min} and ${max}`,
112
+ };
113
+ }
114
+ return { ok: true, value };
115
+ }
116
+
70
117
  /**
71
118
  * Parse boolean query param (true if "true" or "1", false otherwise).
72
119
  */
@@ -160,3 +207,128 @@ export async function handleGraph(
160
207
 
161
208
  return jsonResponse(result.value satisfies GraphResponse);
162
209
  }
210
+
211
+ /**
212
+ * POST /api/graph/query
213
+ * Returns bounded typed-edge traversal from one resolved root document.
214
+ */
215
+ export async function handleGraphQuery(
216
+ store: SqliteAdapter,
217
+ config: Config,
218
+ req: Request
219
+ ): Promise<Response> {
220
+ let body: GraphQueryRequestBody;
221
+ try {
222
+ body = (await req.json()) as GraphQueryRequestBody;
223
+ } catch {
224
+ return errorResponse("VALIDATION", "Invalid JSON body", 400);
225
+ }
226
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
227
+ return errorResponse("VALIDATION", "JSON body must be an object", 400);
228
+ }
229
+
230
+ const docRef = body.doc ?? body.root;
231
+ if (!docRef || typeof docRef !== "string") {
232
+ return errorResponse("VALIDATION", "Missing or invalid doc", 400);
233
+ }
234
+ const rootRef = docRef.trim();
235
+ if (!rootRef) {
236
+ return errorResponse("VALIDATION", "doc cannot be empty", 400);
237
+ }
238
+
239
+ const direction = body.direction ?? "both";
240
+ if (!["out", "in", "both"].includes(direction)) {
241
+ return errorResponse(
242
+ "VALIDATION",
243
+ "direction must be 'out', 'in', or 'both'",
244
+ 400
245
+ );
246
+ }
247
+
248
+ if (body.edgeType !== undefined && typeof body.edgeType !== "string") {
249
+ return errorResponse("VALIDATION", "edgeType must be a string", 400);
250
+ }
251
+ if (body.relation !== undefined && typeof body.relation !== "string") {
252
+ return errorResponse("VALIDATION", "relation must be a string", 400);
253
+ }
254
+ const edgeTypeValue = body.edgeType?.trim();
255
+ const relationValue = body.relation?.trim();
256
+ const edgeType = edgeTypeValue || relationValue || undefined;
257
+ if (
258
+ (body.edgeType !== undefined || body.relation !== undefined) &&
259
+ !edgeType
260
+ ) {
261
+ return errorResponse(
262
+ "VALIDATION",
263
+ "edgeType/relation cannot be empty",
264
+ 400
265
+ );
266
+ }
267
+ if (edgeTypeValue && relationValue && edgeTypeValue !== relationValue) {
268
+ return errorResponse(
269
+ "VALIDATION",
270
+ "edgeType and relation are aliases and must match when both are provided",
271
+ 400
272
+ );
273
+ }
274
+
275
+ const maxDepthResult = parseBodyPositiveInt(
276
+ "maxDepth",
277
+ body.maxDepth ?? body.depth,
278
+ 2,
279
+ 1,
280
+ 6
281
+ );
282
+ if (!maxDepthResult.ok) {
283
+ return errorResponse("VALIDATION", maxDepthResult.message, 400);
284
+ }
285
+ const maxNodesResult = parseBodyPositiveInt(
286
+ "maxNodes",
287
+ body.maxNodes,
288
+ 100,
289
+ 1,
290
+ 1000
291
+ );
292
+ if (!maxNodesResult.ok) {
293
+ return errorResponse("VALIDATION", maxNodesResult.message, 400);
294
+ }
295
+ const frontierLimitResult = parseBodyPositiveInt(
296
+ "frontierLimit",
297
+ body.frontierLimit,
298
+ 100,
299
+ 1,
300
+ 1000
301
+ );
302
+ if (!frontierLimitResult.ok) {
303
+ return errorResponse("VALIDATION", frontierLimitResult.message, 400);
304
+ }
305
+ const visitedLimitResult = parseBodyPositiveInt(
306
+ "visitedLimit",
307
+ body.visitedLimit,
308
+ 500,
309
+ 1,
310
+ 5000
311
+ );
312
+ if (!visitedLimitResult.ok) {
313
+ return errorResponse("VALIDATION", visitedLimitResult.message, 400);
314
+ }
315
+
316
+ const result = await diagnoseGraphQuery(store, rootRef, {
317
+ direction,
318
+ edgeType,
319
+ maxDepth: maxDepthResult.value,
320
+ maxNodes: maxNodesResult.value,
321
+ frontierLimit: frontierLimitResult.value,
322
+ visitedLimit: visitedLimitResult.value,
323
+ contentTypeRules: normalizeContentTypes(config.contentTypes ?? []).rules,
324
+ });
325
+ if (!result.success) {
326
+ return errorResponse(
327
+ result.isValidation ? "VALIDATION" : "RUNTIME",
328
+ result.error,
329
+ result.isValidation ? 400 : 500
330
+ );
331
+ }
332
+
333
+ return jsonResponse(result.data satisfies GraphQueryResponse);
334
+ }
@@ -46,6 +46,7 @@ import {
46
46
  handlePublishExport,
47
47
  handlePresets,
48
48
  handleQuery,
49
+ handleQueryDiagnose,
49
50
  handleRefactorPlan,
50
51
  handleRenameDoc,
51
52
  handleRevealDoc,
@@ -58,7 +59,7 @@ import {
58
59
  handleUpdateCollection,
59
60
  handleUpdateDoc,
60
61
  } from "./routes/api";
61
- import { handleGraph } from "./routes/graph";
62
+ import { handleGraph, handleGraphQuery } from "./routes/graph";
62
63
  import {
63
64
  handleDocBacklinks,
64
65
  handleDocLinks,
@@ -489,6 +490,17 @@ export async function startServer(
489
490
  );
490
491
  },
491
492
  },
493
+ "/api/query/diagnose": {
494
+ POST: async (req: Request) => {
495
+ if (!isRequestAllowed(req, port)) {
496
+ return withSecurityHeaders(forbiddenResponse(), isDev);
497
+ }
498
+ return withSecurityHeaders(
499
+ await handleQueryDiagnose(ctxHolder.current, req),
500
+ isDev
501
+ );
502
+ },
503
+ },
492
504
  "/api/ask": {
493
505
  POST: async (req: Request) => {
494
506
  if (!isRequestAllowed(req, port)) {
@@ -653,6 +665,17 @@ export async function startServer(
653
665
  return withSecurityHeaders(await handleGraph(store, url), isDev);
654
666
  },
655
667
  },
668
+ "/api/graph/query": {
669
+ POST: async (req: Request) => {
670
+ if (!isRequestAllowed(req, port)) {
671
+ return withSecurityHeaders(forbiddenResponse(), isDev);
672
+ }
673
+ return withSecurityHeaders(
674
+ await handleGraphQuery(store, ctxHolder.config, req),
675
+ isDev
676
+ );
677
+ },
678
+ },
656
679
  },
657
680
  });
658
681
  } catch (e) {
@@ -2,7 +2,7 @@ import { watch, type FSWatcher } from "node:fs";
2
2
  import { join, normalize, sep } from "node:path";
3
3
 
4
4
  import type { Collection } from "../config/types";
5
- import type { CollectionSyncResult } from "../ingestion";
5
+ import type { CollectionSyncResult, SyncOptions } from "../ingestion";
6
6
  import type { SqliteAdapter } from "../store/sqlite/adapter";
7
7
  import type { DocumentEvent, DocumentEventBus } from "./doc-events";
8
8
  import type { EmbedScheduler } from "./embed-scheduler";
@@ -39,6 +39,7 @@ interface CollectionWatchServiceOptions {
39
39
  scheduler: EmbedScheduler | null;
40
40
  eventBus?: DocumentEventBus | null;
41
41
  callbacks?: CollectionWatchCallbacks;
42
+ syncOptions?: SyncOptions;
42
43
  watchFactory?: typeof watch;
43
44
  }
44
45
 
@@ -48,6 +49,7 @@ export class CollectionWatchService {
48
49
  readonly #scheduler: EmbedScheduler | null;
49
50
  readonly #eventBus: DocumentEventBus | null;
50
51
  readonly #callbacks: CollectionWatchCallbacks | null;
52
+ #syncOptions: SyncOptions;
51
53
  readonly #watchers = new Map<string, FSWatcher>();
52
54
  readonly #pendingByCollection = new Map<string, Set<string>>();
53
55
  readonly #timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -64,6 +66,7 @@ export class CollectionWatchService {
64
66
  this.#scheduler = options.scheduler;
65
67
  this.#eventBus = options.eventBus ?? null;
66
68
  this.#callbacks = options.callbacks ?? null;
69
+ this.#syncOptions = options.syncOptions ?? {};
67
70
  this.#watchFactory = options.watchFactory ?? watch;
68
71
  }
69
72
 
@@ -71,8 +74,14 @@ export class CollectionWatchService {
71
74
  this.updateCollections(this.#collections);
72
75
  }
73
76
 
74
- updateCollections(collections: Collection[]): void {
77
+ updateCollections(
78
+ collections: Collection[],
79
+ syncOptions?: SyncOptions
80
+ ): void {
75
81
  this.#collections = collections;
82
+ if (syncOptions) {
83
+ this.#syncOptions = syncOptions;
84
+ }
76
85
  const nextNames = new Set(collections.map((collection) => collection.name));
77
86
 
78
87
  for (const [collectionName, watcher] of this.#watchers) {
@@ -204,6 +213,7 @@ export class CollectionWatchService {
204
213
  collection,
205
214
  this.#store,
206
215
  {
216
+ ...this.#syncOptions,
207
217
  runUpdateCmd: false,
208
218
  }
209
219
  );
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Migration: content type rule fingerprint.
3
+ *
4
+ * @module src/store/migrations/009-content-type-rule-fingerprint
5
+ */
6
+
7
+ import type { Migration } from "./runner";
8
+
9
+ export const migration: Migration = {
10
+ version: 9,
11
+ name: "content_type_rule_fingerprint",
12
+
13
+ up(db): void {
14
+ const columns = new Set(
15
+ db
16
+ .query<{ name: string }, []>("PRAGMA table_info(documents)")
17
+ .all()
18
+ .map((row) => row.name)
19
+ );
20
+ if (!columns.has("content_type_rules_fingerprint")) {
21
+ db.exec(
22
+ "ALTER TABLE documents ADD COLUMN content_type_rules_fingerprint TEXT"
23
+ );
24
+ }
25
+ db.exec(
26
+ "CREATE INDEX IF NOT EXISTS idx_documents_content_type_rules_fingerprint ON documents(content_type_rules_fingerprint)"
27
+ );
28
+ },
29
+
30
+ down(db): void {
31
+ db.exec(
32
+ "DROP INDEX IF EXISTS idx_documents_content_type_rules_fingerprint"
33
+ );
34
+ // SQLite cannot drop columns without a table rebuild; keep column on rollback.
35
+ },
36
+ };