@gmickel/gno 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +19 -4
  2. package/assets/skill/SKILL.md +46 -15
  3. package/package.json +1 -1
  4. package/spec/cli.md +100 -0
  5. package/spec/db/schema.sql +170 -0
  6. package/spec/mcp.md +22 -0
  7. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  8. package/spec/output-schemas/changes.schema.json +280 -0
  9. package/spec/output-schemas/document-diff.schema.json +185 -0
  10. package/spec/output-schemas/impact.schema.json +122 -0
  11. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  12. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  13. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  14. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  15. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  16. package/src/cli/commands/changes.ts +160 -0
  17. package/src/cli/commands/context-saved.ts +189 -0
  18. package/src/cli/options.ts +8 -0
  19. package/src/cli/program.ts +195 -0
  20. package/src/core/capsule-registry.ts +279 -0
  21. package/src/core/capsule-reverification-scheduler.ts +218 -0
  22. package/src/core/capsule-reverification.ts +289 -0
  23. package/src/core/change-diff.ts +182 -0
  24. package/src/core/change-journal.ts +228 -0
  25. package/src/core/knowledge-delta.ts +395 -0
  26. package/src/core/knowledge-impact.ts +202 -0
  27. package/src/ingestion/sync.ts +214 -165
  28. package/src/mcp/tools/changes.ts +80 -0
  29. package/src/mcp/tools/index.ts +29 -0
  30. package/src/sdk/client.ts +42 -0
  31. package/src/sdk/index.ts +7 -0
  32. package/src/sdk/types.ts +22 -0
  33. package/src/serve/doc-events.ts +12 -1
  34. package/src/serve/resident-runtime.ts +22 -0
  35. package/src/serve/routes/api.ts +13 -0
  36. package/src/serve/routes/changes.ts +102 -0
  37. package/src/serve/server.ts +34 -0
  38. package/src/serve/watch-service.ts +9 -0
  39. package/src/store/index.ts +21 -0
  40. package/src/store/migrations/015-document-change-journal.ts +85 -0
  41. package/src/store/migrations/016-saved-capsules.ts +131 -0
  42. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  43. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  44. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  45. package/src/store/migrations/index.ts +10 -0
  46. package/src/store/sqlite/adapter.ts +291 -7
  47. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  48. package/src/store/sqlite/change-journal-store.ts +473 -0
  49. package/src/store/types.ts +262 -0
