@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.
- package/README.md +2 -2
- package/assets/skill/SKILL.md +26 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v1.27.0.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/spec/cli.md +41 -4
- package/spec/db/schema.sql +12 -0
- package/spec/mcp.md +5 -0
- package/spec/output-schemas/ask.schema.json +125 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
- package/spec/output-schemas/get.schema.json +94 -0
- package/spec/output-schemas/mcp-job-status.schema.json +28 -0
- package/spec/output-schemas/multi-get.schema.json +128 -0
- package/spec/output-schemas/record-import.schema.json +193 -0
- package/spec/output-schemas/search-result.schema.json +94 -0
- package/spec/output-schemas/search-results.schema.json +125 -0
- package/spec/project-profile.schema.json +66 -0
- package/src/app/context-format.ts +1 -1
- package/src/cli/commands/get.ts +12 -2
- package/src/cli/commands/index-cmd.ts +13 -0
- package/src/cli/commands/multi-get.ts +9 -2
- package/src/cli/commands/shared.ts +28 -0
- package/src/cli/commands/update.ts +5 -0
- package/src/cli/program.ts +4 -0
- package/src/config/project-profile.ts +2 -0
- package/src/config/types.ts +16 -0
- package/src/converters/adapters/browser-export/adapter.ts +199 -0
- package/src/converters/adapters/browser-export/formats.ts +358 -0
- package/src/converters/adapters/email/adapter.ts +429 -0
- package/src/converters/adapters/email/html.ts +162 -0
- package/src/converters/adapters/email/mime.ts +454 -0
- package/src/converters/adapters/email/parameters.ts +77 -0
- package/src/converters/adapters/ical/adapter.ts +475 -0
- package/src/converters/adapters/ical/recurrence.ts +186 -0
- package/src/converters/adapters/jsonl/adapter.ts +238 -0
- package/src/converters/adapters/jsonl/config.ts +105 -0
- package/src/converters/adapters/shared/html-text.ts +165 -0
- package/src/converters/adapters/shared/record-utils.ts +79 -0
- package/src/converters/adapters/shared/utf8-lines.ts +141 -0
- package/src/converters/adapters/transcript/adapter.ts +295 -0
- package/src/converters/adapters/transcript/json.ts +184 -0
- package/src/converters/adapters/transcript/model.ts +171 -0
- package/src/converters/adapters/transcript/text.ts +58 -0
- package/src/converters/adapters/transcript/timed.ts +152 -0
- package/src/converters/index.ts +11 -1
- package/src/converters/mime.ts +8 -0
- package/src/converters/pipeline.ts +15 -1
- package/src/converters/registry.ts +37 -1
- package/src/converters/types.ts +136 -0
- package/src/core/context-capsule-schema.ts +87 -0
- package/src/core/context-capsule.ts +20 -0
- package/src/core/context-evidence.ts +7 -1
- package/src/core/document-capabilities.ts +12 -0
- package/src/core/project-profile-apply-state.ts +5 -0
- package/src/core/project-profile.ts +4 -0
- package/src/core/record-metadata.ts +49 -0
- package/src/ingestion/record-adapter-canonical.ts +433 -0
- package/src/ingestion/record-adapter.ts +437 -0
- package/src/ingestion/record-container.ts +688 -0
- package/src/ingestion/record-path.ts +20 -0
- package/src/ingestion/record-sync.ts +70 -0
- package/src/ingestion/sync.ts +228 -36
- package/src/ingestion/types.ts +69 -1
- package/src/ingestion/walker.ts +31 -11
- package/src/mcp/tools/get.ts +10 -2
- package/src/mcp/tools/multi-get.ts +9 -2
- package/src/mcp/tools/workspace-write.ts +2 -0
- package/src/pipeline/filters.ts +1 -1
- package/src/pipeline/graph-retrieval.ts +7 -4
- package/src/pipeline/hybrid.ts +7 -4
- package/src/pipeline/result-context.ts +12 -4
- package/src/pipeline/search.ts +11 -4
- package/src/pipeline/types.ts +3 -0
- package/src/pipeline/vsearch.ts +26 -8
- package/src/sdk/documents.ts +13 -5
- package/src/serve/browse-tree.ts +4 -2
- package/src/serve/routes/api.ts +63 -65
- package/src/store/migrations/022-record-export-lineage.ts +42 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +114 -7
- package/src/store/types.ts +40 -0
- 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
|
+
};
|