@gmickel/gno 1.20.0 → 1.22.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 (55) hide show
  1. package/README.md +29 -5
  2. package/assets/skill/SKILL.md +46 -15
  3. package/package.json +2 -1
  4. package/spec/cli.md +144 -0
  5. package/spec/db/schema.sql +170 -0
  6. package/spec/evals-agentic.md +48 -0
  7. package/spec/mcp.md +22 -0
  8. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  9. package/spec/output-schemas/changes.schema.json +280 -0
  10. package/spec/output-schemas/document-diff.schema.json +185 -0
  11. package/spec/output-schemas/impact.schema.json +122 -0
  12. package/spec/output-schemas/publish-artifact.schema.json +284 -0
  13. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  14. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  15. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  16. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  17. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  18. package/src/cli/commands/changes.ts +160 -0
  19. package/src/cli/commands/context-saved.ts +189 -0
  20. package/src/cli/options.ts +8 -0
  21. package/src/cli/program.ts +195 -0
  22. package/src/core/capsule-registry.ts +279 -0
  23. package/src/core/capsule-reverification-scheduler.ts +218 -0
  24. package/src/core/capsule-reverification.ts +289 -0
  25. package/src/core/change-diff.ts +182 -0
  26. package/src/core/change-journal.ts +228 -0
  27. package/src/core/knowledge-delta.ts +395 -0
  28. package/src/core/knowledge-impact.ts +202 -0
  29. package/src/ingestion/sync.ts +214 -165
  30. package/src/mcp/tools/changes.ts +80 -0
  31. package/src/mcp/tools/index.ts +29 -0
  32. package/src/publish/artifact-validation.ts +259 -0
  33. package/src/publish/artifact.ts +234 -118
  34. package/src/publish/export-service.ts +5 -9
  35. package/src/publish/metadata.ts +195 -0
  36. package/src/sdk/client.ts +42 -0
  37. package/src/sdk/index.ts +7 -0
  38. package/src/sdk/types.ts +22 -0
  39. package/src/serve/doc-events.ts +12 -1
  40. package/src/serve/resident-runtime.ts +22 -0
  41. package/src/serve/routes/api.ts +13 -0
  42. package/src/serve/routes/changes.ts +102 -0
  43. package/src/serve/server.ts +34 -0
  44. package/src/serve/watch-service.ts +9 -0
  45. package/src/store/index.ts +21 -0
  46. package/src/store/migrations/015-document-change-journal.ts +85 -0
  47. package/src/store/migrations/016-saved-capsules.ts +131 -0
  48. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  49. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  50. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  51. package/src/store/migrations/index.ts +10 -0
  52. package/src/store/sqlite/adapter.ts +291 -7
  53. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  54. package/src/store/sqlite/change-journal-store.ts +473 -0
  55. package/src/store/types.ts +262 -0
