@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.
- package/README.md +6 -2
- package/assets/skill/SKILL.md +41 -16
- package/assets/skill/cli-reference.md +39 -0
- package/assets/skill/examples.md +41 -0
- package/assets/skill/mcp-reference.md +13 -1
- package/package.json +1 -1
- package/src/cli/commands/capture.ts +9 -6
- package/src/cli/commands/graph.ts +89 -2
- package/src/cli/commands/index-cmd.ts +17 -6
- package/src/cli/commands/links.ts +237 -54
- package/src/cli/commands/query.ts +212 -0
- package/src/cli/commands/ref-parser.ts +8 -103
- package/src/cli/commands/shared.ts +17 -1
- package/src/cli/commands/update.ts +17 -6
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +176 -9
- package/src/config/content-types.ts +154 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +14 -0
- package/src/config/loader.ts +11 -2
- package/src/config/types.ts +37 -1
- package/src/core/config-mutation.ts +14 -2
- package/src/core/graph-query.ts +137 -0
- package/src/core/graph-resolver.ts +117 -0
- package/src/core/note-presets.ts +61 -5
- package/src/core/ref-parser.ts +145 -0
- package/src/ingestion/frontmatter.ts +170 -2
- package/src/ingestion/index.ts +2 -0
- package/src/ingestion/sync-options.ts +29 -0
- package/src/ingestion/sync.ts +385 -17
- package/src/ingestion/types.ts +14 -0
- package/src/mcp/tools/add-collection.ts +8 -5
- package/src/mcp/tools/capture.ts +5 -2
- package/src/mcp/tools/get.ts +1 -1
- package/src/mcp/tools/index-cmd.ts +13 -7
- package/src/mcp/tools/index.ts +97 -10
- package/src/mcp/tools/links.ts +83 -1
- package/src/mcp/tools/multi-get.ts +1 -1
- package/src/mcp/tools/query.ts +207 -0
- package/src/mcp/tools/sync.ts +12 -6
- package/src/mcp/tools/workspace-write.ts +16 -10
- package/src/pipeline/diagnose.ts +302 -0
- package/src/pipeline/filters.ts +119 -0
- package/src/pipeline/hybrid.ts +92 -17
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +34 -0
- package/src/pipeline/vsearch.ts +4 -0
- package/src/publish/export-service.ts +1 -1
- package/src/sdk/client.ts +51 -24
- package/src/sdk/documents.ts +2 -2
- package/src/serve/background-runtime.ts +18 -4
- package/src/serve/config-sync.ts +9 -2
- package/src/serve/routes/api.ts +244 -24
- package/src/serve/routes/graph.ts +173 -1
- package/src/serve/server.ts +24 -1
- package/src/serve/watch-service.ts +12 -2
- package/src/store/migrations/009-content-type-rule-fingerprint.ts +36 -0
- package/src/store/migrations/010-typed-edges.ts +67 -0
- package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
- package/src/store/migrations/index.ts +16 -1
- package/src/store/sqlite/adapter.ts +853 -114
- package/src/store/types.ts +147 -0
package/src/serve/routes/api.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
923
|
-
|
|
924
|
-
|
|
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(
|
|
1218
|
-
|
|
1219
|
-
|
|
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(
|
|
1847
|
-
|
|
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(
|
|
2067
|
-
|
|
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(
|
|
2188
|
-
|
|
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(
|
|
2309
|
-
|
|
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 {
|
|
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
|
+
}
|
package/src/serve/server.ts
CHANGED
|
@@ -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(
|
|
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
|
+
};
|