@@ -0,0 +1,80 @@
1
+ /** Read-only MCP adapters for knowledge change, diff, and impact services. */
2
+
3
+ import { z } from "zod";
4
+
5
+ import type { ToolContext } from "../server";
6
+
7
+ import {
8
+ analyzeKnowledgeImpact,
9
+ getKnowledgeDiff,
10
+ listKnowledgeChanges,
11
+ } from "../../core/knowledge-delta";
12
+ import { runTool, type ToolResult } from "./index";
13
+
14
+ export const changesInputSchema = z.object({
15
+ since: z.string().trim().min(1).max(512).optional(),
16
+ collection: z.string().trim().min(1).max(256).optional(),
17
+ limit: z.number().int().min(1).max(1000).default(100),
18
+ });
19
+
20
+ export const diffInputSchema = z.object({
21
+ ref: z.string().trim().min(1).max(4096),
22
+ change: z.string().trim().min(1).max(512).optional(),
23
+ });
24
+
25
+ export const impactInputSchema = z.object({
26
+ ref: z.string().trim().min(1).max(4096),
27
+ maxDepth: z.number().int().min(1).max(6).default(3),
28
+ maxNodes: z.number().int().min(1).max(1000).default(100),
29
+ maxEdges: z.number().int().min(1).max(5000).default(250),
30
+ frontierLimit: z.number().int().min(1).max(1000).default(100),
31
+ visitedLimit: z.number().int().min(1).max(5000).default(500),
32
+ });
33
+
34
+ const unwrap = <T>(
35
+ result:
36
+ | { success: true; data: T }
37
+ | { success: false; error: string; isValidation?: boolean }
38
+ ): T => {
39
+ if (result.success) return result.data;
40
+ throw new Error(
41
+ `${result.isValidation ? "VALIDATION" : "RUNTIME"}: ${result.error}`
42
+ );
43
+ };
44
+
45
+ export const handleChanges = (
46
+ args: z.infer<typeof changesInputSchema>,
47
+ ctx: ToolContext
48
+ ): Promise<ToolResult> =>
49
+ runTool(
50
+ ctx,
51
+ "gno_changes",
52
+ async () => unwrap(await listKnowledgeChanges(ctx.store, args)),
53
+ (data) =>
54
+ `${data.changes.length} retained document changes${data.page.truncated ? " (more available)" : ""}`
55
+ );
56
+
57
+ export const handleDiff = (
58
+ args: z.infer<typeof diffInputSchema>,
59
+ ctx: ToolContext
60
+ ): Promise<ToolResult> =>
61
+ runTool(
62
+ ctx,
63
+ "gno_diff",
64
+ async () =>
65
+ unwrap(await getKnowledgeDiff(ctx.store, args.ref, args.change)),
66
+ (data) =>
67
+ `Structural diff for ${data.document.uri}: ${data.status}; history ${data.history.status}; source bodies not retained`
68
+ );
69
+
70
+ export const handleImpact = (
71
+ args: z.infer<typeof impactInputSchema>,
72
+ ctx: ToolContext
73
+ ): Promise<ToolResult> =>
74
+ runTool(
75
+ ctx,
76
+ "gno_impact",
77
+ async () => unwrap(await analyzeKnowledgeImpact(ctx.store, args.ref, args)),
78
+ (data) =>
79
+ `${data.impacted.length} documents depend on ${data.root.uri}${data.meta.truncated ? " (truncated)" : ""}`
80
+ );
@@ -21,6 +21,14 @@ import { normalizeTag } from "../../core/tags";
21
21
  import { handleAddCollection } from "./add-collection";
22
22
  import { askInputSchema, handleAsk } from "./ask";
23
23
  import { handleCapture } from "./capture";
24
+ import {
25
+ changesInputSchema,
26
+ diffInputSchema,
27
+ handleChanges,
28
+ handleDiff,
29
+ handleImpact,
30
+ impactInputSchema,
31
+ } from "./changes";
24
32
  import { handleClearCollectionEmbeddings } from "./clear-collection-embeddings";
25
33
  import { handleContext, handleContextVerify } from "./context";
26
34
  import { handleEmbed } from "./embed";
@@ -1036,6 +1044,27 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
1036
1044
  (args) => handleStatus(args, ctx)
1037
1045
  );
1038
1046
 
1047
+ server.tool(
1048
+ "gno_changes",
1049
+ "List retained metadata-only document changes with opaque cursor pagination and retention disclosure.",
1050
+ changesInputSchema.shape,
1051
+ (args) => handleChanges(args, ctx)
1052
+ );
1053
+
1054
+ server.tool(
1055
+ "gno_diff",
1056
+ "Inspect one retained metadata-only structural document change. Source bodies are never returned.",
1057
+ diffInputSchema.shape,
1058
+ (args) => handleDiff(args, ctx)
1059
+ );
1060
+
1061
+ server.tool(
1062
+ "gno_impact",
1063
+ "Find bounded inbound typed, wiki, and Markdown dependencies with deterministic evidence paths.",
1064
+ impactInputSchema.shape,
1065
+ (args) => handleImpact(args, ctx)
1066
+ );
1067
+
1039
1068
  server.tool(
1040
1069
  "gno_trace_list",
1041
1070
  "List bounded metadata-only summaries of private local retrieval traces. Raw replay queries are omitted from history.",
package/src/sdk/client.ts CHANGED
@@ -40,6 +40,11 @@ import type {
40
40
  GnoRenameNoteOptions,
41
41
  GnoUpdateOptions,
42
42
  GnoVectorSearchOptions,
43
+ KnowledgeChangesResult,
44
+ KnowledgeDiffResult,
45
+ KnowledgeImpactInput,
46
+ KnowledgeImpactResult,
47
+ ListKnowledgeChangesInput,
43
48
  } from "./types";
44
49
 
45
50
  import {
@@ -84,6 +89,12 @@ import {
84
89
  planRenameRefactor,
85
90
  } from "../core/file-refactors";
86
91
  import { resolveEffectiveIndex } from "../core/indexed-reference";
92
+ import {
93
+ analyzeKnowledgeImpact,
94
+ getKnowledgeDiff,
95
+ listKnowledgeChanges,
96
+ type KnowledgeDeltaServiceResult,
97
+ } from "../core/knowledge-delta";
87
98
  import { resolveNoteCreatePlan } from "../core/note-creation";
88
99
  import { resolveNotePreset } from "../core/note-presets";
89
100
  import { RetrievalTraceManagementService } from "../core/retrieval-trace-management";
@@ -174,6 +185,11 @@ function unwrapTraceStore<T>(result: StoreResult<T>): T {
174
185
  });
175
186
  }