@@ -0,0 +1,289 @@
1
+ /** Non-generative reverification for metadata-only saved Capsule records. */
2
+
3
+ import type { ContextCapsuleRuntimeDeps } from "../app/context-runtime";
4
+ import type {
5
+ SavedCapsuleRegistrationRecord,
6
+ SavedCapsuleRegistrationSnapshot,
7
+ SavedCapsuleTriggerKind,
8
+ SavedCapsuleVerificationRecord,
9
+ StorePort,
10
+ StoreResult,
11
+ } from "../store/types";
12
+ import type { ContextCapsuleVerification } from "./context-capsule";
13
+
14
+ import { DEFAULT_INDEX_NAME } from "../app/constants";
15
+ import {
16
+ canonicalVerifiedContextCapsuleJson,
17
+ verifyContextCapsuleRuntime,
18
+ } from "../app/context-runtime";
19
+ import { canonicalizeIndexName } from "../app/index-name";
20
+ import {
21
+ getSavedCapsule,
22
+ loadSavedCapsuleFile,
23
+ SavedCapsuleRegistryError,
24
+ } from "./capsule-registry";
25
+ import { decodeDocumentChangeCursor } from "./change-journal";
26
+ import { sha256Text } from "./context-capsule-validation";
27
+
28
+ type ReverificationStore = StorePort &
29
+ ContextCapsuleRuntimeDeps["store"] &
30
+ Pick<
31
+ StorePort,
32
+ | "getSavedCapsuleRegistration"
33
+ | "getSavedCapsuleRegistrationSnapshot"
34
+ | "listDocumentChanges"
35
+ | "upsertSavedCapsuleVerification"
36
+ >;
37
+
38
+ const MAX_REGISTRATION_CONFLICT_ATTEMPTS = 2;
39
+
40
+ export interface SavedCapsuleReverificationNotification {
41
+ type: "capsule-reverified";
42
+ registrationId: string;
43
+ capsuleId: string;
44
+ operationStatus: "completed" | "failed";
45
+ affectedQuestionState: "unaffected" | "affected" | "unknown";
46
+ changedAt: string;
47
+ }
48
+
49
+ export interface SavedCapsuleReverificationOutcome {
50
+ registration: SavedCapsuleRegistrationRecord;
51
+ verification: SavedCapsuleVerificationRecord;
52
+ receipt: ContextCapsuleVerification | null;
53
+ }
54
+
55
+ export interface SavedCapsuleReverificationDeps extends Omit<
56
+ ContextCapsuleRuntimeDeps,
57
+ "store" | "indexName"
58
+ > {
59
+ store: ReverificationStore;
60
+ indexName: string;
61
+ now?: () => number;
62
+ notify?: (event: SavedCapsuleReverificationNotification) => void;
63
+ }
64
+
65
+ export interface SavedCapsuleReverificationTrigger {
66
+ kind: SavedCapsuleTriggerKind;
67
+ fromSequence: number;
68
+ throughSequence: number;
69
+ }
70
+
71
+ const unwrapStore = <T>(result: StoreResult<T>, operation: string): T => {
72
+ if (result.ok) return result.value;
73
+ throw new SavedCapsuleRegistryError(
74
+ "store_failed",
75
+ `${operation}: ${result.error.message}`,
76
+ result.error.cause
77
+ );
78
+ };
79
+
80
+ const currentSequence = async (store: ReverificationStore): Promise<number> => {
81
+ const page = unwrapStore(
82
+ await store.listDocumentChanges({ limit: 1 }),
83
+ "Failed to read document change journal"
84
+ );
85
+ return decodeDocumentChangeCursor(page.latestCursor);
86
+ };
87
+
88
+ const getVerificationSnapshot = async (
89
+ store: ReverificationStore,
90
+ registrationId: string
91
+ ): Promise<SavedCapsuleRegistrationSnapshot> => {
92
+ const snapshot = unwrapStore(
93
+ await store.getSavedCapsuleRegistrationSnapshot(registrationId),
94
+ "Failed to read saved Context Capsule verification snapshot"
95
+ );
96
+ if (!snapshot) {
97
+ throw new SavedCapsuleRegistryError(
98
+ "registration_not_found",
99
+ "Saved Context Capsule registration not found"
100
+ );
101
+ }
102
+ return snapshot;
103
+ };
104
+
105
+ const affected = (
106
+ receipt: ContextCapsuleVerification
107
+ ): {
108
+ state: "unaffected" | "affected";
109
+ reasons: string[];
110
+ } => {
111
+ const reasons: string[] = [];
112
+ if (receipt.contentStatus === "stale") reasons.push("content_stale");
113
+ if (receipt.contentStatus === "missing") reasons.push("content_missing");
114
+ if (receipt.rankingStatus === "reranked") reasons.push("ranking_changed");
115
+ if (receipt.fingerprintStatus === "drifted") {
116
+ reasons.push("fingerprint_changed");
117
+ }
118
+ return {
119
+ state: reasons.length === 0 ? "unaffected" : "affected",
120
+ reasons,
121
+ };
122
+ };
123
+
124
+ const errorIdentity = (error: unknown): { code: string; message: string } => {
125
+ if (
126
+ error instanceof Error &&
127
+ "code" in error &&
128
+ typeof error.code === "string"
129
+ ) {
130
+ return { code: error.code, message: error.message.slice(0, 4096) };
131
+ }
132
+ return {
133
+ code: "verification_failed",
134
+ message: (error instanceof Error
135
+ ? error.message
136
+ : "Saved Capsule verification failed"
137
+ ).slice(0, 4096),
138
+ };
139
+ };
140
+
141
+ const persist = async (
142
+ deps: SavedCapsuleReverificationDeps,
143
+ registration: SavedCapsuleRegistrationRecord,
144
+ registrationGeneration: number,
145
+ verification: SavedCapsuleVerificationRecord
146
+ ): Promise<boolean> => {
147
+ const persisted = unwrapStore(
148
+ await deps.store.upsertSavedCapsuleVerification(verification, {
149
+ registrationGeneration,
150
+ }),
151
+ "Failed to persist saved Context Capsule verification"
152
+ );
153
+ if (!persisted) return false;
154
+ if (registration.notificationPreference === "local") {
155
+ deps.notify?.({
156
+ type: "capsule-reverified",
157
+ registrationId: registration.registrationId,
158
+ capsuleId: registration.capsuleId,
159
+ operationStatus: verification.operationStatus,
160
+ affectedQuestionState: verification.affectedQuestionState,
161
+ changedAt: new Date(verification.verifiedAtMs).toISOString(),
162
+ });
163
+ }
164
+ return true;
165
+ };
166
+
167
+ const reverifySavedCapsuleAttempt = async (
168
+ registrationId: string,
169
+ trigger: SavedCapsuleReverificationTrigger,
170
+ deps: SavedCapsuleReverificationDeps
171
+ ): Promise<SavedCapsuleReverificationOutcome | null> => {
172
+ const snapshot = await getVerificationSnapshot(deps.store, registrationId);
173
+ const registration = snapshot.registration;
174
+ const runtimeIndex = canonicalizeIndexName(
175
+ deps.indexName || DEFAULT_INDEX_NAME
176
+ );
177
+ const verifiedAtMs = (deps.now ?? Date.now)();
178
+ let receipt: ContextCapsuleVerification | null = null;
179
+ let verification: SavedCapsuleVerificationRecord;
180
+ try {
181
+ if (runtimeIndex !== registration.indexName) {
182
+ throw Object.assign(
183
+ new Error(
184
+ `Saved Context Capsule index ${registration.indexName} does not match runtime index ${runtimeIndex}`
185
+ ),
186
+ { code: "invalid_filter" }
187
+ );
188
+ }
189
+ const loaded = await loadSavedCapsuleFile(
190
+ registration.filePath,
191
+ registration.fileHash
192
+ );
193
+ if (loaded.capsule.capsuleId !== registration.capsuleId) {
194
+ throw Object.assign(
195
+ new Error("Saved Context Capsule file changed after registration"),
196
+ { code: "capsule_file_changed" }
197
+ );
198
+ }
199
+ receipt = await verifyContextCapsuleRuntime(loaded.capsule, {
200
+ ...deps,
201
+ store: deps.store,
202
+ indexName: runtimeIndex,
203
+ });
204
+ const receiptJson = canonicalVerifiedContextCapsuleJson(receipt);
205
+ const projection = affected(receipt);
206
+ verification = {
207
+ registrationId,
208
+ triggerKind: trigger.kind,
209
+ fromSequence: trigger.fromSequence,
210
+ throughSequence: trigger.throughSequence,
211
+ operationStatus: "completed",
212
+ affectedQuestionState: projection.state,
213
+ affectedReasons: projection.reasons,
214
+ receiptJson,
215
+ receiptHash: sha256Text(receiptJson),
216
+ errorCode: null,
217
+ errorMessage: null,
218
+ verifiedAtMs,
219
+ };
220
+ } catch (error) {
221
+ const failure = errorIdentity(error);
222
+ verification = {
223
+ registrationId,
224
+ triggerKind: trigger.kind,
225
+ fromSequence: trigger.fromSequence,
226
+ throughSequence: trigger.throughSequence,
227
+ operationStatus: "failed",
228
+ affectedQuestionState: "unknown",
229
+ affectedReasons: [],
230
+ receiptJson: null,
231
+ receiptHash: null,
232
+ errorCode: failure.code,
233
+ errorMessage: failure.message,
234
+ verifiedAtMs,
235
+ };
236
+ }
237
+ if (
238
+ !(await persist(
239
+ deps,
240
+ registration,
241
+ snapshot.registrationGeneration,
242
+ verification
243
+ ))
244
+ ) {
245
+ return null;
246
+ }
247
+ const refreshed = await getSavedCapsule(deps.store, registrationId);
248
+ return { registration: refreshed, verification, receipt };
249
+ };
250
+
251
+ export const reverifySavedCapsule = async (
252
+ registrationId: string,
253
+ trigger: SavedCapsuleReverificationTrigger,
254
+ deps: SavedCapsuleReverificationDeps
255
+ ): Promise<SavedCapsuleReverificationOutcome> => {
256
+ for (
257
+ let attempt = 0;
258
+ attempt < MAX_REGISTRATION_CONFLICT_ATTEMPTS;
259
+ attempt += 1
260
+ ) {
261
+ const outcome = await reverifySavedCapsuleAttempt(
262
+ registrationId,
263
+ trigger,
264
+ deps
265
+ );
266
+ if (outcome) return outcome;
267
+ }
268
+ throw new SavedCapsuleRegistryError(
269
+ "store_failed",
270
+ "Saved Context Capsule registration changed repeatedly during verification"
271
+ );
272
+ };
273
+
274
+ export const reverifySavedCapsuleManually = async (
275
+ registrationId: string,
276
+ deps: SavedCapsuleReverificationDeps
277
+ ): Promise<SavedCapsuleReverificationOutcome> => {
278
+ const sequence = await currentSequence(deps.store);
279
+ const registration = await getSavedCapsule(deps.store, registrationId);
280
+ return reverifySavedCapsule(
281
+ registrationId,
282
+ {
283
+ kind: "manual",
284
+ fromSequence: registration.lastAttemptedSequence,
285
+ throughSequence: sequence,
286
+ },
287
+ deps
288
+ );
289
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Bounded, content-free structural snapshots for document change journaling.
3
+ *
4
+ * The snapshot values are normalized summaries only. Source bodies are used
5
+ * transiently during sync and are never included in a journal delta.
6
+ */
7
+
8
+ import type {
9
+ DocumentChangeStructureDelta,
10
+ DocumentChangeSet,
11
+ } from "../store/types";
12
+
13
+ import { parseFrontmatter } from "../ingestion/frontmatter";
14
+ import { buildLineOffsets } from "../ingestion/position";
15
+ import { getExcludedRanges } from "../ingestion/strip";
16
+ import { normalizeMarkdownPath, normalizeWikiName, parseLinks } from "./links";
17
+ import { extractSections } from "./sections";
18
+
19
+ export type RelationMap = Record<string, string[]>;
20
+
21
+ export interface DocumentStructureSnapshot {
22
+ headings: string[];
23
+ links: string[];
24
+ typedEdges: string[];
25
+ dates: Record<string, string>;
26
+ }
27
+
28
+ export interface DocumentStructureDeltaResult {
29
+ delta: DocumentChangeStructureDelta;
30
+ history: "available" | "unavailable";
31
+ }
32
+
33
+ const RELATION_EDGE_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/;
34
+
35
+ export function isRelationMap(value: unknown): value is RelationMap {
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
37
+ return false;
38
+ }
39
+ return Object.values(value).every(
40
+ (targets) =>
41
+ Array.isArray(targets) &&
42
+ targets.every((target) => typeof target === "string")
43
+ );
44
+ }
45
+
46
+ export function normalizeRelationTarget(raw: string): string {
47
+ const trimmed = raw.trim();
48
+ if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
49
+ return trimmed.slice(2, -2).split("|")[0]?.trim() ?? "";
50
+ }
51
+ return trimmed;
52
+ }
53
+
54
+ export function normalizeRelationEdgeType(raw: string): string {
55
+ return raw
56
+ .trim()
57
+ .toLowerCase()
58
+ .replace(/[-\s]+/g, "_");
59
+ }
60
+
61
+ const withOccurrences = (values: readonly string[]): string[] => {
62
+ const counts = new Map<string, number>();
63
+ return values
64
+ .map((value) => {
65
+ const count = (counts.get(value) ?? 0) + 1;
66
+ counts.set(value, count);
67
+ return count === 1 ? value : `${value} [${count}]`;
68
+ })
69
+ .sort();
70
+ };
71
+
72
+ const linkSummary = (
73
+ link: ReturnType<typeof parseLinks>[number],
74
+ relPath: string
75
+ ): string | null => {
76
+ const anchor = link.targetAnchor
77
+ ? `#${normalizeWikiName(link.targetAnchor)}`
78
+ : "";
79
+ if (link.kind === "wiki") {
80
+ const collection = link.targetCollection
81
+ ? `${normalizeWikiName(link.targetCollection)}:`
82
+ : "";
83
+ return `wiki:${collection}${normalizeWikiName(link.targetRef)}${anchor}`;
84
+ }
85
+ const target = normalizeMarkdownPath(link.targetRef, relPath);
86
+ return target ? `markdown:${target}${anchor}` : null;
87
+ };
88
+
89
+ const extractTypedEdges = (markdown: string): string[] => {
90
+ const relations = parseFrontmatter(markdown).metadata.relations;
91
+ if (!isRelationMap(relations)) return [];
92
+
93
+ const values: string[] = [];
94
+ for (const [rawEdgeType, targets] of Object.entries(relations)) {
95
+ const edgeType = normalizeRelationEdgeType(rawEdgeType);
96
+ if (!RELATION_EDGE_TYPE_PATTERN.test(edgeType)) continue;
97
+ for (const rawTarget of targets) {
98
+ const target = normalizeRelationTarget(rawTarget);
99
+ if (target) {
100
+ values.push(`${edgeType}:${normalizeWikiName(target)}`);
101
+ }
102
+ }
103
+ }
104
+ return [...new Set(values)].sort();
105
+ };
106
+
107
+ export const extractDocumentStructure = (
108
+ markdown: string,
109
+ relPath: string,
110
+ dateFields: Readonly<Record<string, string>> | null | undefined
111
+ ): DocumentStructureSnapshot => {
112
+ const excludedRanges = getExcludedRanges(markdown);
113
+ const links = parseLinks(markdown, buildLineOffsets(markdown), excludedRanges)
114
+ .map((link) => linkSummary(link, relPath))
115
+ .filter((value): value is string => value !== null);
116
+
117
+ return {
118
+ headings: withOccurrences(
119
+ extractSections(markdown).map(
120
+ ({ level, title }) => `${"#".repeat(level)} ${title.normalize("NFC")}`
121
+ )
122
+ ),
123
+ links: withOccurrences(links),
124
+ typedEdges: extractTypedEdges(markdown),
125
+ dates: { ...dateFields },
126
+ };
127
+ };
128
+
129
+ const diffValues = (
130
+ previous: readonly string[],
131
+ next: readonly string[]
132
+ ): DocumentChangeSet => {
133
+ const previousValues = new Set(previous);
134
+ const nextValues = new Set(next);
135
+ return {
136
+ added: next.filter((value) => !previousValues.has(value)),
137
+ removed: previous.filter((value) => !nextValues.has(value)),
138
+ };
139
+ };
140
+
141
+ export const diffDocumentStructure = (
142
+ previous: DocumentStructureSnapshot | null | undefined,
143
+ next: DocumentStructureSnapshot
144
+ ): DocumentStructureDeltaResult => {
145
+ if (previous === undefined) {
146
+ return {
147
+ history: "unavailable",
148
+ delta: {
149
+ headings: { added: [], removed: [] },
150
+ links: { added: [], removed: [] },
151
+ typedEdges: { added: [], removed: [] },
152
+ dates: { added: [], removed: [], changed: [] },
153
+ truncated: true,
154
+ },
155
+ };
156
+ }
157
+
158
+ const prior = previous ?? {
159
+ headings: [],
160
+ links: [],
161
+ typedEdges: [],
162
+ dates: {},
163
+ };
164
+ const priorDateKeys = Object.keys(prior.dates).sort();
165
+ const nextDateKeys = Object.keys(next.dates).sort();
166
+ const dates = diffValues(priorDateKeys, nextDateKeys);
167
+ const changed = priorDateKeys.filter(
168
+ (key) =>
169
+ Object.hasOwn(next.dates, key) && prior.dates[key] !== next.dates[key]
170
+ );
171
+
172
+ return {
173
+ history: "available",
174
+ delta: {
175
+ headings: diffValues(prior.headings, next.headings),
176
+ links: diffValues(prior.links, next.links),
177
+ typedEdges: diffValues(prior.typedEdges, next.typedEdges),
178
+ dates: { ...dates, changed },
179
+ truncated: false,
180
+ },
181
+ };
182
+ };
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Shared, storage-agnostic contracts for the document change journal.
3
+ */
4
+
5
+ import type {
6
+ DocumentChangeDateDelta,
7
+ DocumentChangeSet,
8
+ DocumentChangeStructureDelta,
9
+ DocumentChangeRetentionPolicy,
10
+ } from "../store/types";
11
+
12
+ const CURSOR_PREFIX = "gno-change-v1.";
13
+ const MAX_DELTA_ITEMS_PER_SIDE = 16;
14
+ const MAX_DELTA_VALUE_JSON_BYTES = 256;
15
+ export const MAX_DOCUMENT_CHANGE_DELTA_JSON_BYTES = 16 * 1024;
16
+ const UTF8_ENCODER = new TextEncoder();
17
+
18
+ export const DEFAULT_DOCUMENT_CHANGE_RETENTION: DocumentChangeRetentionPolicy =
19
+ {
20
+ maxAgeDays: 30,
21
+ maxEntries: 10_000,
22
+ maxBytes: 16 * 1024 * 1024,
23
+ };
24
+
25
+ export const EMPTY_DOCUMENT_CHANGE_SET: DocumentChangeSet = {
26
+ added: [],
27
+ removed: [],
28
+ };
29
+
30
+ export const EMPTY_DOCUMENT_CHANGE_DATE_DELTA: DocumentChangeDateDelta = {
31
+ added: [],
32
+ removed: [],
33
+ changed: [],
34
+ };
35
+
36
+ export const EMPTY_DOCUMENT_CHANGE_STRUCTURE_DELTA: DocumentChangeStructureDelta =
37
+ {
38
+ headings: EMPTY_DOCUMENT_CHANGE_SET,
39
+ links: EMPTY_DOCUMENT_CHANGE_SET,
40
+ typedEdges: EMPTY_DOCUMENT_CHANGE_SET,
41
+ dates: EMPTY_DOCUMENT_CHANGE_DATE_DELTA,
42
+ truncated: false,
43
+ };
44
+
45
+ const normalizeValues = (
46
+ values: readonly string[] | undefined
47
+ ): { values: string[]; truncated: boolean } => {
48
+ const unique = [
49
+ ...new Set(
50
+ (values ?? [])
51
+ .map((value) => value.trim())
52
+ .filter((value) => value.length > 0)
53
+ ),
54
+ ].sort();
55
+ const selected: string[] = [];
56
+ const selectedValues = new Set<string>();
57
+ let truncated = false;
58
+ for (const value of unique) {
59
+ let normalized = "";
60
+ let jsonByteLength = 0;
61
+ for (const character of value) {
62
+ const escaped = JSON.stringify(character).slice(1, -1);
63
+ const characterBytes = UTF8_ENCODER.encode(escaped).byteLength;
64
+ if (jsonByteLength + characterBytes > MAX_DELTA_VALUE_JSON_BYTES) {
65
+ truncated = true;
66
+ break;
67
+ }
68
+ normalized += character;
69
+ jsonByteLength += characterBytes;
70
+ }
71
+ if (normalized !== value) {
72
+ truncated = true;
73
+ }
74
+ if (selectedValues.has(normalized)) {
75
+ truncated = true;
76
+ continue;
77
+ }
78
+ if (selected.length === MAX_DELTA_ITEMS_PER_SIDE) {
79
+ truncated = true;
80
+ continue;
81
+ }
82
+ selected.push(normalized);
83
+ selectedValues.add(normalized);
84
+ }
85
+ return {
86
+ values: selected,
87
+ truncated,
88
+ };
89
+ };
90
+
91
+ export interface SerializedDocumentChangeStructureDelta {
92
+ delta: DocumentChangeStructureDelta;
93
+ headingDeltaJson: string;
94
+ linkDeltaJson: string;
95
+ typedEdgeDeltaJson: string;
96
+ dateDeltaJson: string;
97
+ }
98
+
99
+ const normalizeSet = (
100
+ value: Partial<DocumentChangeSet> | undefined
101
+ ): { value: DocumentChangeSet; truncated: boolean } => {
102
+ const added = normalizeValues(value?.added);
103
+ const removed = normalizeValues(value?.removed);
104
+ return {
105
+ value: { added: added.values, removed: removed.values },
106
+ truncated: added.truncated || removed.truncated,
107
+ };
108
+ };
109
+
110
+ const normalizeDates = (
111
+ value: Partial<DocumentChangeDateDelta> | undefined
112
+ ): { value: DocumentChangeDateDelta; truncated: boolean } => {
113
+ const added = normalizeValues(value?.added);
114
+ const removed = normalizeValues(value?.removed);
115
+ const changed = normalizeValues(value?.changed);
116
+ return {
117
+ value: {
118
+ added: added.values,
119
+ removed: removed.values,
120
+ changed: changed.values,
121
+ },
122
+ truncated: added.truncated || removed.truncated || changed.truncated,
123
+ };
124
+ };
125
+
126
+ export const normalizeDocumentChangeStructureDelta = (
127
+ value?: Partial<DocumentChangeStructureDelta>
128
+ ): DocumentChangeStructureDelta => {
129
+ const headings = normalizeSet(value?.headings);
130
+ const links = normalizeSet(value?.links);
131
+ const typedEdges = normalizeSet(value?.typedEdges);
132
+ const dates = normalizeDates(value?.dates);
133
+ return {
134
+ headings: headings.value,
135
+ links: links.value,
136
+ typedEdges: typedEdges.value,
137
+ dates: dates.value,
138
+ truncated:
139
+ (value?.truncated ?? false) ||
140
+ headings.truncated ||
141
+ links.truncated ||
142
+ typedEdges.truncated ||
143
+ dates.truncated,
144
+ };
145
+ };
146
+
147
+ /**
148
+ * Canonical storage projection for migration 015's UTF-8 byte constraints.
149
+ * Callers must use this instead of independently stringifying normalized deltas.
150
+ */
151
+ export const serializeDocumentChangeStructureDelta = (
152
+ value?: Partial<DocumentChangeStructureDelta>
153
+ ): SerializedDocumentChangeStructureDelta => {
154
+ const delta = normalizeDocumentChangeStructureDelta(value);
155
+ const serialized = {
156
+ headingDeltaJson: JSON.stringify(delta.headings),
157
+ linkDeltaJson: JSON.stringify(delta.links),
158
+ typedEdgeDeltaJson: JSON.stringify(delta.typedEdges),
159
+ dateDeltaJson: JSON.stringify(delta.dates),
160
+ };
161
+ for (const json of Object.values(serialized)) {
162
+ if (
163
+ UTF8_ENCODER.encode(json).byteLength >
164
+ MAX_DOCUMENT_CHANGE_DELTA_JSON_BYTES
165
+ ) {
166
+ throw new RangeError(
167
+ "Normalized document change structure exceeds its UTF-8 storage limit"
168
+ );
169
+ }
170
+ }
171
+ return { delta, ...serialized };
172
+ };
173
+
174
+ export const encodeDocumentChangeCursor = (sequence: number): string => {
175
+ if (!Number.isSafeInteger(sequence) || sequence < 0) {
176
+ throw new RangeError(
177
+ "Document change cursor sequence must be non-negative"
178
+ );
179
+ }
180
+ return `${CURSOR_PREFIX}${btoa(JSON.stringify({ sequence }))}`;
181
+ };
182
+
183
+ export const decodeDocumentChangeCursor = (cursor: string): number => {
184
+ if (!cursor.startsWith(CURSOR_PREFIX)) {
185
+ throw new TypeError("Invalid document change cursor");
186
+ }
187
+ try {
188
+ const parsed: unknown = JSON.parse(
189
+ atob(cursor.slice(CURSOR_PREFIX.length))
190
+ );
191
+ if (
192
+ !parsed ||
193
+ typeof parsed !== "object" ||
194
+ !("sequence" in parsed) ||
195
+ !Number.isSafeInteger(parsed.sequence) ||
196
+ (parsed.sequence as number) < 0
197
+ ) {
198
+ throw new TypeError("Invalid document change cursor");
199
+ }
200
+ return parsed.sequence as number;
201
+ } catch (cause) {
202
+ if (cause instanceof TypeError) {
203
+ throw cause;
204
+ }
205
+ throw new TypeError("Invalid document change cursor", { cause });
206
+ }
207
+ };
208
+
209
+ export const validateDocumentChangeRetentionPolicy = (
210
+ policy: DocumentChangeRetentionPolicy,
211
+ nowMs: number
212
+ ): void => {
213
+ if (
214
+ !Number.isSafeInteger(nowMs) ||
215
+ nowMs < 0 ||
216
+ !Number.isSafeInteger(policy.maxAgeDays) ||
217
+ policy.maxAgeDays < 1 ||
218
+ policy.maxAgeDays > 3650 ||
219
+ !Number.isSafeInteger(policy.maxEntries) ||
220
+ policy.maxEntries < 1 ||
221
+ policy.maxEntries > 1_000_000 ||
222
+ !Number.isSafeInteger(policy.maxBytes) ||
223
+ policy.maxBytes < 1 ||
224
+ policy.maxBytes > 1024 * 1024 * 1024
225
+ ) {
226
+ throw new RangeError("Invalid document change retention policy");
227
+ }
228
+ };