@remnic/core 1.1.29 → 1.1.31
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 +13 -13
- package/dist/access-http.d.ts +2 -1
- package/dist/access-http.js +8 -8
- package/dist/access-mcp.d.ts +1 -1
- package/dist/access-mcp.js +7 -7
- package/dist/access-schema.d.ts +55 -5
- package/dist/access-schema.js +4 -2
- package/dist/{access-service-CEyV8XJ5.d.ts → access-service-CkZyb35d.d.ts} +10 -2
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +5 -5
- package/dist/briefing.js +2 -2
- package/dist/causal-consolidation.js +3 -3
- package/dist/{chunk-25YQM6XW.js → chunk-2IWUMAES.js} +3 -3
- package/dist/{chunk-6CB4E7ZV.js → chunk-3ZLVGM76.js} +4 -4
- package/dist/{chunk-QYHQ2JHL.js → chunk-43PJZYGL.js} +2 -2
- package/dist/{chunk-YITUHONZ.js → chunk-4KGVTPGD.js} +2 -2
- package/dist/{chunk-TR4DK5OH.js → chunk-76FLAAUC.js} +2 -2
- package/dist/{chunk-6BFAEWQS.js → chunk-77H5NU3M.js} +2 -2
- package/dist/{chunk-IANK6Y5W.js → chunk-A6KTB5R6.js} +2 -2
- package/dist/{chunk-7D6O46PF.js → chunk-BVF3AGJP.js} +2 -2
- package/dist/{chunk-4H6DURG6.js → chunk-JA3AK3PT.js} +2 -2
- package/dist/{chunk-RCZRL5BE.js → chunk-MRILGULB.js} +2 -2
- package/dist/{chunk-CWWDIQZB.js → chunk-QLLBRHAT.js} +8 -8
- package/dist/{chunk-2WIPXV3Y.js → chunk-RR2PKP3I.js} +2 -2
- package/dist/{chunk-3F24QTRI.js → chunk-SAZS2QZB.js} +2 -2
- package/dist/{chunk-VYU7PXUS.js → chunk-SIC6U3GZ.js} +2 -2
- package/dist/{chunk-WDSIV3AK.js → chunk-TPU5L5EY.js} +12 -12
- package/dist/{chunk-AMVN77EU.js → chunk-U7EJOMFC.js} +371 -91
- package/dist/chunk-U7EJOMFC.js.map +1 -0
- package/dist/{chunk-F33CJ5CH.js → chunk-VBJ7V5SK.js} +40 -8
- package/dist/chunk-VBJ7V5SK.js.map +1 -0
- package/dist/{chunk-6WV2HYTZ.js → chunk-W6AQJ2PY.js} +4 -4
- package/dist/{chunk-PUXCIHRL.js → chunk-XSZEP4SF.js} +2 -2
- package/dist/{chunk-NW7JW5GA.js → chunk-YROHKYBY.js} +41 -6
- package/dist/chunk-YROHKYBY.js.map +1 -0
- package/dist/{chunk-JUYT2J3K.js → chunk-YU5KIWYQ.js} +136 -8
- package/dist/chunk-YU5KIWYQ.js.map +1 -0
- package/dist/{chunk-LCTP7YRU.js → chunk-ZAVUCJ4H.js} +38 -7
- package/dist/chunk-ZAVUCJ4H.js.map +1 -0
- package/dist/{cli-BguVmIwO.d.ts → cli-kuh9PwZ5.d.ts} +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +17 -17
- package/dist/compounding/engine.js +2 -2
- package/dist/connectors/codex-materialize-runner.js +2 -2
- package/dist/connectors/index.js +2 -2
- package/dist/entity-retrieval.js +2 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +34 -22
- package/dist/index.js.map +1 -1
- package/dist/maintenance/memory-governance.js +2 -2
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +2 -2
- package/dist/maintenance/rebuild-memory-projection.js +3 -3
- package/dist/mcp-memory-inspector-app.d.ts +1 -1
- package/dist/namespaces/migrate.js +3 -3
- package/dist/namespaces/storage.js +2 -2
- package/dist/offline-sync.d.ts +56 -1
- package/dist/offline-sync.js +15 -1
- package/dist/operator-toolkit.js +5 -5
- package/dist/orchestrator.js +9 -9
- package/dist/schemas.d.ts +22 -22
- package/dist/semantic-consolidation.js +3 -3
- package/dist/semantic-rule-promotion.js +2 -2
- package/dist/semantic-rule-verifier.js +2 -2
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +1 -1
- package/dist/transfer/types.d.ts +12 -12
- package/dist/verified-recall.js +2 -2
- package/package.json +1 -1
- package/src/access-http.test.ts +355 -0
- package/src/access-http.ts +149 -1
- package/src/access-schema.ts +58 -3
- package/src/access-service-namespace.test.ts +56 -1
- package/src/access-service-offline-file-content.test.ts +17 -0
- package/src/access-service.ts +47 -1
- package/src/index.ts +7 -0
- package/src/offline-sync.test.ts +1055 -1
- package/src/offline-sync.ts +465 -97
- package/src/storage.ts +36 -2
- package/dist/chunk-AMVN77EU.js.map +0 -1
- package/dist/chunk-F33CJ5CH.js.map +0 -1
- package/dist/chunk-JUYT2J3K.js.map +0 -1
- package/dist/chunk-LCTP7YRU.js.map +0 -1
- package/dist/chunk-NW7JW5GA.js.map +0 -1
- /package/dist/{chunk-25YQM6XW.js.map → chunk-2IWUMAES.js.map} +0 -0
- /package/dist/{chunk-6CB4E7ZV.js.map → chunk-3ZLVGM76.js.map} +0 -0
- /package/dist/{chunk-QYHQ2JHL.js.map → chunk-43PJZYGL.js.map} +0 -0
- /package/dist/{chunk-YITUHONZ.js.map → chunk-4KGVTPGD.js.map} +0 -0
- /package/dist/{chunk-TR4DK5OH.js.map → chunk-76FLAAUC.js.map} +0 -0
- /package/dist/{chunk-6BFAEWQS.js.map → chunk-77H5NU3M.js.map} +0 -0
- /package/dist/{chunk-IANK6Y5W.js.map → chunk-A6KTB5R6.js.map} +0 -0
- /package/dist/{chunk-7D6O46PF.js.map → chunk-BVF3AGJP.js.map} +0 -0
- /package/dist/{chunk-4H6DURG6.js.map → chunk-JA3AK3PT.js.map} +0 -0
- /package/dist/{chunk-RCZRL5BE.js.map → chunk-MRILGULB.js.map} +0 -0
- /package/dist/{chunk-CWWDIQZB.js.map → chunk-QLLBRHAT.js.map} +0 -0
- /package/dist/{chunk-2WIPXV3Y.js.map → chunk-RR2PKP3I.js.map} +0 -0
- /package/dist/{chunk-3F24QTRI.js.map → chunk-SAZS2QZB.js.map} +0 -0
- /package/dist/{chunk-VYU7PXUS.js.map → chunk-SIC6U3GZ.js.map} +0 -0
- /package/dist/{chunk-WDSIV3AK.js.map → chunk-TPU5L5EY.js.map} +0 -0
- /package/dist/{chunk-6WV2HYTZ.js.map → chunk-W6AQJ2PY.js.map} +0 -0
- /package/dist/{chunk-PUXCIHRL.js.map → chunk-XSZEP4SF.js.map} +0 -0
package/src/offline-sync.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
2
3
|
import {
|
|
3
4
|
lstat,
|
|
4
5
|
mkdir,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
rm,
|
|
10
11
|
stat,
|
|
11
12
|
unlink,
|
|
13
|
+
utimes,
|
|
12
14
|
writeFile,
|
|
13
15
|
} from "node:fs/promises";
|
|
14
16
|
import path from "node:path";
|
|
@@ -31,6 +33,8 @@ export const OFFLINE_SYNC_STATE_VERSION = 1;
|
|
|
31
33
|
export const OFFLINE_SYNC_FILE_CONTENT_MAX_CHUNK_BYTES = 64 * 1024 * 1024;
|
|
32
34
|
export const OFFLINE_SYNC_FILE_CONTENT_TRANSFER_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
33
35
|
export const OFFLINE_SYNC_APPLY_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
36
|
+
export const OFFLINE_SYNC_SNAPSHOT_BASE_MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
37
|
+
export const OFFLINE_SYNC_MAX_MTIME_MS = 8_640_000_000_000_000;
|
|
34
38
|
|
|
35
39
|
export interface OfflineSyncFileState {
|
|
36
40
|
path: string;
|
|
@@ -44,6 +48,11 @@ export interface OfflineSyncFileRecord extends OfflineSyncFileState {
|
|
|
44
48
|
contentBase64?: string;
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
export interface OfflineSyncFileDigest {
|
|
52
|
+
sha256: string;
|
|
53
|
+
bytes: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
export interface OfflineSyncSnapshot {
|
|
48
57
|
format: typeof OFFLINE_SYNC_SNAPSHOT_FORMAT;
|
|
49
58
|
schemaVersion: 1;
|
|
@@ -115,6 +124,7 @@ export interface OfflineSyncApplyChangesetResult {
|
|
|
115
124
|
skipped: number;
|
|
116
125
|
conflicts: OfflineSyncConflict[];
|
|
117
126
|
currentFiles: OfflineSyncFileState[];
|
|
127
|
+
currentFilesComplete?: boolean;
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
export interface OfflineSyncChangesetSummary {
|
|
@@ -172,10 +182,12 @@ interface OfflineSyncFileRecordOptions {
|
|
|
172
182
|
filePath: string;
|
|
173
183
|
includeContent: boolean;
|
|
174
184
|
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
185
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
const SYNC_INTERNAL_DIR = ".offline-sync";
|
|
178
189
|
const OFFLINE_SYNC_UPLOAD_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
190
|
+
const OFFLINE_SYNC_FAST_BASE_MTIME_TOLERANCE_MS = 1_000;
|
|
179
191
|
const EXCLUDED_FILE_NAMES = new Set([
|
|
180
192
|
".sync-state.json",
|
|
181
193
|
]);
|
|
@@ -223,6 +235,14 @@ function assertNonNegativeFinite(value: unknown, field: string): number {
|
|
|
223
235
|
return value;
|
|
224
236
|
}
|
|
225
237
|
|
|
238
|
+
function assertOfflineSyncMtimeMs(value: unknown, field: string): number {
|
|
239
|
+
const mtimeMs = assertNonNegativeFinite(value, field);
|
|
240
|
+
if (mtimeMs > OFFLINE_SYNC_MAX_MTIME_MS) {
|
|
241
|
+
throw new Error(`${field} must be within JavaScript Date range`);
|
|
242
|
+
}
|
|
243
|
+
return mtimeMs;
|
|
244
|
+
}
|
|
245
|
+
|
|
226
246
|
function assertBoolean(value: unknown, field: string): boolean {
|
|
227
247
|
if (typeof value !== "boolean") {
|
|
228
248
|
throw new Error(`${field} must be a boolean`);
|
|
@@ -247,7 +267,7 @@ function normalizeFileState(input: unknown, fieldPrefix: string): OfflineSyncFil
|
|
|
247
267
|
path: relPath,
|
|
248
268
|
sha256: assertSha256(obj.sha256, `${fieldPrefix}.sha256`),
|
|
249
269
|
bytes: assertNonNegativeInteger(obj.bytes, `${fieldPrefix}.bytes`),
|
|
250
|
-
mtimeMs:
|
|
270
|
+
mtimeMs: assertOfflineSyncMtimeMs(obj.mtimeMs, `${fieldPrefix}.mtimeMs`),
|
|
251
271
|
};
|
|
252
272
|
}
|
|
253
273
|
|
|
@@ -454,6 +474,23 @@ function isCanonicalRuntimeStatePath(parts: string[]): boolean {
|
|
|
454
474
|
return parts[0] === "namespaces" && parts.length >= 4 && parts[2] === "state";
|
|
455
475
|
}
|
|
456
476
|
|
|
477
|
+
const REMOTE_AUTHORITATIVE_RUNTIME_STATE_FILES = new Set([
|
|
478
|
+
".artifact-write-version.log",
|
|
479
|
+
".memory-status-version.log",
|
|
480
|
+
"buffer.json",
|
|
481
|
+
"embeddings.json",
|
|
482
|
+
"index_time.json",
|
|
483
|
+
"last_recall.json",
|
|
484
|
+
"memory-lifecycle-ledger.jsonl",
|
|
485
|
+
"recall_impressions.jsonl",
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
export function shouldPreferIncomingOfflineRuntimeFile(relPosix: string): boolean {
|
|
489
|
+
const parts = relPosix.split("/");
|
|
490
|
+
const basename = parts[parts.length - 1] ?? "";
|
|
491
|
+
return isCanonicalRuntimeStatePath(parts) && REMOTE_AUTHORITATIVE_RUNTIME_STATE_FILES.has(basename);
|
|
492
|
+
}
|
|
493
|
+
|
|
457
494
|
function filterBaseFilesForMode(
|
|
458
495
|
files: readonly OfflineSyncFileState[],
|
|
459
496
|
includeTranscripts: boolean,
|
|
@@ -461,21 +498,70 @@ function filterBaseFilesForMode(
|
|
|
461
498
|
return files.filter((file) => !shouldExcludeRelPath(file.path, includeTranscripts));
|
|
462
499
|
}
|
|
463
500
|
|
|
501
|
+
function canReuseFastBaseFileState(
|
|
502
|
+
baseEntry: OfflineSyncFileState,
|
|
503
|
+
st: { size: number; mtimeMs: number; ctimeMs: number },
|
|
504
|
+
baseCapturedAtMs: number | null,
|
|
505
|
+
): boolean {
|
|
506
|
+
if (baseEntry.bytes !== st.size) return false;
|
|
507
|
+
if (Math.abs(baseEntry.mtimeMs - st.mtimeMs) > OFFLINE_SYNC_FAST_BASE_MTIME_TOLERANCE_MS) {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
if (baseCapturedAtMs === null) return false;
|
|
511
|
+
return st.ctimeMs - baseCapturedAtMs <= OFFLINE_SYNC_FAST_BASE_MTIME_TOLERANCE_MS;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function canReuseFastBaseFileStateFromDisk(
|
|
515
|
+
baseEntry: OfflineSyncFileState,
|
|
516
|
+
filePath: string,
|
|
517
|
+
st: { size: number; mtimeMs: number; ctimeMs: number },
|
|
518
|
+
baseCapturedAtMs: number | null,
|
|
519
|
+
): Promise<boolean> {
|
|
520
|
+
if (!canReuseFastBaseFileState(baseEntry, st, baseCapturedAtMs)) return false;
|
|
521
|
+
return !(await fileIsSecureStoreEncrypted(filePath).catch(() => true));
|
|
522
|
+
}
|
|
523
|
+
|
|
464
524
|
async function readOfflineSyncFileRecord(
|
|
465
525
|
options: OfflineSyncFileRecordOptions,
|
|
466
526
|
): Promise<OfflineSyncFileRecord> {
|
|
467
527
|
const relPath = validateArchiveRelativePath(options.relPath, "offlineSyncFile.path");
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
528
|
+
let content: Buffer | null = null;
|
|
529
|
+
let digest: OfflineSyncFileDigest;
|
|
530
|
+
if (options.includeContent) {
|
|
531
|
+
content = options.readFile
|
|
532
|
+
? await options.readFile({ root: options.root.abs, path: relPath, filePath: options.filePath })
|
|
533
|
+
: await readFile(options.filePath);
|
|
534
|
+
digest = sha256Buffer(content);
|
|
535
|
+
} else if (options.readFileDigest) {
|
|
536
|
+
digest = await options.readFileDigest({ root: options.root.abs, path: relPath, filePath: options.filePath });
|
|
537
|
+
} else if (options.readFile) {
|
|
538
|
+
content = await options.readFile({ root: options.root.abs, path: relPath, filePath: options.filePath });
|
|
539
|
+
digest = sha256Buffer(content);
|
|
540
|
+
content = null;
|
|
541
|
+
} else {
|
|
542
|
+
digest = await sha256File(options.filePath);
|
|
543
|
+
}
|
|
472
544
|
const st = await stat(options.filePath);
|
|
473
545
|
return {
|
|
474
546
|
path: relPath,
|
|
475
547
|
sha256: digest.sha256,
|
|
476
548
|
bytes: digest.bytes,
|
|
477
549
|
mtimeMs: st.mtimeMs,
|
|
478
|
-
...(
|
|
550
|
+
...(content ? { contentBase64: content.toString("base64") } : {}),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function sha256File(filePath: string): Promise<OfflineSyncFileDigest> {
|
|
555
|
+
const hash = createHash("sha256");
|
|
556
|
+
let bytes = 0;
|
|
557
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
558
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
559
|
+
hash.update(buffer);
|
|
560
|
+
bytes += buffer.length;
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
sha256: hash.digest("hex"),
|
|
564
|
+
bytes,
|
|
479
565
|
};
|
|
480
566
|
}
|
|
481
567
|
|
|
@@ -508,6 +594,44 @@ async function readPlainFileContentChunk(options: {
|
|
|
508
594
|
}
|
|
509
595
|
}
|
|
510
596
|
|
|
597
|
+
export async function* iterateOfflineSyncSnapshotFileRecords(options: {
|
|
598
|
+
root: string;
|
|
599
|
+
includeContent?: boolean;
|
|
600
|
+
includeTranscripts?: boolean;
|
|
601
|
+
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
602
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
603
|
+
}): AsyncIterable<OfflineSyncFileRecord> {
|
|
604
|
+
const rootAbs = path.resolve(options.root);
|
|
605
|
+
const root = await prepareSafeArchiveRoot(rootAbs, "iterateOfflineSyncSnapshotFileRecords", "root");
|
|
606
|
+
const includeTranscripts = options.includeTranscripts !== false;
|
|
607
|
+
|
|
608
|
+
async function* walk(dirAbs: string): AsyncIterable<OfflineSyncFileRecord> {
|
|
609
|
+
let entries = await readdir(dirAbs, { withFileTypes: true });
|
|
610
|
+
entries = entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
611
|
+
for (const entry of entries) {
|
|
612
|
+
const abs = path.join(dirAbs, entry.name);
|
|
613
|
+
const relPosix = path.relative(root.abs, abs).split(path.sep).join("/");
|
|
614
|
+
if (shouldExcludeRelPath(relPosix, includeTranscripts)) continue;
|
|
615
|
+
if (entry.isSymbolicLink()) continue;
|
|
616
|
+
if (entry.isDirectory()) {
|
|
617
|
+
yield* walk(abs);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (!entry.isFile()) continue;
|
|
621
|
+
yield await readOfflineSyncFileRecord({
|
|
622
|
+
root,
|
|
623
|
+
relPath: relPosix,
|
|
624
|
+
filePath: abs,
|
|
625
|
+
includeContent: options.includeContent === true,
|
|
626
|
+
readFile: options.readFile,
|
|
627
|
+
readFileDigest: options.readFileDigest,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
yield* walk(root.abs);
|
|
633
|
+
}
|
|
634
|
+
|
|
511
635
|
export async function buildOfflineSyncSnapshot(options: {
|
|
512
636
|
root: string;
|
|
513
637
|
sourceId: string;
|
|
@@ -515,10 +639,44 @@ export async function buildOfflineSyncSnapshot(options: {
|
|
|
515
639
|
includeTranscripts?: boolean;
|
|
516
640
|
now?: Date;
|
|
517
641
|
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
642
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
643
|
+
}): Promise<OfflineSyncSnapshot> {
|
|
644
|
+
const includeTranscripts = options.includeTranscripts !== false;
|
|
645
|
+
const files: OfflineSyncFileRecord[] = [];
|
|
646
|
+
for await (const file of iterateOfflineSyncSnapshotFileRecords(options)) files.push(file);
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
format: OFFLINE_SYNC_SNAPSHOT_FORMAT,
|
|
650
|
+
schemaVersion: 1,
|
|
651
|
+
createdAt: (options.now ?? new Date()).toISOString(),
|
|
652
|
+
sourceId: normalizeSourceId(options.sourceId, "sourceId"),
|
|
653
|
+
includeTranscripts,
|
|
654
|
+
files: files.sort(compareByPath),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export async function buildOfflineSyncSnapshotFromBase(options: {
|
|
659
|
+
root: string;
|
|
660
|
+
sourceId: string;
|
|
661
|
+
baseFiles?: readonly OfflineSyncFileState[];
|
|
662
|
+
baseCapturedAt?: Date;
|
|
663
|
+
includeContent?: boolean;
|
|
664
|
+
includeTranscripts?: boolean;
|
|
665
|
+
now?: Date;
|
|
666
|
+
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
667
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
518
668
|
}): Promise<OfflineSyncSnapshot> {
|
|
519
669
|
const rootAbs = path.resolve(options.root);
|
|
520
|
-
const root = await prepareSafeArchiveRoot(rootAbs, "
|
|
670
|
+
const root = await prepareSafeArchiveRoot(rootAbs, "buildOfflineSyncSnapshotFromBase", "root");
|
|
521
671
|
const includeTranscripts = options.includeTranscripts !== false;
|
|
672
|
+
const base = byPath(filterBaseFilesForMode(
|
|
673
|
+
normalizeFileStates(options.baseFiles),
|
|
674
|
+
includeTranscripts,
|
|
675
|
+
));
|
|
676
|
+
const rawBaseCapturedAtMs = options.baseCapturedAt?.getTime();
|
|
677
|
+
const baseCapturedAtMs = rawBaseCapturedAtMs !== undefined && Number.isFinite(rawBaseCapturedAtMs)
|
|
678
|
+
? rawBaseCapturedAtMs
|
|
679
|
+
: null;
|
|
522
680
|
const files: OfflineSyncFileRecord[] = [];
|
|
523
681
|
|
|
524
682
|
async function walk(dirAbs: string): Promise<void> {
|
|
@@ -534,12 +692,24 @@ export async function buildOfflineSyncSnapshot(options: {
|
|
|
534
692
|
continue;
|
|
535
693
|
}
|
|
536
694
|
if (!entry.isFile()) continue;
|
|
695
|
+
const st = await stat(abs);
|
|
696
|
+
const baseEntry = base.get(relPosix);
|
|
697
|
+
if (
|
|
698
|
+
options.includeContent !== true &&
|
|
699
|
+
baseEntry &&
|
|
700
|
+
baseCapturedAtMs !== null &&
|
|
701
|
+
await canReuseFastBaseFileStateFromDisk(baseEntry, abs, st, baseCapturedAtMs)
|
|
702
|
+
) {
|
|
703
|
+
files.push(baseEntry);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
537
706
|
files.push(await readOfflineSyncFileRecord({
|
|
538
707
|
root,
|
|
539
708
|
relPath: relPosix,
|
|
540
709
|
filePath: abs,
|
|
541
710
|
includeContent: options.includeContent === true,
|
|
542
711
|
readFile: options.readFile,
|
|
712
|
+
readFileDigest: options.readFileDigest,
|
|
543
713
|
}));
|
|
544
714
|
}
|
|
545
715
|
}
|
|
@@ -564,6 +734,7 @@ export async function buildOfflineSyncSnapshotForPaths(options: {
|
|
|
564
734
|
includeTranscripts?: boolean;
|
|
565
735
|
now?: Date;
|
|
566
736
|
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
737
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
567
738
|
}): Promise<OfflineSyncSnapshot> {
|
|
568
739
|
const rootAbs = path.resolve(options.root);
|
|
569
740
|
const root = await prepareSafeArchiveRoot(rootAbs, "buildOfflineSyncSnapshotForPaths", "root");
|
|
@@ -590,6 +761,7 @@ export async function buildOfflineSyncSnapshotForPaths(options: {
|
|
|
590
761
|
filePath,
|
|
591
762
|
includeContent: options.includeContent === true,
|
|
592
763
|
readFile: options.readFile,
|
|
764
|
+
readFileDigest: options.readFileDigest,
|
|
593
765
|
}));
|
|
594
766
|
}
|
|
595
767
|
|
|
@@ -682,6 +854,42 @@ export async function buildOfflineSyncChangeset(options: {
|
|
|
682
854
|
root: string;
|
|
683
855
|
sourceId: string;
|
|
684
856
|
baseFiles?: readonly OfflineSyncFileState[];
|
|
857
|
+
baseCapturedAt?: Date;
|
|
858
|
+
excludePaths?: readonly string[];
|
|
859
|
+
includeTranscripts?: boolean;
|
|
860
|
+
now?: Date;
|
|
861
|
+
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
862
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
863
|
+
}): Promise<OfflineSyncChangeset> {
|
|
864
|
+
const includeTranscripts = options.includeTranscripts !== false;
|
|
865
|
+
const current = await buildOfflineSyncSnapshotFromBase({
|
|
866
|
+
root: options.root,
|
|
867
|
+
sourceId: options.sourceId,
|
|
868
|
+
baseFiles: options.baseFiles,
|
|
869
|
+
baseCapturedAt: options.baseCapturedAt,
|
|
870
|
+
includeContent: false,
|
|
871
|
+
includeTranscripts,
|
|
872
|
+
now: options.now,
|
|
873
|
+
readFile: options.readFile,
|
|
874
|
+
readFileDigest: options.readFileDigest,
|
|
875
|
+
});
|
|
876
|
+
return buildOfflineSyncChangesetFromSnapshot({
|
|
877
|
+
root: options.root,
|
|
878
|
+
sourceId: options.sourceId,
|
|
879
|
+
baseFiles: options.baseFiles,
|
|
880
|
+
currentFiles: current.files,
|
|
881
|
+
excludePaths: options.excludePaths,
|
|
882
|
+
includeTranscripts,
|
|
883
|
+
now: options.now,
|
|
884
|
+
readFile: options.readFile,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
export async function buildOfflineSyncChangesetFromSnapshot(options: {
|
|
889
|
+
root: string;
|
|
890
|
+
sourceId: string;
|
|
891
|
+
currentFiles: readonly OfflineSyncFileState[];
|
|
892
|
+
baseFiles?: readonly OfflineSyncFileState[];
|
|
685
893
|
excludePaths?: readonly string[];
|
|
686
894
|
includeTranscripts?: boolean;
|
|
687
895
|
now?: Date;
|
|
@@ -695,19 +903,18 @@ export async function buildOfflineSyncChangeset(options: {
|
|
|
695
903
|
normalizeFileStates(options.baseFiles),
|
|
696
904
|
includeTranscripts,
|
|
697
905
|
));
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
sourceId: options.sourceId,
|
|
701
|
-
includeContent: false,
|
|
906
|
+
const currentMap = byPath(filterBaseFilesForMode(
|
|
907
|
+
normalizeFileStates(options.currentFiles),
|
|
702
908
|
includeTranscripts,
|
|
703
|
-
|
|
704
|
-
readFile: options.readFile,
|
|
705
|
-
});
|
|
706
|
-
const currentMap = byPath(current.files);
|
|
909
|
+
));
|
|
707
910
|
const changes: OfflineSyncChange[] = [];
|
|
708
911
|
|
|
709
912
|
for (const relPath of unionPaths(base, currentMap)) {
|
|
710
913
|
if (excludedPaths.has(relPath)) continue;
|
|
914
|
+
// Runtime state is remote-authoritative in offline sync: local edits and
|
|
915
|
+
// deletes are not pushed; the pull phase restores or removes these files
|
|
916
|
+
// from the remote snapshot.
|
|
917
|
+
if (shouldPreferIncomingOfflineRuntimeFile(relPath)) continue;
|
|
711
918
|
const baseEntry = base.get(relPath);
|
|
712
919
|
const currentEntry = currentMap.get(relPath);
|
|
713
920
|
if (currentEntry && currentEntry.sha256 !== baseEntry?.sha256) {
|
|
@@ -746,7 +953,7 @@ export async function buildOfflineSyncChangeset(options: {
|
|
|
746
953
|
schemaVersion: 1,
|
|
747
954
|
createdAt: (options.now ?? new Date()).toISOString(),
|
|
748
955
|
sourceId: normalizeSourceId(options.sourceId, "sourceId"),
|
|
749
|
-
includeTranscripts
|
|
956
|
+
includeTranscripts,
|
|
750
957
|
changes: changes.sort(compareByPath),
|
|
751
958
|
};
|
|
752
959
|
}
|
|
@@ -767,28 +974,50 @@ export async function summarizeOfflineSyncPendingChanges(options: {
|
|
|
767
974
|
root: string;
|
|
768
975
|
sourceId: string;
|
|
769
976
|
baseFiles?: readonly OfflineSyncFileState[];
|
|
977
|
+
baseCapturedAt?: Date;
|
|
770
978
|
includeTranscripts?: boolean;
|
|
771
979
|
now?: Date;
|
|
772
980
|
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
981
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
773
982
|
}): Promise<OfflineSyncChangesetSummary> {
|
|
774
983
|
const includeTranscripts = options.includeTranscripts !== false;
|
|
775
|
-
const
|
|
776
|
-
normalizeFileStates(options.baseFiles),
|
|
777
|
-
includeTranscripts,
|
|
778
|
-
));
|
|
779
|
-
const current = await buildOfflineSyncSnapshot({
|
|
984
|
+
const current = await buildOfflineSyncSnapshotFromBase({
|
|
780
985
|
root: options.root,
|
|
781
986
|
sourceId: options.sourceId,
|
|
987
|
+
baseFiles: options.baseFiles,
|
|
988
|
+
baseCapturedAt: options.baseCapturedAt,
|
|
782
989
|
includeContent: false,
|
|
783
990
|
includeTranscripts,
|
|
784
991
|
now: options.now,
|
|
785
992
|
readFile: options.readFile,
|
|
993
|
+
readFileDigest: options.readFileDigest,
|
|
994
|
+
});
|
|
995
|
+
return summarizeOfflineSyncPendingFiles({
|
|
996
|
+
baseFiles: options.baseFiles,
|
|
997
|
+
currentFiles: current.files,
|
|
998
|
+
includeTranscripts,
|
|
786
999
|
});
|
|
787
|
-
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export function summarizeOfflineSyncPendingFiles(options: {
|
|
1003
|
+
baseFiles?: readonly OfflineSyncFileState[];
|
|
1004
|
+
currentFiles: readonly OfflineSyncFileState[];
|
|
1005
|
+
includeTranscripts?: boolean;
|
|
1006
|
+
}): OfflineSyncChangesetSummary {
|
|
1007
|
+
const includeTranscripts = options.includeTranscripts !== false;
|
|
1008
|
+
const base = byPath(filterBaseFilesForMode(
|
|
1009
|
+
normalizeFileStates(options.baseFiles),
|
|
1010
|
+
includeTranscripts,
|
|
1011
|
+
));
|
|
1012
|
+
const currentMap = byPath(filterBaseFilesForMode(
|
|
1013
|
+
normalizeFileStates(options.currentFiles),
|
|
1014
|
+
includeTranscripts,
|
|
1015
|
+
));
|
|
788
1016
|
let upserts = 0;
|
|
789
1017
|
let deletes = 0;
|
|
790
1018
|
|
|
791
1019
|
for (const relPath of unionPaths(base, currentMap)) {
|
|
1020
|
+
if (shouldPreferIncomingOfflineRuntimeFile(relPath)) continue;
|
|
792
1021
|
const baseEntry = base.get(relPath);
|
|
793
1022
|
const currentEntry = currentMap.get(relPath);
|
|
794
1023
|
if (currentEntry && currentEntry.sha256 !== baseEntry?.sha256) {
|
|
@@ -811,8 +1040,12 @@ export async function applyOfflineSyncSnapshot(options: {
|
|
|
811
1040
|
root: string;
|
|
812
1041
|
snapshot: unknown;
|
|
813
1042
|
baseFiles?: readonly OfflineSyncFileState[];
|
|
1043
|
+
currentFiles?: readonly OfflineSyncFileState[];
|
|
1044
|
+
deferredPaths?: readonly string[];
|
|
1045
|
+
allowMissingConflictContent?: boolean;
|
|
814
1046
|
writeConflictCopies?: boolean;
|
|
815
1047
|
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
1048
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
816
1049
|
writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
|
|
817
1050
|
deleteFile?: (target: OfflineSyncFileTarget) => Promise<void>;
|
|
818
1051
|
}): Promise<OfflineSyncApplySnapshotResult> {
|
|
@@ -826,32 +1059,66 @@ export async function applyOfflineSyncSnapshot(options: {
|
|
|
826
1059
|
requireContent: false,
|
|
827
1060
|
});
|
|
828
1061
|
const root = await ensureSyncRoot(options.root, "applyOfflineSyncSnapshot");
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1062
|
+
const currentFiles = options.currentFiles
|
|
1063
|
+
? filterBaseFilesForMode(normalizeFileStates(options.currentFiles), snapshot.includeTranscripts).sort(compareByPath)
|
|
1064
|
+
: (await buildOfflineSyncSnapshot({
|
|
1065
|
+
root: root.abs,
|
|
1066
|
+
sourceId: "local",
|
|
1067
|
+
includeContent: false,
|
|
1068
|
+
includeTranscripts: snapshot.includeTranscripts,
|
|
1069
|
+
readFile: options.readFile,
|
|
1070
|
+
readFileDigest: options.readFileDigest,
|
|
1071
|
+
})).files;
|
|
1072
|
+
const currentMap = byPath(currentFiles);
|
|
1073
|
+
const deferredPaths = new Set(options.deferredPaths ?? []);
|
|
837
1074
|
const nextBase = new Map(baseMap);
|
|
838
1075
|
const conflicts: OfflineSyncConflict[] = [];
|
|
839
1076
|
let upserted = 0;
|
|
840
1077
|
let deleted = 0;
|
|
841
1078
|
let skipped = 0;
|
|
842
1079
|
let pendingLocal = 0;
|
|
1080
|
+
const conflictIncomingBuffer = (relPath: string): Buffer | undefined => {
|
|
1081
|
+
if (options.writeConflictCopies === false) return undefined;
|
|
1082
|
+
const buffer = incomingBuffers.get(relPath);
|
|
1083
|
+
if (buffer || options.allowMissingConflictContent === true) return buffer;
|
|
1084
|
+
return requiredBuffer(incomingBuffers, relPath);
|
|
1085
|
+
};
|
|
843
1086
|
|
|
844
1087
|
for (const relPath of unionPaths(baseMap, incomingMap, currentMap)) {
|
|
845
1088
|
const base = baseMap.get(relPath);
|
|
846
1089
|
const incoming = incomingMap.get(relPath);
|
|
847
1090
|
const currentEntry = currentMap.get(relPath);
|
|
848
1091
|
|
|
1092
|
+
if (deferredPaths.has(relPath)) {
|
|
1093
|
+
if (base) nextBase.set(relPath, base);
|
|
1094
|
+
else nextBase.delete(relPath);
|
|
1095
|
+
skipped += 1;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
849
1099
|
if (incoming) {
|
|
850
1100
|
if (currentEntry?.sha256 === incoming.sha256) {
|
|
851
|
-
|
|
1101
|
+
if (await setSafeFileMtime(root, relPath, incoming.mtimeMs)) {
|
|
1102
|
+
nextBase.set(relPath, toFileState(incoming));
|
|
1103
|
+
} else {
|
|
1104
|
+
if (base) nextBase.set(relPath, base);
|
|
1105
|
+
else nextBase.delete(relPath);
|
|
1106
|
+
pendingLocal += 1;
|
|
1107
|
+
}
|
|
852
1108
|
skipped += 1;
|
|
853
1109
|
continue;
|
|
854
1110
|
}
|
|
1111
|
+
if (shouldPreferIncomingOfflineRuntimeFile(relPath) && currentEntry && base && incoming.sha256 === base.sha256) {
|
|
1112
|
+
nextBase.set(relPath, base);
|
|
1113
|
+
skipped += 1;
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
if (shouldPreferIncomingOfflineRuntimeFile(relPath)) {
|
|
1117
|
+
await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile, incoming.mtimeMs);
|
|
1118
|
+
nextBase.set(relPath, toFileState(incoming));
|
|
1119
|
+
upserted += 1;
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
855
1122
|
if (!currentEntry && base && incoming.sha256 === base.sha256) {
|
|
856
1123
|
nextBase.set(relPath, base);
|
|
857
1124
|
pendingLocal += 1;
|
|
@@ -865,9 +1132,7 @@ export async function applyOfflineSyncSnapshot(options: {
|
|
|
865
1132
|
reason: "local_deleted_remote_modified",
|
|
866
1133
|
baseSha256: base.sha256,
|
|
867
1134
|
incomingSha256: incoming.sha256,
|
|
868
|
-
incomingBuffer:
|
|
869
|
-
? incomingBuffers.get(relPath)
|
|
870
|
-
: requiredBuffer(incomingBuffers, relPath),
|
|
1135
|
+
incomingBuffer: conflictIncomingBuffer(relPath),
|
|
871
1136
|
writeConflictCopies: options.writeConflictCopies !== false,
|
|
872
1137
|
sourceId: snapshot.sourceId,
|
|
873
1138
|
writeFile: options.writeFile,
|
|
@@ -876,13 +1141,13 @@ export async function applyOfflineSyncSnapshot(options: {
|
|
|
876
1141
|
continue;
|
|
877
1142
|
}
|
|
878
1143
|
if (!currentEntry && !base) {
|
|
879
|
-
await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile);
|
|
1144
|
+
await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile, incoming.mtimeMs);
|
|
880
1145
|
nextBase.set(relPath, toFileState(incoming));
|
|
881
1146
|
upserted += 1;
|
|
882
1147
|
continue;
|
|
883
1148
|
}
|
|
884
1149
|
if (base && currentEntry && currentEntry.sha256 === base.sha256) {
|
|
885
|
-
await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile);
|
|
1150
|
+
await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile, incoming.mtimeMs);
|
|
886
1151
|
nextBase.set(relPath, toFileState(incoming));
|
|
887
1152
|
upserted += 1;
|
|
888
1153
|
continue;
|
|
@@ -900,9 +1165,7 @@ export async function applyOfflineSyncSnapshot(options: {
|
|
|
900
1165
|
baseSha256: base?.sha256,
|
|
901
1166
|
localSha256: currentEntry?.sha256,
|
|
902
1167
|
incomingSha256: incoming.sha256,
|
|
903
|
-
incomingBuffer:
|
|
904
|
-
? incomingBuffers.get(relPath)
|
|
905
|
-
: requiredBuffer(incomingBuffers, relPath),
|
|
1168
|
+
incomingBuffer: conflictIncomingBuffer(relPath),
|
|
906
1169
|
writeConflictCopies: options.writeConflictCopies !== false,
|
|
907
1170
|
sourceId: snapshot.sourceId,
|
|
908
1171
|
writeFile: options.writeFile,
|
|
@@ -916,6 +1179,17 @@ export async function applyOfflineSyncSnapshot(options: {
|
|
|
916
1179
|
skipped += 1;
|
|
917
1180
|
continue;
|
|
918
1181
|
}
|
|
1182
|
+
if (shouldPreferIncomingOfflineRuntimeFile(relPath) && base) {
|
|
1183
|
+
await deleteSafeFile(root, relPath, options.deleteFile);
|
|
1184
|
+
nextBase.delete(relPath);
|
|
1185
|
+
deleted += 1;
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
if (shouldPreferIncomingOfflineRuntimeFile(relPath)) {
|
|
1189
|
+
pendingLocal += 1;
|
|
1190
|
+
skipped += 1;
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
919
1193
|
if (base && currentEntry.sha256 === base.sha256) {
|
|
920
1194
|
await deleteSafeFile(root, relPath, options.deleteFile);
|
|
921
1195
|
nextBase.delete(relPath);
|
|
@@ -949,8 +1223,11 @@ export async function applyOfflineSyncSnapshot(options: {
|
|
|
949
1223
|
export async function applyOfflineSyncChangeset(options: {
|
|
950
1224
|
root: string;
|
|
951
1225
|
changeset: unknown;
|
|
1226
|
+
currentFiles?: readonly OfflineSyncFileState[];
|
|
1227
|
+
returnCurrentFiles?: boolean;
|
|
952
1228
|
writeConflictCopies?: boolean;
|
|
953
1229
|
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
1230
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
954
1231
|
writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
|
|
955
1232
|
deleteFile?: (target: OfflineSyncFileTarget) => Promise<void>;
|
|
956
1233
|
}): Promise<OfflineSyncApplyChangesetResult> {
|
|
@@ -970,14 +1247,18 @@ export async function applyOfflineSyncChangeset(options: {
|
|
|
970
1247
|
.filter((change): change is Extract<OfflineSyncChange, { type: "upsert" }> => change.type === "upsert")
|
|
971
1248
|
.map((change) => change.file);
|
|
972
1249
|
const incomingBuffers = verifyRecordContents(records, "offline sync changeset");
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1250
|
+
const currentFiles = options.currentFiles
|
|
1251
|
+
? filterBaseFilesForMode(normalizeFileStates(options.currentFiles), changeset.includeTranscripts).sort(compareByPath)
|
|
1252
|
+
: (await buildOfflineSyncSnapshotForPaths({
|
|
1253
|
+
root: root.abs,
|
|
1254
|
+
sourceId: "local",
|
|
1255
|
+
paths: changeset.changes.map((change) => change.path),
|
|
1256
|
+
includeContent: false,
|
|
1257
|
+
includeTranscripts: changeset.includeTranscripts,
|
|
1258
|
+
readFile: options.readFile,
|
|
1259
|
+
readFileDigest: options.readFileDigest,
|
|
1260
|
+
})).files;
|
|
1261
|
+
const currentMap = byPath(currentFiles);
|
|
981
1262
|
const conflicts: OfflineSyncConflict[] = [];
|
|
982
1263
|
let appliedUpserts = 0;
|
|
983
1264
|
let appliedDeletes = 0;
|
|
@@ -987,12 +1268,18 @@ export async function applyOfflineSyncChangeset(options: {
|
|
|
987
1268
|
const currentEntry = currentMap.get(change.path);
|
|
988
1269
|
if (change.type === "upsert") {
|
|
989
1270
|
if (currentEntry?.sha256 === change.file.sha256) {
|
|
990
|
-
|
|
1271
|
+
if (await setSafeFileMtime(root, change.path, change.file.mtimeMs)) {
|
|
1272
|
+
skipped += 1;
|
|
1273
|
+
} else {
|
|
1274
|
+
await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile, change.file.mtimeMs);
|
|
1275
|
+
currentMap.set(change.path, toFileState(change.file));
|
|
1276
|
+
appliedUpserts += 1;
|
|
1277
|
+
}
|
|
991
1278
|
continue;
|
|
992
1279
|
}
|
|
993
1280
|
if (!change.baseSha256) {
|
|
994
1281
|
if (!currentEntry) {
|
|
995
|
-
await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile);
|
|
1282
|
+
await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile, change.file.mtimeMs);
|
|
996
1283
|
currentMap.set(change.path, toFileState(change.file));
|
|
997
1284
|
appliedUpserts += 1;
|
|
998
1285
|
continue;
|
|
@@ -1011,7 +1298,7 @@ export async function applyOfflineSyncChangeset(options: {
|
|
|
1011
1298
|
continue;
|
|
1012
1299
|
}
|
|
1013
1300
|
if (currentEntry?.sha256 === change.baseSha256) {
|
|
1014
|
-
await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile);
|
|
1301
|
+
await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile, change.file.mtimeMs);
|
|
1015
1302
|
currentMap.set(change.path, toFileState(change.file));
|
|
1016
1303
|
appliedUpserts += 1;
|
|
1017
1304
|
continue;
|
|
@@ -1054,7 +1341,17 @@ export async function applyOfflineSyncChangeset(options: {
|
|
|
1054
1341
|
appliedDeletes,
|
|
1055
1342
|
skipped,
|
|
1056
1343
|
conflicts,
|
|
1057
|
-
currentFiles:
|
|
1344
|
+
currentFiles: options.returnCurrentFiles === false
|
|
1345
|
+
? [...currentMap.values()].sort(compareByPath)
|
|
1346
|
+
: (await buildOfflineSyncSnapshot({
|
|
1347
|
+
root: root.abs,
|
|
1348
|
+
sourceId: "local",
|
|
1349
|
+
includeContent: false,
|
|
1350
|
+
includeTranscripts: changeset.includeTranscripts,
|
|
1351
|
+
readFile: options.readFile,
|
|
1352
|
+
readFileDigest: options.readFileDigest,
|
|
1353
|
+
})).files,
|
|
1354
|
+
...(options.returnCurrentFiles === false ? { currentFilesComplete: false } : {}),
|
|
1058
1355
|
};
|
|
1059
1356
|
}
|
|
1060
1357
|
|
|
@@ -1125,10 +1422,12 @@ async function writeSafeFile(
|
|
|
1125
1422
|
relPath: string,
|
|
1126
1423
|
content: Buffer,
|
|
1127
1424
|
writeFileHook?: (target: OfflineSyncFileWriteTarget) => Promise<void>,
|
|
1425
|
+
mtimeMs?: number,
|
|
1128
1426
|
): Promise<void> {
|
|
1129
1427
|
const target = await resolveSafeArchiveTarget(root, relPath);
|
|
1130
1428
|
if (writeFileHook) {
|
|
1131
1429
|
await writeFileHook({ root: root.abs, path: relPath, filePath: target, content });
|
|
1430
|
+
await setSafeFileMtime(root, relPath, mtimeMs);
|
|
1132
1431
|
return;
|
|
1133
1432
|
}
|
|
1134
1433
|
await mkdir(path.dirname(target), { recursive: true });
|
|
@@ -1146,12 +1445,33 @@ async function writeSafeFile(
|
|
|
1146
1445
|
throw new Error(`offline sync target is a symlink: ${relPath}`);
|
|
1147
1446
|
}
|
|
1148
1447
|
await rename(tmp, target);
|
|
1448
|
+
await setSafeFileMtime(root, relPath, mtimeMs);
|
|
1149
1449
|
} catch (error) {
|
|
1150
1450
|
await unlink(tmp).catch(() => {});
|
|
1151
1451
|
throw error;
|
|
1152
1452
|
}
|
|
1153
1453
|
}
|
|
1154
1454
|
|
|
1455
|
+
async function setSafeFileMtime(
|
|
1456
|
+
root: SafeArchiveRoot,
|
|
1457
|
+
relPath: string,
|
|
1458
|
+
mtimeMs: number | undefined,
|
|
1459
|
+
): Promise<boolean> {
|
|
1460
|
+
if (mtimeMs === undefined) return true;
|
|
1461
|
+
const target = await resolveSafeArchiveTarget(root, relPath);
|
|
1462
|
+
const targetStat = await lstat(target).catch((error: unknown) => {
|
|
1463
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
1464
|
+
throw error;
|
|
1465
|
+
});
|
|
1466
|
+
if (!targetStat) return false;
|
|
1467
|
+
if (targetStat.isSymbolicLink()) {
|
|
1468
|
+
throw new Error(`offline sync target is a symlink: ${relPath}`);
|
|
1469
|
+
}
|
|
1470
|
+
const mtime = new Date(assertOfflineSyncMtimeMs(mtimeMs, "mtimeMs"));
|
|
1471
|
+
await utimes(target, mtime, mtime);
|
|
1472
|
+
return true;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1155
1475
|
export async function applyOfflineSyncFileContentChunk(options: {
|
|
1156
1476
|
root: string;
|
|
1157
1477
|
sourceId: string;
|
|
@@ -1164,6 +1484,7 @@ export async function applyOfflineSyncFileContentChunk(options: {
|
|
|
1164
1484
|
baseSha256?: string;
|
|
1165
1485
|
includeTranscripts?: boolean;
|
|
1166
1486
|
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
1487
|
+
readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
|
|
1167
1488
|
writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
|
|
1168
1489
|
writeStagingFile?: (target: OfflineSyncFileStagingWriteTarget) => Promise<void>;
|
|
1169
1490
|
writeFileChunks?: (target: OfflineSyncFileWriteChunksTarget) => Promise<void>;
|
|
@@ -1177,13 +1498,14 @@ export async function applyOfflineSyncFileContentChunk(options: {
|
|
|
1177
1498
|
}
|
|
1178
1499
|
const sha256 = assertSha256(options.sha256, "sha256");
|
|
1179
1500
|
const bytes = assertNonNegativeInteger(options.bytes, "bytes");
|
|
1180
|
-
const mtimeMs =
|
|
1501
|
+
const mtimeMs = assertOfflineSyncMtimeMs(options.mtimeMs, "mtimeMs");
|
|
1181
1502
|
const offset = options.offset === undefined
|
|
1182
1503
|
? 0
|
|
1183
1504
|
: assertNonNegativeInteger(options.offset, "offset");
|
|
1184
1505
|
const baseSha256 = options.baseSha256 === undefined
|
|
1185
1506
|
? undefined
|
|
1186
1507
|
: assertSha256(options.baseSha256, "baseSha256");
|
|
1508
|
+
const preferIncomingRuntimeFile = shouldPreferIncomingOfflineRuntimeFile(relPath);
|
|
1187
1509
|
if (!Buffer.isBuffer(options.content)) {
|
|
1188
1510
|
throw new Error("content must be a Buffer");
|
|
1189
1511
|
}
|
|
@@ -1204,8 +1526,87 @@ export async function applyOfflineSyncFileContentChunk(options: {
|
|
|
1204
1526
|
if (options.writeFile && !options.writeStagingFile) {
|
|
1205
1527
|
throw new Error("offline sync upload storage hooks require writeStagingFile");
|
|
1206
1528
|
}
|
|
1529
|
+
const baseResult = {
|
|
1530
|
+
path: relPath,
|
|
1531
|
+
sha256,
|
|
1532
|
+
bytes,
|
|
1533
|
+
mtimeMs,
|
|
1534
|
+
offset,
|
|
1535
|
+
chunkBytes: options.content.length,
|
|
1536
|
+
done: offset + options.content.length === bytes,
|
|
1537
|
+
};
|
|
1538
|
+
const currentFileConflict = async (
|
|
1539
|
+
currentFile: OfflineSyncFileState | undefined,
|
|
1540
|
+
): Promise<{ conflict: OfflineSyncConflict; currentFile?: OfflineSyncFileState } | null> => {
|
|
1541
|
+
if (!baseSha256 && currentFile && !preferIncomingRuntimeFile) {
|
|
1542
|
+
const conflict = await recordConflict({
|
|
1543
|
+
root,
|
|
1544
|
+
relPath,
|
|
1545
|
+
reason: "remote_exists_for_local_create",
|
|
1546
|
+
localSha256: currentFile.sha256,
|
|
1547
|
+
incomingSha256: sha256,
|
|
1548
|
+
writeConflictCopies: false,
|
|
1549
|
+
sourceId,
|
|
1550
|
+
writeFile: options.writeFile,
|
|
1551
|
+
});
|
|
1552
|
+
return {
|
|
1553
|
+
conflict,
|
|
1554
|
+
currentFile,
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
if (baseSha256 && currentFile?.sha256 !== baseSha256 && !preferIncomingRuntimeFile) {
|
|
1558
|
+
const conflict = await recordConflict({
|
|
1559
|
+
root,
|
|
1560
|
+
relPath,
|
|
1561
|
+
reason: currentFile ? "remote_changed_for_local_update" : "remote_deleted_for_local_update",
|
|
1562
|
+
baseSha256,
|
|
1563
|
+
localSha256: currentFile?.sha256,
|
|
1564
|
+
incomingSha256: sha256,
|
|
1565
|
+
writeConflictCopies: false,
|
|
1566
|
+
sourceId,
|
|
1567
|
+
writeFile: options.writeFile,
|
|
1568
|
+
});
|
|
1569
|
+
return {
|
|
1570
|
+
conflict,
|
|
1571
|
+
...(currentFile ? { currentFile } : {}),
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
return null;
|
|
1575
|
+
};
|
|
1207
1576
|
if (offset === 0) {
|
|
1208
1577
|
await pruneOfflineUploadStaging(root);
|
|
1578
|
+
const currentSnapshot = await buildOfflineSyncSnapshotForPaths({
|
|
1579
|
+
root: root.abs,
|
|
1580
|
+
sourceId: "local",
|
|
1581
|
+
paths: [relPath],
|
|
1582
|
+
includeContent: false,
|
|
1583
|
+
includeTranscripts,
|
|
1584
|
+
readFile: options.readFile,
|
|
1585
|
+
readFileDigest: options.readFileDigest,
|
|
1586
|
+
});
|
|
1587
|
+
const currentFile = currentSnapshot.files[0];
|
|
1588
|
+
if (currentFile?.sha256 === sha256) {
|
|
1589
|
+
await setSafeFileMtime(root, relPath, mtimeMs);
|
|
1590
|
+
return {
|
|
1591
|
+
...baseResult,
|
|
1592
|
+
done: true,
|
|
1593
|
+
chunkBytes: 0,
|
|
1594
|
+
applied: false,
|
|
1595
|
+
skipped: true,
|
|
1596
|
+
currentFile: toFileState(currentFile),
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
const conflictResult = await currentFileConflict(currentFile ? toFileState(currentFile) : undefined);
|
|
1600
|
+
if (conflictResult) {
|
|
1601
|
+
return {
|
|
1602
|
+
...baseResult,
|
|
1603
|
+
done: true,
|
|
1604
|
+
chunkBytes: 0,
|
|
1605
|
+
applied: false,
|
|
1606
|
+
skipped: false,
|
|
1607
|
+
...conflictResult,
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1209
1610
|
}
|
|
1210
1611
|
|
|
1211
1612
|
const upload = await writeOfflineUploadChunk({
|
|
@@ -1220,16 +1621,7 @@ export async function applyOfflineSyncFileContentChunk(options: {
|
|
|
1220
1621
|
writeFile: options.writeFile,
|
|
1221
1622
|
writeStagingFile: options.writeStagingFile,
|
|
1222
1623
|
});
|
|
1223
|
-
const done =
|
|
1224
|
-
const baseResult = {
|
|
1225
|
-
path: relPath,
|
|
1226
|
-
sha256,
|
|
1227
|
-
bytes,
|
|
1228
|
-
mtimeMs,
|
|
1229
|
-
offset,
|
|
1230
|
-
chunkBytes: options.content.length,
|
|
1231
|
-
done,
|
|
1232
|
-
};
|
|
1624
|
+
const done = baseResult.done;
|
|
1233
1625
|
if (!done) {
|
|
1234
1626
|
return {
|
|
1235
1627
|
...baseResult,
|
|
@@ -1255,6 +1647,7 @@ export async function applyOfflineSyncFileContentChunk(options: {
|
|
|
1255
1647
|
includeContent: false,
|
|
1256
1648
|
includeTranscripts,
|
|
1257
1649
|
readFile: options.readFile,
|
|
1650
|
+
readFileDigest: options.readFileDigest,
|
|
1258
1651
|
});
|
|
1259
1652
|
const currentFile = currentSnapshot.files[0];
|
|
1260
1653
|
const uploadedState: OfflineSyncFileState = {
|
|
@@ -1266,54 +1659,26 @@ export async function applyOfflineSyncFileContentChunk(options: {
|
|
|
1266
1659
|
|
|
1267
1660
|
try {
|
|
1268
1661
|
if (currentFile?.sha256 === sha256) {
|
|
1662
|
+
await setSafeFileMtime(root, relPath, mtimeMs);
|
|
1269
1663
|
return {
|
|
1270
1664
|
...baseResult,
|
|
1271
1665
|
applied: false,
|
|
1272
1666
|
skipped: true,
|
|
1273
|
-
currentFile:
|
|
1274
|
-
};
|
|
1275
|
-
}
|
|
1276
|
-
if (!baseSha256 && currentFile) {
|
|
1277
|
-
const conflict = await recordConflict({
|
|
1278
|
-
root,
|
|
1279
|
-
relPath,
|
|
1280
|
-
reason: "remote_exists_for_local_create",
|
|
1281
|
-
localSha256: currentFile.sha256,
|
|
1282
|
-
incomingSha256: sha256,
|
|
1283
|
-
writeConflictCopies: false,
|
|
1284
|
-
sourceId,
|
|
1285
|
-
writeFile: options.writeFile,
|
|
1286
|
-
});
|
|
1287
|
-
return {
|
|
1288
|
-
...baseResult,
|
|
1289
|
-
applied: false,
|
|
1290
|
-
skipped: false,
|
|
1291
|
-
conflict,
|
|
1292
|
-
currentFile: toFileState(currentFile),
|
|
1667
|
+
currentFile: uploadedState,
|
|
1293
1668
|
};
|
|
1294
1669
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
relPath,
|
|
1299
|
-
reason: currentFile ? "remote_changed_for_local_update" : "remote_deleted_for_local_update",
|
|
1300
|
-
baseSha256,
|
|
1301
|
-
localSha256: currentFile?.sha256,
|
|
1302
|
-
incomingSha256: sha256,
|
|
1303
|
-
writeConflictCopies: false,
|
|
1304
|
-
sourceId,
|
|
1305
|
-
writeFile: options.writeFile,
|
|
1306
|
-
});
|
|
1670
|
+
|
|
1671
|
+
const conflictResult = await currentFileConflict(currentFile ? toFileState(currentFile) : undefined);
|
|
1672
|
+
if (conflictResult) {
|
|
1307
1673
|
return {
|
|
1308
1674
|
...baseResult,
|
|
1309
1675
|
applied: false,
|
|
1310
1676
|
skipped: false,
|
|
1311
|
-
|
|
1312
|
-
...(currentFile ? { currentFile: toFileState(currentFile) } : {}),
|
|
1677
|
+
...conflictResult,
|
|
1313
1678
|
};
|
|
1314
1679
|
}
|
|
1315
1680
|
|
|
1316
|
-
await writeSafeFileFromUpload(root, relPath, upload, options.readFile, options.writeFileChunks);
|
|
1681
|
+
await writeSafeFileFromUpload(root, relPath, upload, options.readFile, options.writeFileChunks, mtimeMs);
|
|
1317
1682
|
return {
|
|
1318
1683
|
...baseResult,
|
|
1319
1684
|
applied: true,
|
|
@@ -1513,11 +1878,13 @@ async function writeSafeFileFromUpload(
|
|
|
1513
1878
|
upload: OfflineUploadStaging,
|
|
1514
1879
|
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>,
|
|
1515
1880
|
writeFileChunks?: (target: OfflineSyncFileWriteChunksTarget) => Promise<void>,
|
|
1881
|
+
mtimeMs?: number,
|
|
1516
1882
|
): Promise<void> {
|
|
1517
1883
|
const target = await resolveSafeArchiveTarget(root, relPath);
|
|
1518
1884
|
const chunks = readOfflineUploadStagingChunks({ root, upload, readFile });
|
|
1519
1885
|
if (writeFileChunks) {
|
|
1520
1886
|
await writeFileChunks({ root: root.abs, path: relPath, filePath: target, chunks });
|
|
1887
|
+
await setSafeFileMtime(root, relPath, mtimeMs);
|
|
1521
1888
|
return;
|
|
1522
1889
|
}
|
|
1523
1890
|
|
|
@@ -1540,6 +1907,7 @@ async function writeSafeFileFromUpload(
|
|
|
1540
1907
|
throw new Error(`offline sync target is a symlink: ${relPath}`);
|
|
1541
1908
|
}
|
|
1542
1909
|
await rename(tmp, target);
|
|
1910
|
+
await setSafeFileMtime(root, relPath, mtimeMs);
|
|
1543
1911
|
} catch (error) {
|
|
1544
1912
|
await handle.close().catch(() => {});
|
|
1545
1913
|
await unlink(tmp).catch(() => {});
|