@gmickel/gno 1.7.1 → 1.9.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 +10 -1
- package/assets/skill/SKILL.md +41 -0
- package/assets/skill/cli-reference.md +34 -0
- package/assets/skill/examples.md +37 -0
- package/assets/skill/mcp-reference.md +21 -0
- package/package.json +1 -1
- package/src/cli/commands/capture.ts +282 -0
- package/src/cli/commands/index-cmd.ts +17 -6
- package/src/cli/commands/shared.ts +17 -1
- package/src/cli/commands/update.ts +17 -6
- package/src/cli/options.ts +2 -0
- package/src/cli/program.ts +64 -0
- package/src/config/content-types.ts +140 -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/capture-write.ts +38 -0
- package/src/core/capture.ts +746 -0
- package/src/core/config-mutation.ts +14 -2
- package/src/core/file-ops.ts +21 -2
- package/src/core/note-presets.ts +61 -5
- package/src/ingestion/frontmatter.ts +77 -2
- package/src/ingestion/index.ts +2 -0
- package/src/ingestion/sync-options.ts +29 -0
- package/src/ingestion/sync.ts +104 -16
- package/src/ingestion/types.ts +14 -0
- package/src/mcp/tools/add-collection.ts +8 -5
- package/src/mcp/tools/capture.ts +137 -191
- package/src/mcp/tools/index-cmd.ts +13 -7
- package/src/mcp/tools/index.ts +43 -9
- package/src/mcp/tools/sync.ts +12 -6
- package/src/mcp/tools/workspace-write.ts +16 -10
- package/src/pipeline/hybrid.ts +2 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +2 -0
- package/src/pipeline/vsearch.ts +4 -0
- package/src/sdk/client.ts +156 -20
- package/src/sdk/index.ts +2 -0
- package/src/sdk/types.ts +6 -0
- package/src/serve/background-runtime.ts +18 -4
- package/src/serve/config-sync.ts +9 -2
- package/src/serve/public/components/CaptureModal.tsx +248 -10
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/routes/api.ts +238 -26
- package/src/serve/server.ts +12 -0
- package/src/serve/watch-service.ts +12 -2
- package/src/store/migrations/009-content-type-rule-fingerprint.ts +36 -0
- package/src/store/migrations/index.ts +12 -1
- package/src/store/sqlite/adapter.ts +29 -14
- package/src/store/types.ts +6 -0
package/src/serve/routes/api.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* @module src/serve/routes/api
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
// node:fs/promises structure
|
|
9
|
-
import { readdir } from "node:fs/promises";
|
|
8
|
+
// node:fs/promises structure ops have no Bun equivalent
|
|
9
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
10
10
|
// node:path has no Bun equivalent
|
|
11
|
-
import { posix as pathPosix } from "node:path";
|
|
11
|
+
import { dirname, join as pathJoin, posix as pathPosix } from "node:path";
|
|
12
12
|
|
|
13
13
|
import type {
|
|
14
14
|
Collection,
|
|
@@ -35,6 +35,14 @@ import {
|
|
|
35
35
|
removeCollection,
|
|
36
36
|
updateCollection,
|
|
37
37
|
} from "../../collection";
|
|
38
|
+
import {
|
|
39
|
+
buildCaptureReceipt,
|
|
40
|
+
listCaptureDiskRelPaths,
|
|
41
|
+
planCapture,
|
|
42
|
+
type CapturePlan,
|
|
43
|
+
type PublicCaptureInput,
|
|
44
|
+
} from "../../core/capture";
|
|
45
|
+
import { writeCapturePlanFile } from "../../core/capture-write";
|
|
38
46
|
import {
|
|
39
47
|
buildEditableCopyContent,
|
|
40
48
|
deriveEditableCopyRelPath,
|
|
@@ -74,7 +82,11 @@ import {
|
|
|
74
82
|
validateTag,
|
|
75
83
|
} from "../../core/tags";
|
|
76
84
|
import { validateRelPath } from "../../core/validation";
|
|
77
|
-
import {
|
|
85
|
+
import {
|
|
86
|
+
defaultSyncService,
|
|
87
|
+
type SyncResult,
|
|
88
|
+
withContentTypeRules,
|
|
89
|
+
} from "../../ingestion";
|
|
78
90
|
import { updateFrontmatterTags } from "../../ingestion/frontmatter";
|
|
79
91
|
import {
|
|
80
92
|
getCollectionEffectiveModels,
|
|
@@ -292,6 +304,8 @@ export interface CreateDocRequestBody {
|
|
|
292
304
|
tags?: string[];
|
|
293
305
|
}
|
|
294
306
|
|
|
307
|
+
export interface CreateCaptureRequestBody extends PublicCaptureInput {}
|
|
308
|
+
|
|
295
309
|
export interface RenameDocRequestBody {
|
|
296
310
|
name: string;
|
|
297
311
|
uri?: string;
|
|
@@ -909,10 +923,17 @@ export async function handleCreateCollection(
|
|
|
909
923
|
return errorResponse("RUNTIME", "Collection not found after add", 500);
|
|
910
924
|
}
|
|
911
925
|
const jobResult = startJob("add", async (): Promise<SyncResult> => {
|
|
912
|
-
const result = await defaultSyncService.syncCollection(
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
926
|
+
const result = await defaultSyncService.syncCollection(
|
|
927
|
+
collection,
|
|
928
|
+
store,
|
|
929
|
+
withContentTypeRules(
|
|
930
|
+
{
|
|
931
|
+
gitPull: body.gitPull,
|
|
932
|
+
runUpdateCmd: true,
|
|
933
|
+
},
|
|
934
|
+
syncResult.config
|
|
935
|
+
)
|
|
936
|
+
);
|
|
916
937
|
if (result.filesAdded > 0 || result.filesUpdated > 0) {
|
|
917
938
|
if (ctxHolder.scheduler) {
|
|
918
939
|
await ctxHolder.scheduler.triggerNow();
|
|
@@ -1204,10 +1225,17 @@ export async function handleSync(
|
|
|
1204
1225
|
|
|
1205
1226
|
// Start background sync job
|
|
1206
1227
|
const jobResult = startJob("sync", async (): Promise<SyncResult> => {
|
|
1207
|
-
const result = await defaultSyncService.syncAll(
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1228
|
+
const result = await defaultSyncService.syncAll(
|
|
1229
|
+
collections,
|
|
1230
|
+
store,
|
|
1231
|
+
withContentTypeRules(
|
|
1232
|
+
{
|
|
1233
|
+
gitPull: body.gitPull,
|
|
1234
|
+
runUpdateCmd: true,
|
|
1235
|
+
},
|
|
1236
|
+
ctxHolder.config
|
|
1237
|
+
)
|
|
1238
|
+
);
|
|
1211
1239
|
if (result.totalFilesAdded > 0 || result.totalFilesUpdated > 0) {
|
|
1212
1240
|
if (ctxHolder.scheduler) {
|
|
1213
1241
|
await ctxHolder.scheduler.triggerNow();
|
|
@@ -1833,9 +1861,11 @@ export async function handleRenameDoc(
|
|
|
1833
1861
|
const nextUri = `gno://${collection.name}/${nextRelPath}`;
|
|
1834
1862
|
let warning: string | undefined;
|
|
1835
1863
|
try {
|
|
1836
|
-
await syncCollection(
|
|
1837
|
-
|
|
1838
|
-
|
|
1864
|
+
await syncCollection(
|
|
1865
|
+
collection,
|
|
1866
|
+
store,
|
|
1867
|
+
withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
|
|
1868
|
+
);
|
|
1839
1869
|
} catch {
|
|
1840
1870
|
warning =
|
|
1841
1871
|
"File renamed on disk, but index refresh failed. Run Update All to reconcile the workspace.";
|
|
@@ -2053,9 +2083,11 @@ export async function handleTrashDoc(
|
|
|
2053
2083
|
}
|
|
2054
2084
|
let warning: string | undefined;
|
|
2055
2085
|
try {
|
|
2056
|
-
await syncCollection(
|
|
2057
|
-
|
|
2058
|
-
|
|
2086
|
+
await syncCollection(
|
|
2087
|
+
collection,
|
|
2088
|
+
store,
|
|
2089
|
+
withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
|
|
2090
|
+
);
|
|
2059
2091
|
} catch {
|
|
2060
2092
|
warning =
|
|
2061
2093
|
"File moved to Trash, but index refresh failed. Run Update All to reconcile the workspace.";
|
|
@@ -2174,9 +2206,11 @@ export async function handleMoveDoc(
|
|
|
2174
2206
|
await renameFilePath(fullPath, nextFullPath);
|
|
2175
2207
|
let warning: string | undefined;
|
|
2176
2208
|
try {
|
|
2177
|
-
await defaultSyncService.syncCollection(
|
|
2178
|
-
|
|
2179
|
-
|
|
2209
|
+
await defaultSyncService.syncCollection(
|
|
2210
|
+
collection,
|
|
2211
|
+
store,
|
|
2212
|
+
withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
|
|
2213
|
+
);
|
|
2180
2214
|
} catch {
|
|
2181
2215
|
warning =
|
|
2182
2216
|
"File moved on disk, but index refresh failed. Run Update All to reconcile the workspace.";
|
|
@@ -2295,9 +2329,11 @@ export async function handleDuplicateDoc(
|
|
|
2295
2329
|
await copyFilePath(fullPath, nextFullPath);
|
|
2296
2330
|
let warning: string | undefined;
|
|
2297
2331
|
try {
|
|
2298
|
-
await defaultSyncService.syncCollection(
|
|
2299
|
-
|
|
2300
|
-
|
|
2332
|
+
await defaultSyncService.syncCollection(
|
|
2333
|
+
collection,
|
|
2334
|
+
store,
|
|
2335
|
+
withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
|
|
2336
|
+
);
|
|
2301
2337
|
} catch {
|
|
2302
2338
|
warning =
|
|
2303
2339
|
"File duplicated on disk, but index refresh failed. Run Update All to reconcile the workspace.";
|
|
@@ -2626,7 +2662,7 @@ export async function handleUpdateDoc(
|
|
|
2626
2662
|
const result = await defaultSyncService.syncCollection(
|
|
2627
2663
|
collection,
|
|
2628
2664
|
store,
|
|
2629
|
-
{ runUpdateCmd: false }
|
|
2665
|
+
withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
|
|
2630
2666
|
);
|
|
2631
2667
|
// Notify scheduler after sync completes
|
|
2632
2668
|
ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
|
|
@@ -2795,6 +2831,157 @@ export async function handleCreateEditableCopy(
|
|
|
2795
2831
|
return handleCreateDoc(ctxHolder, store, createReq);
|
|
2796
2832
|
}
|
|
2797
2833
|
|
|
2834
|
+
/**
|
|
2835
|
+
* POST /api/capture
|
|
2836
|
+
* Capture a note with structured provenance.
|
|
2837
|
+
*/
|
|
2838
|
+
export async function handleCreateCapture(
|
|
2839
|
+
ctxHolder: ContextHolder,
|
|
2840
|
+
store: SqliteAdapter,
|
|
2841
|
+
req: Request
|
|
2842
|
+
): Promise<Response> {
|
|
2843
|
+
let body: CreateCaptureRequestBody;
|
|
2844
|
+
try {
|
|
2845
|
+
body = (await req.json()) as CreateCaptureRequestBody;
|
|
2846
|
+
} catch {
|
|
2847
|
+
return errorResponse("VALIDATION", "Invalid JSON body");
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
if (!body.collection || typeof body.collection !== "string") {
|
|
2851
|
+
return errorResponse("VALIDATION", "Missing or invalid collection");
|
|
2852
|
+
}
|
|
2853
|
+
if (body.content !== undefined && typeof body.content !== "string") {
|
|
2854
|
+
return errorResponse("VALIDATION", "content must be a string");
|
|
2855
|
+
}
|
|
2856
|
+
if (body.title !== undefined && typeof body.title !== "string") {
|
|
2857
|
+
return errorResponse("VALIDATION", "title must be a string");
|
|
2858
|
+
}
|
|
2859
|
+
if (body.relPath !== undefined && typeof body.relPath !== "string") {
|
|
2860
|
+
return errorResponse("VALIDATION", "relPath must be a string");
|
|
2861
|
+
}
|
|
2862
|
+
if (body.folderPath !== undefined && typeof body.folderPath !== "string") {
|
|
2863
|
+
return errorResponse("VALIDATION", "folderPath must be a string");
|
|
2864
|
+
}
|
|
2865
|
+
if ("overwrite" in body) {
|
|
2866
|
+
return errorResponse(
|
|
2867
|
+
"VALIDATION",
|
|
2868
|
+
"overwrite is not supported by /api/capture; use collisionPolicy instead"
|
|
2869
|
+
);
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
const collectionName = body.collection.toLowerCase();
|
|
2873
|
+
const collection = ctxHolder.config.collections.find(
|
|
2874
|
+
(candidate) => candidate.name.toLowerCase() === collectionName
|
|
2875
|
+
);
|
|
2876
|
+
if (!collection) {
|
|
2877
|
+
return errorResponse(
|
|
2878
|
+
"NOT_FOUND",
|
|
2879
|
+
`Collection not found: ${body.collection}`,
|
|
2880
|
+
404
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
let plan: CapturePlan;
|
|
2885
|
+
try {
|
|
2886
|
+
plan = planCapture({
|
|
2887
|
+
input: {
|
|
2888
|
+
...body,
|
|
2889
|
+
collection: collection.name,
|
|
2890
|
+
},
|
|
2891
|
+
existingRelPaths: await listCollectionRelPaths(store, collection.name),
|
|
2892
|
+
diskRelPaths: await listCaptureDiskRelPaths(collection.path),
|
|
2893
|
+
});
|
|
2894
|
+
} catch (error) {
|
|
2895
|
+
return errorResponse(
|
|
2896
|
+
"VALIDATION",
|
|
2897
|
+
error instanceof Error ? error.message : String(error),
|
|
2898
|
+
409
|
|
2899
|
+
);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
const fullPath = pathJoin(collection.path, plan.relPath);
|
|
2903
|
+
if (plan.openedExisting) {
|
|
2904
|
+
const existingDoc = await store.getDocument(collection.name, plan.relPath);
|
|
2905
|
+
if (!existingDoc.ok) {
|
|
2906
|
+
return errorResponse("RUNTIME", existingDoc.error.message, 500);
|
|
2907
|
+
}
|
|
2908
|
+
return jsonResponse(
|
|
2909
|
+
buildCaptureReceipt({
|
|
2910
|
+
plan,
|
|
2911
|
+
absPath: fullPath,
|
|
2912
|
+
docid: existingDoc.value?.docid,
|
|
2913
|
+
sync: existingDoc.value
|
|
2914
|
+
? { status: "completed" }
|
|
2915
|
+
: {
|
|
2916
|
+
status: "skipped",
|
|
2917
|
+
reason: "Existing file is not indexed yet.",
|
|
2918
|
+
},
|
|
2919
|
+
})
|
|
2920
|
+
);
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
try {
|
|
2924
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
2925
|
+
ctxHolder.watchService?.suppress(fullPath);
|
|
2926
|
+
await writeCapturePlanFile(plan, fullPath);
|
|
2927
|
+
|
|
2928
|
+
const gnoUri = `gno://${collection.name}/${plan.relPath}`;
|
|
2929
|
+
const jobResult = startJob("sync", async (): Promise<SyncResult> => {
|
|
2930
|
+
const result = await defaultSyncService.syncCollection(
|
|
2931
|
+
collection,
|
|
2932
|
+
store,
|
|
2933
|
+
withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
|
|
2934
|
+
);
|
|
2935
|
+
ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
|
|
2936
|
+
ctxHolder.eventBus?.emit({
|
|
2937
|
+
type: "document-changed",
|
|
2938
|
+
uri: gnoUri,
|
|
2939
|
+
collection: collection.name,
|
|
2940
|
+
relPath: plan.relPath,
|
|
2941
|
+
origin: "create",
|
|
2942
|
+
changedAt: new Date().toISOString(),
|
|
2943
|
+
});
|
|
2944
|
+
return {
|
|
2945
|
+
collections: [result],
|
|
2946
|
+
totalDurationMs: result.durationMs,
|
|
2947
|
+
totalFilesProcessed: result.filesProcessed,
|
|
2948
|
+
totalFilesAdded: result.filesAdded,
|
|
2949
|
+
totalFilesUpdated: result.filesUpdated,
|
|
2950
|
+
totalFilesErrored: result.filesErrored,
|
|
2951
|
+
totalFilesSkipped: result.filesSkipped,
|
|
2952
|
+
};
|
|
2953
|
+
});
|
|
2954
|
+
|
|
2955
|
+
return jsonResponse(
|
|
2956
|
+
buildCaptureReceipt({
|
|
2957
|
+
plan,
|
|
2958
|
+
absPath: fullPath,
|
|
2959
|
+
sync: jobResult.ok
|
|
2960
|
+
? {
|
|
2961
|
+
status: "pending",
|
|
2962
|
+
jobId: jobResult.jobId,
|
|
2963
|
+
reason: "Sync job started; poll /api/jobs/:id for status.",
|
|
2964
|
+
}
|
|
2965
|
+
: {
|
|
2966
|
+
status: "skipped",
|
|
2967
|
+
jobId: jobResult.activeJobId,
|
|
2968
|
+
reason: "Sync skipped because another job is running.",
|
|
2969
|
+
error: jobResult.error,
|
|
2970
|
+
},
|
|
2971
|
+
}),
|
|
2972
|
+
202
|
|
2973
|
+
);
|
|
2974
|
+
} catch (error) {
|
|
2975
|
+
return errorResponse(
|
|
2976
|
+
"RUNTIME",
|
|
2977
|
+
`Failed to capture document: ${
|
|
2978
|
+
error instanceof Error ? error.message : String(error)
|
|
2979
|
+
}`,
|
|
2980
|
+
500
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2798
2985
|
/**
|
|
2799
2986
|
* POST /api/docs
|
|
2800
2987
|
* Create a new document in a collection.
|
|
@@ -2979,7 +3166,7 @@ export async function handleCreateDoc(
|
|
|
2979
3166
|
const result = await defaultSyncService.syncCollection(
|
|
2980
3167
|
collection,
|
|
2981
3168
|
store,
|
|
2982
|
-
{ runUpdateCmd: false }
|
|
3169
|
+
withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
|
|
2983
3170
|
);
|
|
2984
3171
|
// Notify scheduler after sync completes (use gnoUri as docid placeholder)
|
|
2985
3172
|
// The sync will create a proper docid, but we don't have it here yet
|
|
@@ -3989,6 +4176,31 @@ export async function routeApi(
|
|
|
3989
4176
|
return handlePublishExport(config, store, req);
|
|
3990
4177
|
}
|
|
3991
4178
|
|
|
4179
|
+
if (path === "/api/capture" && req.method === "POST") {
|
|
4180
|
+
const ctxHolder: ContextHolder = {
|
|
4181
|
+
current: {
|
|
4182
|
+
config,
|
|
4183
|
+
store,
|
|
4184
|
+
vectorIndex: null,
|
|
4185
|
+
embedPort: null,
|
|
4186
|
+
expandPort: null,
|
|
4187
|
+
answerPort: null,
|
|
4188
|
+
rerankPort: null,
|
|
4189
|
+
capabilities: {
|
|
4190
|
+
bm25: true,
|
|
4191
|
+
vector: false,
|
|
4192
|
+
hybrid: false,
|
|
4193
|
+
answer: false,
|
|
4194
|
+
},
|
|
4195
|
+
},
|
|
4196
|
+
config,
|
|
4197
|
+
scheduler: null,
|
|
4198
|
+
eventBus: null,
|
|
4199
|
+
watchService: null,
|
|
4200
|
+
};
|
|
4201
|
+
return handleCreateCapture(ctxHolder, store, req);
|
|
4202
|
+
}
|
|
4203
|
+
|
|
3992
4204
|
if (path === "/api/docs") {
|
|
3993
4205
|
return handleDocs(store, url);
|
|
3994
4206
|
}
|
package/src/serve/server.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
handleCreateFolder,
|
|
24
24
|
handleCreateCollection,
|
|
25
25
|
handleCreateEditableCopy,
|
|
26
|
+
handleCreateCapture,
|
|
26
27
|
handleCreateDoc,
|
|
27
28
|
handleDeactivateDoc,
|
|
28
29
|
handleDeleteCollection,
|
|
@@ -256,6 +257,17 @@ export async function startServer(
|
|
|
256
257
|
);
|
|
257
258
|
},
|
|
258
259
|
},
|
|
260
|
+
"/api/capture": {
|
|
261
|
+
POST: async (req: Request) => {
|
|
262
|
+
if (!isRequestAllowed(req, port)) {
|
|
263
|
+
return withSecurityHeaders(forbiddenResponse(), isDev);
|
|
264
|
+
}
|
|
265
|
+
return withSecurityHeaders(
|
|
266
|
+
await handleCreateCapture(ctxHolder, store, req),
|
|
267
|
+
isDev
|
|
268
|
+
);
|
|
269
|
+
},
|
|
270
|
+
},
|
|
259
271
|
"/api/docs": {
|
|
260
272
|
GET: async (req: Request) => {
|
|
261
273
|
const url = new URL(req.url);
|
|
@@ -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
|
+
};
|
|
@@ -22,6 +22,17 @@ import { migration as m005 } from "./005-graph-indexes";
|
|
|
22
22
|
import { migration as m006 } from "./006-document-metadata";
|
|
23
23
|
import { migration as m007 } from "./007-document-date-fields";
|
|
24
24
|
import { migration as m008 } from "./008-vector-fingerprints";
|
|
25
|
+
import { migration as m009 } from "./009-content-type-rule-fingerprint";
|
|
25
26
|
|
|
26
27
|
/** All migrations in order */
|
|
27
|
-
export const migrations = [
|
|
28
|
+
export const migrations = [
|
|
29
|
+
m001,
|
|
30
|
+
m002,
|
|
31
|
+
m003,
|
|
32
|
+
m004,
|
|
33
|
+
m005,
|
|
34
|
+
m006,
|
|
35
|
+
m007,
|
|
36
|
+
m008,
|
|
37
|
+
m009,
|
|
38
|
+
];
|
|
@@ -554,9 +554,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
554
554
|
collection, rel_path, source_hash, source_mime, source_ext,
|
|
555
555
|
source_size, source_mtime, source_ctime, docid, uri, title, mirror_hash,
|
|
556
556
|
converter_id, converter_version, language_hint, content_type, categories,
|
|
557
|
-
author, frontmatter_date, date_fields,
|
|
558
|
-
|
|
559
|
-
|
|
557
|
+
author, frontmatter_date, date_fields, content_type_rules_fingerprint,
|
|
558
|
+
active, indexed_at, last_error_code, last_error_message, last_error_at,
|
|
559
|
+
ingest_version, updated_at
|
|
560
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'), ?, ?, ?, ?, datetime('now'))
|
|
560
561
|
ON CONFLICT(collection, rel_path) DO UPDATE SET
|
|
561
562
|
source_hash = excluded.source_hash,
|
|
562
563
|
source_mime = excluded.source_mime,
|
|
@@ -576,6 +577,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
576
577
|
author = excluded.author,
|
|
577
578
|
frontmatter_date = excluded.frontmatter_date,
|
|
578
579
|
date_fields = excluded.date_fields,
|
|
580
|
+
content_type_rules_fingerprint = excluded.content_type_rules_fingerprint,
|
|
579
581
|
active = 1,
|
|
580
582
|
indexed_at = datetime('now'),
|
|
581
583
|
last_error_code = excluded.last_error_code,
|
|
@@ -605,6 +607,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
605
607
|
doc.author ?? null,
|
|
606
608
|
doc.frontmatterDate ?? null,
|
|
607
609
|
doc.dateFields ? JSON.stringify(doc.dateFields) : null,
|
|
610
|
+
doc.contentTypeRulesFingerprint ?? null,
|
|
608
611
|
doc.lastErrorCode ?? null,
|
|
609
612
|
doc.lastErrorMessage ?? null,
|
|
610
613
|
doc.lastErrorCode ? new Date().toISOString() : null,
|
|
@@ -1366,7 +1369,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1366
1369
|
d.source_mtime,
|
|
1367
1370
|
d.frontmatter_date,
|
|
1368
1371
|
d.source_size,
|
|
1369
|
-
d.source_hash
|
|
1372
|
+
d.source_hash,
|
|
1373
|
+
d.content_type,
|
|
1374
|
+
d.categories
|
|
1370
1375
|
FROM fts_matches fm
|
|
1371
1376
|
JOIN documents d ON d.id = fm.rowid AND d.active = 1
|
|
1372
1377
|
WHERE 1 = 1
|
|
@@ -1392,6 +1397,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1392
1397
|
frontmatter_date: string | null;
|
|
1393
1398
|
source_size: number | null;
|
|
1394
1399
|
source_hash: string | null;
|
|
1400
|
+
content_type: string | null;
|
|
1401
|
+
categories: string | null;
|
|
1395
1402
|
}
|
|
1396
1403
|
|
|
1397
1404
|
const queryParams = [builtQuery.query, ftsLimit, ...params];
|
|
@@ -1416,6 +1423,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
1416
1423
|
frontmatterDate: r.frontmatter_date ?? undefined,
|
|
1417
1424
|
sourceSize: r.source_size ?? undefined,
|
|
1418
1425
|
sourceHash: r.source_hash ?? undefined,
|
|
1426
|
+
contentType: r.content_type ?? undefined,
|
|
1427
|
+
categories: parseCategoriesJson(r.categories) ?? undefined,
|
|
1419
1428
|
}))
|
|
1420
1429
|
);
|
|
1421
1430
|
} catch (cause) {
|
|
@@ -3522,6 +3531,7 @@ interface DbDocumentRow {
|
|
|
3522
3531
|
author: string | null;
|
|
3523
3532
|
frontmatter_date: string | null;
|
|
3524
3533
|
date_fields: string | null;
|
|
3534
|
+
content_type_rules_fingerprint: string | null;
|
|
3525
3535
|
indexed_at: string | null;
|
|
3526
3536
|
active: number;
|
|
3527
3537
|
ingest_version: number | null;
|
|
@@ -3580,19 +3590,23 @@ function mapContextRow(row: DbContextRow): ContextRow {
|
|
|
3580
3590
|
};
|
|
3581
3591
|
}
|
|
3582
3592
|
|
|
3583
|
-
function
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
} catch {
|
|
3592
|
-
categories = null;
|
|
3593
|
+
function parseCategoriesJson(raw: string | null): string[] | null {
|
|
3594
|
+
if (!raw) {
|
|
3595
|
+
return null;
|
|
3596
|
+
}
|
|
3597
|
+
try {
|
|
3598
|
+
const parsed = JSON.parse(raw);
|
|
3599
|
+
if (Array.isArray(parsed)) {
|
|
3600
|
+
return parsed.filter((v): v is string => typeof v === "string");
|
|
3593
3601
|
}
|
|
3602
|
+
} catch {
|
|
3603
|
+
return null;
|
|
3594
3604
|
}
|
|
3605
|
+
return null;
|
|
3606
|
+
}
|
|
3595
3607
|
|
|
3608
|
+
function mapDocumentRow(row: DbDocumentRow): DocumentRow {
|
|
3609
|
+
const categories = parseCategoriesJson(row.categories);
|
|
3596
3610
|
let dateFields: Record<string, string> | null = null;
|
|
3597
3611
|
if (row.date_fields) {
|
|
3598
3612
|
try {
|
|
@@ -3638,6 +3652,7 @@ function mapDocumentRow(row: DbDocumentRow): DocumentRow {
|
|
|
3638
3652
|
indexedAt: row.indexed_at,
|
|
3639
3653
|
active: row.active === 1,
|
|
3640
3654
|
ingestVersion: row.ingest_version,
|
|
3655
|
+
contentTypeRulesFingerprint: row.content_type_rules_fingerprint,
|
|
3641
3656
|
lastErrorCode: row.last_error_code,
|
|
3642
3657
|
lastErrorMessage: row.last_error_message,
|
|
3643
3658
|
lastErrorAt: row.last_error_at,
|
package/src/store/types.ts
CHANGED
|
@@ -118,6 +118,8 @@ export interface DocumentRow {
|
|
|
118
118
|
active: boolean;
|
|
119
119
|
/** Ingest schema version for backfill detection */
|
|
120
120
|
ingestVersion: number | null;
|
|
121
|
+
/** Fingerprint of normalized content type rules used for derived metadata */
|
|
122
|
+
contentTypeRulesFingerprint?: string | null;
|
|
121
123
|
|
|
122
124
|
// Error tracking
|
|
123
125
|
lastErrorCode: string | null;
|
|
@@ -264,6 +266,8 @@ export interface DocumentInput {
|
|
|
264
266
|
lastErrorMessage?: string;
|
|
265
267
|
/** Ingest schema version for backfill detection */
|
|
266
268
|
ingestVersion?: number;
|
|
269
|
+
/** Fingerprint of normalized content type rules used for derived metadata */
|
|
270
|
+
contentTypeRulesFingerprint?: string;
|
|
267
271
|
}
|
|
268
272
|
|
|
269
273
|
/** Result of upserting a document */
|
|
@@ -345,6 +349,8 @@ export interface FtsResult {
|
|
|
345
349
|
frontmatterDate?: string;
|
|
346
350
|
sourceSize?: number;
|
|
347
351
|
sourceHash?: string;
|
|
352
|
+
contentType?: string;
|
|
353
|
+
categories?: string[];
|
|
348
354
|
}
|
|
349
355
|
|
|
350
356
|
// ─────────────────────────────────────────────────────────────────────────────
|