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