@remnic/core 1.1.13 → 1.1.15
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/dist/access-cli.js +34 -33
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +2 -1
- package/dist/access-http.js +15 -14
- package/dist/access-mcp.d.ts +2 -1
- package/dist/access-mcp.js +14 -13
- package/dist/access-schema.d.ts +22 -5
- package/dist/access-schema.js +7 -5
- package/dist/{access-service-DcCDmNYC.d.ts → access-service-BCMine1s.d.ts} +21 -1
- package/dist/access-service.d.ts +2 -1
- package/dist/access-service.js +12 -11
- package/dist/briefing.js +4 -4
- package/dist/causal-consolidation.js +5 -5
- package/dist/{chunk-VNO6ZJ35.js → chunk-2PRLKQAH.js} +5 -5
- package/dist/{chunk-M23FSH32.js → chunk-5D2G67ZQ.js} +53 -6
- package/dist/chunk-5D2G67ZQ.js.map +1 -0
- package/dist/{chunk-EFJ3MQ4V.js → chunk-65HQPW6O.js} +2 -2
- package/dist/{chunk-GA454ALV.js → chunk-AAX3SUM3.js} +39 -39
- package/dist/{chunk-QQUAB63I.js → chunk-BEB4GUU5.js} +2 -2
- package/dist/{chunk-WZYKANL3.js → chunk-BNATB54A.js} +4 -4
- package/dist/{chunk-KUJVMMZQ.js → chunk-C7DGCHJE.js} +2 -2
- package/dist/{chunk-PR5FBTFU.js → chunk-CYFQJMUV.js} +5 -5
- package/dist/{chunk-KLAO5DGL.js → chunk-G7JBLD65.js} +3 -3
- package/dist/chunk-GSP6ZKOY.js +769 -0
- package/dist/chunk-GSP6ZKOY.js.map +1 -0
- package/dist/{chunk-CQZRLNMV.js → chunk-HJ2WMBFB.js} +42 -4
- package/dist/chunk-HJ2WMBFB.js.map +1 -0
- package/dist/{chunk-ME6ESPZU.js → chunk-IG5VGHYB.js} +2 -2
- package/dist/{chunk-7AAT6G4Q.js → chunk-IOAY54RF.js} +57 -5
- package/dist/chunk-IOAY54RF.js.map +1 -0
- package/dist/{chunk-XVZ7B3HG.js → chunk-JFEH2LZM.js} +2 -2
- package/dist/{chunk-JLFA7DQG.js → chunk-M3AA636B.js} +2 -2
- package/dist/{chunk-P4NEIHUT.js → chunk-MS3ULOZF.js} +2 -2
- package/dist/{chunk-7IASACLB.js → chunk-NOHC2L57.js} +2 -2
- package/dist/{chunk-6RVI47ZR.js → chunk-NTUNYIF7.js} +5 -5
- package/dist/{chunk-CK5NTM2S.js → chunk-OGROP7ZN.js} +2 -2
- package/dist/{chunk-MT25YHYH.js → chunk-OJRKZLZ4.js} +5 -5
- package/dist/{chunk-2F2W355T.js → chunk-QA2ZAPBU.js} +4 -4
- package/dist/{chunk-MC26UJIM.js → chunk-QLKBF3TI.js} +2 -2
- package/dist/{chunk-YNJHCGDT.js → chunk-SH5S7XYD.js} +8 -5
- package/dist/chunk-SH5S7XYD.js.map +1 -0
- package/dist/{chunk-VW676BEI.js → chunk-V7WH7DEM.js} +2 -2
- package/dist/{chunk-A2XUIMJ3.js → chunk-VWFIQOTJ.js} +11 -2
- package/dist/chunk-VWFIQOTJ.js.map +1 -0
- package/dist/{chunk-PU63GXWS.js → chunk-W7DK3CYM.js} +2 -2
- package/dist/{chunk-TFO23QT4.js → chunk-XKLD5OK4.js} +4 -4
- package/dist/{chunk-I5V2VDIW.js → chunk-YCVWX2NF.js} +2 -2
- package/dist/{chunk-UXHQAFNA.js → chunk-ZPXYWTN5.js} +4 -4
- package/dist/{chunk-CHEL3SKB.js → chunk-ZYRMKWVW.js} +27 -27
- package/dist/{chunk-GGKRUQOO.js → chunk-ZYVPLJ4T.js} +4 -4
- package/dist/{cli-D3VpkVwB.d.ts → cli-B71zQ6XK.d.ts} +1 -1
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +35 -34
- package/dist/compounding/engine.js +4 -4
- package/dist/connectors/codex-materialize-runner.js +4 -4
- package/dist/connectors/index.js +4 -4
- package/dist/conversation-index/backend.js +2 -2
- package/dist/entity-retrieval.js +4 -4
- package/dist/index.d.ts +4 -3
- package/dist/index.js +90 -58
- package/dist/index.js.map +1 -1
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.js +5 -5
- package/dist/maintenance/memory-governance.js +4 -4
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
- package/dist/maintenance/rebuild-memory-projection.js +5 -5
- package/dist/mcp-memory-inspector-app.d.ts +2 -1
- package/dist/namespaces/migrate.js +10 -10
- package/dist/namespaces/search.js +5 -5
- package/dist/namespaces/storage.js +4 -4
- package/dist/offline-sync.d.ts +136 -0
- package/dist/offline-sync.js +41 -0
- package/dist/offline-sync.js.map +1 -0
- package/dist/operator-toolkit.js +13 -13
- package/dist/orchestrator.js +24 -24
- package/dist/search/factory.js +4 -4
- package/dist/search/index.js +6 -6
- package/dist/secure-store/index.d.ts +1 -15
- package/dist/secure-store/index.js +2 -2
- package/dist/semantic-consolidation.js +5 -5
- package/dist/semantic-rule-promotion.js +4 -4
- package/dist/semantic-rule-verifier.js +4 -4
- package/dist/storage.d.ts +7 -0
- package/dist/storage.js +3 -3
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +4 -4
- package/dist/transfer/capsule-import.js +3 -3
- package/dist/transfer/import-sqlite.js +2 -2
- package/dist/verified-recall.js +4 -4
- package/package.json +1 -1
- package/src/access-http.test.ts +216 -0
- package/src/access-http.ts +54 -0
- package/src/access-schema.ts +18 -0
- package/src/access-service.ts +76 -0
- package/src/index.ts +33 -0
- package/src/offline-sync.test.ts +521 -0
- package/src/offline-sync.ts +998 -0
- package/src/qmd.test.ts +1 -0
- package/src/secure-store/secure-fs.ts +14 -7
- package/src/storage.ts +59 -0
- package/dist/chunk-7AAT6G4Q.js.map +0 -1
- package/dist/chunk-A2XUIMJ3.js.map +0 -1
- package/dist/chunk-CQZRLNMV.js.map +0 -1
- package/dist/chunk-M23FSH32.js.map +0 -1
- package/dist/chunk-YNJHCGDT.js.map +0 -1
- /package/dist/{chunk-VNO6ZJ35.js.map → chunk-2PRLKQAH.js.map} +0 -0
- /package/dist/{chunk-EFJ3MQ4V.js.map → chunk-65HQPW6O.js.map} +0 -0
- /package/dist/{chunk-GA454ALV.js.map → chunk-AAX3SUM3.js.map} +0 -0
- /package/dist/{chunk-QQUAB63I.js.map → chunk-BEB4GUU5.js.map} +0 -0
- /package/dist/{chunk-WZYKANL3.js.map → chunk-BNATB54A.js.map} +0 -0
- /package/dist/{chunk-KUJVMMZQ.js.map → chunk-C7DGCHJE.js.map} +0 -0
- /package/dist/{chunk-PR5FBTFU.js.map → chunk-CYFQJMUV.js.map} +0 -0
- /package/dist/{chunk-KLAO5DGL.js.map → chunk-G7JBLD65.js.map} +0 -0
- /package/dist/{chunk-ME6ESPZU.js.map → chunk-IG5VGHYB.js.map} +0 -0
- /package/dist/{chunk-XVZ7B3HG.js.map → chunk-JFEH2LZM.js.map} +0 -0
- /package/dist/{chunk-JLFA7DQG.js.map → chunk-M3AA636B.js.map} +0 -0
- /package/dist/{chunk-P4NEIHUT.js.map → chunk-MS3ULOZF.js.map} +0 -0
- /package/dist/{chunk-7IASACLB.js.map → chunk-NOHC2L57.js.map} +0 -0
- /package/dist/{chunk-6RVI47ZR.js.map → chunk-NTUNYIF7.js.map} +0 -0
- /package/dist/{chunk-CK5NTM2S.js.map → chunk-OGROP7ZN.js.map} +0 -0
- /package/dist/{chunk-MT25YHYH.js.map → chunk-OJRKZLZ4.js.map} +0 -0
- /package/dist/{chunk-2F2W355T.js.map → chunk-QA2ZAPBU.js.map} +0 -0
- /package/dist/{chunk-MC26UJIM.js.map → chunk-QLKBF3TI.js.map} +0 -0
- /package/dist/{chunk-VW676BEI.js.map → chunk-V7WH7DEM.js.map} +0 -0
- /package/dist/{chunk-PU63GXWS.js.map → chunk-W7DK3CYM.js.map} +0 -0
- /package/dist/{chunk-TFO23QT4.js.map → chunk-XKLD5OK4.js.map} +0 -0
- /package/dist/{chunk-I5V2VDIW.js.map → chunk-YCVWX2NF.js.map} +0 -0
- /package/dist/{chunk-UXHQAFNA.js.map → chunk-ZPXYWTN5.js.map} +0 -0
- /package/dist/{chunk-CHEL3SKB.js.map → chunk-ZYRMKWVW.js.map} +0 -0
- /package/dist/{chunk-GGKRUQOO.js.map → chunk-ZYVPLJ4T.js.map} +0 -0
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
readdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
stat,
|
|
9
|
+
unlink,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_TRANSFER_EXCLUDE_DIRS,
|
|
15
|
+
} from "./transfer/exclusions.js";
|
|
16
|
+
import {
|
|
17
|
+
prepareSafeArchiveRoot,
|
|
18
|
+
resolveSafeArchiveTarget,
|
|
19
|
+
sha256Bytes,
|
|
20
|
+
validateArchiveRelativePath,
|
|
21
|
+
type SafeArchiveRoot,
|
|
22
|
+
} from "./transfer/fs-utils.js";
|
|
23
|
+
import { parseFlexibleIsoTimestamp } from "./utils/iso-timestamp.js";
|
|
24
|
+
|
|
25
|
+
export const OFFLINE_SYNC_SNAPSHOT_FORMAT = "remnic.offline-sync.snapshot.v1";
|
|
26
|
+
export const OFFLINE_SYNC_CHANGESET_FORMAT = "remnic.offline-sync.changeset.v1";
|
|
27
|
+
export const OFFLINE_SYNC_STATE_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
export interface OfflineSyncFileState {
|
|
30
|
+
path: string;
|
|
31
|
+
sha256: string;
|
|
32
|
+
/** Byte length of the transferable content, after any readFile hook such as secure-store decryption. */
|
|
33
|
+
bytes: number;
|
|
34
|
+
mtimeMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface OfflineSyncFileRecord extends OfflineSyncFileState {
|
|
38
|
+
contentBase64?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface OfflineSyncSnapshot {
|
|
42
|
+
format: typeof OFFLINE_SYNC_SNAPSHOT_FORMAT;
|
|
43
|
+
schemaVersion: 1;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
sourceId: string;
|
|
46
|
+
includeTranscripts: boolean;
|
|
47
|
+
files: OfflineSyncFileRecord[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type OfflineSyncChange =
|
|
51
|
+
| {
|
|
52
|
+
type: "upsert";
|
|
53
|
+
path: string;
|
|
54
|
+
baseSha256?: string;
|
|
55
|
+
file: OfflineSyncFileRecord & { contentBase64: string };
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
type: "delete";
|
|
59
|
+
path: string;
|
|
60
|
+
baseSha256: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface OfflineSyncChangeset {
|
|
64
|
+
format: typeof OFFLINE_SYNC_CHANGESET_FORMAT;
|
|
65
|
+
schemaVersion: 1;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
sourceId: string;
|
|
68
|
+
includeTranscripts: boolean;
|
|
69
|
+
changes: OfflineSyncChange[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface OfflineSyncState {
|
|
73
|
+
version: typeof OFFLINE_SYNC_STATE_VERSION;
|
|
74
|
+
remoteId: string;
|
|
75
|
+
namespace?: string;
|
|
76
|
+
includeTranscripts: boolean;
|
|
77
|
+
lastSyncedAt: string;
|
|
78
|
+
baseFiles: OfflineSyncFileState[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface OfflineSyncConflict {
|
|
82
|
+
path: string;
|
|
83
|
+
reason:
|
|
84
|
+
| "both_modified"
|
|
85
|
+
| "local_deleted_remote_modified"
|
|
86
|
+
| "local_modified_remote_deleted"
|
|
87
|
+
| "remote_exists_for_local_create"
|
|
88
|
+
| "remote_changed_for_local_update"
|
|
89
|
+
| "remote_deleted_for_local_update"
|
|
90
|
+
| "remote_changed_for_local_delete";
|
|
91
|
+
baseSha256?: string;
|
|
92
|
+
localSha256?: string;
|
|
93
|
+
incomingSha256?: string;
|
|
94
|
+
conflictPath?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface OfflineSyncApplySnapshotResult {
|
|
98
|
+
upserted: number;
|
|
99
|
+
deleted: number;
|
|
100
|
+
skipped: number;
|
|
101
|
+
pendingLocal: number;
|
|
102
|
+
conflicts: OfflineSyncConflict[];
|
|
103
|
+
nextBaseFiles: OfflineSyncFileState[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface OfflineSyncApplyChangesetResult {
|
|
107
|
+
appliedUpserts: number;
|
|
108
|
+
appliedDeletes: number;
|
|
109
|
+
skipped: number;
|
|
110
|
+
conflicts: OfflineSyncConflict[];
|
|
111
|
+
currentFiles: OfflineSyncFileState[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface OfflineSyncChangesetSummary {
|
|
115
|
+
upserts: number;
|
|
116
|
+
deletes: number;
|
|
117
|
+
total: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface OfflineSyncFileTarget {
|
|
121
|
+
root: string;
|
|
122
|
+
path: string;
|
|
123
|
+
filePath: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface OfflineSyncFileWriteTarget extends OfflineSyncFileTarget {
|
|
127
|
+
content: Buffer;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SYNC_INTERNAL_DIR = ".offline-sync";
|
|
131
|
+
const EXCLUDED_FILE_NAMES = new Set([
|
|
132
|
+
".sync-state.json",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
const EXCLUDED_REL_PATHS = new Set([
|
|
136
|
+
"state/fact-hashes.ready",
|
|
137
|
+
"state/fact-hashes.txt",
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
const EXCLUDED_FILE_PREFIXES = [
|
|
141
|
+
".remnic-sync.",
|
|
142
|
+
".remnic-sync-state.",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
function hashText(value: string): string {
|
|
146
|
+
return createHash("sha256").update(value).digest("hex");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sha256Buffer(buffer: Buffer): { sha256: string; bytes: number } {
|
|
150
|
+
return sha256Bytes(buffer);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function compareByPath<T extends { path: string }>(left: T, right: T): number {
|
|
154
|
+
return left.path.localeCompare(right.path);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function assertSha256(value: unknown, field: string): string {
|
|
158
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/i.test(value)) {
|
|
159
|
+
throw new Error(`${field} must be a 64-character sha256 hex string`);
|
|
160
|
+
}
|
|
161
|
+
return value.toLowerCase();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function assertNonNegativeInteger(value: unknown, field: string): number {
|
|
165
|
+
if (
|
|
166
|
+
typeof value !== "number" ||
|
|
167
|
+
!Number.isFinite(value) ||
|
|
168
|
+
!Number.isInteger(value) ||
|
|
169
|
+
value < 0
|
|
170
|
+
) {
|
|
171
|
+
throw new Error(`${field} must be a non-negative integer`);
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function assertNonNegativeFinite(value: unknown, field: string): number {
|
|
177
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
178
|
+
throw new Error(`${field} must be a non-negative finite number`);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function assertBoolean(value: unknown, field: string): boolean {
|
|
184
|
+
if (typeof value !== "boolean") {
|
|
185
|
+
throw new Error(`${field} must be a boolean`);
|
|
186
|
+
}
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeSourceId(value: unknown, field: string): string {
|
|
191
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.length > 512) {
|
|
192
|
+
throw new Error(`${field} must be a non-empty string no longer than 512 characters`);
|
|
193
|
+
}
|
|
194
|
+
return value.trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeFileState(input: unknown, fieldPrefix: string): OfflineSyncFileState {
|
|
198
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
199
|
+
throw new Error(`${fieldPrefix} must be an object`);
|
|
200
|
+
}
|
|
201
|
+
const obj = input as Record<string, unknown>;
|
|
202
|
+
const relPath = normalizeRelativePath(obj.path, `${fieldPrefix}.path`);
|
|
203
|
+
return {
|
|
204
|
+
path: relPath,
|
|
205
|
+
sha256: assertSha256(obj.sha256, `${fieldPrefix}.sha256`),
|
|
206
|
+
bytes: assertNonNegativeInteger(obj.bytes, `${fieldPrefix}.bytes`),
|
|
207
|
+
mtimeMs: assertNonNegativeFinite(obj.mtimeMs, `${fieldPrefix}.mtimeMs`),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeFileRecord(
|
|
212
|
+
input: unknown,
|
|
213
|
+
fieldPrefix: string,
|
|
214
|
+
requireContent: boolean,
|
|
215
|
+
): OfflineSyncFileRecord {
|
|
216
|
+
const state = normalizeFileState(input, fieldPrefix);
|
|
217
|
+
const obj = input as Record<string, unknown>;
|
|
218
|
+
const contentBase64 = obj.contentBase64;
|
|
219
|
+
if (requireContent && typeof contentBase64 !== "string") {
|
|
220
|
+
throw new Error(`${fieldPrefix}.contentBase64 is required`);
|
|
221
|
+
}
|
|
222
|
+
if (contentBase64 !== undefined && typeof contentBase64 !== "string") {
|
|
223
|
+
throw new Error(`${fieldPrefix}.contentBase64 must be a base64 string`);
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
...state,
|
|
227
|
+
...(contentBase64 !== undefined ? { contentBase64 } : {}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeFileStates(input: readonly unknown[] | undefined): OfflineSyncFileState[] {
|
|
232
|
+
if (!input) return [];
|
|
233
|
+
if (!Array.isArray(input)) {
|
|
234
|
+
throw new Error("baseFiles must be an array");
|
|
235
|
+
}
|
|
236
|
+
return input.map((entry, index) => normalizeFileState(entry, `baseFiles[${index}]`));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function normalizeOfflineSyncSnapshot(
|
|
240
|
+
input: unknown,
|
|
241
|
+
options: { requireContent?: boolean } = {},
|
|
242
|
+
): OfflineSyncSnapshot {
|
|
243
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
244
|
+
throw new Error("offline sync snapshot must be an object");
|
|
245
|
+
}
|
|
246
|
+
const obj = input as Record<string, unknown>;
|
|
247
|
+
if (obj.format !== OFFLINE_SYNC_SNAPSHOT_FORMAT) {
|
|
248
|
+
throw new Error(`offline sync snapshot format must be ${OFFLINE_SYNC_SNAPSHOT_FORMAT}`);
|
|
249
|
+
}
|
|
250
|
+
if (obj.schemaVersion !== 1) {
|
|
251
|
+
throw new Error("offline sync snapshot schemaVersion must be 1");
|
|
252
|
+
}
|
|
253
|
+
const createdAt = normalizeIsoString(obj.createdAt, "createdAt");
|
|
254
|
+
const sourceId = normalizeSourceId(obj.sourceId, "sourceId");
|
|
255
|
+
const includeTranscripts = assertBoolean(obj.includeTranscripts, "includeTranscripts");
|
|
256
|
+
if (!Array.isArray(obj.files)) {
|
|
257
|
+
throw new Error("offline sync snapshot files must be an array");
|
|
258
|
+
}
|
|
259
|
+
const files = obj.files
|
|
260
|
+
.map((entry, index) =>
|
|
261
|
+
normalizeFileRecord(entry, `files[${index}]`, options.requireContent === true))
|
|
262
|
+
.sort(compareByPath);
|
|
263
|
+
assertUniquePaths(files, "offline sync snapshot");
|
|
264
|
+
if (!includeTranscripts) {
|
|
265
|
+
const transcriptPath = files.find((file) => file.path.split("/")[0] === "transcripts")?.path;
|
|
266
|
+
if (transcriptPath) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`offline sync snapshot includeTranscripts is false but contains transcript path: ${transcriptPath}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const excludedPath = files.find((file) => shouldExcludeRelPath(file.path, true))?.path;
|
|
273
|
+
if (excludedPath) {
|
|
274
|
+
throw new Error(`offline sync snapshot contains excluded path: ${excludedPath}`);
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
format: OFFLINE_SYNC_SNAPSHOT_FORMAT,
|
|
278
|
+
schemaVersion: 1,
|
|
279
|
+
createdAt,
|
|
280
|
+
sourceId,
|
|
281
|
+
includeTranscripts,
|
|
282
|
+
files,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function normalizeOfflineSyncChangeset(input: unknown): OfflineSyncChangeset {
|
|
287
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
288
|
+
throw new Error("offline sync changeset must be an object");
|
|
289
|
+
}
|
|
290
|
+
const obj = input as Record<string, unknown>;
|
|
291
|
+
if (obj.format !== OFFLINE_SYNC_CHANGESET_FORMAT) {
|
|
292
|
+
throw new Error(`offline sync changeset format must be ${OFFLINE_SYNC_CHANGESET_FORMAT}`);
|
|
293
|
+
}
|
|
294
|
+
if (obj.schemaVersion !== 1) {
|
|
295
|
+
throw new Error("offline sync changeset schemaVersion must be 1");
|
|
296
|
+
}
|
|
297
|
+
const createdAt = normalizeIsoString(obj.createdAt, "createdAt");
|
|
298
|
+
const sourceId = normalizeSourceId(obj.sourceId, "sourceId");
|
|
299
|
+
const includeTranscripts = assertBoolean(obj.includeTranscripts, "includeTranscripts");
|
|
300
|
+
if (!Array.isArray(obj.changes)) {
|
|
301
|
+
throw new Error("offline sync changeset changes must be an array");
|
|
302
|
+
}
|
|
303
|
+
const changes = obj.changes.map((entry, index): OfflineSyncChange => {
|
|
304
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
305
|
+
throw new Error(`changes[${index}] must be an object`);
|
|
306
|
+
}
|
|
307
|
+
const change = entry as Record<string, unknown>;
|
|
308
|
+
const type = change.type;
|
|
309
|
+
const relPath = normalizeRelativePath(change.path, `changes[${index}].path`);
|
|
310
|
+
if (type === "upsert") {
|
|
311
|
+
const file = normalizeFileRecord(
|
|
312
|
+
change.file,
|
|
313
|
+
`changes[${index}].file`,
|
|
314
|
+
true,
|
|
315
|
+
) as OfflineSyncFileRecord & { contentBase64: string };
|
|
316
|
+
if (file.path !== relPath) {
|
|
317
|
+
throw new Error(`changes[${index}].file.path must match changes[${index}].path`);
|
|
318
|
+
}
|
|
319
|
+
const baseSha256 =
|
|
320
|
+
change.baseSha256 === undefined
|
|
321
|
+
? undefined
|
|
322
|
+
: assertSha256(change.baseSha256, `changes[${index}].baseSha256`);
|
|
323
|
+
return {
|
|
324
|
+
type: "upsert",
|
|
325
|
+
path: relPath,
|
|
326
|
+
...(baseSha256 ? { baseSha256 } : {}),
|
|
327
|
+
file,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (type === "delete") {
|
|
331
|
+
return {
|
|
332
|
+
type: "delete",
|
|
333
|
+
path: relPath,
|
|
334
|
+
baseSha256: assertSha256(change.baseSha256, `changes[${index}].baseSha256`),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
throw new Error(`changes[${index}].type must be "upsert" or "delete"`);
|
|
338
|
+
});
|
|
339
|
+
assertUniquePaths(changes, "offline sync changeset");
|
|
340
|
+
if (!includeTranscripts) {
|
|
341
|
+
const transcriptPath = changes.find((change) => change.path.split("/")[0] === "transcripts")?.path;
|
|
342
|
+
if (transcriptPath) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`offline sync changeset includeTranscripts is false but contains transcript path: ${transcriptPath}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const excludedPath = changes.find((change) => shouldExcludeRelPath(change.path, true))?.path;
|
|
349
|
+
if (excludedPath) {
|
|
350
|
+
throw new Error(`offline sync changeset contains excluded path: ${excludedPath}`);
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
format: OFFLINE_SYNC_CHANGESET_FORMAT,
|
|
354
|
+
schemaVersion: 1,
|
|
355
|
+
createdAt,
|
|
356
|
+
sourceId,
|
|
357
|
+
includeTranscripts,
|
|
358
|
+
changes: changes.sort(compareByPath),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function normalizeIsoString(input: unknown, field: string): string {
|
|
363
|
+
if (typeof input !== "string" || input.trim().length === 0) {
|
|
364
|
+
throw new Error(`${field} must be an ISO timestamp string`);
|
|
365
|
+
}
|
|
366
|
+
const parsed = parseFlexibleIsoTimestamp(input.trim());
|
|
367
|
+
if (parsed === null) {
|
|
368
|
+
throw new Error(`${field} must be a parseable ISO timestamp`);
|
|
369
|
+
}
|
|
370
|
+
return new Date(parsed).toISOString();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function normalizeRelativePath(input: unknown, field: string): string {
|
|
374
|
+
if (typeof input !== "string") {
|
|
375
|
+
throw new Error(`${field} must be a POSIX relative path string`);
|
|
376
|
+
}
|
|
377
|
+
return validateArchiveRelativePath(input, field);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function assertUniquePaths(entries: readonly { path: string }[], context: string): void {
|
|
381
|
+
const seen = new Set<string>();
|
|
382
|
+
for (const entry of entries) {
|
|
383
|
+
const key = entry.path.toLowerCase();
|
|
384
|
+
if (seen.has(key)) {
|
|
385
|
+
throw new Error(`${context} contains duplicate path: ${entry.path}`);
|
|
386
|
+
}
|
|
387
|
+
seen.add(key);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function shouldExcludeRelPath(relPosix: string, includeTranscripts: boolean): boolean {
|
|
392
|
+
const parts = relPosix.split("/");
|
|
393
|
+
if (parts.some((part) => DEFAULT_TRANSFER_EXCLUDE_DIRS.has(part))) return true;
|
|
394
|
+
if (parts.some((part) => part === SYNC_INTERNAL_DIR)) return true;
|
|
395
|
+
if (EXCLUDED_REL_PATHS.has(relPosix)) return true;
|
|
396
|
+
if (!includeTranscripts && parts[0] === "transcripts") return true;
|
|
397
|
+
const basename = parts[parts.length - 1] ?? "";
|
|
398
|
+
if (EXCLUDED_FILE_NAMES.has(basename)) return true;
|
|
399
|
+
return EXCLUDED_FILE_PREFIXES.some((prefix) => basename.startsWith(prefix));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function filterBaseFilesForMode(
|
|
403
|
+
files: readonly OfflineSyncFileState[],
|
|
404
|
+
includeTranscripts: boolean,
|
|
405
|
+
): OfflineSyncFileState[] {
|
|
406
|
+
return files.filter((file) => !shouldExcludeRelPath(file.path, includeTranscripts));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function buildOfflineSyncSnapshot(options: {
|
|
410
|
+
root: string;
|
|
411
|
+
sourceId: string;
|
|
412
|
+
includeContent?: boolean;
|
|
413
|
+
includeTranscripts?: boolean;
|
|
414
|
+
now?: Date;
|
|
415
|
+
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
416
|
+
}): Promise<OfflineSyncSnapshot> {
|
|
417
|
+
const rootAbs = path.resolve(options.root);
|
|
418
|
+
const root = await prepareSafeArchiveRoot(rootAbs, "buildOfflineSyncSnapshot", "root");
|
|
419
|
+
const includeTranscripts = options.includeTranscripts !== false;
|
|
420
|
+
const files: OfflineSyncFileRecord[] = [];
|
|
421
|
+
|
|
422
|
+
async function walk(dirAbs: string): Promise<void> {
|
|
423
|
+
let entries = await readdir(dirAbs, { withFileTypes: true });
|
|
424
|
+
entries = entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
const abs = path.join(dirAbs, entry.name);
|
|
427
|
+
const relPosix = path.relative(root.abs, abs).split(path.sep).join("/");
|
|
428
|
+
if (shouldExcludeRelPath(relPosix, includeTranscripts)) continue;
|
|
429
|
+
if (entry.isSymbolicLink()) continue;
|
|
430
|
+
if (entry.isDirectory()) {
|
|
431
|
+
await walk(abs);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (!entry.isFile()) continue;
|
|
435
|
+
const bytes = options.readFile
|
|
436
|
+
? await options.readFile({ root: root.abs, path: relPosix, filePath: abs })
|
|
437
|
+
: await readFile(abs);
|
|
438
|
+
const digest = sha256Buffer(bytes);
|
|
439
|
+
const st = await stat(abs);
|
|
440
|
+
files.push({
|
|
441
|
+
path: validateArchiveRelativePath(relPosix, "buildOfflineSyncSnapshot"),
|
|
442
|
+
sha256: digest.sha256,
|
|
443
|
+
bytes: digest.bytes,
|
|
444
|
+
mtimeMs: st.mtimeMs,
|
|
445
|
+
...(options.includeContent === true ? { contentBase64: bytes.toString("base64") } : {}),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
await walk(root.abs);
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
format: OFFLINE_SYNC_SNAPSHOT_FORMAT,
|
|
454
|
+
schemaVersion: 1,
|
|
455
|
+
createdAt: (options.now ?? new Date()).toISOString(),
|
|
456
|
+
sourceId: normalizeSourceId(options.sourceId, "sourceId"),
|
|
457
|
+
includeTranscripts,
|
|
458
|
+
files: files.sort(compareByPath),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export async function buildOfflineSyncChangeset(options: {
|
|
463
|
+
root: string;
|
|
464
|
+
sourceId: string;
|
|
465
|
+
baseFiles?: readonly OfflineSyncFileState[];
|
|
466
|
+
includeTranscripts?: boolean;
|
|
467
|
+
now?: Date;
|
|
468
|
+
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
469
|
+
}): Promise<OfflineSyncChangeset> {
|
|
470
|
+
const includeTranscripts = options.includeTranscripts !== false;
|
|
471
|
+
const base = byPath(filterBaseFilesForMode(
|
|
472
|
+
normalizeFileStates(options.baseFiles),
|
|
473
|
+
includeTranscripts,
|
|
474
|
+
));
|
|
475
|
+
const current = await buildOfflineSyncSnapshot({
|
|
476
|
+
root: options.root,
|
|
477
|
+
sourceId: options.sourceId,
|
|
478
|
+
includeContent: true,
|
|
479
|
+
includeTranscripts,
|
|
480
|
+
now: options.now,
|
|
481
|
+
readFile: options.readFile,
|
|
482
|
+
});
|
|
483
|
+
const currentMap = byPath(current.files);
|
|
484
|
+
const changes: OfflineSyncChange[] = [];
|
|
485
|
+
|
|
486
|
+
for (const relPath of unionPaths(base, currentMap)) {
|
|
487
|
+
const baseEntry = base.get(relPath);
|
|
488
|
+
const currentEntry = currentMap.get(relPath);
|
|
489
|
+
if (currentEntry && currentEntry.sha256 !== baseEntry?.sha256) {
|
|
490
|
+
changes.push({
|
|
491
|
+
type: "upsert",
|
|
492
|
+
path: relPath,
|
|
493
|
+
...(baseEntry ? { baseSha256: baseEntry.sha256 } : {}),
|
|
494
|
+
file: currentEntry as OfflineSyncFileRecord & { contentBase64: string },
|
|
495
|
+
});
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (!currentEntry && baseEntry) {
|
|
499
|
+
changes.push({
|
|
500
|
+
type: "delete",
|
|
501
|
+
path: relPath,
|
|
502
|
+
baseSha256: baseEntry.sha256,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
format: OFFLINE_SYNC_CHANGESET_FORMAT,
|
|
509
|
+
schemaVersion: 1,
|
|
510
|
+
createdAt: (options.now ?? new Date()).toISOString(),
|
|
511
|
+
sourceId: normalizeSourceId(options.sourceId, "sourceId"),
|
|
512
|
+
includeTranscripts: current.includeTranscripts,
|
|
513
|
+
changes: changes.sort(compareByPath),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function summarizeOfflineSyncChangeset(
|
|
518
|
+
changeset: OfflineSyncChangeset,
|
|
519
|
+
): OfflineSyncChangesetSummary {
|
|
520
|
+
const upserts = changeset.changes.filter((change) => change.type === "upsert").length;
|
|
521
|
+
const deletes = changeset.changes.filter((change) => change.type === "delete").length;
|
|
522
|
+
return {
|
|
523
|
+
upserts,
|
|
524
|
+
deletes,
|
|
525
|
+
total: changeset.changes.length,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function applyOfflineSyncSnapshot(options: {
|
|
530
|
+
root: string;
|
|
531
|
+
snapshot: unknown;
|
|
532
|
+
baseFiles?: readonly OfflineSyncFileState[];
|
|
533
|
+
writeConflictCopies?: boolean;
|
|
534
|
+
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
535
|
+
writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
|
|
536
|
+
deleteFile?: (target: OfflineSyncFileTarget) => Promise<void>;
|
|
537
|
+
}): Promise<OfflineSyncApplySnapshotResult> {
|
|
538
|
+
const snapshot = normalizeOfflineSyncSnapshot(options.snapshot, { requireContent: true });
|
|
539
|
+
const baseMap = byPath(filterBaseFilesForMode(
|
|
540
|
+
normalizeFileStates(options.baseFiles),
|
|
541
|
+
snapshot.includeTranscripts,
|
|
542
|
+
));
|
|
543
|
+
const incomingMap = byPath(snapshot.files);
|
|
544
|
+
const incomingBuffers = verifyRecordContents(snapshot.files, "offline sync snapshot");
|
|
545
|
+
const root = await ensureSyncRoot(options.root, "applyOfflineSyncSnapshot");
|
|
546
|
+
const current = await buildOfflineSyncSnapshot({
|
|
547
|
+
root: root.abs,
|
|
548
|
+
sourceId: "local",
|
|
549
|
+
includeContent: false,
|
|
550
|
+
includeTranscripts: snapshot.includeTranscripts,
|
|
551
|
+
readFile: options.readFile,
|
|
552
|
+
});
|
|
553
|
+
const currentMap = byPath(current.files);
|
|
554
|
+
const nextBase = new Map(baseMap);
|
|
555
|
+
const conflicts: OfflineSyncConflict[] = [];
|
|
556
|
+
let upserted = 0;
|
|
557
|
+
let deleted = 0;
|
|
558
|
+
let skipped = 0;
|
|
559
|
+
let pendingLocal = 0;
|
|
560
|
+
|
|
561
|
+
for (const relPath of unionPaths(baseMap, incomingMap, currentMap)) {
|
|
562
|
+
const base = baseMap.get(relPath);
|
|
563
|
+
const incoming = incomingMap.get(relPath);
|
|
564
|
+
const currentEntry = currentMap.get(relPath);
|
|
565
|
+
|
|
566
|
+
if (incoming) {
|
|
567
|
+
if (currentEntry?.sha256 === incoming.sha256) {
|
|
568
|
+
nextBase.set(relPath, toFileState(incoming));
|
|
569
|
+
skipped += 1;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (!currentEntry && base && incoming.sha256 === base.sha256) {
|
|
573
|
+
nextBase.set(relPath, base);
|
|
574
|
+
pendingLocal += 1;
|
|
575
|
+
skipped += 1;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (!currentEntry && base && incoming.sha256 !== base.sha256) {
|
|
579
|
+
conflicts.push(await recordConflict({
|
|
580
|
+
root,
|
|
581
|
+
relPath,
|
|
582
|
+
reason: "local_deleted_remote_modified",
|
|
583
|
+
baseSha256: base.sha256,
|
|
584
|
+
incomingSha256: incoming.sha256,
|
|
585
|
+
incomingBuffer: incomingBuffers.get(relPath),
|
|
586
|
+
writeConflictCopies: options.writeConflictCopies !== false,
|
|
587
|
+
sourceId: snapshot.sourceId,
|
|
588
|
+
writeFile: options.writeFile,
|
|
589
|
+
}));
|
|
590
|
+
nextBase.set(relPath, base);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (!currentEntry && !base) {
|
|
594
|
+
await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile);
|
|
595
|
+
nextBase.set(relPath, toFileState(incoming));
|
|
596
|
+
upserted += 1;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (base && currentEntry && currentEntry.sha256 === base.sha256) {
|
|
600
|
+
await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile);
|
|
601
|
+
nextBase.set(relPath, toFileState(incoming));
|
|
602
|
+
upserted += 1;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (base && incoming.sha256 === base.sha256) {
|
|
606
|
+
nextBase.set(relPath, base);
|
|
607
|
+
pendingLocal += 1;
|
|
608
|
+
skipped += 1;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
conflicts.push(await recordConflict({
|
|
612
|
+
root,
|
|
613
|
+
relPath,
|
|
614
|
+
reason: base ? "both_modified" : "remote_exists_for_local_create",
|
|
615
|
+
baseSha256: base?.sha256,
|
|
616
|
+
localSha256: currentEntry?.sha256,
|
|
617
|
+
incomingSha256: incoming.sha256,
|
|
618
|
+
incomingBuffer: incomingBuffers.get(relPath),
|
|
619
|
+
writeConflictCopies: options.writeConflictCopies !== false,
|
|
620
|
+
sourceId: snapshot.sourceId,
|
|
621
|
+
writeFile: options.writeFile,
|
|
622
|
+
}));
|
|
623
|
+
if (base) nextBase.set(relPath, base);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (!currentEntry) {
|
|
628
|
+
nextBase.delete(relPath);
|
|
629
|
+
skipped += 1;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (base && currentEntry.sha256 === base.sha256) {
|
|
633
|
+
await deleteSafeFile(root, relPath, options.deleteFile);
|
|
634
|
+
nextBase.delete(relPath);
|
|
635
|
+
deleted += 1;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (base) {
|
|
639
|
+
conflicts.push({
|
|
640
|
+
path: relPath,
|
|
641
|
+
reason: "local_modified_remote_deleted",
|
|
642
|
+
baseSha256: base.sha256,
|
|
643
|
+
localSha256: currentEntry.sha256,
|
|
644
|
+
});
|
|
645
|
+
nextBase.set(relPath, base);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
pendingLocal += 1;
|
|
649
|
+
skipped += 1;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
upserted,
|
|
654
|
+
deleted,
|
|
655
|
+
skipped,
|
|
656
|
+
pendingLocal,
|
|
657
|
+
conflicts,
|
|
658
|
+
nextBaseFiles: [...nextBase.values()].sort(compareByPath),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export async function applyOfflineSyncChangeset(options: {
|
|
663
|
+
root: string;
|
|
664
|
+
changeset: unknown;
|
|
665
|
+
writeConflictCopies?: boolean;
|
|
666
|
+
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
667
|
+
writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
|
|
668
|
+
deleteFile?: (target: OfflineSyncFileTarget) => Promise<void>;
|
|
669
|
+
}): Promise<OfflineSyncApplyChangesetResult> {
|
|
670
|
+
let changeset: OfflineSyncChangeset;
|
|
671
|
+
try {
|
|
672
|
+
changeset = normalizeOfflineSyncChangeset(options.changeset);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
675
|
+
throw new Error(
|
|
676
|
+
message.startsWith("offline sync")
|
|
677
|
+
? message
|
|
678
|
+
: `offline sync changeset invalid: ${message}`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
const root = await ensureSyncRoot(options.root, "applyOfflineSyncChangeset");
|
|
682
|
+
const records = changeset.changes
|
|
683
|
+
.filter((change): change is Extract<OfflineSyncChange, { type: "upsert" }> => change.type === "upsert")
|
|
684
|
+
.map((change) => change.file);
|
|
685
|
+
const incomingBuffers = verifyRecordContents(records, "offline sync changeset");
|
|
686
|
+
const current = await buildOfflineSyncSnapshot({
|
|
687
|
+
root: root.abs,
|
|
688
|
+
sourceId: "local",
|
|
689
|
+
includeContent: false,
|
|
690
|
+
includeTranscripts: changeset.includeTranscripts,
|
|
691
|
+
readFile: options.readFile,
|
|
692
|
+
});
|
|
693
|
+
const currentMap = byPath(current.files);
|
|
694
|
+
const conflicts: OfflineSyncConflict[] = [];
|
|
695
|
+
let appliedUpserts = 0;
|
|
696
|
+
let appliedDeletes = 0;
|
|
697
|
+
let skipped = 0;
|
|
698
|
+
|
|
699
|
+
for (const change of changeset.changes) {
|
|
700
|
+
const currentEntry = currentMap.get(change.path);
|
|
701
|
+
if (change.type === "upsert") {
|
|
702
|
+
if (currentEntry?.sha256 === change.file.sha256) {
|
|
703
|
+
skipped += 1;
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
if (!change.baseSha256) {
|
|
707
|
+
if (!currentEntry) {
|
|
708
|
+
await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile);
|
|
709
|
+
currentMap.set(change.path, toFileState(change.file));
|
|
710
|
+
appliedUpserts += 1;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
conflicts.push(await recordConflict({
|
|
714
|
+
root,
|
|
715
|
+
relPath: change.path,
|
|
716
|
+
reason: "remote_exists_for_local_create",
|
|
717
|
+
localSha256: currentEntry.sha256,
|
|
718
|
+
incomingSha256: change.file.sha256,
|
|
719
|
+
incomingBuffer: incomingBuffers.get(change.path),
|
|
720
|
+
writeConflictCopies: options.writeConflictCopies !== false,
|
|
721
|
+
sourceId: changeset.sourceId,
|
|
722
|
+
writeFile: options.writeFile,
|
|
723
|
+
}));
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (currentEntry?.sha256 === change.baseSha256) {
|
|
727
|
+
await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile);
|
|
728
|
+
currentMap.set(change.path, toFileState(change.file));
|
|
729
|
+
appliedUpserts += 1;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
conflicts.push(await recordConflict({
|
|
733
|
+
root,
|
|
734
|
+
relPath: change.path,
|
|
735
|
+
reason: currentEntry ? "remote_changed_for_local_update" : "remote_deleted_for_local_update",
|
|
736
|
+
baseSha256: change.baseSha256,
|
|
737
|
+
localSha256: currentEntry?.sha256,
|
|
738
|
+
incomingSha256: change.file.sha256,
|
|
739
|
+
incomingBuffer: incomingBuffers.get(change.path),
|
|
740
|
+
writeConflictCopies: options.writeConflictCopies !== false,
|
|
741
|
+
sourceId: changeset.sourceId,
|
|
742
|
+
writeFile: options.writeFile,
|
|
743
|
+
}));
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (!currentEntry) {
|
|
748
|
+
skipped += 1;
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (currentEntry.sha256 === change.baseSha256) {
|
|
752
|
+
await deleteSafeFile(root, change.path, options.deleteFile);
|
|
753
|
+
currentMap.delete(change.path);
|
|
754
|
+
appliedDeletes += 1;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
conflicts.push({
|
|
758
|
+
path: change.path,
|
|
759
|
+
reason: "remote_changed_for_local_delete",
|
|
760
|
+
baseSha256: change.baseSha256,
|
|
761
|
+
localSha256: currentEntry.sha256,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
appliedUpserts,
|
|
767
|
+
appliedDeletes,
|
|
768
|
+
skipped,
|
|
769
|
+
conflicts,
|
|
770
|
+
currentFiles: [...currentMap.values()].sort(compareByPath),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function verifyRecordContents(
|
|
775
|
+
records: readonly OfflineSyncFileRecord[],
|
|
776
|
+
context: string,
|
|
777
|
+
): Map<string, Buffer> {
|
|
778
|
+
const buffers = new Map<string, Buffer>();
|
|
779
|
+
for (const record of records) {
|
|
780
|
+
if (typeof record.contentBase64 !== "string") {
|
|
781
|
+
throw new Error(`${context}: contentBase64 is required for ${record.path}`);
|
|
782
|
+
}
|
|
783
|
+
const buffer = Buffer.from(record.contentBase64, "base64");
|
|
784
|
+
const digest = sha256Buffer(buffer);
|
|
785
|
+
if (digest.sha256 !== record.sha256 || digest.bytes !== record.bytes) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
`${context}: content checksum mismatch for ${record.path}`,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
buffers.set(record.path, buffer);
|
|
791
|
+
}
|
|
792
|
+
return buffers;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function requiredBuffer(buffers: Map<string, Buffer>, relPath: string): Buffer {
|
|
796
|
+
const buffer = buffers.get(relPath);
|
|
797
|
+
if (!buffer) {
|
|
798
|
+
throw new Error(`missing decoded content for ${relPath}`);
|
|
799
|
+
}
|
|
800
|
+
return buffer;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function ensureSyncRoot(rootPath: string, errorPrefix: string): Promise<SafeArchiveRoot> {
|
|
804
|
+
const rootAbs = path.resolve(rootPath);
|
|
805
|
+
await mkdir(rootAbs, { recursive: true });
|
|
806
|
+
return prepareSafeArchiveRoot(rootAbs, errorPrefix, "root");
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function byPath<T extends OfflineSyncFileState>(files: readonly T[]): Map<string, T> {
|
|
810
|
+
const out = new Map<string, T>();
|
|
811
|
+
for (const file of files) {
|
|
812
|
+
out.set(validateArchiveRelativePath(file.path, "offlineSync"), file);
|
|
813
|
+
}
|
|
814
|
+
return out;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function unionPaths(...maps: Array<Map<string, unknown>>): string[] {
|
|
818
|
+
const paths = new Set<string>();
|
|
819
|
+
for (const map of maps) {
|
|
820
|
+
for (const key of map.keys()) paths.add(key);
|
|
821
|
+
}
|
|
822
|
+
return [...paths].sort();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function toFileState(file: OfflineSyncFileState): OfflineSyncFileState {
|
|
826
|
+
return {
|
|
827
|
+
path: file.path,
|
|
828
|
+
sha256: file.sha256,
|
|
829
|
+
bytes: file.bytes,
|
|
830
|
+
mtimeMs: file.mtimeMs,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function writeSafeFile(
|
|
835
|
+
root: SafeArchiveRoot,
|
|
836
|
+
relPath: string,
|
|
837
|
+
content: Buffer,
|
|
838
|
+
writeFileHook?: (target: OfflineSyncFileWriteTarget) => Promise<void>,
|
|
839
|
+
): Promise<void> {
|
|
840
|
+
const target = await resolveSafeArchiveTarget(root, relPath);
|
|
841
|
+
if (writeFileHook) {
|
|
842
|
+
await writeFileHook({ root: root.abs, path: relPath, filePath: target, content });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
846
|
+
const tmp = path.join(
|
|
847
|
+
path.dirname(target),
|
|
848
|
+
`.remnic-sync.${process.pid}.${randomUUID()}.tmp`,
|
|
849
|
+
);
|
|
850
|
+
await writeFile(tmp, content);
|
|
851
|
+
try {
|
|
852
|
+
const targetStat = await lstat(target).catch((error: unknown) => {
|
|
853
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
854
|
+
throw error;
|
|
855
|
+
});
|
|
856
|
+
if (targetStat?.isSymbolicLink()) {
|
|
857
|
+
throw new Error(`offline sync target is a symlink: ${relPath}`);
|
|
858
|
+
}
|
|
859
|
+
await rename(tmp, target);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
await unlink(tmp).catch(() => {});
|
|
862
|
+
throw error;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async function deleteSafeFile(
|
|
867
|
+
root: SafeArchiveRoot,
|
|
868
|
+
relPath: string,
|
|
869
|
+
deleteFile?: (target: OfflineSyncFileTarget) => Promise<void>,
|
|
870
|
+
): Promise<void> {
|
|
871
|
+
const target = await resolveSafeArchiveTarget(root, relPath);
|
|
872
|
+
if (deleteFile) {
|
|
873
|
+
await deleteFile({ root: root.abs, path: relPath, filePath: target });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
await unlink(target).catch((error: unknown) => {
|
|
877
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
878
|
+
throw error;
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function recordConflict(options: {
|
|
883
|
+
root: SafeArchiveRoot;
|
|
884
|
+
relPath: string;
|
|
885
|
+
reason: OfflineSyncConflict["reason"];
|
|
886
|
+
baseSha256?: string;
|
|
887
|
+
localSha256?: string;
|
|
888
|
+
incomingSha256?: string;
|
|
889
|
+
incomingBuffer?: Buffer;
|
|
890
|
+
writeConflictCopies: boolean;
|
|
891
|
+
sourceId: string;
|
|
892
|
+
writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
|
|
893
|
+
}): Promise<OfflineSyncConflict> {
|
|
894
|
+
let conflictPath: string | undefined;
|
|
895
|
+
if (options.writeConflictCopies && options.incomingBuffer) {
|
|
896
|
+
const sourceHash = hashText(options.sourceId).slice(0, 12);
|
|
897
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
898
|
+
conflictPath = `${SYNC_INTERNAL_DIR}/conflicts/${stamp}-${sourceHash}/${options.relPath}`;
|
|
899
|
+
await writeSafeFile(options.root, conflictPath, options.incomingBuffer, options.writeFile);
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
path: options.relPath,
|
|
903
|
+
reason: options.reason,
|
|
904
|
+
baseSha256: options.baseSha256,
|
|
905
|
+
localSha256: options.localSha256,
|
|
906
|
+
incomingSha256: options.incomingSha256,
|
|
907
|
+
...(conflictPath ? { conflictPath } : {}),
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export function defaultOfflineSyncStatePath(
|
|
912
|
+
memoryDir: string,
|
|
913
|
+
remoteId: string,
|
|
914
|
+
namespace?: string,
|
|
915
|
+
): string {
|
|
916
|
+
const key = hashText(`${remoteId}\0${namespace ?? ""}`).slice(0, 16);
|
|
917
|
+
return path.join(path.resolve(memoryDir), SYNC_INTERNAL_DIR, "state", `${key}.json`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export async function readOfflineSyncState(
|
|
921
|
+
statePath: string,
|
|
922
|
+
): Promise<OfflineSyncState | null> {
|
|
923
|
+
let raw: string;
|
|
924
|
+
try {
|
|
925
|
+
raw = await readFile(path.resolve(statePath), "utf-8");
|
|
926
|
+
} catch (error) {
|
|
927
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
928
|
+
throw error;
|
|
929
|
+
}
|
|
930
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
931
|
+
return normalizeOfflineSyncState(parsed);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export async function writeOfflineSyncState(
|
|
935
|
+
statePath: string,
|
|
936
|
+
state: OfflineSyncState,
|
|
937
|
+
): Promise<void> {
|
|
938
|
+
const normalized = normalizeOfflineSyncState(state);
|
|
939
|
+
const target = path.resolve(statePath);
|
|
940
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
941
|
+
const tmp = path.join(
|
|
942
|
+
path.dirname(target),
|
|
943
|
+
`.remnic-sync-state.${process.pid}.${randomUUID()}.tmp`,
|
|
944
|
+
);
|
|
945
|
+
await writeFile(tmp, JSON.stringify(normalized, null, 2) + "\n", "utf-8");
|
|
946
|
+
try {
|
|
947
|
+
await rename(tmp, target);
|
|
948
|
+
} catch (error) {
|
|
949
|
+
await unlink(tmp).catch(() => {});
|
|
950
|
+
throw error;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export function offlineSyncStateFromSnapshot(options: {
|
|
955
|
+
remoteId: string;
|
|
956
|
+
namespace?: string;
|
|
957
|
+
snapshot: OfflineSyncSnapshot;
|
|
958
|
+
baseFiles?: readonly OfflineSyncFileState[];
|
|
959
|
+
}): OfflineSyncState {
|
|
960
|
+
const snapshot = normalizeOfflineSyncSnapshot(options.snapshot);
|
|
961
|
+
return normalizeOfflineSyncState({
|
|
962
|
+
version: OFFLINE_SYNC_STATE_VERSION,
|
|
963
|
+
remoteId: options.remoteId,
|
|
964
|
+
namespace: options.namespace,
|
|
965
|
+
includeTranscripts: snapshot.includeTranscripts,
|
|
966
|
+
lastSyncedAt: new Date().toISOString(),
|
|
967
|
+
baseFiles: options.baseFiles ?? snapshot.files.map(toFileState),
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export function normalizeOfflineSyncState(input: unknown): OfflineSyncState {
|
|
972
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
973
|
+
throw new Error("offline sync state must be an object");
|
|
974
|
+
}
|
|
975
|
+
const obj = input as Record<string, unknown>;
|
|
976
|
+
if (obj.version !== OFFLINE_SYNC_STATE_VERSION) {
|
|
977
|
+
throw new Error(`offline sync state version must be ${OFFLINE_SYNC_STATE_VERSION}`);
|
|
978
|
+
}
|
|
979
|
+
const namespace =
|
|
980
|
+
typeof obj.namespace === "string" && obj.namespace.trim().length > 0
|
|
981
|
+
? obj.namespace.trim()
|
|
982
|
+
: undefined;
|
|
983
|
+
const baseFiles = normalizeFileStates(obj.baseFiles as readonly unknown[] | undefined)
|
|
984
|
+
.sort(compareByPath);
|
|
985
|
+
assertUniquePaths(baseFiles, "offline sync state");
|
|
986
|
+
return {
|
|
987
|
+
version: OFFLINE_SYNC_STATE_VERSION,
|
|
988
|
+
remoteId: normalizeSourceId(obj.remoteId, "remoteId"),
|
|
989
|
+
...(namespace ? { namespace } : {}),
|
|
990
|
+
includeTranscripts: assertBoolean(obj.includeTranscripts, "includeTranscripts"),
|
|
991
|
+
lastSyncedAt: normalizeIsoString(obj.lastSyncedAt, "lastSyncedAt"),
|
|
992
|
+
baseFiles,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
export function fileStatesFromSnapshot(snapshot: OfflineSyncSnapshot): OfflineSyncFileState[] {
|
|
997
|
+
return normalizeOfflineSyncSnapshot(snapshot).files.map(toFileState).sort(compareByPath);
|
|
998
|
+
}
|