176
187
 
188
+ function unwrapKnowledgeDelta<T>(result: KnowledgeDeltaServiceResult<T>): T {
189
+ if (result.success) return result.data;
190
+ throw sdkError(result.isValidation ? "VALIDATION" : "STORE", result.error);
191
+ }
192
+
177
193
  async function resolveClientState(
178
194
  options: GnoClientInitOptions = {}
179
195
  ): Promise<OpenedClientState> {
@@ -1067,6 +1083,32 @@ class GnoClientImpl implements GnoClient {
1067
1083
  };
1068
1084
  }
1069
1085
 
1086
+ async changes(
1087
+ options: ListKnowledgeChangesInput = {}
1088
+ ): Promise<KnowledgeChangesResult> {
1089
+ this.assertOpen();
1090
+ return unwrapKnowledgeDelta(
1091
+ await listKnowledgeChanges(this.store, options)
1092
+ );
1093
+ }
1094
+
1095
+ async diff(ref: string, changeId?: string): Promise<KnowledgeDiffResult> {
1096
+ this.assertOpen();
1097
+ return unwrapKnowledgeDelta(
1098
+ await getKnowledgeDiff(this.store, ref, changeId)
1099
+ );
1100
+ }
1101
+
1102
+ async impact(
1103
+ ref: string,
1104
+ options: KnowledgeImpactInput = {}
1105
+ ): Promise<KnowledgeImpactResult> {
1106
+ this.assertOpen();
1107
+ return unwrapKnowledgeDelta(
1108
+ await analyzeKnowledgeImpact(this.store, ref, options)
1109
+ );
1110
+ }
1111
+
1070
1112
  async status(): Promise<IndexStatus> {
1071
1113
  this.assertOpen();
1072
1114
  return unwrapStore(
package/src/sdk/index.ts CHANGED
@@ -54,6 +54,13 @@ export type {
54
54
  GnoSkippedDocument,
55
55
  GnoUpdateOptions,
56
56
  GnoVectorSearchOptions,
57
+ KnowledgeChange,
58
+ KnowledgeChangesResult,
59
+ KnowledgeDiffResult,
60
+ KnowledgeImpactEvidenceStep,
61
+ KnowledgeImpactInput,
62
+ KnowledgeImpactResult,
63
+ ListKnowledgeChangesInput,
57
64
  } from "./types";
58
65
  export {
59
66
  ContextCapsuleContractError,
package/src/sdk/types.ts CHANGED
@@ -17,6 +17,13 @@ import type {
17
17
  } from "../core/context-capsule";
18
18
  import type { ContextEvidenceErrorCode } from "../core/context-evidence";
19
19
  import type { ContextVerifierErrorCode } from "../core/context-verifier";
20
+ import type {
21
+ KnowledgeChangesResult,
22
+ KnowledgeDiffResult,
23
+ KnowledgeImpactInput,
24
+ KnowledgeImpactResult,
25
+ ListKnowledgeChangesInput,
26
+ } from "../core/knowledge-delta";
20
27
  import type { NoteCollisionPolicy } from "../core/note-creation";
21
28
  import type { NotePresetId } from "../core/note-presets";
22
29
  import type {
@@ -52,6 +59,15 @@ export type {
52
59
  SyncResult,
53
60
  };
54
61
  export type { AskOptions, HybridSearchOptions } from "../pipeline/types";
62
+ export type {
63
+ KnowledgeChange,
64
+ KnowledgeChangesResult,
65
+ KnowledgeDiffResult,
66
+ KnowledgeImpactEvidenceStep,
67
+ KnowledgeImpactInput,
68
+ KnowledgeImpactResult,
69
+ ListKnowledgeChangesInput,
70
+ } from "../core/knowledge-delta";
55
71
  export type { Collection, Config as GnoConfig, Context } from "../config/types";
56
72
  export type { GetResponse as GnoGetResult } from "../cli/commands/get";
57
73
  export type {
@@ -233,6 +249,12 @@ export interface GnoClient {
233
249
  list(
234
250
  options?: GnoListOptions
235
251
  ): Promise<import("../cli/commands/ls").LsResponse>;
252
+ changes(options?: ListKnowledgeChangesInput): Promise<KnowledgeChangesResult>;
253
+ diff(ref: string, changeId?: string): Promise<KnowledgeDiffResult>;
254
+ impact(
255
+ ref: string,
256
+ options?: KnowledgeImpactInput
257
+ ): Promise<KnowledgeImpactResult>;
236
258
  status(): Promise<IndexStatus>;
237
259
  listRetrievalTraces(
238
260
  options?: RetrievalTraceListRequest
@@ -1,6 +1,6 @@
1
1
  export type DocumentEventOrigin = "watcher" | "save" | "create";
2
2
 
3
- export interface DocumentEvent {
3
+ export interface DocumentChangedEvent {
4
4
  type: "document-changed";
5
5
  uri: string;
6
6
  collection: string;
@@ -9,6 +9,17 @@ export interface DocumentEvent {
9
9
  changedAt: string;
10
10
  }
11
11
 
12
+ export interface CapsuleReverifiedEvent {
13
+ type: "capsule-reverified";
14
+ registrationId: string;
15
+ capsuleId: string;
16
+ operationStatus: "completed" | "failed";
17
+ affectedQuestionState: "unaffected" | "affected" | "unknown";
18
+ changedAt: string;
19
+ }
20
+
21
+ export type DocumentEvent = DocumentChangedEvent | CapsuleReverifiedEvent;
22
+
12
23
  export interface DocumentEventBusState {
13
24
  connectedClients: number;
14
25
  retryMs: number;
@@ -31,6 +31,7 @@ import {
31
31
  isInitialized,
32
32
  loadConfig,
33
33
  } from "../config";
34
+ import { SavedCapsuleReverificationScheduler } from "../core/capsule-reverification-scheduler";
34
35
  import { acquireWriteLock } from "../core/file-lock";
35
36
  import { JobManager } from "../core/job-manager";
36
37
  import { recordContentMutation } from "../core/mutation-generations";
@@ -94,6 +95,7 @@ export interface ResidentRuntime {
94
95
  readonly toolMutex: Mutex;
95
96
  readonly readerGate: ReaderGate;
96
97
  readonly jobManager: JobManager;
98
+ readonly capsuleReverificationScheduler: SavedCapsuleReverificationScheduler;
97
99
  readonly modelManager: ModelManager;
98
100
  readonly mcpContext: ToolContext;
99
101
  readonly generations: ResidentGeneration;
@@ -266,6 +268,8 @@ export async function startResidentRuntime(
266
268
  ctxHolder.current.scheduler = scheduler;
267
269
  ctxHolder.current.eventBus = options.eventBus ?? null;
268
270
 
271
+ let capsuleReverificationScheduler: SavedCapsuleReverificationScheduler | null =
272
+ null;
269
273
  const watchService = (
270
274
  deps.watchServiceFactory ??
271
275
  ((watchOptions) => new DefaultCollectionWatchService(watchOptions))
@@ -282,6 +286,10 @@ export async function startResidentRuntime(
282
286
  });
283
287
  options.watchCallbacks?.onSyncComplete?.(event);
284
288
  },
289
+ onSettled: () => {
290
+ capsuleReverificationScheduler?.notifySyncSettled();
291
+ options.watchCallbacks?.onSettled?.();
292
+ },
285
293
  },
286
294
  syncOptions: withContentTypeRules({}, initialConfig),
287
295
  });
@@ -312,6 +320,17 @@ export async function startResidentRuntime(
312
320
  const backgroundWork = new ResidentBackgroundWork(
313
321
  () => !disposed && admission.accepting
314
322
  );
323
+ capsuleReverificationScheduler = new SavedCapsuleReverificationScheduler({
324
+ deps: {
325
+ store,
326
+ get config() {
327
+ return ctxHolder.config;
328
+ },
329
+ indexName: canonicalizeIndexName(options.index ?? DEFAULT_INDEX_NAME),
330
+ notify: (event) => options.eventBus?.emit(event),
331
+ },
332
+ startBackgroundWork: (operation) => backgroundWork.start(operation),
333
+ });
315
334
 
316
335
  const mcpContext = createToolContext({
317
336
  store,
@@ -355,6 +374,7 @@ export async function startResidentRuntime(
355
374
  toolMutex,
356
375
  readerGate,
357
376
  jobManager,
377
+ capsuleReverificationScheduler,
358
378
  modelManager,
359
379
  mcpContext,
360
380
  generations,
@@ -453,6 +473,7 @@ export async function startResidentRuntime(
453
473
  syncOptions.triggerEmbed === false
454
474
  ? null
455
475
  : await scheduler.triggerNow();
476
+ capsuleReverificationScheduler.notifySyncSettled();
456
477
  return { syncResult, embedResult };
457
478
  },
458
479
  async dispose() {
@@ -466,6 +487,7 @@ export async function startResidentRuntime(
466
487
  );
467
488
  if (deadlineReached) shutdownState = "deadline";
468
489
  await backgroundWork.cancelAndDrain();
490
+ await capsuleReverificationScheduler.dispose();
469
491
  await jobManager.shutdown().catch(() => undefined);
470
492
  await Promise.allSettled([
471
493
  Promise.resolve().then(() => watchService.dispose()),
@@ -160,6 +160,7 @@ import {
160
160
  withRetrievalTraceHeader,
161
161
  } from "../retrieval-trace";
162
162
  import { buildAppStatus, type StatusBuildDeps } from "../status";
163
+ import { handleChanges, handleDiff, handleImpact } from "./changes";
163
164
 
164
165
  /** Mutable context holder for hot-reloading presets */
165
166
  export interface ContextHolder {
@@ -5122,6 +5123,18 @@ export async function routeApi(
5122
5123
  );
5123
5124
  }
5124
5125
 
5126
+ if (path === "/api/changes" && req.method === "GET") {
5127
+ return handleChanges(store, url);
5128
+ }
5129
+
5130
+ if (path === "/api/diff" && req.method === "GET") {
5131
+ return handleDiff(store, url);
5132
+ }
5133
+
5134
+ if (path === "/api/impact" && req.method === "GET") {
5135
+ return handleImpact(store, url);
5136
+ }
5137
+
5125
5138
  // Unknown API route
5126
5139
  if (path.startsWith("/api/")) {
5127
5140
  return errorResponse("NOT_FOUND", `Unknown API endpoint: ${path}`, 404);
@@ -0,0 +1,102 @@
1
+ /** Read-only REST handlers for knowledge change, diff, and impact services. */
2
+
3
+ import type { KnowledgeImpactInput } from "../../core/knowledge-delta";
4
+ import type { StorePort } from "../../store/types";
5
+
6
+ import {
7
+ analyzeKnowledgeImpact,
8
+ getKnowledgeDiff,
9
+ listKnowledgeChanges,
10
+ } from "../../core/knowledge-delta";
11
+
12
+ const errorResponse = (
13
+ code: "VALIDATION" | "RUNTIME",
14
+ message: string
15
+ ): Response =>
16
+ Response.json(
17
+ { error: { code, message } },
18
+ { status: code === "VALIDATION" ? 400 : 500 }
19
+ );
20
+
21
+ const parsePositiveInt = (
22
+ url: URL,
23
+ name: string
24
+ ): number | undefined | Response => {
25
+ const raw = url.searchParams.get(name);
26
+ if (raw === null) return;
27
+ if (!/^\d+$/.test(raw)) {
28
+ return errorResponse("VALIDATION", `${name} must be a positive integer`);
29
+ }
30
+ const value = Number(raw);
31
+ return Number.isSafeInteger(value) && value > 0
32
+ ? value
33
+ : errorResponse("VALIDATION", `${name} must be a positive integer`);
34
+ };
35
+
36
+ export async function handleChanges(
37
+ store: StorePort,
38
+ url: URL
39
+ ): Promise<Response> {
40
+ const limit = parsePositiveInt(url, "limit");
41
+ if (limit instanceof Response) return limit;
42
+ const result = await listKnowledgeChanges(store, {
43
+ since: url.searchParams.get("since") ?? undefined,
44
+ collection: url.searchParams.get("collection") ?? undefined,
45
+ limit,
46
+ });
47
+ if (!result.success) {
48
+ return errorResponse(
49
+ result.isValidation ? "VALIDATION" : "RUNTIME",
50
+ result.error
51
+ );
52
+ }
53
+ return Response.json(result.data);
54
+ }
55
+
56
+ export async function handleDiff(
57
+ store: StorePort,
58
+ url: URL
59
+ ): Promise<Response> {
60
+ const ref = url.searchParams.get("ref")?.trim();
61
+ if (!ref) return errorResponse("VALIDATION", "ref is required");
62
+ const result = await getKnowledgeDiff(
63
+ store,
64
+ ref,
65
+ url.searchParams.get("change") ?? undefined
66
+ );
67
+ if (!result.success) {
68
+ return errorResponse(
69
+ result.isValidation ? "VALIDATION" : "RUNTIME",
70
+ result.error
71
+ );
72
+ }
73
+ return Response.json(result.data);
74
+ }
75
+
76
+ export async function handleImpact(
77
+ store: StorePort,
78
+ url: URL
79
+ ): Promise<Response> {
80
+ const ref = url.searchParams.get("ref")?.trim();
81
+ if (!ref) return errorResponse("VALIDATION", "ref is required");
82
+ const input: KnowledgeImpactInput = {};
83
+ for (const [queryName, inputName] of [
84
+ ["maxDepth", "maxDepth"],
85
+ ["maxNodes", "maxNodes"],
86
+ ["maxEdges", "maxEdges"],
87
+ ["frontierLimit", "frontierLimit"],
88
+ ["visitedLimit", "visitedLimit"],
89
+ ] as const) {
90
+ const value = parsePositiveInt(url, queryName);
91
+ if (value instanceof Response) return value;
92
+ if (value !== undefined) input[inputName] = value;
93
+ }
94
+ const result = await analyzeKnowledgeImpact(store, ref, input);
95
+ if (!result.success) {
96
+ return errorResponse(
97
+ result.isValidation ? "VALIDATION" : "RUNTIME",
98
+ result.error
99
+ );
100
+ }
101
+ return Response.json(result.data);
102
+ }
@@ -69,6 +69,7 @@ import {
69
69
  handleUpdateDoc,
70
70
  handleVerifyConnector,
71
71
  } from "./routes/api";
72
+ import { handleChanges, handleDiff, handleImpact } from "./routes/changes";
72
73
  import { handleGraph, handleGraphQuery } from "./routes/graph";
73
74
  import {
74
75
  handleDocBacklinks,
@@ -968,6 +969,39 @@ export async function startServer(
968
969
  );
969
970
  },
970
971
  },
972
+ "/api/changes": {
973
+ GET: async (req: Request) => {
974
+ const url = new URL(req.url);
975
+ return withSecurityHeaders(
976
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
977
+ handleChanges(store, url)
978
+ ),
979
+ isDev
980
+ );
981
+ },
982
+ },
983
+ "/api/diff": {
984
+ GET: async (req: Request) => {
985
+ const url = new URL(req.url);
986
+ return withSecurityHeaders(
987
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
988
+ handleDiff(store, url)
989
+ ),
990
+ isDev
991
+ );
992
+ },
993
+ },
994
+ "/api/impact": {
995
+ GET: async (req: Request) => {
996
+ const url = new URL(req.url);
997
+ return withSecurityHeaders(
998
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
999
+ handleImpact(store, url)
1000
+ ),
1001
+ isDev
1002
+ );
1003
+ },
1004
+ },
971
1005
  },
972
1006
  });
973
1007
  } catch (e) {
@@ -31,6 +31,8 @@ export interface CollectionWatchCallbacks {
31
31
  relPaths: string[];
32
32
  error: unknown;
33
33
  }) => void;
34
+ /** Fires after all watcher syncs and queued paths have settled. */
35
+ onSettled?: () => void;
34
36
  }
35
37
 
36
38
  interface CollectionWatchServiceOptions {
@@ -236,6 +238,13 @@ export class CollectionWatchService {
236
238
  const remaining = this.#pendingByCollection.get(collectionName);
237
239
  if (remaining && remaining.size > 0) {
238
240
  void this.#flushCollection(collectionName);
241
+ } else if (
242
+ this.#syncing.size === 0 &&
243
+ ![...this.#pendingByCollection.values()].some(
244
+ (relPaths) => relPaths.size > 0
245
+ )
246
+ ) {
247
+ this.#callbacks?.onSettled?.();
239
248
  }
240
249
  }
241
250
  }
@@ -33,6 +33,16 @@ export type {
33
33
  CollectionRow,
34
34
  CollectionStatus,
35
35
  ContextRow,
36
+ DocumentChangeDateDelta,
37
+ DocumentChangeKind,
38
+ DocumentChangeListOptions,
39
+ DocumentChangePage,
40
+ DocumentChangePurgeResult,
41
+ DocumentChangeRetentionPolicy,
42
+ DocumentChangeRetentionResult,
43
+ DocumentChangeRow,
44
+ DocumentChangeSet,
45
+ DocumentChangeStructureDelta,
36
46
  DocumentInput,
37
47
  DocumentRow,
38
48
  FtsResult,
@@ -72,6 +82,17 @@ export type {
72
82
  RetrievalTraceRunRow,
73
83
  RetrievalTraceStatus,
74
84
  RetrievalTraceTerminalStatus,
85
+ RenameDocumentOptions,
86
+ SavedCapsuleAffectedQuestionState,
87
+ SavedCapsuleEvidenceReference,
88
+ SavedCapsuleNotificationPreference,
89
+ SavedCapsuleOperationStatus,
90
+ SavedCapsuleRegistration,
91
+ SavedCapsuleRegistrationInput,
92
+ SavedCapsuleRegistrationRecord,
93
+ SavedCapsuleReverificationState,
94
+ SavedCapsuleTriggerKind,
95
+ SavedCapsuleVerificationRecord,
75
96
  StoreError,
76
97
  StoreErrorCode,
77
98
  StorePort,
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Migration: bounded, metadata-only document change journal.
3
+ *
4
+ * The journal intentionally stores document identity, hashes, active state,
5
+ * and compact structural summaries. Source and converted document bodies are
6
+ * never copied into this table.
7
+ *
8
+ * @module src/store/migrations/015-document-change-journal
9
+ */
10
+
11
+ import type { Database } from "bun:sqlite";
12
+
13
+ import type { Migration } from "./runner";
14
+
15
+ export const migration: Migration = {
16
+ version: 15,
17
+ name: "document_change_journal",
18
+
19
+ up(db: Database): void {
20
+ db.exec(`
21
+ CREATE TABLE document_changes (
22
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ document_id INTEGER NOT NULL CHECK (document_id > 0),
24
+ collection TEXT NOT NULL,
25
+ change_kind TEXT NOT NULL
26
+ CHECK (change_kind IN ('create', 'update', 'rename', 'inactivate', 'reactivate')),
27
+ old_rel_path TEXT,
28
+ new_rel_path TEXT,
29
+ old_docid TEXT,
30
+ new_docid TEXT,
31
+ old_uri TEXT,
32
+ new_uri TEXT,
33
+ old_source_hash TEXT,
34
+ new_source_hash TEXT,
35
+ old_mirror_hash TEXT,
36
+ new_mirror_hash TEXT,
37
+ old_active INTEGER CHECK (old_active IS NULL OR old_active IN (0, 1)),
38
+ new_active INTEGER CHECK (new_active IS NULL OR new_active IN (0, 1)),
39
+ heading_delta_json TEXT NOT NULL DEFAULT '{"added":[],"removed":[]}',
40
+ link_delta_json TEXT NOT NULL DEFAULT '{"added":[],"removed":[]}',
41
+ typed_edge_delta_json TEXT NOT NULL DEFAULT '{"added":[],"removed":[]}',
42
+ date_delta_json TEXT NOT NULL DEFAULT '{"added":[],"removed":[],"changed":[]}',
43
+ structure_truncated INTEGER NOT NULL DEFAULT 0
44
+ CHECK (structure_truncated IN (0, 1)),
45
+ observed_at_ms INTEGER NOT NULL CHECK (observed_at_ms >= 0),
46
+ byte_size INTEGER NOT NULL CHECK (byte_size > 0 AND byte_size <= 131072),
47
+ CHECK (length(CAST(collection AS BLOB)) BETWEEN 1 AND 256),
48
+ CHECK (old_rel_path IS NULL OR length(CAST(old_rel_path AS BLOB)) BETWEEN 1 AND 4096),
49
+ CHECK (new_rel_path IS NULL OR length(CAST(new_rel_path AS BLOB)) BETWEEN 1 AND 4096),
50
+ CHECK (old_docid IS NULL OR length(CAST(old_docid AS BLOB)) BETWEEN 1 AND 256),
51
+ CHECK (new_docid IS NULL OR length(CAST(new_docid AS BLOB)) BETWEEN 1 AND 256),
52
+ CHECK (old_uri IS NULL OR length(CAST(old_uri AS BLOB)) BETWEEN 1 AND 8192),
53
+ CHECK (new_uri IS NULL OR length(CAST(new_uri AS BLOB)) BETWEEN 1 AND 8192),
54
+ CHECK (old_source_hash IS NULL OR length(CAST(old_source_hash AS BLOB)) BETWEEN 1 AND 256),
55
+ CHECK (new_source_hash IS NULL OR length(CAST(new_source_hash AS BLOB)) BETWEEN 1 AND 256),
56
+ CHECK (old_mirror_hash IS NULL OR length(CAST(old_mirror_hash AS BLOB)) BETWEEN 1 AND 256),
57
+ CHECK (new_mirror_hash IS NULL OR length(CAST(new_mirror_hash AS BLOB)) BETWEEN 1 AND 256),
58
+ CHECK (length(CAST(heading_delta_json AS BLOB)) <= 16384),
59
+ CHECK (length(CAST(link_delta_json AS BLOB)) <= 16384),
60
+ CHECK (length(CAST(typed_edge_delta_json AS BLOB)) <= 16384),
61
+ CHECK (length(CAST(date_delta_json AS BLOB)) <= 16384)
62
+ );
63
+
64
+ CREATE INDEX idx_document_changes_collection_sequence
65
+ ON document_changes(collection, sequence);
66
+
67
+ CREATE INDEX idx_document_changes_document_sequence
68
+ ON document_changes(document_id, sequence);
69
+
70
+ CREATE INDEX idx_document_changes_retention
71
+ ON document_changes(observed_at_ms, sequence);
72
+
73
+ CREATE TABLE document_change_journal_state (
74
+ singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
75
+ last_sequence INTEGER NOT NULL DEFAULT 0 CHECK (last_sequence >= 0),
76
+ retention_floor INTEGER NOT NULL DEFAULT 0
77
+ CHECK (retention_floor >= 0 AND retention_floor <= last_sequence)
78
+ );
79
+
80
+ INSERT INTO document_change_journal_state (
81
+ singleton_id, last_sequence, retention_floor
82
+ ) VALUES (1, 0, 0);
83
+ `);
84
+ },
85
+ };