@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,70 @@
|
|
|
1
|
+
import type { StoredRecordState } from "../store/types";
|
|
2
|
+
import type { CanonicalRecord, RecordAdapterRunResult } from "./record-adapter";
|
|
3
|
+
|
|
4
|
+
export type RecordSyncAction =
|
|
5
|
+
| { type: "add"; record: CanonicalRecord }
|
|
6
|
+
| { type: "update"; previous: StoredRecordState; record: CanonicalRecord }
|
|
7
|
+
| {
|
|
8
|
+
type: "reactivate";
|
|
9
|
+
previous: StoredRecordState;
|
|
10
|
+
record: CanonicalRecord;
|
|
11
|
+
}
|
|
12
|
+
| { type: "unchanged"; previous: StoredRecordState; record?: CanonicalRecord }
|
|
13
|
+
| { type: "deactivate"; previous: StoredRecordState }
|
|
14
|
+
| { type: "preserve"; previous: StoredRecordState };
|
|
15
|
+
|
|
16
|
+
export interface RecordSyncPlan {
|
|
17
|
+
authoritative: boolean;
|
|
18
|
+
actions: RecordSyncAction[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pure snapshot reconciliation. Persistence/transactions are intentionally
|
|
23
|
+
* deferred until the virtual-record lineage schema exists.
|
|
24
|
+
*/
|
|
25
|
+
export function reconcileRecordSnapshot(
|
|
26
|
+
priorRecords: readonly StoredRecordState[],
|
|
27
|
+
snapshot: RecordAdapterRunResult
|
|
28
|
+
): RecordSyncPlan {
|
|
29
|
+
const priorByKey = new Map(
|
|
30
|
+
priorRecords.map((record) => [record.recordKey, record])
|
|
31
|
+
);
|
|
32
|
+
const seen = new Set<string>();
|
|
33
|
+
const failed = new Set(snapshot.failedRecordKeys);
|
|
34
|
+
const actions: RecordSyncAction[] = [];
|
|
35
|
+
|
|
36
|
+
for (const record of snapshot.records) {
|
|
37
|
+
seen.add(record.recordKey);
|
|
38
|
+
const previous = priorByKey.get(record.recordKey);
|
|
39
|
+
if (!previous) {
|
|
40
|
+
actions.push({ type: "add", record });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!previous.active) {
|
|
44
|
+
actions.push({ type: "reactivate", previous, record });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
previous.sourceHash === record.sourceHash &&
|
|
49
|
+
previous.adapterVersion === record.adapterVersion &&
|
|
50
|
+
previous.adapterFingerprint === record.adapterFingerprint
|
|
51
|
+
) {
|
|
52
|
+
actions.push({ type: "unchanged", previous, record });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
actions.push({ type: "update", previous, record });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const previous of priorRecords) {
|
|
59
|
+
if (seen.has(previous.recordKey)) continue;
|
|
60
|
+
if (failed.has(previous.recordKey) || !snapshot.authoritative) {
|
|
61
|
+
actions.push({ type: "preserve", previous });
|
|
62
|
+
} else if (previous.active) {
|
|
63
|
+
actions.push({ type: "deactivate", previous });
|
|
64
|
+
} else {
|
|
65
|
+
actions.push({ type: "unchanged", previous });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { authoritative: snapshot.authoritative, actions };
|
|
70
|
+
}
|
package/src/ingestion/sync.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* @module src/ingestion/sync
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
// node:fs/promises for stat (no Bun equivalent for file stats)
|
|
9
|
-
import { stat } from "node:fs/promises";
|
|
8
|
+
// node:fs/promises for realpath/stat (no Bun equivalent for canonical paths or file stats)
|
|
9
|
+
import { realpath, stat } from "node:fs/promises";
|
|
10
10
|
// node:path for join (no Bun path utils)
|
|
11
|
-
import { join } from "node:path";
|
|
11
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
12
12
|
|
|
13
13
|
import type { NormalizedContentTypeRule } from "../config";
|
|
14
14
|
import type { Collection } from "../config/types";
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
ChunkInput,
|
|
17
17
|
DocEdgeInput,
|
|
18
18
|
DocLinkInput,
|
|
19
|
+
DocumentInput,
|
|
19
20
|
DocumentRow,
|
|
20
21
|
IngestErrorInput,
|
|
21
22
|
StorePort,
|
|
@@ -37,12 +38,14 @@ import {
|
|
|
37
38
|
fingerprintContentTypeMetadataRules,
|
|
38
39
|
resolveContentTypeRule,
|
|
39
40
|
} from "../config";
|
|
41
|
+
import { createJsonlAdapter } from "../converters/adapters/jsonl/adapter";
|
|
42
|
+
import { createTranscriptAdapter } from "../converters/adapters/transcript/adapter";
|
|
40
43
|
import { getDefaultMimeDetector, type MimeDetector } from "../converters/mime";
|
|
41
44
|
import {
|
|
42
45
|
type ConversionPipeline,
|
|
43
46
|
getDefaultPipeline,
|
|
44
47
|
} from "../converters/pipeline";
|
|
45
|
-
import { DEFAULT_LIMITS } from "../converters/types";
|
|
48
|
+
import { DEFAULT_LIMITS, type RecordAdapter } from "../converters/types";
|
|
46
49
|
import {
|
|
47
50
|
diffDocumentStructure,
|
|
48
51
|
extractDocumentStructure,
|
|
@@ -64,6 +67,7 @@ import {
|
|
|
64
67
|
stripFrontmatter,
|
|
65
68
|
} from "./frontmatter";
|
|
66
69
|
import { buildLineOffsets } from "./position";
|
|
70
|
+
import { processRecordContainer } from "./record-container";
|
|
67
71
|
import { getExcludedRanges } from "./strip";
|
|
68
72
|
import { collectionToWalkConfig, DEFAULT_CHUNK_PARAMS } from "./types";
|
|
69
73
|
import { defaultWalker } from "./walker";
|
|
@@ -542,6 +546,44 @@ function mustOk<T>(
|
|
|
542
546
|
return result.value;
|
|
543
547
|
}
|
|
544
548
|
|
|
549
|
+
const preserveDocumentWithError = (
|
|
550
|
+
existing: DocumentRow,
|
|
551
|
+
code: string,
|
|
552
|
+
message: string
|
|
553
|
+
): DocumentInput => ({
|
|
554
|
+
collection: existing.collection,
|
|
555
|
+
relPath: existing.relPath,
|
|
556
|
+
sourceHash: existing.sourceHash,
|
|
557
|
+
sourceMime: existing.sourceMime,
|
|
558
|
+
sourceExt: existing.sourceExt,
|
|
559
|
+
sourceSize: existing.sourceSize,
|
|
560
|
+
sourceMtime: existing.sourceMtime,
|
|
561
|
+
sourceCtime: existing.sourceCtime ?? existing.sourceMtime,
|
|
562
|
+
title: existing.title ?? undefined,
|
|
563
|
+
mirrorHash: existing.mirrorHash ?? undefined,
|
|
564
|
+
converterId: existing.converterId ?? undefined,
|
|
565
|
+
converterVersion: existing.converterVersion ?? undefined,
|
|
566
|
+
languageHint: existing.languageHint ?? undefined,
|
|
567
|
+
contentType: existing.contentType ?? undefined,
|
|
568
|
+
contentTypeSource: existing.contentTypeSource ?? undefined,
|
|
569
|
+
categories: existing.categories ?? undefined,
|
|
570
|
+
author: existing.author ?? undefined,
|
|
571
|
+
frontmatterDate: existing.frontmatterDate ?? undefined,
|
|
572
|
+
dateFields: existing.dateFields ?? undefined,
|
|
573
|
+
recordKey: existing.recordKey ?? undefined,
|
|
574
|
+
recordSourcePath: existing.recordSourcePath ?? undefined,
|
|
575
|
+
recordSourceLocator: existing.recordSourceLocator ?? undefined,
|
|
576
|
+
recordMetadata: existing.recordMetadata ?? undefined,
|
|
577
|
+
recordAnchors: existing.recordAnchors ?? undefined,
|
|
578
|
+
recordAdapterFingerprint: existing.recordAdapterFingerprint ?? undefined,
|
|
579
|
+
lastErrorCode: code,
|
|
580
|
+
lastErrorMessage: message,
|
|
581
|
+
ingestVersion: existing.ingestVersion ?? undefined,
|
|
582
|
+
contentTypeRulesFingerprint:
|
|
583
|
+
existing.contentTypeRulesFingerprint ?? undefined,
|
|
584
|
+
changeJournal: false,
|
|
585
|
+
});
|
|
586
|
+
|
|
545
587
|
/**
|
|
546
588
|
* Simple semaphore for bounded concurrency.
|
|
547
589
|
*/
|
|
@@ -594,6 +636,30 @@ export class SyncService {
|
|
|
594
636
|
this.pipeline = pipeline ?? getDefaultPipeline();
|
|
595
637
|
}
|
|
596
638
|
|
|
639
|
+
private async selectRecordAdapter(
|
|
640
|
+
collection: Collection,
|
|
641
|
+
mime: string,
|
|
642
|
+
ext: string
|
|
643
|
+
): Promise<RecordAdapter | undefined> {
|
|
644
|
+
const transcriptConfig = collection.recordAdapters?.transcript;
|
|
645
|
+
if (transcriptConfig) {
|
|
646
|
+
const adapter = createTranscriptAdapter(transcriptConfig);
|
|
647
|
+
if (adapter.canHandle(mime, ext)) return adapter;
|
|
648
|
+
}
|
|
649
|
+
const jsonlConfig = collection.recordAdapters?.jsonl;
|
|
650
|
+
if (jsonlConfig) {
|
|
651
|
+
const adapter = createJsonlAdapter(jsonlConfig.fieldMapping);
|
|
652
|
+
if (adapter.canHandle(mime, ext)) return adapter;
|
|
653
|
+
}
|
|
654
|
+
const selectDefault = (
|
|
655
|
+
this.pipeline as ConversionPipeline & {
|
|
656
|
+
selectRecordAdapter?: ConversionPipeline["selectRecordAdapter"];
|
|
657
|
+
}
|
|
658
|
+
).selectRecordAdapter;
|
|
659
|
+
if (!selectDefault) return undefined;
|
|
660
|
+
return selectDefault.call(this.pipeline, mime, ext);
|
|
661
|
+
}
|
|
662
|
+
|
|
597
663
|
/**
|
|
598
664
|
* Process a single file through the ingestion pipeline.
|
|
599
665
|
* All store operations are checked and errors are propagated.
|
|
@@ -661,17 +727,57 @@ export class SyncService {
|
|
|
661
727
|
};
|
|
662
728
|
}
|
|
663
729
|
|
|
664
|
-
|
|
730
|
+
const extensionMime = this.mimeDetector.detect(
|
|
731
|
+
entry.relPath,
|
|
732
|
+
new Uint8Array()
|
|
733
|
+
);
|
|
734
|
+
const recordAdapter = await this.selectRecordAdapter(
|
|
735
|
+
collection,
|
|
736
|
+
extensionMime.mime,
|
|
737
|
+
extensionMime.ext
|
|
738
|
+
);
|
|
739
|
+
const contentTypeRules = options.contentTypeRules ?? [];
|
|
740
|
+
const contentTypeRulesFingerprint =
|
|
741
|
+
options.contentTypeRulesFingerprint ??
|
|
742
|
+
fingerprintContentTypeMetadataRules(contentTypeRules);
|
|
743
|
+
if (recordAdapter) {
|
|
744
|
+
return await processRecordContainer({
|
|
745
|
+
adapter: recordAdapter,
|
|
746
|
+
chunker: this.chunker,
|
|
747
|
+
collection,
|
|
748
|
+
contentTypeRules,
|
|
749
|
+
contentTypeRulesFingerprint,
|
|
750
|
+
entry,
|
|
751
|
+
ext: extensionMime.ext,
|
|
752
|
+
extractMetadata: extractDocumentMetadata,
|
|
753
|
+
ingestVersion: INGEST_VERSION,
|
|
754
|
+
mime: extensionMime.mime,
|
|
755
|
+
options,
|
|
756
|
+
sourceCtime,
|
|
757
|
+
sourceMtime,
|
|
758
|
+
sourceSize,
|
|
759
|
+
store,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const sniffBytes = new Uint8Array(
|
|
764
|
+
await Bun.file(entry.absPath).slice(0, 512).arrayBuffer()
|
|
765
|
+
);
|
|
766
|
+
const mime = this.mimeDetector.detect(entry.relPath, sniffBytes);
|
|
767
|
+
|
|
768
|
+
const priorRecordDocuments = mustOk(
|
|
769
|
+
await store.listRecordDocuments(collection.name, entry.relPath),
|
|
770
|
+
"listRecordDocuments",
|
|
771
|
+
{ collection: collection.name, relPath: entry.relPath }
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
// 2. Read byte-oriented files only after record-adapter selection.
|
|
665
775
|
const bytes = await Bun.file(entry.absPath).bytes();
|
|
666
776
|
|
|
667
777
|
// 3. Compute sourceHash
|
|
668
778
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
669
779
|
hasher.update(bytes);
|
|
670
780
|
const sourceHash = hasher.digest("hex");
|
|
671
|
-
const contentTypeRules = options.contentTypeRules ?? [];
|
|
672
|
-
const contentTypeRulesFingerprint =
|
|
673
|
-
options.contentTypeRulesFingerprint ??
|
|
674
|
-
fingerprintContentTypeMetadataRules(contentTypeRules);
|
|
675
781
|
|
|
676
782
|
// 4. Check existing doc for skip/repair decision
|
|
677
783
|
const existingResult = await store.getDocument(
|
|
@@ -686,12 +792,20 @@ export class SyncService {
|
|
|
686
792
|
);
|
|
687
793
|
|
|
688
794
|
if (decision.kind === "skip") {
|
|
795
|
+
const activeRecordPaths = priorRecordDocuments
|
|
796
|
+
.filter((document) => document.active)
|
|
797
|
+
.map((document) => document.relPath);
|
|
798
|
+
if (activeRecordPaths.length > 0) {
|
|
799
|
+
mustOk(
|
|
800
|
+
await store.markInactive(collection.name, activeRecordPaths),
|
|
801
|
+
"markInactive",
|
|
802
|
+
{ collection: collection.name, relPath: entry.relPath }
|
|
803
|
+
);
|
|
804
|
+
return { relPath: entry.relPath, status: "updated" };
|
|
805
|
+
}
|
|
689
806
|
return { relPath: entry.relPath, status: "unchanged" };
|
|
690
807
|
}
|
|
691
808
|
|
|
692
|
-
// 5. Detect MIME (bytes is already Uint8Array from Bun.file().bytes())
|
|
693
|
-
const mime = this.mimeDetector.detect(entry.absPath, bytes);
|
|
694
|
-
|
|
695
809
|
// 6. Convert via pipeline
|
|
696
810
|
const convertResult = await this.pipeline.convert({
|
|
697
811
|
sourcePath: entry.absPath,
|
|
@@ -938,7 +1052,19 @@ export class SyncService {
|
|
|
938
1052
|
linkCount: linkInputs.length,
|
|
939
1053
|
});
|
|
940
1054
|
|
|
941
|
-
const
|
|
1055
|
+
const activeRecordPaths = priorRecordDocuments
|
|
1056
|
+
.filter((document) => document.active)
|
|
1057
|
+
.map((document) => document.relPath);
|
|
1058
|
+
if (activeRecordPaths.length > 0) {
|
|
1059
|
+
mustOk(
|
|
1060
|
+
await store.markInactive(collection.name, activeRecordPaths),
|
|
1061
|
+
"markInactive",
|
|
1062
|
+
{ collection: collection.name, relPath: entry.relPath }
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const status =
|
|
1067
|
+
existing || priorRecordDocuments.length > 0 ? "updated" : "added";
|
|
942
1068
|
return {
|
|
943
1069
|
relPath: entry.relPath,
|
|
944
1070
|
status,
|
|
@@ -982,22 +1108,10 @@ export class SyncService {
|
|
|
982
1108
|
collection.name,
|
|
983
1109
|
entry.relPath
|
|
984
1110
|
);
|
|
985
|
-
if (existingResult.ok && existingResult.value) {
|
|
986
|
-
await store.upsertDocument(
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
sourceHash: existingResult.value.sourceHash,
|
|
990
|
-
sourceMime: existingResult.value.sourceMime,
|
|
991
|
-
sourceExt: existingResult.value.sourceExt,
|
|
992
|
-
sourceSize: existingResult.value.sourceSize,
|
|
993
|
-
sourceMtime: existingResult.value.sourceMtime,
|
|
994
|
-
sourceCtime:
|
|
995
|
-
existingResult.value.sourceCtime ??
|
|
996
|
-
existingResult.value.sourceMtime,
|
|
997
|
-
lastErrorCode: code,
|
|
998
|
-
lastErrorMessage: message,
|
|
999
|
-
changeJournal: false,
|
|
1000
|
-
});
|
|
1111
|
+
if (existingResult.ok && existingResult.value?.active) {
|
|
1112
|
+
await store.upsertDocument(
|
|
1113
|
+
preserveDocumentWithError(existingResult.value, code, message)
|
|
1114
|
+
);
|
|
1001
1115
|
}
|
|
1002
1116
|
} catch {
|
|
1003
1117
|
// Best-effort error recording
|
|
@@ -1065,6 +1179,20 @@ export class SyncService {
|
|
|
1065
1179
|
let markedInactive = 0;
|
|
1066
1180
|
|
|
1067
1181
|
for (const relPath of relPaths) {
|
|
1182
|
+
const recordDocumentsResult = await store.listRecordDocuments(
|
|
1183
|
+
collection.name,
|
|
1184
|
+
relPath
|
|
1185
|
+
);
|
|
1186
|
+
if (!recordDocumentsResult.ok) {
|
|
1187
|
+
results.push({
|
|
1188
|
+
relPath,
|
|
1189
|
+
status: "error",
|
|
1190
|
+
errorCode: recordDocumentsResult.error.code,
|
|
1191
|
+
errorMessage: recordDocumentsResult.error.message,
|
|
1192
|
+
});
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
const recordDocuments = recordDocumentsResult.value;
|
|
1068
1196
|
const existingResult = await store.getDocument(collection.name, relPath);
|
|
1069
1197
|
const existingDoc = existingResult.ok ? existingResult.value : null;
|
|
1070
1198
|
if (existingDoc) {
|
|
@@ -1074,6 +1202,13 @@ export class SyncService {
|
|
|
1074
1202
|
projectionSourceIds
|
|
1075
1203
|
);
|
|
1076
1204
|
}
|
|
1205
|
+
for (const recordDocument of recordDocuments) {
|
|
1206
|
+
await this.collectProjectionSourceIds(
|
|
1207
|
+
store,
|
|
1208
|
+
recordDocument.id,
|
|
1209
|
+
projectionSourceIds
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1077
1212
|
|
|
1078
1213
|
const absPath = join(collection.path, relPath);
|
|
1079
1214
|
let stats: Awaited<ReturnType<typeof stat>>;
|
|
@@ -1094,10 +1229,17 @@ export class SyncService {
|
|
|
1094
1229
|
});
|
|
1095
1230
|
continue;
|
|
1096
1231
|
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1232
|
+
const activePaths = [
|
|
1233
|
+
...(existingDoc?.active ? [relPath] : []),
|
|
1234
|
+
...recordDocuments
|
|
1235
|
+
.filter((document) => document.active)
|
|
1236
|
+
.map((document) => document.relPath),
|
|
1237
|
+
];
|
|
1238
|
+
if (activePaths.length > 0) {
|
|
1239
|
+
const inactiveResult = await store.markInactive(
|
|
1240
|
+
collection.name,
|
|
1241
|
+
activePaths
|
|
1242
|
+
);
|
|
1101
1243
|
if (!inactiveResult.ok) {
|
|
1102
1244
|
results.push({
|
|
1103
1245
|
relPath,
|
|
@@ -1111,7 +1253,7 @@ export class SyncService {
|
|
|
1111
1253
|
results.push({
|
|
1112
1254
|
relPath,
|
|
1113
1255
|
status: "updated",
|
|
1114
|
-
docid: existingDoc
|
|
1256
|
+
docid: existingDoc?.docid,
|
|
1115
1257
|
});
|
|
1116
1258
|
continue;
|
|
1117
1259
|
}
|
|
@@ -1133,8 +1275,39 @@ export class SyncService {
|
|
|
1133
1275
|
continue;
|
|
1134
1276
|
}
|
|
1135
1277
|
|
|
1278
|
+
let canonicalSourcePath: string;
|
|
1279
|
+
try {
|
|
1280
|
+
const [collectionRoot, sourcePath] = await Promise.all([
|
|
1281
|
+
realpath(collection.path),
|
|
1282
|
+
realpath(absPath),
|
|
1283
|
+
]);
|
|
1284
|
+
const sourceRelative = relative(collectionRoot, sourcePath);
|
|
1285
|
+
if (
|
|
1286
|
+
sourceRelative === ".." ||
|
|
1287
|
+
sourceRelative.startsWith(`..${sep}`) ||
|
|
1288
|
+
isAbsolute(sourceRelative)
|
|
1289
|
+
) {
|
|
1290
|
+
results.push({
|
|
1291
|
+
relPath,
|
|
1292
|
+
status: "error",
|
|
1293
|
+
errorCode: "PATH_OUTSIDE_COLLECTION",
|
|
1294
|
+
errorMessage: "Source path resolves outside the collection root.",
|
|
1295
|
+
});
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
canonicalSourcePath = sourcePath;
|
|
1299
|
+
} catch {
|
|
1300
|
+
results.push({
|
|
1301
|
+
relPath,
|
|
1302
|
+
status: "error",
|
|
1303
|
+
errorCode: "PATH_UNRESOLVED",
|
|
1304
|
+
errorMessage: "Source path could not be resolved safely.",
|
|
1305
|
+
});
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1136
1309
|
const entry: WalkEntry = {
|
|
1137
|
-
absPath,
|
|
1310
|
+
absPath: canonicalSourcePath,
|
|
1138
1311
|
relPath,
|
|
1139
1312
|
size: stats.size,
|
|
1140
1313
|
mtime: stats.mtime.toISOString(),
|
|
@@ -1157,6 +1330,21 @@ export class SyncService {
|
|
|
1157
1330
|
projectionSourceIds
|
|
1158
1331
|
);
|
|
1159
1332
|
}
|
|
1333
|
+
const currentDocuments = await store.listRecordDocuments(
|
|
1334
|
+
collection.name,
|
|
1335
|
+
relPath
|
|
1336
|
+
);
|
|
1337
|
+
if (currentDocuments.ok) {
|
|
1338
|
+
for (const recordDocument of currentDocuments.value) {
|
|
1339
|
+
if (recordDocument.active) {
|
|
1340
|
+
await this.collectProjectionSourceIds(
|
|
1341
|
+
store,
|
|
1342
|
+
recordDocument.id,
|
|
1343
|
+
projectionSourceIds
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1160
1348
|
}
|
|
1161
1349
|
|
|
1162
1350
|
const errors =
|
|
@@ -1600,7 +1788,11 @@ export class SyncService {
|
|
|
1600
1788
|
const existingDocsResult = await store.listDocuments(collection.name);
|
|
1601
1789
|
if (existingDocsResult.ok) {
|
|
1602
1790
|
const missingPaths = existingDocsResult.value
|
|
1603
|
-
.filter(
|
|
1791
|
+
.filter(
|
|
1792
|
+
(document) =>
|
|
1793
|
+
document.active &&
|
|
1794
|
+
!seenPaths.has(document.recordSourcePath ?? document.relPath)
|
|
1795
|
+
)
|
|
1604
1796
|
.map((d) => d.relPath);
|
|
1605
1797
|
|
|
1606
1798
|
if (missingPaths.length > 0) {
|
package/src/ingestion/types.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import type { NormalizedContentTypeRule } from "../config";
|
|
9
9
|
import type { Collection } from "../config/types";
|
|
10
|
+
import type {
|
|
11
|
+
RecordAdapterFailure,
|
|
12
|
+
RecordAttachmentInventoryItem,
|
|
13
|
+
} from "../converters/types";
|
|
10
14
|
|
|
11
15
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
16
|
// Walker Types
|
|
@@ -32,8 +36,10 @@ export interface WalkConfig {
|
|
|
32
36
|
root: string;
|
|
33
37
|
/** Glob pattern (default: **\/*) */
|
|
34
38
|
pattern: string;
|
|
35
|
-
/** Extension allowlist (empty =
|
|
39
|
+
/** Extension allowlist (empty = supported defaults) */
|
|
36
40
|
include: string[];
|
|
41
|
+
/** Adapter-configured extensions added only to the supported defaults. */
|
|
42
|
+
additionalDefaultExtensions?: string[];
|
|
37
43
|
/** Paths/patterns to exclude */
|
|
38
44
|
exclude: string[];
|
|
39
45
|
/** Max file size in bytes (files larger are skipped) */
|
|
@@ -147,6 +153,34 @@ export type ContentTypeSource =
|
|
|
147
153
|
| "path-ext"
|
|
148
154
|
| "fallback";
|
|
149
155
|
|
|
156
|
+
/** Maximum per-record actions retained in one sync receipt. */
|
|
157
|
+
export const MAX_RECORD_IMPORT_RECEIPT_ITEMS = 1_000;
|
|
158
|
+
|
|
159
|
+
export type RecordImportOutcome =
|
|
160
|
+
| "added"
|
|
161
|
+
| "updated"
|
|
162
|
+
| "reactivated"
|
|
163
|
+
| "unchanged"
|
|
164
|
+
| "deactivated"
|
|
165
|
+
| "preserved";
|
|
166
|
+
|
|
167
|
+
/** Bounded, privacy-safe identity and provenance for one reconciled record. */
|
|
168
|
+
export interface RecordImportItemReceipt {
|
|
169
|
+
outcome: RecordImportOutcome;
|
|
170
|
+
recordKey: string;
|
|
171
|
+
sourceLocator: string;
|
|
172
|
+
sourceHash: string;
|
|
173
|
+
mirrorHash?: string;
|
|
174
|
+
adapterFingerprint: string;
|
|
175
|
+
attachments: RecordAttachmentInventoryItem[];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface RecordImportWarning {
|
|
179
|
+
code: "PARTIAL_SNAPSHOT";
|
|
180
|
+
message: string;
|
|
181
|
+
retryable: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
150
184
|
/** Per-file sync status */
|
|
151
185
|
export type FileSyncStatus =
|
|
152
186
|
| "added"
|
|
@@ -165,6 +199,29 @@ export interface FileSyncResult {
|
|
|
165
199
|
contentTypeSource?: ContentTypeSource;
|
|
166
200
|
errorCode?: string;
|
|
167
201
|
errorMessage?: string;
|
|
202
|
+
recordImport?: {
|
|
203
|
+
adapterId: string;
|
|
204
|
+
adapterVersion: string;
|
|
205
|
+
adapterFingerprint: string;
|
|
206
|
+
snapshotState: "complete" | "partial";
|
|
207
|
+
authoritative: boolean;
|
|
208
|
+
stoppedByCap: boolean;
|
|
209
|
+
sourceBytesRead: number;
|
|
210
|
+
records: {
|
|
211
|
+
accepted: number;
|
|
212
|
+
added: number;
|
|
213
|
+
updated: number;
|
|
214
|
+
reactivated: number;
|
|
215
|
+
unchanged: number;
|
|
216
|
+
deactivated: number;
|
|
217
|
+
preserved: number;
|
|
218
|
+
failed: number;
|
|
219
|
+
};
|
|
220
|
+
items: RecordImportItemReceipt[];
|
|
221
|
+
itemsTruncated: number;
|
|
222
|
+
warnings: RecordImportWarning[];
|
|
223
|
+
failures: RecordAdapterFailure[];
|
|
224
|
+
};
|
|
168
225
|
}
|
|
169
226
|
|
|
170
227
|
/** Collection sync summary */
|
|
@@ -221,6 +278,13 @@ export interface LanguageDetectorPort {
|
|
|
221
278
|
// Helper to create WalkConfig from Collection
|
|
222
279
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
223
280
|
|
|
281
|
+
const TRANSCRIPT_EXTENSION_BY_FORMAT = {
|
|
282
|
+
json: ".json",
|
|
283
|
+
srt: ".srt",
|
|
284
|
+
text: ".txt",
|
|
285
|
+
vtt: ".vtt",
|
|
286
|
+
} as const;
|
|
287
|
+
|
|
224
288
|
/**
|
|
225
289
|
* Create WalkConfig from Collection with maxBytes override.
|
|
226
290
|
*/
|
|
@@ -228,10 +292,14 @@ export function collectionToWalkConfig(
|
|
|
228
292
|
collection: Collection,
|
|
229
293
|
maxBytes: number
|
|
230
294
|
): WalkConfig {
|
|
295
|
+
const transcriptFormat = collection.recordAdapters?.transcript?.format;
|
|
231
296
|
return {
|
|
232
297
|
root: collection.path,
|
|
233
298
|
pattern: collection.pattern,
|
|
234
299
|
include: collection.include,
|
|
300
|
+
additionalDefaultExtensions: transcriptFormat
|
|
301
|
+
? [TRANSCRIPT_EXTENSION_BY_FORMAT[transcriptFormat]]
|
|
302
|
+
: [],
|
|
235
303
|
exclude: collection.exclude,
|
|
236
304
|
maxBytes,
|
|
237
305
|
};
|
package/src/ingestion/walker.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type { SkippedEntry, WalkConfig, WalkEntry, WalkerPort } from "./types";
|
|
|
21
21
|
|
|
22
22
|
import { SUPPORTED_EXTENSIONS } from "../converters/mime";
|
|
23
23
|
import { matchesCollectionExclusion } from "../core/path-rules";
|
|
24
|
+
import { isRecordVirtualPath } from "./record-path";
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Regex to detect dangerous patterns with parent directory traversal.
|
|
@@ -124,7 +125,7 @@ function scanPatterns(pattern: string): string[] {
|
|
|
124
125
|
async function safeRelPath(
|
|
125
126
|
rootReal: string,
|
|
126
127
|
absPath: string
|
|
127
|
-
): Promise<string | null> {
|
|
128
|
+
): Promise<{ absPath: string; relPath: string } | null> {
|
|
128
129
|
try {
|
|
129
130
|
const fileReal = await realpath(absPath);
|
|
130
131
|
const rel = relative(rootReal, fileReal);
|
|
@@ -135,7 +136,7 @@ async function safeRelPath(
|
|
|
135
136
|
return null;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
return toPosixPath(rel);
|
|
139
|
+
return { absPath: fileReal, relPath: toPosixPath(rel) };
|
|
139
140
|
} catch {
|
|
140
141
|
// Can't resolve path (e.g., broken symlink)
|
|
141
142
|
return null;
|
|
@@ -145,18 +146,25 @@ async function safeRelPath(
|
|
|
145
146
|
/**
|
|
146
147
|
* Check if a file extension matches the include list.
|
|
147
148
|
* Include list contains extensions like ".md" or "md" (normalized).
|
|
148
|
-
* When include is empty, falls back to SUPPORTED_EXTENSIONS
|
|
149
|
-
*
|
|
149
|
+
* When include is empty, falls back to SUPPORTED_EXTENSIONS plus extensions
|
|
150
|
+
* made convertible by explicit adapter configuration.
|
|
150
151
|
*/
|
|
151
|
-
function matchesInclude(
|
|
152
|
+
function matchesInclude(
|
|
153
|
+
relPath: string,
|
|
154
|
+
include: string[],
|
|
155
|
+
additionalDefaultExtensions: string[]
|
|
156
|
+
): boolean {
|
|
152
157
|
const ext = extname(relPath).toLowerCase();
|
|
153
158
|
if (!ext) {
|
|
154
159
|
return false;
|
|
155
160
|
}
|
|
156
161
|
|
|
157
|
-
|
|
162
|
+
if (include.length === 0 && SUPPORTED_EXTENSIONS.includes(ext)) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
158
166
|
const effectiveInclude =
|
|
159
|
-
include.length === 0 ?
|
|
167
|
+
include.length === 0 ? additionalDefaultExtensions : include;
|
|
160
168
|
|
|
161
169
|
return effectiveInclude.some((inc) => {
|
|
162
170
|
const normalizedInc = inc.startsWith(".")
|
|
@@ -211,13 +219,19 @@ export class FileWalker implements WalkerPort {
|
|
|
211
219
|
}
|
|
212
220
|
}
|
|
213
221
|
|
|
214
|
-
for (const
|
|
222
|
+
for (const matchedPath of [...matches].sort()) {
|
|
215
223
|
// Security: Compute safe relative path (validates file is within root)
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
224
|
+
const safePath = await safeRelPath(rootReal, matchedPath);
|
|
225
|
+
if (safePath === null) {
|
|
218
226
|
// File outside root or unresolvable - silently skip (security)
|
|
219
227
|
continue;
|
|
220
228
|
}
|
|
229
|
+
const { absPath, relPath } = safePath;
|
|
230
|
+
|
|
231
|
+
if (isRecordVirtualPath(relPath)) {
|
|
232
|
+
skipped.push({ absPath, relPath, reason: "EXCLUDED" });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
221
235
|
|
|
222
236
|
// Check exclude patterns
|
|
223
237
|
if (matchesCollectionExclusion(relPath, config.exclude)) {
|
|
@@ -230,7 +244,13 @@ export class FileWalker implements WalkerPort {
|
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
// Check include extensions
|
|
233
|
-
if (
|
|
247
|
+
if (
|
|
248
|
+
!matchesInclude(
|
|
249
|
+
relPath,
|
|
250
|
+
config.include,
|
|
251
|
+
config.additionalDefaultExtensions ?? []
|
|
252
|
+
)
|
|
253
|
+
) {
|
|
234
254
|
skipped.push({
|
|
235
255
|
absPath,
|
|
236
256
|
relPath,
|