@gmickel/gno 1.27.1 → 1.29.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 (195) hide show
  1. package/README.md +46 -20
  2. package/assets/skill/SKILL.md +59 -1
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/browser-extension/dist/preview.html +1 -1
  8. package/browser-extension/dist/service-worker.js +6 -6
  9. package/package.json +2 -2
  10. package/spec/cli.md +82 -5
  11. package/spec/db/schema.sql +31 -0
  12. package/spec/mcp.md +71 -0
  13. package/spec/output-schemas/ask.schema.json +156 -1
  14. package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
  15. package/spec/output-schemas/collection-egress-check.schema.json +91 -0
  16. package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
  17. package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
  18. package/spec/output-schemas/context-capsule-v1.schema.json +183 -3
  19. package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
  20. package/spec/output-schemas/egress-audit-management.schema.json +88 -0
  21. package/spec/output-schemas/get.schema.json +94 -0
  22. package/spec/output-schemas/mcp-http-error.schema.json +113 -2
  23. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  24. package/spec/output-schemas/multi-get.schema.json +128 -0
  25. package/spec/output-schemas/publish-artifact.schema.json +32 -0
  26. package/spec/output-schemas/record-import.schema.json +223 -0
  27. package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
  28. package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
  29. package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
  30. package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
  31. package/spec/output-schemas/search-result.schema.json +94 -0
  32. package/spec/output-schemas/search-results.schema.json +155 -0
  33. package/spec/project-profile.schema.json +66 -0
  34. package/src/app/context-format.ts +1 -1
  35. package/src/app/context-runtime-contract.ts +16 -7
  36. package/src/cli/commands/ask.ts +7 -0
  37. package/src/cli/commands/collection/index.ts +5 -0
  38. package/src/cli/commands/collection/policy.ts +126 -0
  39. package/src/cli/commands/context-build.ts +5 -0
  40. package/src/cli/commands/daemon.ts +45 -3
  41. package/src/cli/commands/egress-audit.ts +68 -0
  42. package/src/cli/commands/embed.ts +2 -0
  43. package/src/cli/commands/get.ts +12 -2
  44. package/src/cli/commands/index-cmd.ts +13 -0
  45. package/src/cli/commands/multi-get.ts +9 -2
  46. package/src/cli/commands/query.ts +12 -0
  47. package/src/cli/commands/replay.ts +13 -3
  48. package/src/cli/commands/shared.ts +28 -0
  49. package/src/cli/commands/update.ts +5 -0
  50. package/src/cli/commands/vsearch.ts +3 -1
  51. package/src/cli/program.ts +127 -0
  52. package/src/config/index.ts +9 -0
  53. package/src/config/project-profile.ts +2 -0
  54. package/src/config/types.ts +66 -0
  55. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  56. package/src/converters/adapters/browser-export/formats.ts +358 -0
  57. package/src/converters/adapters/email/adapter.ts +429 -0
  58. package/src/converters/adapters/email/html.ts +162 -0
  59. package/src/converters/adapters/email/mime.ts +454 -0
  60. package/src/converters/adapters/email/parameters.ts +77 -0
  61. package/src/converters/adapters/ical/adapter.ts +475 -0
  62. package/src/converters/adapters/ical/recurrence.ts +186 -0
  63. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  64. package/src/converters/adapters/jsonl/config.ts +105 -0
  65. package/src/converters/adapters/shared/html-text.ts +165 -0
  66. package/src/converters/adapters/shared/record-utils.ts +79 -0
  67. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  68. package/src/converters/adapters/transcript/adapter.ts +295 -0
  69. package/src/converters/adapters/transcript/json.ts +184 -0
  70. package/src/converters/adapters/transcript/model.ts +171 -0
  71. package/src/converters/adapters/transcript/text.ts +58 -0
  72. package/src/converters/adapters/transcript/timed.ts +152 -0
  73. package/src/converters/index.ts +11 -1
  74. package/src/converters/mime.ts +8 -0
  75. package/src/converters/pipeline.ts +15 -1
  76. package/src/converters/registry.ts +37 -1
  77. package/src/converters/types.ts +136 -0
  78. package/src/core/browser-clip.ts +16 -1
  79. package/src/core/collection-egress-policy-projection.ts +28 -0
  80. package/src/core/collection-egress-policy-service.ts +443 -0
  81. package/src/core/collection-egress-policy-validation.ts +242 -0
  82. package/src/core/config-mutation.ts +31 -19
  83. package/src/core/context-capsule-schema.ts +198 -1
  84. package/src/core/context-capsule-validation.ts +4 -4
  85. package/src/core/context-capsule-verification.ts +5 -1
  86. package/src/core/context-capsule.ts +177 -112
  87. package/src/core/context-evidence.ts +93 -15
  88. package/src/core/destination-classifier.ts +402 -0
  89. package/src/core/document-capabilities.ts +12 -0
  90. package/src/core/egress-audit.ts +239 -0
  91. package/src/core/egress-authorization.ts +50 -0
  92. package/src/core/egress-enforcement.ts +264 -0
  93. package/src/core/egress-policy.ts +440 -0
  94. package/src/core/egress-provenance.ts +336 -0
  95. package/src/core/job-manager.ts +37 -0
  96. package/src/core/network-boundary-inventory.ts +261 -0
  97. package/src/core/project-profile-apply-state.ts +5 -0
  98. package/src/core/project-profile.ts +4 -0
  99. package/src/core/record-metadata.ts +49 -0
  100. package/src/core/retrieval-qrels.ts +6 -0
  101. package/src/core/retrieval-replay.ts +18 -4
  102. package/src/core/retrieval-trace-export.ts +23 -4
  103. package/src/core/retrieval-trace-management-helpers.ts +1 -0
  104. package/src/core/retrieval-trace-management-types.ts +3 -0
  105. package/src/core/retrieval-trace-management.ts +9 -1
  106. package/src/core/retrieval-trace-session.ts +94 -0
  107. package/src/core/retrieval-trace.ts +22 -0
  108. package/src/ingestion/record-adapter-canonical.ts +433 -0
  109. package/src/ingestion/record-adapter.ts +448 -0
  110. package/src/ingestion/record-container.ts +704 -0
  111. package/src/ingestion/record-path.ts +20 -0
  112. package/src/ingestion/record-sync.ts +70 -0
  113. package/src/ingestion/sync.ts +243 -36
  114. package/src/ingestion/types.ts +71 -1
  115. package/src/ingestion/walker.ts +31 -11
  116. package/src/llm/errors.ts +10 -0
  117. package/src/llm/http-inference.ts +175 -0
  118. package/src/llm/http-policy.ts +537 -0
  119. package/src/llm/httpEmbedding.ts +47 -28
  120. package/src/llm/httpGeneration.ts +31 -18
  121. package/src/llm/httpRerank.ts +30 -18
  122. package/src/llm/index.ts +1 -0
  123. package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
  124. package/src/llm/pinned-http-connection.ts +177 -0
  125. package/src/mcp/context.ts +53 -1
  126. package/src/mcp/http-egress.ts +220 -0
  127. package/src/mcp/http-security.ts +32 -21
  128. package/src/mcp/http-session.ts +13 -2
  129. package/src/mcp/http-transport.ts +96 -6
  130. package/src/mcp/sync-egress.ts +24 -0
  131. package/src/mcp/tools/add-collection.ts +4 -0
  132. package/src/mcp/tools/ask.ts +1 -1
  133. package/src/mcp/tools/context.ts +9 -2
  134. package/src/mcp/tools/egress.ts +247 -0
  135. package/src/mcp/tools/embed.ts +1 -0
  136. package/src/mcp/tools/get.ts +10 -2
  137. package/src/mcp/tools/index-cmd.ts +7 -0
  138. package/src/mcp/tools/index.ts +98 -0
  139. package/src/mcp/tools/multi-get.ts +9 -2
  140. package/src/mcp/tools/query.ts +10 -0
  141. package/src/mcp/tools/remove-collection.ts +4 -0
  142. package/src/mcp/tools/sync.ts +6 -0
  143. package/src/mcp/tools/trace.ts +7 -1
  144. package/src/mcp/tools/vsearch.ts +1 -0
  145. package/src/mcp/tools/workspace-write.ts +2 -0
  146. package/src/pipeline/egress-lineage.ts +124 -0
  147. package/src/pipeline/filters.ts +1 -1
  148. package/src/pipeline/graph-retrieval.ts +7 -4
  149. package/src/pipeline/hybrid.ts +21 -4
  150. package/src/pipeline/result-context.ts +12 -4
  151. package/src/pipeline/search.ts +23 -4
  152. package/src/pipeline/types.ts +6 -0
  153. package/src/pipeline/vsearch.ts +67 -28
  154. package/src/publish/artifact.ts +31 -3
  155. package/src/publish/export-service.ts +25 -0
  156. package/src/sdk/client.ts +119 -2
  157. package/src/sdk/documents.ts +13 -5
  158. package/src/sdk/embed.ts +1 -0
  159. package/src/sdk/types.ts +33 -0
  160. package/src/serve/browse-tree.ts +4 -2
  161. package/src/serve/clipper-capture.ts +5 -1
  162. package/src/serve/closed-json.ts +61 -0
  163. package/src/serve/config-sync.ts +4 -1
  164. package/src/serve/context.ts +1 -0
  165. package/src/serve/doc-events.ts +110 -39
  166. package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
  167. package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
  168. package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
  169. package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
  170. package/src/serve/public/globals.built.css +1 -1
  171. package/src/serve/public/pages/Collections.tsx +3 -0
  172. package/src/serve/resident-request.ts +80 -3
  173. package/src/serve/resident-runtime.ts +63 -1
  174. package/src/serve/routes/api.ts +237 -65
  175. package/src/serve/routes/clipper.ts +64 -1
  176. package/src/serve/routes/mcp.ts +6 -0
  177. package/src/serve/routes/traces.ts +27 -4
  178. package/src/serve/server.ts +118 -7
  179. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  180. package/src/store/migrations/023-collection-egress-policy.ts +61 -0
  181. package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
  182. package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
  183. package/src/store/migrations/index.ts +8 -0
  184. package/src/store/retrieval-trace-codec.ts +15 -0
  185. package/src/store/sqlite/adapter.ts +284 -14
  186. package/src/store/sqlite/change-journal-store.ts +41 -2
  187. package/src/store/sqlite/egress-audit-store.ts +485 -0
  188. package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
  189. package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
  190. package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
  191. package/src/store/sqlite/retrieval-trace-store.ts +104 -5
  192. package/src/store/types.ts +166 -1
  193. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip +0 -0
  194. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip.sha256 +0 -1
  195. package/browser-extension/dist/chunk-vn5f663b.js +0 -50
@@ -0,0 +1,704 @@
1
+ import type { NormalizedContentTypeRule } from "../config";
2
+ import type { Collection } from "../config/types";
3
+ import type { RecordAdapter, RecordMetadata } from "../converters/types";
4
+ import type {
5
+ ChunkInput,
6
+ DocumentRow,
7
+ StorePort,
8
+ StoreResult,
9
+ } from "../store/types";
10
+ import type {
11
+ ChunkerPort,
12
+ FileSyncResult,
13
+ RecordImportItemReceipt,
14
+ SyncOptions,
15
+ WalkEntry,
16
+ } from "./types";
17
+
18
+ import { resolveConfiguredEgressPolicy } from "../config/types";
19
+ import { DEFAULT_RECORD_ADAPTER_LIMITS } from "../converters/types";
20
+ import {
21
+ diffDocumentStructure,
22
+ extractDocumentStructure,
23
+ } from "../core/change-diff";
24
+ import { createEgressLineage } from "../core/egress-provenance";
25
+ import { normalizeTag, validateTag } from "../core/tags";
26
+ import { runRecordAdapter } from "./record-adapter";
27
+ import { recordVirtualPath } from "./record-path";
28
+ import { reconcileRecordSnapshot, type RecordSyncPlan } from "./record-sync";
29
+ import { DEFAULT_CHUNK_PARAMS, MAX_RECORD_IMPORT_RECEIPT_ITEMS } from "./types";
30
+
31
+ interface RecordDocumentMetadata {
32
+ contentType?: string;
33
+ contentTypeSource: "frontmatter-type" | "prefix" | "path-ext" | "fallback";
34
+ categories?: string[];
35
+ author?: string;
36
+ frontmatterDate?: string;
37
+ dateFields?: Record<string, string>;
38
+ }
39
+
40
+ interface RecordContainerInput {
41
+ adapter: RecordAdapter;
42
+ chunker: ChunkerPort;
43
+ collection: Collection;
44
+ contentTypeRules: NormalizedContentTypeRule[];
45
+ contentTypeRulesFingerprint: string;
46
+ entry: WalkEntry;
47
+ ext: string;
48
+ extractMetadata: (
49
+ markdown: string,
50
+ relPath: string,
51
+ ext: string,
52
+ rules: NormalizedContentTypeRule[]
53
+ ) => RecordDocumentMetadata;
54
+ ingestVersion: number;
55
+ mime: string;
56
+ options: SyncOptions;
57
+ sourceCtime: string;
58
+ sourceMtime: string;
59
+ sourceSize: number;
60
+ store: StorePort;
61
+ }
62
+
63
+ interface AppliedRecordReconciliation {
64
+ changed: boolean;
65
+ hadSourceDocument: boolean;
66
+ plan: RecordSyncPlan;
67
+ priorByKey: Map<string, DocumentRow>;
68
+ priorDocumentCount: number;
69
+ }
70
+
71
+ const mustOk = <T>(result: StoreResult<T>, operation: string): T => {
72
+ if (result.ok) return result.value;
73
+ throw new Error(
74
+ `Store operation failed: ${operation} - ${result.error.message}`
75
+ );
76
+ };
77
+
78
+ const normalizedDateFields = (
79
+ metadata: RecordMetadata | undefined
80
+ ): Record<string, string> | undefined => {
81
+ const fields: Record<string, string> = {};
82
+ for (const [key, raw] of Object.entries(metadata?.dateFields ?? {})) {
83
+ const normalized = normalizeRecordDate(raw);
84
+ if (normalized) fields[key] = normalized;
85
+ }
86
+ return Object.keys(fields).length > 0 ? fields : undefined;
87
+ };
88
+
89
+ const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
90
+ const FLOATING_DATE_TIME_PATTERN =
91
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?$/;
92
+ const OFFSET_DATE_TIME_PATTERN =
93
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
94
+ const TZID_DATE_TIME_PATTERN =
95
+ /^TZID=([^:]{1,128}):(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})$/;
96
+
97
+ const timeZoneParts = (
98
+ epochMs: number,
99
+ timeZone: string
100
+ ): [number, number, number, number, number, number] | undefined => {
101
+ try {
102
+ const parts = new Intl.DateTimeFormat("en-CA", {
103
+ timeZone,
104
+ calendar: "iso8601",
105
+ numberingSystem: "latn",
106
+ year: "numeric",
107
+ month: "2-digit",
108
+ day: "2-digit",
109
+ hour: "2-digit",
110
+ minute: "2-digit",
111
+ second: "2-digit",
112
+ hourCycle: "h23",
113
+ }).formatToParts(new Date(epochMs));
114
+ const values = new Map(
115
+ parts.map((part) => [part.type, Number(part.value)])
116
+ );
117
+ const tuple = [
118
+ values.get("year"),
119
+ values.get("month"),
120
+ values.get("day"),
121
+ values.get("hour"),
122
+ values.get("minute"),
123
+ values.get("second"),
124
+ ];
125
+ if (tuple.some((value) => !Number.isInteger(value))) return undefined;
126
+ return tuple as [number, number, number, number, number, number];
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ };
131
+
132
+ const zonedDateTimeToIso = (
133
+ timeZone: string,
134
+ localDateTime: string
135
+ ): string | undefined => {
136
+ const match = FLOATING_DATE_TIME_PATTERN.exec(localDateTime);
137
+ if (!match) return undefined;
138
+ const desired = match.slice(1).map(Number) as [
139
+ number,
140
+ number,
141
+ number,
142
+ number,
143
+ number,
144
+ number,
145
+ ];
146
+ const desiredMs = Date.UTC(
147
+ desired[0],
148
+ desired[1] - 1,
149
+ desired[2],
150
+ desired[3],
151
+ desired[4],
152
+ desired[5]
153
+ );
154
+ let candidateMs = desiredMs;
155
+ for (let attempt = 0; attempt < 3; attempt += 1) {
156
+ const projected = timeZoneParts(candidateMs, timeZone);
157
+ if (!projected) return undefined;
158
+ const projectedMs = Date.UTC(
159
+ projected[0],
160
+ projected[1] - 1,
161
+ projected[2],
162
+ projected[3],
163
+ projected[4],
164
+ projected[5]
165
+ );
166
+ candidateMs += desiredMs - projectedMs;
167
+ }
168
+ const verified = timeZoneParts(candidateMs, timeZone);
169
+ if (!verified || verified.some((value, index) => value !== desired[index])) {
170
+ return undefined;
171
+ }
172
+ return new Date(candidateMs).toISOString();
173
+ };
174
+
175
+ const isValidDateOnly = (raw: string): boolean => {
176
+ if (!DATE_ONLY_PATTERN.test(raw)) return false;
177
+ const parsed = new Date(`${raw}T00:00:00.000Z`);
178
+ return (
179
+ !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(raw)
180
+ );
181
+ };
182
+
183
+ const isValidFloatingDateTime = (raw: string): boolean => {
184
+ const match = FLOATING_DATE_TIME_PATTERN.exec(raw);
185
+ if (!match) return false;
186
+ const parsed = new Date(`${raw}Z`);
187
+ if (Number.isNaN(parsed.getTime())) return false;
188
+ const values = match.slice(1, 7).map(Number);
189
+ return (
190
+ parsed.getUTCFullYear() === values[0] &&
191
+ parsed.getUTCMonth() + 1 === values[1] &&
192
+ parsed.getUTCDate() === values[2] &&
193
+ parsed.getUTCHours() === values[3] &&
194
+ parsed.getUTCMinutes() === values[4] &&
195
+ parsed.getUTCSeconds() === values[5]
196
+ );
197
+ };
198
+
199
+ export const normalizeRecordDate = (raw: string): string | undefined => {
200
+ if (isValidDateOnly(raw)) return raw;
201
+ if (isValidFloatingDateTime(raw)) return raw;
202
+ const zoned = TZID_DATE_TIME_PATTERN.exec(raw);
203
+ if (zoned?.[1] && zoned[2]) {
204
+ return zonedDateTimeToIso(zoned[1], zoned[2]);
205
+ }
206
+ if (!OFFSET_DATE_TIME_PATTERN.test(raw)) return undefined;
207
+ const parsed = new Date(raw);
208
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
209
+ };
210
+
211
+ const primaryDate = (
212
+ fields: Record<string, string> | undefined
213
+ ): string | undefined => {
214
+ if (!fields) return undefined;
215
+ const priorities = [
216
+ "date",
217
+ "start",
218
+ "sentAt",
219
+ "created",
220
+ "addedAt",
221
+ "visitedAt",
222
+ "updated",
223
+ "end",
224
+ ];
225
+ for (const key of priorities) {
226
+ if (fields[key]) return fields[key];
227
+ }
228
+ return Object.entries(fields).sort(([left], [right]) =>
229
+ left.localeCompare(right)
230
+ )[0]?.[1];
231
+ };
232
+
233
+ const recordContentType = (
234
+ adapterId: string,
235
+ fallback: string | undefined
236
+ ): string | undefined => {
237
+ if (adapterId.includes("email")) return "email";
238
+ if (adapterId.includes("ical")) return "event";
239
+ if (adapterId.includes("transcript")) return "transcript";
240
+ if (adapterId.includes("browser")) return "browser-export";
241
+ if (adapterId.includes("jsonl")) return "record";
242
+ return fallback;
243
+ };
244
+
245
+ const normalizedCategories = (
246
+ metadataCategories: readonly string[] | undefined,
247
+ inferredCategories: readonly string[] | undefined,
248
+ contentType: string | undefined
249
+ ): string[] => {
250
+ const values = [
251
+ ...(metadataCategories ?? []),
252
+ ...(inferredCategories ?? []),
253
+ ...(contentType ? [contentType] : []),
254
+ ];
255
+ const categories = new Set<string>();
256
+ for (const value of values) {
257
+ const normalized = normalizeTag(value);
258
+ if (validateTag(normalized)) categories.add(normalized);
259
+ }
260
+ return [...categories].sort();
261
+ };
262
+
263
+ const sourceStream = (
264
+ path: string,
265
+ signal?: AbortSignal
266
+ ): AsyncIterable<Uint8Array> => ({
267
+ async *[Symbol.asyncIterator]() {
268
+ const reader = Bun.file(path).stream().getReader();
269
+ const abort = (): void => {
270
+ void reader.cancel("record adapter aborted");
271
+ };
272
+ signal?.addEventListener("abort", abort, { once: true });
273
+ try {
274
+ while (true) {
275
+ const next = await reader.read();
276
+ if (next.done) return;
277
+ yield next.value;
278
+ }
279
+ } finally {
280
+ signal?.removeEventListener("abort", abort);
281
+ reader.releaseLock();
282
+ }
283
+ },
284
+ });
285
+
286
+ const loadPreviousStructure = async (
287
+ store: StorePort,
288
+ existing: DocumentRow | undefined
289
+ ): Promise<ReturnType<typeof extractDocumentStructure> | null | undefined> => {
290
+ if (!existing) return null;
291
+ if (!existing.mirrorHash) return undefined;
292
+ const content = mustOk(
293
+ await store.getContent(existing.mirrorHash),
294
+ "getContent"
295
+ );
296
+ if (content === null) return undefined;
297
+ return extractDocumentStructure(
298
+ content,
299
+ existing.relPath,
300
+ existing.dateFields
301
+ );
302
+ };
303
+
304
+ const persistRecord = async (
305
+ input: RecordContainerInput,
306
+ record: Awaited<ReturnType<typeof runRecordAdapter>>["records"][number],
307
+ existing: DocumentRow | undefined,
308
+ wrapInTransaction = true,
309
+ rebuildEvidence = true
310
+ ): Promise<void> => {
311
+ const virtualPath = recordVirtualPath(input.entry.relPath, record.recordKey);
312
+ const inferred = input.extractMetadata(
313
+ record.markdown,
314
+ virtualPath,
315
+ ".md",
316
+ input.contentTypeRules
317
+ );
318
+ const dateFields =
319
+ normalizedDateFields(record.metadata) ?? inferred.dateFields;
320
+ const contentType = recordContentType(record.adapterId, inferred.contentType);
321
+ const categories = normalizedCategories(
322
+ record.metadata?.categories,
323
+ inferred.categories,
324
+ contentType
325
+ );
326
+ const previousStructure = await loadPreviousStructure(input.store, existing);
327
+ const nextStructure = extractDocumentStructure(
328
+ record.markdown,
329
+ virtualPath,
330
+ dateFields
331
+ );
332
+ const structureDelta = diffDocumentStructure(
333
+ previousStructure,
334
+ nextStructure
335
+ ).delta;
336
+
337
+ const persist = async (): Promise<void> => {
338
+ const document = mustOk(
339
+ await input.store.upsertDocument({
340
+ collection: input.collection.name,
341
+ relPath: virtualPath,
342
+ sourceHash: record.sourceHash,
343
+ sourceMime: input.mime,
344
+ sourceExt: input.ext,
345
+ sourceSize: input.sourceSize,
346
+ sourceMtime: input.sourceMtime,
347
+ sourceCtime: input.sourceCtime,
348
+ title: record.title,
349
+ mirrorHash: record.mirrorHash,
350
+ converterId: record.adapterId,
351
+ converterVersion: record.adapterVersion,
352
+ languageHint: record.languageHint ?? input.collection.languageHint,
353
+ contentType,
354
+ contentTypeSource: inferred.contentTypeSource,
355
+ categories,
356
+ author: record.metadata?.author ?? inferred.author,
357
+ frontmatterDate: primaryDate(dateFields) ?? inferred.frontmatterDate,
358
+ dateFields,
359
+ recordKey: record.recordKey,
360
+ recordSourcePath: input.entry.relPath,
361
+ recordSourceLocator: record.sourceLocator,
362
+ recordMetadata: record.metadata,
363
+ recordAnchors: record.anchors,
364
+ recordAdapterFingerprint: record.adapterFingerprint,
365
+ contentTypeRulesFingerprint: input.contentTypeRulesFingerprint,
366
+ lastErrorCode: undefined,
367
+ lastErrorMessage: undefined,
368
+ ingestVersion: input.ingestVersion,
369
+ changeJournal: { structureDelta },
370
+ }),
371
+ "upsertDocument"
372
+ );
373
+ if (!rebuildEvidence) return;
374
+ mustOk(
375
+ await input.store.upsertContent(record.mirrorHash, record.markdown),
376
+ "upsertContent"
377
+ );
378
+ const chunks: ChunkInput[] = input.chunker
379
+ .chunk(
380
+ record.markdown,
381
+ DEFAULT_CHUNK_PARAMS,
382
+ record.languageHint ?? input.collection.languageHint,
383
+ virtualPath
384
+ )
385
+ .map((chunk) => ({
386
+ seq: chunk.seq,
387
+ pos: chunk.pos,
388
+ text: chunk.text,
389
+ startLine: chunk.startLine,
390
+ endLine: chunk.endLine,
391
+ language: chunk.language ?? undefined,
392
+ tokenCount: chunk.tokenCount ?? undefined,
393
+ }));
394
+ mustOk(
395
+ await input.store.upsertChunks(record.mirrorHash, chunks),
396
+ "upsertChunks"
397
+ );
398
+ mustOk(
399
+ await input.store.rebuildFtsForHash(record.mirrorHash),
400
+ "rebuildFtsForHash"
401
+ );
402
+ mustOk(
403
+ await input.store.setDocTags(document.id, categories, "frontmatter"),
404
+ "setDocTags"
405
+ );
406
+ mustOk(
407
+ await input.store.setDocLinks(document.id, [], "parsed"),
408
+ "setDocLinks"
409
+ );
410
+ };
411
+
412
+ if (!(wrapInTransaction && input.store.withTransaction)) return persist();
413
+ mustOk(await input.store.withTransaction(persist), "persistRecord");
414
+ };
415
+
416
+ const sameJsonValue = (left: unknown, right: unknown): boolean =>
417
+ JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
418
+
419
+ const recordProvenanceChanged = (
420
+ input: RecordContainerInput,
421
+ record: Awaited<ReturnType<typeof runRecordAdapter>>["records"][number],
422
+ existing: DocumentRow
423
+ ): boolean =>
424
+ existing.sourceMime !== input.mime ||
425
+ existing.sourceExt !== input.ext ||
426
+ existing.sourceSize !== input.sourceSize ||
427
+ existing.sourceMtime !== input.sourceMtime ||
428
+ existing.sourceCtime !== input.sourceCtime ||
429
+ existing.recordSourcePath !== input.entry.relPath ||
430
+ existing.recordSourceLocator !== record.sourceLocator ||
431
+ !sameJsonValue(existing.recordAnchors, record.anchors);
432
+
433
+ /** Stream, reconcile, and persist one export container as virtual documents. */
434
+ export async function processRecordContainer(
435
+ input: RecordContainerInput
436
+ ): Promise<FileSyncResult> {
437
+ const effectiveEgress = resolveConfiguredEgressPolicy(input.collection);
438
+ const snapshot = await runRecordAdapter(
439
+ input.adapter,
440
+ {
441
+ sourcePath: input.entry.absPath,
442
+ relativePath: input.entry.relPath,
443
+ collection: input.collection.name,
444
+ mime: input.mime,
445
+ ext: input.ext,
446
+ open: (signal) => sourceStream(input.entry.absPath, signal),
447
+ limits: {
448
+ ...DEFAULT_RECORD_ADAPTER_LIMITS,
449
+ timeoutMs: Math.min(
450
+ DEFAULT_RECORD_ADAPTER_LIMITS.timeoutMs ?? 60_000,
451
+ input.options.limits?.timeoutMs ?? Number.POSITIVE_INFINITY
452
+ ),
453
+ maxSourceBytes: Math.min(
454
+ DEFAULT_RECORD_ADAPTER_LIMITS.maxSourceBytes,
455
+ input.options.limits?.maxBytes ?? Number.POSITIVE_INFINITY
456
+ ),
457
+ maxTotalChars: Math.min(
458
+ DEFAULT_RECORD_ADAPTER_LIMITS.maxTotalChars,
459
+ input.options.limits?.maxOutputChars ?? Number.POSITIVE_INFINITY
460
+ ),
461
+ },
462
+ },
463
+ {
464
+ egressLineage: createEgressLineage([
465
+ {
466
+ collection: input.collection.name,
467
+ policy: effectiveEgress.policy,
468
+ source: effectiveEgress.source,
469
+ },
470
+ ]),
471
+ }
472
+ );
473
+
474
+ const reconcileAndApply = async (): Promise<AppliedRecordReconciliation> => {
475
+ const priorDocuments = mustOk(
476
+ await input.store.listRecordDocuments(
477
+ input.collection.name,
478
+ input.entry.relPath
479
+ ),
480
+ "listRecordDocuments"
481
+ );
482
+ const sourceDocument = mustOk(
483
+ await input.store.getDocument(input.collection.name, input.entry.relPath),
484
+ "getDocument"
485
+ );
486
+ const priorByKey = new Map(
487
+ priorDocuments
488
+ .filter((document) => document.recordKey)
489
+ .map((document) => [document.recordKey as string, document])
490
+ );
491
+ const plan = reconcileRecordSnapshot(
492
+ priorDocuments
493
+ .filter((document) => document.recordKey)
494
+ .map((document) => ({
495
+ recordKey: document.recordKey as string,
496
+ sourceHash: document.sourceHash,
497
+ adapterVersion: document.converterVersion ?? "",
498
+ adapterFingerprint: document.recordAdapterFingerprint ?? "",
499
+ active: document.active,
500
+ relativePath: document.relPath,
501
+ })),
502
+ snapshot
503
+ );
504
+
505
+ let changed = false;
506
+ for (const action of plan.actions) {
507
+ if (
508
+ action.type === "add" ||
509
+ action.type === "update" ||
510
+ action.type === "reactivate"
511
+ ) {
512
+ await persistRecord(
513
+ input,
514
+ action.record,
515
+ priorByKey.get(action.record.recordKey),
516
+ false
517
+ );
518
+ changed = true;
519
+ continue;
520
+ }
521
+ if (action.type === "unchanged" && action.record) {
522
+ const existing = priorByKey.get(action.record.recordKey);
523
+ const projectionChanged =
524
+ existing?.ingestVersion !== input.ingestVersion ||
525
+ existing.contentTypeRulesFingerprint !==
526
+ input.contentTypeRulesFingerprint;
527
+ if (projectionChanged) {
528
+ await persistRecord(input, action.record, existing, false);
529
+ changed = true;
530
+ } else if (
531
+ existing &&
532
+ recordProvenanceChanged(input, action.record, existing)
533
+ ) {
534
+ await persistRecord(input, action.record, existing, false, false);
535
+ changed = true;
536
+ }
537
+ continue;
538
+ }
539
+ if (action.type === "deactivate") {
540
+ const result = mustOk(
541
+ await input.store.markInactive(input.collection.name, [
542
+ action.previous.relativePath,
543
+ ]),
544
+ "markInactive"
545
+ );
546
+ changed ||= result > 0;
547
+ }
548
+ }
549
+
550
+ if (sourceDocument?.active && snapshot.authoritative) {
551
+ const result = mustOk(
552
+ await input.store.markInactive(input.collection.name, [
553
+ input.entry.relPath,
554
+ ]),
555
+ "markInactive"
556
+ );
557
+ changed ||= result > 0;
558
+ }
559
+ return {
560
+ changed,
561
+ hadSourceDocument: sourceDocument !== null,
562
+ plan,
563
+ priorByKey,
564
+ priorDocumentCount: priorDocuments.length,
565
+ };
566
+ };
567
+ const applied = input.store.withTransaction
568
+ ? mustOk(
569
+ await input.store.withTransaction(reconcileAndApply),
570
+ "reconcileRecordSnapshot"
571
+ )
572
+ : await reconcileAndApply();
573
+ const actionCount = (
574
+ type: (typeof applied.plan.actions)[number]["type"]
575
+ ): number =>
576
+ applied.plan.actions.filter((action) => action.type === type).length;
577
+ const receiptItems = applied.plan.actions
578
+ .map((action): RecordImportItemReceipt => {
579
+ const record = "record" in action ? action.record : undefined;
580
+ const previous =
581
+ "previous" in action
582
+ ? applied.priorByKey.get(action.previous.recordKey)
583
+ : undefined;
584
+ const recordKey =
585
+ record?.recordKey ??
586
+ ("previous" in action ? action.previous.recordKey : "");
587
+ return {
588
+ outcome:
589
+ action.type === "add"
590
+ ? "added"
591
+ : action.type === "update"
592
+ ? "updated"
593
+ : action.type === "reactivate"
594
+ ? "reactivated"
595
+ : action.type === "deactivate"
596
+ ? "deactivated"
597
+ : action.type === "preserve"
598
+ ? "preserved"
599
+ : action.type,
600
+ recordKey,
601
+ sourceLocator:
602
+ record?.sourceLocator ??
603
+ previous?.recordSourceLocator ??
604
+ `record:${recordKey}`,
605
+ sourceHash: record?.sourceHash ?? previous?.sourceHash ?? "",
606
+ ...(record?.mirrorHash || previous?.mirrorHash
607
+ ? { mirrorHash: record?.mirrorHash ?? previous?.mirrorHash ?? "" }
608
+ : {}),
609
+ adapterFingerprint:
610
+ record?.adapterFingerprint ??
611
+ previous?.recordAdapterFingerprint ??
612
+ snapshot.adapterFingerprint,
613
+ attachments: (
614
+ record?.metadata?.attachments ??
615
+ previous?.recordMetadata?.attachments ??
616
+ []
617
+ ).map((attachment) => ({ ...attachment })),
618
+ };
619
+ })
620
+ .sort(
621
+ (left, right) =>
622
+ left.recordKey.localeCompare(right.recordKey) ||
623
+ left.outcome.localeCompare(right.outcome)
624
+ );
625
+ const boundedReceiptItems = receiptItems.slice(
626
+ 0,
627
+ MAX_RECORD_IMPORT_RECEIPT_ITEMS
628
+ );
629
+ const recordImport: NonNullable<FileSyncResult["recordImport"]> = {
630
+ adapterId: snapshot.adapterId,
631
+ adapterVersion: snapshot.adapterVersion,
632
+ adapterFingerprint: snapshot.adapterFingerprint,
633
+ egressLineage: snapshot.egressLineage,
634
+ snapshotState: snapshot.snapshotState,
635
+ authoritative: snapshot.authoritative,
636
+ stoppedByCap: snapshot.stoppedByCap,
637
+ sourceBytesRead: snapshot.sourceBytesRead,
638
+ records: {
639
+ accepted: snapshot.records.length,
640
+ added: actionCount("add"),
641
+ updated: actionCount("update"),
642
+ reactivated: actionCount("reactivate"),
643
+ unchanged: actionCount("unchanged"),
644
+ deactivated: actionCount("deactivate"),
645
+ preserved: actionCount("preserve"),
646
+ failed: snapshot.failures.length,
647
+ },
648
+ items: boundedReceiptItems,
649
+ itemsTruncated: receiptItems.length - boundedReceiptItems.length,
650
+ warnings:
651
+ snapshot.snapshotState === "partial" && snapshot.failures.length === 0
652
+ ? [
653
+ {
654
+ code: "PARTIAL_SNAPSHOT",
655
+ message:
656
+ "Adapter reported a partial snapshot; unseen records were preserved.",
657
+ retryable: true,
658
+ },
659
+ ]
660
+ : [],
661
+ failures: snapshot.failures,
662
+ };
663
+
664
+ for (const failure of snapshot.failures) {
665
+ await input.store.recordError({
666
+ collection: input.collection.name,
667
+ relPath: input.entry.relPath,
668
+ code: failure.code,
669
+ message: failure.message,
670
+ details: {
671
+ retryable: failure.retryable,
672
+ ...(failure.sourceLocator
673
+ ? { sourceLocator: failure.sourceLocator }
674
+ : {}),
675
+ ...(failure.stableId ? { stableId: failure.stableId } : {}),
676
+ },
677
+ });
678
+ }
679
+
680
+ if (snapshot.records.length === 0 && snapshot.failures.length > 0) {
681
+ return {
682
+ relPath: input.entry.relPath,
683
+ status: "error",
684
+ errorCode: snapshot.failures[0]?.code ?? "ADAPTER_FAILURE",
685
+ errorMessage:
686
+ snapshot.failures[0]?.message ?? "Export conversion failed.",
687
+ recordImport,
688
+ };
689
+ }
690
+ if (!applied.changed)
691
+ return {
692
+ relPath: input.entry.relPath,
693
+ status: "unchanged",
694
+ recordImport,
695
+ };
696
+ return {
697
+ relPath: input.entry.relPath,
698
+ status:
699
+ applied.priorDocumentCount > 0 || applied.hadSourceDocument
700
+ ? "updated"
701
+ : "added",
702
+ recordImport,
703
+ };
704
+ }