@remnic/core 1.1.28 → 1.1.30
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 +17 -17
- package/dist/access-http.d.ts +1 -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-B5hgZPCN.d.ts} +4 -1
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +5 -5
- package/dist/active-recall.js +1 -1
- 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-JUYT2J3K.js → chunk-3OWUCDKH.js} +39 -7
- package/dist/chunk-3OWUCDKH.js.map +1 -0
- package/dist/{chunk-BJ3KMYTB.js → chunk-3TNBOMQT.js} +21 -10
- package/dist/chunk-3TNBOMQT.js.map +1 -0
- package/dist/{chunk-QYHQ2JHL.js → chunk-43PJZYGL.js} +2 -2
- package/dist/{chunk-YITUHONZ.js → chunk-4KGVTPGD.js} +2 -2
- package/dist/{chunk-W7DK3CYM.js → chunk-575RMLWN.js} +2 -2
- package/dist/{chunk-TR4DK5OH.js → chunk-76FLAAUC.js} +2 -2
- package/dist/{chunk-S27EXIHY.js → chunk-77H5NU3M.js} +3 -3
- package/dist/{chunk-IANK6Y5W.js → chunk-A6KTB5R6.js} +2 -2
- package/dist/{chunk-7D6O46PF.js → chunk-BVF3AGJP.js} +2 -2
- package/dist/{chunk-NTUNYIF7.js → chunk-I5GLV3VE.js} +2 -2
- package/dist/{chunk-4H6DURG6.js → chunk-JA3AK3PT.js} +2 -2
- package/dist/{chunk-2DM72JF3.js → chunk-KRBK4BQH.js} +14 -14
- package/dist/{chunk-AMVN77EU.js → chunk-MG7NA5H3.js} +365 -90
- package/dist/chunk-MG7NA5H3.js.map +1 -0
- package/dist/{chunk-RCZRL5BE.js → chunk-MRILGULB.js} +2 -2
- package/dist/{chunk-2QR3XXIC.js → chunk-MZH6EHNR.js} +3 -3
- package/dist/{chunk-2QR3XXIC.js.map → chunk-MZH6EHNR.js.map} +1 -1
- package/dist/{chunk-NW7JW5GA.js → chunk-OC7KHOOX.js} +41 -6
- package/dist/chunk-OC7KHOOX.js.map +1 -0
- package/dist/{chunk-LCTP7YRU.js → chunk-QKZGQIPJ.js} +16 -7
- package/dist/chunk-QKZGQIPJ.js.map +1 -0
- package/dist/{chunk-MVAOT247.js → chunk-QLLBRHAT.js} +11 -11
- 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-6CB4E7ZV.js → chunk-UL2NNBUL.js} +4 -4
- package/dist/{chunk-F33CJ5CH.js → chunk-VBJ7V5SK.js} +40 -8
- package/dist/chunk-VBJ7V5SK.js.map +1 -0
- package/dist/{chunk-TFORLO3O.js → chunk-W6AQJ2PY.js} +5 -5
- package/dist/{chunk-PUXCIHRL.js → chunk-XSZEP4SF.js} +2 -2
- package/dist/{chunk-4CRG46BG.js → chunk-ZK7I7JYV.js} +2 -2
- package/dist/{cli-BguVmIwO.d.ts → cli-CJKI2JIe.d.ts} +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +22 -22
- package/dist/compounding/engine.js +2 -2
- package/dist/config.js +1 -1
- 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 +37 -27
- 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 +6 -6
- package/dist/namespaces/search.js +3 -3
- package/dist/namespaces/storage.js +2 -2
- package/dist/offline-sync.d.ts +49 -1
- package/dist/offline-sync.js +13 -1
- package/dist/operator-toolkit.js +9 -9
- package/dist/orchestrator.js +12 -12
- package/dist/qmd.d.ts +3 -1
- package/dist/qmd.js +1 -1
- package/dist/resume-bundles.js +2 -2
- package/dist/schemas.d.ts +22 -22
- package/dist/search/factory.js +2 -2
- package/dist/search/index.js +2 -2
- 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 +184 -0
- package/src/access-http.ts +37 -0
- 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 +16 -1
- package/src/config.ts +2 -2
- package/src/index.ts +6 -0
- package/src/offline-sync.test.ts +1055 -1
- package/src/offline-sync.ts +453 -96
- package/src/qmd.test.ts +65 -10
- package/src/qmd.ts +22 -9
- package/src/storage.ts +36 -2
- package/dist/chunk-AMVN77EU.js.map +0 -1
- package/dist/chunk-BJ3KMYTB.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-QYHQ2JHL.js.map → chunk-43PJZYGL.js.map} +0 -0
- /package/dist/{chunk-YITUHONZ.js.map → chunk-4KGVTPGD.js.map} +0 -0
- /package/dist/{chunk-W7DK3CYM.js.map → chunk-575RMLWN.js.map} +0 -0
- /package/dist/{chunk-TR4DK5OH.js.map → chunk-76FLAAUC.js.map} +0 -0
- /package/dist/{chunk-S27EXIHY.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-NTUNYIF7.js.map → chunk-I5GLV3VE.js.map} +0 -0
- /package/dist/{chunk-4H6DURG6.js.map → chunk-JA3AK3PT.js.map} +0 -0
- /package/dist/{chunk-2DM72JF3.js.map → chunk-KRBK4BQH.js.map} +0 -0
- /package/dist/{chunk-RCZRL5BE.js.map → chunk-MRILGULB.js.map} +0 -0
- /package/dist/{chunk-MVAOT247.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-6CB4E7ZV.js.map → chunk-UL2NNBUL.js.map} +0 -0
- /package/dist/{chunk-TFORLO3O.js.map → chunk-W6AQJ2PY.js.map} +0 -0
- /package/dist/{chunk-PUXCIHRL.js.map → chunk-XSZEP4SF.js.map} +0 -0
- /package/dist/{chunk-4CRG46BG.js.map → chunk-ZK7I7JYV.js.map} +0 -0
package/src/offline-sync.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -10,10 +10,14 @@ import {
|
|
|
10
10
|
applyOfflineSyncChangeset,
|
|
11
11
|
applyOfflineSyncSnapshot,
|
|
12
12
|
buildOfflineSyncChangeset,
|
|
13
|
+
buildOfflineSyncChangesetFromSnapshot,
|
|
13
14
|
buildOfflineSyncSnapshot,
|
|
15
|
+
buildOfflineSyncSnapshotFromBase,
|
|
14
16
|
buildOfflineSyncSnapshotForPaths,
|
|
17
|
+
OFFLINE_SYNC_MAX_MTIME_MS,
|
|
15
18
|
readOfflineSyncFileContentChunk,
|
|
16
19
|
summarizeOfflineSyncPendingChanges,
|
|
20
|
+
summarizeOfflineSyncPendingFiles,
|
|
17
21
|
} from "./offline-sync.js";
|
|
18
22
|
import { isEncryptedFile } from "./secure-store/secure-fs.js";
|
|
19
23
|
import { StorageManager } from "./storage.js";
|
|
@@ -300,6 +304,315 @@ test("offline sync includes durable runtime state and excludes only transient sy
|
|
|
300
304
|
}
|
|
301
305
|
});
|
|
302
306
|
|
|
307
|
+
test("offline snapshot from base avoids rehashing unchanged files", async () => {
|
|
308
|
+
const root = await tempDir("remnic-offline-fast-base");
|
|
309
|
+
try {
|
|
310
|
+
await write(root, "facts/a.md", "alpha");
|
|
311
|
+
const baseSnapshot = await buildOfflineSyncSnapshot({
|
|
312
|
+
root,
|
|
313
|
+
sourceId: "remote",
|
|
314
|
+
includeContent: false,
|
|
315
|
+
});
|
|
316
|
+
const baseCapturedAt = new Date(Date.now() + 60_000);
|
|
317
|
+
let readCount = 0;
|
|
318
|
+
|
|
319
|
+
const unchanged = await buildOfflineSyncSnapshotFromBase({
|
|
320
|
+
root,
|
|
321
|
+
sourceId: "remote",
|
|
322
|
+
baseFiles: baseSnapshot.files,
|
|
323
|
+
baseCapturedAt,
|
|
324
|
+
includeContent: false,
|
|
325
|
+
readFile: async () => {
|
|
326
|
+
readCount += 1;
|
|
327
|
+
throw new Error("unchanged file should not be read");
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
assert.equal(readCount, 0);
|
|
332
|
+
assert.deepEqual(unchanged.files, baseSnapshot.files);
|
|
333
|
+
|
|
334
|
+
await write(root, "facts/b.md", "beta");
|
|
335
|
+
const changed = await buildOfflineSyncSnapshotFromBase({
|
|
336
|
+
root,
|
|
337
|
+
sourceId: "remote",
|
|
338
|
+
baseFiles: baseSnapshot.files,
|
|
339
|
+
baseCapturedAt,
|
|
340
|
+
includeContent: false,
|
|
341
|
+
readFile: async ({ filePath }) => {
|
|
342
|
+
readCount += 1;
|
|
343
|
+
return readFile(filePath);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
assert.equal(readCount, 1);
|
|
348
|
+
assert.deepEqual(changed.files.map((file) => file.path), ["facts/a.md", "facts/b.md"]);
|
|
349
|
+
|
|
350
|
+
let digestReadCount = 0;
|
|
351
|
+
const digestSnapshot = await buildOfflineSyncSnapshotFromBase({
|
|
352
|
+
root,
|
|
353
|
+
sourceId: "remote",
|
|
354
|
+
baseFiles: baseSnapshot.files,
|
|
355
|
+
baseCapturedAt,
|
|
356
|
+
includeContent: false,
|
|
357
|
+
readFile: async () => {
|
|
358
|
+
throw new Error("digest hook should avoid buffered reads");
|
|
359
|
+
},
|
|
360
|
+
readFileDigest: async ({ filePath }) => {
|
|
361
|
+
digestReadCount += 1;
|
|
362
|
+
const bytes = await readFile(filePath);
|
|
363
|
+
return {
|
|
364
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
365
|
+
bytes: bytes.byteLength,
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
assert.equal(digestReadCount, 1);
|
|
371
|
+
assert.deepEqual(digestSnapshot.files.map((file) => file.path), ["facts/a.md", "facts/b.md"]);
|
|
372
|
+
} finally {
|
|
373
|
+
await rm(root, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("offline snapshot from base rehashes same-size rewrites when mtime changes", async () => {
|
|
378
|
+
const root = await tempDir("remnic-offline-fast-base-ctime");
|
|
379
|
+
try {
|
|
380
|
+
await write(root, "facts/a.md", "alpha");
|
|
381
|
+
const filePath = path.join(root, "facts/a.md");
|
|
382
|
+
const baseSnapshot = await buildOfflineSyncSnapshot({
|
|
383
|
+
root,
|
|
384
|
+
sourceId: "remote",
|
|
385
|
+
includeContent: false,
|
|
386
|
+
});
|
|
387
|
+
const baseFile = baseSnapshot.files[0];
|
|
388
|
+
assert.ok(baseFile);
|
|
389
|
+
const baseCapturedAt = new Date();
|
|
390
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
391
|
+
await write(root, "facts/a.md", "bravo");
|
|
392
|
+
const rewritten = await buildOfflineSyncSnapshot({
|
|
393
|
+
root,
|
|
394
|
+
sourceId: "remote",
|
|
395
|
+
includeContent: false,
|
|
396
|
+
});
|
|
397
|
+
const rewrittenFile = rewritten.files[0];
|
|
398
|
+
assert.ok(rewrittenFile);
|
|
399
|
+
if (Math.abs(rewrittenFile.mtimeMs - baseFile.mtimeMs) <= 1_000) {
|
|
400
|
+
await utimes(filePath, (baseFile.mtimeMs + 2_000) / 1000, (baseFile.mtimeMs + 2_000) / 1000);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let readCount = 0;
|
|
404
|
+
const snapshot = await buildOfflineSyncSnapshotFromBase({
|
|
405
|
+
root,
|
|
406
|
+
sourceId: "remote",
|
|
407
|
+
baseFiles: baseSnapshot.files,
|
|
408
|
+
baseCapturedAt,
|
|
409
|
+
includeContent: false,
|
|
410
|
+
readFile: async ({ filePath }) => {
|
|
411
|
+
readCount += 1;
|
|
412
|
+
return readFile(filePath);
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
assert.equal(readCount, 1);
|
|
417
|
+
assert.equal(snapshot.files[0]?.path, "facts/a.md");
|
|
418
|
+
assert.notEqual(snapshot.files[0]?.sha256, baseFile.sha256);
|
|
419
|
+
} finally {
|
|
420
|
+
await rm(root, { recursive: true, force: true });
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("offline snapshot from base trusts mtime precision drift without rehashing", async () => {
|
|
425
|
+
const root = await tempDir("remnic-offline-fast-base-large");
|
|
426
|
+
try {
|
|
427
|
+
await write(root, "facts/a.md", "alpha");
|
|
428
|
+
const baseSnapshot = await buildOfflineSyncSnapshot({
|
|
429
|
+
root,
|
|
430
|
+
sourceId: "remote",
|
|
431
|
+
includeContent: false,
|
|
432
|
+
});
|
|
433
|
+
const baseFile = baseSnapshot.files.find((file) => file.path === "facts/a.md");
|
|
434
|
+
assert.ok(baseFile);
|
|
435
|
+
const driftedBase = { ...baseFile, mtimeMs: baseFile.mtimeMs + 500 };
|
|
436
|
+
|
|
437
|
+
const reused = await buildOfflineSyncSnapshotFromBase({
|
|
438
|
+
root,
|
|
439
|
+
sourceId: "remote",
|
|
440
|
+
baseFiles: [driftedBase],
|
|
441
|
+
baseCapturedAt: new Date(Date.now() + 60_000),
|
|
442
|
+
includeContent: false,
|
|
443
|
+
readFileDigest: async () => {
|
|
444
|
+
throw new Error("unchanged file should reuse trusted mtime metadata");
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
assert.deepEqual(reused.files, [driftedBase]);
|
|
449
|
+
} finally {
|
|
450
|
+
await rm(root, { recursive: true, force: true });
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("offline snapshot from base rehashes preserved-mtime rewrites when ctime changed after capture", async () => {
|
|
455
|
+
const root = await tempDir("remnic-offline-fast-base-preserved-mtime");
|
|
456
|
+
try {
|
|
457
|
+
await write(root, "facts/a.md", "alpha");
|
|
458
|
+
const filePath = path.join(root, "facts/a.md");
|
|
459
|
+
const baseSnapshot = await buildOfflineSyncSnapshot({
|
|
460
|
+
root,
|
|
461
|
+
sourceId: "remote",
|
|
462
|
+
includeContent: false,
|
|
463
|
+
});
|
|
464
|
+
const baseFile = baseSnapshot.files[0];
|
|
465
|
+
assert.ok(baseFile);
|
|
466
|
+
await write(root, "facts/a.md", "bravo");
|
|
467
|
+
await utimes(filePath, baseFile.mtimeMs / 1000, baseFile.mtimeMs / 1000);
|
|
468
|
+
|
|
469
|
+
let digestReadCount = 0;
|
|
470
|
+
const snapshot = await buildOfflineSyncSnapshotFromBase({
|
|
471
|
+
root,
|
|
472
|
+
sourceId: "remote",
|
|
473
|
+
baseFiles: baseSnapshot.files,
|
|
474
|
+
baseCapturedAt: new Date(0),
|
|
475
|
+
includeContent: false,
|
|
476
|
+
readFileDigest: async ({ filePath }) => {
|
|
477
|
+
digestReadCount += 1;
|
|
478
|
+
const bytes = await readFile(filePath);
|
|
479
|
+
return {
|
|
480
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
481
|
+
bytes: bytes.byteLength,
|
|
482
|
+
};
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
assert.equal(digestReadCount, 1);
|
|
487
|
+
assert.equal(snapshot.files[0]?.path, "facts/a.md");
|
|
488
|
+
assert.notEqual(snapshot.files[0]?.sha256, baseFile.sha256);
|
|
489
|
+
} finally {
|
|
490
|
+
await rm(root, { recursive: true, force: true });
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("offline snapshot apply preserves incoming mtime for future fast-base reuse", async () => {
|
|
495
|
+
const root = await tempDir("remnic-offline-apply-mtime");
|
|
496
|
+
try {
|
|
497
|
+
const content = Buffer.from("remote fact");
|
|
498
|
+
const remoteMtimeMs = 1_700_000_000_123;
|
|
499
|
+
const capturedAtMs = Date.now() + 60_000;
|
|
500
|
+
const snapshot = {
|
|
501
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
502
|
+
schemaVersion: 1,
|
|
503
|
+
createdAt: new Date(capturedAtMs).toISOString(),
|
|
504
|
+
sourceId: "remote",
|
|
505
|
+
includeTranscripts: true,
|
|
506
|
+
files: [{
|
|
507
|
+
path: "facts/a.md",
|
|
508
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
509
|
+
bytes: content.byteLength,
|
|
510
|
+
mtimeMs: remoteMtimeMs,
|
|
511
|
+
contentBase64: content.toString("base64"),
|
|
512
|
+
}],
|
|
513
|
+
} as const;
|
|
514
|
+
|
|
515
|
+
const applied = await applyOfflineSyncSnapshot({
|
|
516
|
+
root,
|
|
517
|
+
snapshot,
|
|
518
|
+
baseFiles: [],
|
|
519
|
+
});
|
|
520
|
+
assert.equal(applied.upserted, 1);
|
|
521
|
+
const fileStat = await stat(path.join(root, "facts/a.md"));
|
|
522
|
+
assert.ok(Math.abs(fileStat.mtimeMs - remoteMtimeMs) <= 1_000);
|
|
523
|
+
|
|
524
|
+
const reused = await buildOfflineSyncSnapshotFromBase({
|
|
525
|
+
root,
|
|
526
|
+
sourceId: "local",
|
|
527
|
+
baseFiles: applied.nextBaseFiles,
|
|
528
|
+
baseCapturedAt: new Date(capturedAtMs),
|
|
529
|
+
includeContent: false,
|
|
530
|
+
readFileDigest: async () => {
|
|
531
|
+
throw new Error("applied file should reuse preserved mtime metadata");
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
assert.deepEqual(reused.files, applied.nextBaseFiles);
|
|
535
|
+
} finally {
|
|
536
|
+
await rm(root, { recursive: true, force: true });
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("offline snapshot apply does not checkpoint a vanished same-hash file", async () => {
|
|
541
|
+
const root = await tempDir("remnic-offline-apply-mtime-vanished");
|
|
542
|
+
try {
|
|
543
|
+
const content = Buffer.from("remote fact");
|
|
544
|
+
const file = {
|
|
545
|
+
path: "facts/a.md",
|
|
546
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
547
|
+
bytes: content.byteLength,
|
|
548
|
+
mtimeMs: 1_700_000_000_123,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const applied = await applyOfflineSyncSnapshot({
|
|
552
|
+
root,
|
|
553
|
+
snapshot: {
|
|
554
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
555
|
+
schemaVersion: 1,
|
|
556
|
+
createdAt: new Date().toISOString(),
|
|
557
|
+
sourceId: "remote",
|
|
558
|
+
includeTranscripts: true,
|
|
559
|
+
files: [file],
|
|
560
|
+
},
|
|
561
|
+
baseFiles: [],
|
|
562
|
+
currentFiles: [file],
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
assert.equal(applied.upserted, 0);
|
|
566
|
+
assert.equal(applied.pendingLocal, 1);
|
|
567
|
+
assert.equal(applied.skipped, 1);
|
|
568
|
+
assert.deepEqual(applied.nextBaseFiles, []);
|
|
569
|
+
} finally {
|
|
570
|
+
await rm(root, { recursive: true, force: true });
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("offline changeset rewrites a same-hash file that vanished before mtime alignment", async () => {
|
|
575
|
+
const root = await tempDir("remnic-offline-apply-changeset-mtime-vanished");
|
|
576
|
+
try {
|
|
577
|
+
const content = Buffer.from("remote fact");
|
|
578
|
+
const file = {
|
|
579
|
+
path: "facts/a.md",
|
|
580
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
581
|
+
bytes: content.byteLength,
|
|
582
|
+
mtimeMs: 1_700_000_000_123,
|
|
583
|
+
contentBase64: content.toString("base64"),
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const applied = await applyOfflineSyncChangeset({
|
|
587
|
+
root,
|
|
588
|
+
changeset: {
|
|
589
|
+
format: "remnic.offline-sync.changeset.v1",
|
|
590
|
+
schemaVersion: 1,
|
|
591
|
+
createdAt: new Date().toISOString(),
|
|
592
|
+
sourceId: "laptop",
|
|
593
|
+
includeTranscripts: true,
|
|
594
|
+
changes: [{
|
|
595
|
+
type: "upsert",
|
|
596
|
+
path: file.path,
|
|
597
|
+
file,
|
|
598
|
+
}],
|
|
599
|
+
},
|
|
600
|
+
currentFiles: [{
|
|
601
|
+
path: file.path,
|
|
602
|
+
sha256: file.sha256,
|
|
603
|
+
bytes: file.bytes,
|
|
604
|
+
mtimeMs: file.mtimeMs,
|
|
605
|
+
}],
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
assert.equal(applied.appliedUpserts, 1);
|
|
609
|
+
assert.equal(applied.skipped, 0);
|
|
610
|
+
assert.equal(await readUtf8(root, file.path), content.toString("utf-8"));
|
|
611
|
+
} finally {
|
|
612
|
+
await rm(root, { recursive: true, force: true });
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
303
616
|
test("offline sync accepts durable runtime records from older peers", async () => {
|
|
304
617
|
const root = await tempDir("remnic-offline-legacy-runtime-state");
|
|
305
618
|
try {
|
|
@@ -551,6 +864,23 @@ test("offline sync applies chunked file content with base conflict checks", asyn
|
|
|
551
864
|
assert.equal(second.done, true);
|
|
552
865
|
assert.equal(second.applied, true);
|
|
553
866
|
assert.equal(await readUtf8(root, "state/lcm.sqlite"), "new durable sqlite content");
|
|
867
|
+
const appliedStat = await stat(path.join(root, "state/lcm.sqlite"));
|
|
868
|
+
assert.ok(Math.abs(appliedStat.mtimeMs - 123) <= 1_000);
|
|
869
|
+
|
|
870
|
+
const retry = await applyOfflineSyncFileContentChunk({
|
|
871
|
+
root,
|
|
872
|
+
sourceId: "laptop",
|
|
873
|
+
path: "state/lcm.sqlite",
|
|
874
|
+
sha256: nextSha,
|
|
875
|
+
bytes: next.byteLength,
|
|
876
|
+
mtimeMs: 123,
|
|
877
|
+
offset: 0,
|
|
878
|
+
baseSha256: oldSha,
|
|
879
|
+
content: next.subarray(0, 8),
|
|
880
|
+
});
|
|
881
|
+
assert.equal(retry.done, true);
|
|
882
|
+
assert.equal(retry.skipped, true);
|
|
883
|
+
assert.equal(retry.conflict, undefined);
|
|
554
884
|
|
|
555
885
|
const conflictContent = Buffer.from("conflicting local sqlite");
|
|
556
886
|
const conflictSha = createHash("sha256").update(conflictContent).digest("hex");
|
|
@@ -566,9 +896,105 @@ test("offline sync applies chunked file content with base conflict checks", asyn
|
|
|
566
896
|
content: conflictContent,
|
|
567
897
|
});
|
|
568
898
|
assert.equal(conflict.done, true);
|
|
899
|
+
assert.equal(conflict.chunkBytes, 0);
|
|
569
900
|
assert.equal(conflict.applied, false);
|
|
570
901
|
assert.equal(conflict.conflict?.reason, "remote_changed_for_local_update");
|
|
571
902
|
assert.equal(await readUtf8(root, "state/lcm.sqlite"), "new durable sqlite content");
|
|
903
|
+
|
|
904
|
+
const partialConflictContent = Buffer.from("another conflicting sqlite payload");
|
|
905
|
+
const partialConflictSha = createHash("sha256").update(partialConflictContent).digest("hex");
|
|
906
|
+
const partialConflict = await applyOfflineSyncFileContentChunk({
|
|
907
|
+
root,
|
|
908
|
+
sourceId: "laptop",
|
|
909
|
+
path: "state/lcm.sqlite",
|
|
910
|
+
sha256: partialConflictSha,
|
|
911
|
+
bytes: partialConflictContent.byteLength,
|
|
912
|
+
mtimeMs: 789,
|
|
913
|
+
offset: 0,
|
|
914
|
+
baseSha256: oldSha,
|
|
915
|
+
content: partialConflictContent.subarray(0, 8),
|
|
916
|
+
});
|
|
917
|
+
assert.equal(partialConflict.done, true);
|
|
918
|
+
assert.equal(partialConflict.chunkBytes, 0);
|
|
919
|
+
assert.equal(partialConflict.applied, false);
|
|
920
|
+
assert.equal(partialConflict.conflict?.reason, "remote_changed_for_local_update");
|
|
921
|
+
assert.equal(await readUtf8(root, "state/lcm.sqlite"), "new durable sqlite content");
|
|
922
|
+
} finally {
|
|
923
|
+
await rm(root, { recursive: true, force: true });
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("offline sync chunked apply lets incoming runtime state replace local conflicts", async () => {
|
|
928
|
+
const root = await tempDir("remnic-offline-file-content-runtime");
|
|
929
|
+
try {
|
|
930
|
+
const relPath = "state/memory-lifecycle-ledger.jsonl";
|
|
931
|
+
await write(root, relPath, "local ledger\n");
|
|
932
|
+
const baseSha = createHash("sha256").update("base ledger\n").digest("hex");
|
|
933
|
+
const next = Buffer.from("remote ledger\n");
|
|
934
|
+
const nextSha = createHash("sha256").update(next).digest("hex");
|
|
935
|
+
|
|
936
|
+
const result = await applyOfflineSyncFileContentChunk({
|
|
937
|
+
root,
|
|
938
|
+
sourceId: "remote",
|
|
939
|
+
path: relPath,
|
|
940
|
+
sha256: nextSha,
|
|
941
|
+
bytes: next.byteLength,
|
|
942
|
+
mtimeMs: 123,
|
|
943
|
+
offset: 0,
|
|
944
|
+
baseSha256: baseSha,
|
|
945
|
+
content: next,
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
assert.equal(result.done, true);
|
|
949
|
+
assert.equal(result.applied, true);
|
|
950
|
+
assert.equal(result.conflict, undefined);
|
|
951
|
+
assert.equal(await readUtf8(root, relPath), "remote ledger\n");
|
|
952
|
+
} finally {
|
|
953
|
+
await rm(root, { recursive: true, force: true });
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test("offline sync chunked apply skips final conflict when target already matches incoming", async () => {
|
|
958
|
+
const root = await tempDir("remnic-offline-file-content-final-skip");
|
|
959
|
+
try {
|
|
960
|
+
const relPath = "facts/chunked.md";
|
|
961
|
+
await write(root, relPath, "old chunked content");
|
|
962
|
+
const oldSha = createHash("sha256").update("old chunked content").digest("hex");
|
|
963
|
+
const next = Buffer.from("new chunked content that arrives in pieces");
|
|
964
|
+
const nextSha = createHash("sha256").update(next).digest("hex");
|
|
965
|
+
|
|
966
|
+
const first = await applyOfflineSyncFileContentChunk({
|
|
967
|
+
root,
|
|
968
|
+
sourceId: "laptop",
|
|
969
|
+
path: relPath,
|
|
970
|
+
sha256: nextSha,
|
|
971
|
+
bytes: next.byteLength,
|
|
972
|
+
mtimeMs: 456,
|
|
973
|
+
offset: 0,
|
|
974
|
+
baseSha256: oldSha,
|
|
975
|
+
content: next.subarray(0, 8),
|
|
976
|
+
});
|
|
977
|
+
assert.equal(first.done, false);
|
|
978
|
+
|
|
979
|
+
await write(root, relPath, next);
|
|
980
|
+
const second = await applyOfflineSyncFileContentChunk({
|
|
981
|
+
root,
|
|
982
|
+
sourceId: "laptop",
|
|
983
|
+
path: relPath,
|
|
984
|
+
sha256: nextSha,
|
|
985
|
+
bytes: next.byteLength,
|
|
986
|
+
mtimeMs: 456,
|
|
987
|
+
offset: 8,
|
|
988
|
+
baseSha256: oldSha,
|
|
989
|
+
content: next.subarray(8),
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
assert.equal(second.done, true);
|
|
993
|
+
assert.equal(second.applied, false);
|
|
994
|
+
assert.equal(second.skipped, true);
|
|
995
|
+
assert.equal(second.conflict, undefined);
|
|
996
|
+
assert.equal(await readUtf8(root, relPath), next.toString("utf-8"));
|
|
997
|
+
assert.equal((await readdir(path.join(root, ".offline-sync", "uploads"))).length, 0);
|
|
572
998
|
} finally {
|
|
573
999
|
await rm(root, { recursive: true, force: true });
|
|
574
1000
|
}
|
|
@@ -704,6 +1130,120 @@ test("offline sync prunes stale staged uploads when starting a new upload", asyn
|
|
|
704
1130
|
}
|
|
705
1131
|
});
|
|
706
1132
|
|
|
1133
|
+
test("offline snapshot apply follows remote deletions for remote-authoritative runtime state", async () => {
|
|
1134
|
+
const root = await tempDir("remnic-offline-runtime-delete");
|
|
1135
|
+
try {
|
|
1136
|
+
const relPath = "state/buffer.json";
|
|
1137
|
+
const baseContent = Buffer.from("{\"turns\":[]}");
|
|
1138
|
+
const localContent = Buffer.from("{\"turns\":[\"local\"]}");
|
|
1139
|
+
await write(root, relPath, localContent);
|
|
1140
|
+
|
|
1141
|
+
const pull = await applyOfflineSyncSnapshot({
|
|
1142
|
+
root,
|
|
1143
|
+
snapshot: {
|
|
1144
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
1145
|
+
schemaVersion: 1,
|
|
1146
|
+
createdAt: new Date().toISOString(),
|
|
1147
|
+
sourceId: "remote",
|
|
1148
|
+
includeTranscripts: true,
|
|
1149
|
+
files: [],
|
|
1150
|
+
},
|
|
1151
|
+
baseFiles: [{
|
|
1152
|
+
path: relPath,
|
|
1153
|
+
sha256: createHash("sha256").update(baseContent).digest("hex"),
|
|
1154
|
+
bytes: baseContent.byteLength,
|
|
1155
|
+
mtimeMs: 0,
|
|
1156
|
+
}],
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
assert.equal(pull.deleted, 1);
|
|
1160
|
+
assert.equal(pull.conflicts.length, 0);
|
|
1161
|
+
assert.deepEqual(pull.nextBaseFiles, []);
|
|
1162
|
+
await assert.rejects(
|
|
1163
|
+
() => readFile(path.join(root, relPath)),
|
|
1164
|
+
/ENOENT/,
|
|
1165
|
+
);
|
|
1166
|
+
} finally {
|
|
1167
|
+
await rm(root, { recursive: true, force: true });
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("offline snapshot apply preserves new local runtime files without a base", async () => {
|
|
1172
|
+
const root = await tempDir("remnic-offline-runtime-local-create");
|
|
1173
|
+
try {
|
|
1174
|
+
const relPath = "state/buffer.json";
|
|
1175
|
+
const localContent = "{\"turns\":[\"local\"]}";
|
|
1176
|
+
await write(root, relPath, localContent);
|
|
1177
|
+
|
|
1178
|
+
const pull = await applyOfflineSyncSnapshot({
|
|
1179
|
+
root,
|
|
1180
|
+
snapshot: {
|
|
1181
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
1182
|
+
schemaVersion: 1,
|
|
1183
|
+
createdAt: new Date().toISOString(),
|
|
1184
|
+
sourceId: "remote",
|
|
1185
|
+
includeTranscripts: true,
|
|
1186
|
+
files: [],
|
|
1187
|
+
},
|
|
1188
|
+
baseFiles: [],
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
assert.equal(pull.deleted, 0);
|
|
1192
|
+
assert.equal(pull.pendingLocal, 1);
|
|
1193
|
+
assert.equal(pull.skipped, 1);
|
|
1194
|
+
assert.equal(pull.conflicts.length, 0);
|
|
1195
|
+
assert.deepEqual(pull.nextBaseFiles, []);
|
|
1196
|
+
assert.equal(await readUtf8(root, relPath), localContent);
|
|
1197
|
+
} finally {
|
|
1198
|
+
await rm(root, { recursive: true, force: true });
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
test("offline snapshot apply restores locally deleted remote-authoritative runtime files", async () => {
|
|
1203
|
+
const root = await tempDir("remnic-offline-runtime-local-delete-restore");
|
|
1204
|
+
try {
|
|
1205
|
+
const relPath = "state/buffer.json";
|
|
1206
|
+
const remoteContent = Buffer.from("{\"turns\":[]}");
|
|
1207
|
+
const remoteFile = {
|
|
1208
|
+
path: relPath,
|
|
1209
|
+
sha256: createHash("sha256").update(remoteContent).digest("hex"),
|
|
1210
|
+
bytes: remoteContent.byteLength,
|
|
1211
|
+
mtimeMs: 1,
|
|
1212
|
+
contentBase64: remoteContent.toString("base64"),
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const pull = await applyOfflineSyncSnapshot({
|
|
1216
|
+
root,
|
|
1217
|
+
snapshot: {
|
|
1218
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
1219
|
+
schemaVersion: 1,
|
|
1220
|
+
createdAt: new Date().toISOString(),
|
|
1221
|
+
sourceId: "remote",
|
|
1222
|
+
includeTranscripts: true,
|
|
1223
|
+
files: [remoteFile],
|
|
1224
|
+
},
|
|
1225
|
+
baseFiles: [{
|
|
1226
|
+
path: remoteFile.path,
|
|
1227
|
+
sha256: remoteFile.sha256,
|
|
1228
|
+
bytes: remoteFile.bytes,
|
|
1229
|
+
mtimeMs: remoteFile.mtimeMs,
|
|
1230
|
+
}],
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
assert.equal(pull.upserted, 1);
|
|
1234
|
+
assert.equal(pull.conflicts.length, 0);
|
|
1235
|
+
assert.equal(await readUtf8(root, relPath), remoteContent.toString("utf-8"));
|
|
1236
|
+
assert.deepEqual(pull.nextBaseFiles, [{
|
|
1237
|
+
path: remoteFile.path,
|
|
1238
|
+
sha256: remoteFile.sha256,
|
|
1239
|
+
bytes: remoteFile.bytes,
|
|
1240
|
+
mtimeMs: remoteFile.mtimeMs,
|
|
1241
|
+
}]);
|
|
1242
|
+
} finally {
|
|
1243
|
+
await rm(root, { recursive: true, force: true });
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
|
|
707
1247
|
test("offline changeset pushes local edits when the remote is still at the shared base", async () => {
|
|
708
1248
|
const remote = await tempDir("remnic-offline-remote");
|
|
709
1249
|
const local = await tempDir("remnic-offline-local");
|
|
@@ -803,6 +1343,68 @@ test("offline changeset can exclude directly pushed large files without reading
|
|
|
803
1343
|
}
|
|
804
1344
|
});
|
|
805
1345
|
|
|
1346
|
+
test("offline changeset skips local edits to remote-authoritative runtime state", async () => {
|
|
1347
|
+
const local = await tempDir("remnic-offline-runtime-authoritative-push");
|
|
1348
|
+
try {
|
|
1349
|
+
const relPath = "state/buffer.json";
|
|
1350
|
+
const baseContent = Buffer.from("{\"turns\":[]}");
|
|
1351
|
+
const localContent = Buffer.from("{\"turns\":[\"local\"]}");
|
|
1352
|
+
await write(local, relPath, localContent);
|
|
1353
|
+
const baseFile = {
|
|
1354
|
+
path: relPath,
|
|
1355
|
+
sha256: createHash("sha256").update(baseContent).digest("hex"),
|
|
1356
|
+
bytes: baseContent.byteLength,
|
|
1357
|
+
mtimeMs: 1,
|
|
1358
|
+
};
|
|
1359
|
+
const currentFile = {
|
|
1360
|
+
path: relPath,
|
|
1361
|
+
sha256: createHash("sha256").update(localContent).digest("hex"),
|
|
1362
|
+
bytes: localContent.byteLength,
|
|
1363
|
+
mtimeMs: 2,
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
assert.equal(
|
|
1367
|
+
(await summarizeOfflineSyncPendingChanges({
|
|
1368
|
+
root: local,
|
|
1369
|
+
sourceId: "laptop",
|
|
1370
|
+
baseFiles: [baseFile],
|
|
1371
|
+
})).total,
|
|
1372
|
+
0,
|
|
1373
|
+
);
|
|
1374
|
+
|
|
1375
|
+
const changeset = await buildOfflineSyncChangesetFromSnapshot({
|
|
1376
|
+
root: local,
|
|
1377
|
+
sourceId: "laptop",
|
|
1378
|
+
baseFiles: [baseFile],
|
|
1379
|
+
currentFiles: [currentFile],
|
|
1380
|
+
readFile: async () => {
|
|
1381
|
+
throw new Error("runtime state should not be read for push");
|
|
1382
|
+
},
|
|
1383
|
+
});
|
|
1384
|
+
assert.equal(changeset.changes.length, 0);
|
|
1385
|
+
|
|
1386
|
+
assert.equal(
|
|
1387
|
+
summarizeOfflineSyncPendingFiles({
|
|
1388
|
+
baseFiles: [baseFile],
|
|
1389
|
+
currentFiles: [],
|
|
1390
|
+
}).total,
|
|
1391
|
+
0,
|
|
1392
|
+
);
|
|
1393
|
+
const deleteChangeset = await buildOfflineSyncChangesetFromSnapshot({
|
|
1394
|
+
root: local,
|
|
1395
|
+
sourceId: "laptop",
|
|
1396
|
+
baseFiles: [baseFile],
|
|
1397
|
+
currentFiles: [],
|
|
1398
|
+
readFile: async () => {
|
|
1399
|
+
throw new Error("deleted runtime state should not be read for push");
|
|
1400
|
+
},
|
|
1401
|
+
});
|
|
1402
|
+
assert.equal(deleteChangeset.changes.length, 0);
|
|
1403
|
+
} finally {
|
|
1404
|
+
await rm(local, { recursive: true, force: true });
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
|
|
806
1408
|
test("offline pull accepts metadata-only snapshots when files are unchanged", async () => {
|
|
807
1409
|
const remote = await tempDir("remnic-offline-metadata-remote");
|
|
808
1410
|
const local = await tempDir("remnic-offline-metadata-local");
|
|
@@ -838,6 +1440,92 @@ test("offline pull accepts metadata-only snapshots when files are unchanged", as
|
|
|
838
1440
|
}
|
|
839
1441
|
});
|
|
840
1442
|
|
|
1443
|
+
test("offline pull lets incoming runtime state replace local runtime conflicts", async () => {
|
|
1444
|
+
const root = await tempDir("remnic-offline-runtime-authoritative-pull");
|
|
1445
|
+
try {
|
|
1446
|
+
const relPath = "state/buffer.json";
|
|
1447
|
+
const baseContent = Buffer.from("{\"turns\":[]}");
|
|
1448
|
+
const localContent = Buffer.from("{\"turns\":[\"local\"]}");
|
|
1449
|
+
const remoteContent = Buffer.from("{\"turns\":[\"remote\"]}");
|
|
1450
|
+
await write(root, relPath, localContent);
|
|
1451
|
+
const baseFile = {
|
|
1452
|
+
path: relPath,
|
|
1453
|
+
sha256: createHash("sha256").update(baseContent).digest("hex"),
|
|
1454
|
+
bytes: baseContent.byteLength,
|
|
1455
|
+
mtimeMs: 1,
|
|
1456
|
+
};
|
|
1457
|
+
const incomingFile = {
|
|
1458
|
+
path: relPath,
|
|
1459
|
+
sha256: createHash("sha256").update(remoteContent).digest("hex"),
|
|
1460
|
+
bytes: remoteContent.byteLength,
|
|
1461
|
+
mtimeMs: 2,
|
|
1462
|
+
contentBase64: remoteContent.toString("base64"),
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1465
|
+
const pull = await applyOfflineSyncSnapshot({
|
|
1466
|
+
root,
|
|
1467
|
+
snapshot: {
|
|
1468
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
1469
|
+
schemaVersion: 1,
|
|
1470
|
+
createdAt: new Date().toISOString(),
|
|
1471
|
+
sourceId: "remote",
|
|
1472
|
+
includeTranscripts: true,
|
|
1473
|
+
files: [incomingFile],
|
|
1474
|
+
},
|
|
1475
|
+
baseFiles: [baseFile],
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
assert.equal(pull.upserted, 1);
|
|
1479
|
+
assert.equal(pull.conflicts.length, 0);
|
|
1480
|
+
assert.equal(await readUtf8(root, relPath), remoteContent.toString("utf-8"));
|
|
1481
|
+
assert.deepEqual(pull.nextBaseFiles, [{
|
|
1482
|
+
path: relPath,
|
|
1483
|
+
sha256: incomingFile.sha256,
|
|
1484
|
+
bytes: incomingFile.bytes,
|
|
1485
|
+
mtimeMs: incomingFile.mtimeMs,
|
|
1486
|
+
}]);
|
|
1487
|
+
} finally {
|
|
1488
|
+
await rm(root, { recursive: true, force: true });
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
test("offline pull skips local runtime drift when remote still matches base", async () => {
|
|
1493
|
+
const root = await tempDir("remnic-offline-runtime-authoritative-base");
|
|
1494
|
+
try {
|
|
1495
|
+
const relPath = "state/buffer.json";
|
|
1496
|
+
const baseContent = Buffer.from("{\"turns\":[]}");
|
|
1497
|
+
const localContent = Buffer.from("{\"turns\":[\"local\"]}");
|
|
1498
|
+
await write(root, relPath, localContent);
|
|
1499
|
+
const baseFile = {
|
|
1500
|
+
path: relPath,
|
|
1501
|
+
sha256: createHash("sha256").update(baseContent).digest("hex"),
|
|
1502
|
+
bytes: baseContent.byteLength,
|
|
1503
|
+
mtimeMs: 1,
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
const pull = await applyOfflineSyncSnapshot({
|
|
1507
|
+
root,
|
|
1508
|
+
snapshot: {
|
|
1509
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
1510
|
+
schemaVersion: 1,
|
|
1511
|
+
createdAt: new Date().toISOString(),
|
|
1512
|
+
sourceId: "remote",
|
|
1513
|
+
includeTranscripts: true,
|
|
1514
|
+
files: [baseFile],
|
|
1515
|
+
},
|
|
1516
|
+
baseFiles: [baseFile],
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
assert.equal(pull.upserted, 0);
|
|
1520
|
+
assert.equal(pull.skipped, 1);
|
|
1521
|
+
assert.equal(pull.pendingLocal, 0);
|
|
1522
|
+
assert.equal(pull.conflicts.length, 0);
|
|
1523
|
+
assert.equal(await readUtf8(root, relPath), localContent.toString("utf-8"));
|
|
1524
|
+
} finally {
|
|
1525
|
+
await rm(root, { recursive: true, force: true });
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
|
|
841
1529
|
test("offline pull applies snapshots with content only for remote-changed files", async () => {
|
|
842
1530
|
const remote = await tempDir("remnic-offline-partial-remote");
|
|
843
1531
|
const local = await tempDir("remnic-offline-partial-local");
|
|
@@ -934,6 +1622,46 @@ test("offline pull preserves local edits when both sides changed since the base"
|
|
|
934
1622
|
}
|
|
935
1623
|
});
|
|
936
1624
|
|
|
1625
|
+
test("offline pull can record conflicts without hydrating oversized incoming content", async () => {
|
|
1626
|
+
const remote = await tempDir("remnic-offline-metadata-conflict-remote");
|
|
1627
|
+
const local = await tempDir("remnic-offline-metadata-conflict-local");
|
|
1628
|
+
try {
|
|
1629
|
+
await write(remote, "state/lcm.sqlite", "base");
|
|
1630
|
+
const initial = await buildOfflineSyncSnapshot({
|
|
1631
|
+
root: remote,
|
|
1632
|
+
sourceId: "remote",
|
|
1633
|
+
includeContent: true,
|
|
1634
|
+
});
|
|
1635
|
+
const firstPull = await applyOfflineSyncSnapshot({
|
|
1636
|
+
root: local,
|
|
1637
|
+
snapshot: initial,
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
await write(local, "state/lcm.sqlite", "local edit");
|
|
1641
|
+
await write(remote, "state/lcm.sqlite", "remote edit");
|
|
1642
|
+
const metadataOnly = await buildOfflineSyncSnapshot({
|
|
1643
|
+
root: remote,
|
|
1644
|
+
sourceId: "remote",
|
|
1645
|
+
includeContent: false,
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
const secondPull = await applyOfflineSyncSnapshot({
|
|
1649
|
+
root: local,
|
|
1650
|
+
snapshot: metadataOnly,
|
|
1651
|
+
baseFiles: firstPull.nextBaseFiles,
|
|
1652
|
+
allowMissingConflictContent: true,
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
assert.equal(secondPull.conflicts.length, 1);
|
|
1656
|
+
assert.equal(secondPull.conflicts[0]?.reason, "both_modified");
|
|
1657
|
+
assert.equal(secondPull.conflicts[0]?.conflictPath, undefined);
|
|
1658
|
+
assert.equal(await readUtf8(local, "state/lcm.sqlite"), "local edit");
|
|
1659
|
+
} finally {
|
|
1660
|
+
await rm(remote, { recursive: true, force: true });
|
|
1661
|
+
await rm(local, { recursive: true, force: true });
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
|
|
937
1665
|
test("offline push preserves remote edits when both sides changed since the base", async () => {
|
|
938
1666
|
const remote = await tempDir("remnic-offline-push-conflict-remote");
|
|
939
1667
|
const local = await tempDir("remnic-offline-push-conflict-local");
|
|
@@ -1141,6 +1869,80 @@ test("offline payloads require explicit includeTranscripts booleans", async () =
|
|
|
1141
1869
|
}
|
|
1142
1870
|
});
|
|
1143
1871
|
|
|
1872
|
+
test("offline payloads reject mtimes outside JavaScript Date range", async () => {
|
|
1873
|
+
const root = await tempDir("remnic-offline-invalid-mtime");
|
|
1874
|
+
try {
|
|
1875
|
+
const content = Buffer.from("remote");
|
|
1876
|
+
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
1877
|
+
const mtimeMs = OFFLINE_SYNC_MAX_MTIME_MS + 1;
|
|
1878
|
+
|
|
1879
|
+
await assert.rejects(
|
|
1880
|
+
() =>
|
|
1881
|
+
applyOfflineSyncSnapshot({
|
|
1882
|
+
root,
|
|
1883
|
+
snapshot: {
|
|
1884
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
1885
|
+
schemaVersion: 1,
|
|
1886
|
+
createdAt: new Date().toISOString(),
|
|
1887
|
+
sourceId: "remote",
|
|
1888
|
+
includeTranscripts: true,
|
|
1889
|
+
files: [{
|
|
1890
|
+
path: "facts/remote.md",
|
|
1891
|
+
sha256,
|
|
1892
|
+
bytes: content.byteLength,
|
|
1893
|
+
mtimeMs,
|
|
1894
|
+
contentBase64: content.toString("base64"),
|
|
1895
|
+
}],
|
|
1896
|
+
},
|
|
1897
|
+
}),
|
|
1898
|
+
/files\[0\]\.mtimeMs must be within JavaScript Date range/,
|
|
1899
|
+
);
|
|
1900
|
+
|
|
1901
|
+
await assert.rejects(
|
|
1902
|
+
() =>
|
|
1903
|
+
applyOfflineSyncChangeset({
|
|
1904
|
+
root,
|
|
1905
|
+
changeset: {
|
|
1906
|
+
format: "remnic.offline-sync.changeset.v1",
|
|
1907
|
+
schemaVersion: 1,
|
|
1908
|
+
createdAt: new Date().toISOString(),
|
|
1909
|
+
sourceId: "laptop",
|
|
1910
|
+
includeTranscripts: true,
|
|
1911
|
+
changes: [{
|
|
1912
|
+
type: "upsert",
|
|
1913
|
+
path: "facts/remote.md",
|
|
1914
|
+
file: {
|
|
1915
|
+
path: "facts/remote.md",
|
|
1916
|
+
sha256,
|
|
1917
|
+
bytes: content.byteLength,
|
|
1918
|
+
mtimeMs,
|
|
1919
|
+
contentBase64: content.toString("base64"),
|
|
1920
|
+
},
|
|
1921
|
+
}],
|
|
1922
|
+
},
|
|
1923
|
+
}),
|
|
1924
|
+
/offline sync changeset invalid: changes\[0\]\.file\.mtimeMs must be within JavaScript Date range/,
|
|
1925
|
+
);
|
|
1926
|
+
|
|
1927
|
+
await assert.rejects(
|
|
1928
|
+
() =>
|
|
1929
|
+
applyOfflineSyncFileContentChunk({
|
|
1930
|
+
root,
|
|
1931
|
+
sourceId: "remote",
|
|
1932
|
+
path: "facts/remote.md",
|
|
1933
|
+
sha256,
|
|
1934
|
+
bytes: content.byteLength,
|
|
1935
|
+
mtimeMs,
|
|
1936
|
+
offset: 0,
|
|
1937
|
+
content,
|
|
1938
|
+
}),
|
|
1939
|
+
/mtimeMs must be within JavaScript Date range/,
|
|
1940
|
+
);
|
|
1941
|
+
} finally {
|
|
1942
|
+
await rm(root, { recursive: true, force: true });
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1144
1946
|
test("offline payloads reject excluded internal paths", async () => {
|
|
1145
1947
|
const root = await tempDir("remnic-offline-internal-path-invalid");
|
|
1146
1948
|
try {
|
|
@@ -1225,6 +2027,13 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
|
|
|
1225
2027
|
(await storage.readOfflineSyncFile(path.join(root, "facts", "secure.md"))).toString("utf8"),
|
|
1226
2028
|
"secret fact",
|
|
1227
2029
|
);
|
|
2030
|
+
assert.deepEqual(
|
|
2031
|
+
await storage.digestOfflineSyncFile(path.join(root, "facts", "secure.md")),
|
|
2032
|
+
{
|
|
2033
|
+
sha256: createHash("sha256").update("secret fact").digest("hex"),
|
|
2034
|
+
bytes: Buffer.byteLength("secret fact"),
|
|
2035
|
+
},
|
|
2036
|
+
);
|
|
1228
2037
|
|
|
1229
2038
|
const snapshot = await buildOfflineSyncSnapshot({
|
|
1230
2039
|
root,
|
|
@@ -1237,6 +2046,21 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
|
|
|
1237
2046
|
"secret fact",
|
|
1238
2047
|
);
|
|
1239
2048
|
assert.equal(snapshot.files[0]?.bytes, Buffer.byteLength("secret fact"));
|
|
2049
|
+
const encryptedStat = await stat(path.join(root, "facts", "secure.md"));
|
|
2050
|
+
assert.notEqual(snapshot.files[0]?.bytes, encryptedStat.size);
|
|
2051
|
+
let digestReads = 0;
|
|
2052
|
+
const fastBase = await buildOfflineSyncSnapshotFromBase({
|
|
2053
|
+
root,
|
|
2054
|
+
sourceId: "remote",
|
|
2055
|
+
baseFiles: snapshot.files,
|
|
2056
|
+
baseCapturedAt: new Date(),
|
|
2057
|
+
readFileDigest: async ({ filePath }) => {
|
|
2058
|
+
digestReads += 1;
|
|
2059
|
+
return storage.digestOfflineSyncFile(filePath);
|
|
2060
|
+
},
|
|
2061
|
+
});
|
|
2062
|
+
assert.equal(digestReads, 1);
|
|
2063
|
+
assert.deepEqual(fastBase.files, snapshot.files.map(({ contentBase64: _contentBase64, ...file }) => file));
|
|
1240
2064
|
|
|
1241
2065
|
const sqlite = Buffer.from("streamed durable sqlite content");
|
|
1242
2066
|
const sqliteSha = createHash("sha256").update(sqlite).digest("hex");
|
|
@@ -1250,6 +2074,7 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
|
|
|
1250
2074
|
offset: 0,
|
|
1251
2075
|
content: sqlite.subarray(0, 8),
|
|
1252
2076
|
readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
|
|
2077
|
+
readFileDigest: async ({ filePath }) => storage.digestOfflineSyncFile(filePath),
|
|
1253
2078
|
writeFile: async ({ filePath, content }) => storage.writeOfflineSyncFile(filePath, content),
|
|
1254
2079
|
writeStagingFile: async ({ filePath, content }) => storage.writeOfflineSyncStagingFile(filePath, content),
|
|
1255
2080
|
writeFileChunks: async ({ filePath, chunks }) => storage.writeOfflineSyncFileChunks(filePath, chunks),
|
|
@@ -1265,6 +2090,7 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
|
|
|
1265
2090
|
offset: 8,
|
|
1266
2091
|
content: sqlite.subarray(8),
|
|
1267
2092
|
readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
|
|
2093
|
+
readFileDigest: async ({ filePath }) => storage.digestOfflineSyncFile(filePath),
|
|
1268
2094
|
writeFile: async ({ filePath, content }) => storage.writeOfflineSyncFile(filePath, content),
|
|
1269
2095
|
writeStagingFile: async ({ filePath, content }) => storage.writeOfflineSyncStagingFile(filePath, content),
|
|
1270
2096
|
writeFileChunks: async ({ filePath, chunks }) => storage.writeOfflineSyncFileChunks(filePath, chunks),
|
|
@@ -1282,6 +2108,47 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
|
|
|
1282
2108
|
}
|
|
1283
2109
|
});
|
|
1284
2110
|
|
|
2111
|
+
test("offline snapshot fast-base rehashes when encrypted header probe fails", async () => {
|
|
2112
|
+
const root = await tempDir("remnic-offline-fast-base-probe-error");
|
|
2113
|
+
const relPath = "facts/same-size.md";
|
|
2114
|
+
const oldContent = Buffer.from("old!");
|
|
2115
|
+
const newContent = Buffer.from("new!");
|
|
2116
|
+
const filePath = path.join(root, relPath);
|
|
2117
|
+
try {
|
|
2118
|
+
await write(root, relPath, newContent);
|
|
2119
|
+
await chmod(filePath, 0o000);
|
|
2120
|
+
const st = await stat(filePath);
|
|
2121
|
+
let digestReads = 0;
|
|
2122
|
+
|
|
2123
|
+
const snapshot = await buildOfflineSyncSnapshotFromBase({
|
|
2124
|
+
root,
|
|
2125
|
+
sourceId: "remote",
|
|
2126
|
+
baseFiles: [{
|
|
2127
|
+
path: relPath,
|
|
2128
|
+
sha256: createHash("sha256").update(oldContent).digest("hex"),
|
|
2129
|
+
bytes: newContent.byteLength,
|
|
2130
|
+
mtimeMs: st.mtimeMs,
|
|
2131
|
+
}],
|
|
2132
|
+
baseCapturedAt: new Date(),
|
|
2133
|
+
readFileDigest: async ({ path: targetPath, filePath: targetFilePath }) => {
|
|
2134
|
+
assert.equal(targetPath, relPath);
|
|
2135
|
+
assert.equal(targetFilePath, filePath);
|
|
2136
|
+
digestReads += 1;
|
|
2137
|
+
return {
|
|
2138
|
+
sha256: createHash("sha256").update(newContent).digest("hex"),
|
|
2139
|
+
bytes: newContent.byteLength,
|
|
2140
|
+
};
|
|
2141
|
+
},
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
assert.equal(digestReads, 1);
|
|
2145
|
+
assert.equal(snapshot.files[0]?.sha256, createHash("sha256").update(newContent).digest("hex"));
|
|
2146
|
+
} finally {
|
|
2147
|
+
await chmod(filePath, 0o600).catch(() => {});
|
|
2148
|
+
await rm(root, { recursive: true, force: true });
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
|
|
1285
2152
|
test("offline storage writes invalidate fact hash readiness for rebuild", async () => {
|
|
1286
2153
|
const root = await tempDir("remnic-offline-hash-index-local");
|
|
1287
2154
|
const source = await tempDir("remnic-offline-hash-index-source");
|
|
@@ -1323,6 +2190,193 @@ test("offline storage writes invalidate fact hash readiness for rebuild", async
|
|
|
1323
2190
|
}
|
|
1324
2191
|
});
|
|
1325
2192
|
|
|
2193
|
+
test("offline changeset apply fingerprints only changed paths when current files are omitted", async () => {
|
|
2194
|
+
const root = await tempDir("remnic-offline-apply-path-scoped");
|
|
2195
|
+
try {
|
|
2196
|
+
const oldContent = Buffer.from("old target");
|
|
2197
|
+
const newContent = Buffer.from("new target");
|
|
2198
|
+
await write(root, "facts/target.md", oldContent);
|
|
2199
|
+
await write(root, "facts/unrelated.md", "do not scan me");
|
|
2200
|
+
const digestPaths: string[] = [];
|
|
2201
|
+
|
|
2202
|
+
const apply = await applyOfflineSyncChangeset({
|
|
2203
|
+
root,
|
|
2204
|
+
changeset: {
|
|
2205
|
+
format: "remnic.offline-sync.changeset.v1",
|
|
2206
|
+
schemaVersion: 1,
|
|
2207
|
+
createdAt: new Date().toISOString(),
|
|
2208
|
+
sourceId: "laptop",
|
|
2209
|
+
includeTranscripts: true,
|
|
2210
|
+
changes: [{
|
|
2211
|
+
type: "upsert",
|
|
2212
|
+
path: "facts/target.md",
|
|
2213
|
+
baseSha256: createHash("sha256").update(oldContent).digest("hex"),
|
|
2214
|
+
file: {
|
|
2215
|
+
path: "facts/target.md",
|
|
2216
|
+
sha256: createHash("sha256").update(newContent).digest("hex"),
|
|
2217
|
+
bytes: newContent.byteLength,
|
|
2218
|
+
mtimeMs: Date.now(),
|
|
2219
|
+
contentBase64: newContent.toString("base64"),
|
|
2220
|
+
},
|
|
2221
|
+
}],
|
|
2222
|
+
},
|
|
2223
|
+
returnCurrentFiles: false,
|
|
2224
|
+
readFileDigest: async ({ path: relPath, filePath }) => {
|
|
2225
|
+
digestPaths.push(relPath);
|
|
2226
|
+
if (relPath === "facts/unrelated.md") {
|
|
2227
|
+
throw new Error("apply scanned an unrelated file");
|
|
2228
|
+
}
|
|
2229
|
+
const content = await readFile(filePath);
|
|
2230
|
+
return {
|
|
2231
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
2232
|
+
bytes: content.byteLength,
|
|
2233
|
+
};
|
|
2234
|
+
},
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
assert.equal(apply.appliedUpserts, 1);
|
|
2238
|
+
assert.equal(apply.conflicts.length, 0);
|
|
2239
|
+
assert.equal(apply.currentFilesComplete, false);
|
|
2240
|
+
assert.deepEqual(digestPaths, ["facts/target.md"]);
|
|
2241
|
+
assert.equal(await readUtf8(root, "facts/target.md"), "new target");
|
|
2242
|
+
} finally {
|
|
2243
|
+
await rm(root, { recursive: true, force: true });
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
|
|
2247
|
+
test("offline changeset apply returns full current files by default", async () => {
|
|
2248
|
+
const root = await tempDir("remnic-offline-apply-full-result");
|
|
2249
|
+
try {
|
|
2250
|
+
const oldContent = Buffer.from("old target");
|
|
2251
|
+
const newContent = Buffer.from("new target");
|
|
2252
|
+
await write(root, "facts/target.md", oldContent);
|
|
2253
|
+
await write(root, "facts/unrelated.md", "keep me");
|
|
2254
|
+
|
|
2255
|
+
const apply = await applyOfflineSyncChangeset({
|
|
2256
|
+
root,
|
|
2257
|
+
changeset: {
|
|
2258
|
+
format: "remnic.offline-sync.changeset.v1",
|
|
2259
|
+
schemaVersion: 1,
|
|
2260
|
+
createdAt: new Date().toISOString(),
|
|
2261
|
+
sourceId: "laptop",
|
|
2262
|
+
includeTranscripts: true,
|
|
2263
|
+
changes: [{
|
|
2264
|
+
type: "upsert",
|
|
2265
|
+
path: "facts/target.md",
|
|
2266
|
+
baseSha256: createHash("sha256").update(oldContent).digest("hex"),
|
|
2267
|
+
file: {
|
|
2268
|
+
path: "facts/target.md",
|
|
2269
|
+
sha256: createHash("sha256").update(newContent).digest("hex"),
|
|
2270
|
+
bytes: newContent.byteLength,
|
|
2271
|
+
mtimeMs: Date.now(),
|
|
2272
|
+
contentBase64: newContent.toString("base64"),
|
|
2273
|
+
},
|
|
2274
|
+
}],
|
|
2275
|
+
},
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
assert.equal(apply.currentFilesComplete, undefined);
|
|
2279
|
+
assert.deepEqual(
|
|
2280
|
+
apply.currentFiles.map((entry) => entry.path),
|
|
2281
|
+
["facts/target.md", "facts/unrelated.md"],
|
|
2282
|
+
);
|
|
2283
|
+
} finally {
|
|
2284
|
+
await rm(root, { recursive: true, force: true });
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
test("offline changeset apply refreshes full current files when caller supplied conflict snapshot", async () => {
|
|
2289
|
+
const root = await tempDir("remnic-offline-apply-supplied-current-result");
|
|
2290
|
+
try {
|
|
2291
|
+
const oldContent = Buffer.from("old target");
|
|
2292
|
+
const newContent = Buffer.from("new target");
|
|
2293
|
+
await write(root, "facts/target.md", oldContent);
|
|
2294
|
+
await write(root, "facts/unrelated.md", "keep me");
|
|
2295
|
+
|
|
2296
|
+
const apply = await applyOfflineSyncChangeset({
|
|
2297
|
+
root,
|
|
2298
|
+
currentFiles: [{
|
|
2299
|
+
path: "facts/target.md",
|
|
2300
|
+
sha256: createHash("sha256").update(oldContent).digest("hex"),
|
|
2301
|
+
bytes: oldContent.byteLength,
|
|
2302
|
+
mtimeMs: 0,
|
|
2303
|
+
}],
|
|
2304
|
+
changeset: {
|
|
2305
|
+
format: "remnic.offline-sync.changeset.v1",
|
|
2306
|
+
schemaVersion: 1,
|
|
2307
|
+
createdAt: new Date().toISOString(),
|
|
2308
|
+
sourceId: "laptop",
|
|
2309
|
+
includeTranscripts: true,
|
|
2310
|
+
changes: [{
|
|
2311
|
+
type: "upsert",
|
|
2312
|
+
path: "facts/target.md",
|
|
2313
|
+
baseSha256: createHash("sha256").update(oldContent).digest("hex"),
|
|
2314
|
+
file: {
|
|
2315
|
+
path: "facts/target.md",
|
|
2316
|
+
sha256: createHash("sha256").update(newContent).digest("hex"),
|
|
2317
|
+
bytes: newContent.byteLength,
|
|
2318
|
+
mtimeMs: Date.now(),
|
|
2319
|
+
contentBase64: newContent.toString("base64"),
|
|
2320
|
+
},
|
|
2321
|
+
}],
|
|
2322
|
+
},
|
|
2323
|
+
});
|
|
2324
|
+
|
|
2325
|
+
assert.equal(apply.currentFilesComplete, undefined);
|
|
2326
|
+
assert.deepEqual(
|
|
2327
|
+
apply.currentFiles.map((entry) => entry.path),
|
|
2328
|
+
["facts/target.md", "facts/unrelated.md"],
|
|
2329
|
+
);
|
|
2330
|
+
} finally {
|
|
2331
|
+
await rm(root, { recursive: true, force: true });
|
|
2332
|
+
}
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
test("offline snapshot apply defers volatile remote paths without changing local files", async () => {
|
|
2336
|
+
const root = await tempDir("remnic-offline-deferred-remote");
|
|
2337
|
+
try {
|
|
2338
|
+
const relPath = "state/memory-lifecycle-ledger.jsonl";
|
|
2339
|
+
const localContent = Buffer.from("base ledger\n");
|
|
2340
|
+
const remoteContent = Buffer.from("remote ledger changed during fetch\n");
|
|
2341
|
+
await write(root, relPath, localContent);
|
|
2342
|
+
const baseFile = {
|
|
2343
|
+
path: relPath,
|
|
2344
|
+
sha256: createHash("sha256").update(localContent).digest("hex"),
|
|
2345
|
+
bytes: localContent.byteLength,
|
|
2346
|
+
mtimeMs: 1,
|
|
2347
|
+
};
|
|
2348
|
+
const incomingFile = {
|
|
2349
|
+
path: relPath,
|
|
2350
|
+
sha256: createHash("sha256").update(remoteContent).digest("hex"),
|
|
2351
|
+
bytes: remoteContent.byteLength,
|
|
2352
|
+
mtimeMs: 2,
|
|
2353
|
+
};
|
|
2354
|
+
|
|
2355
|
+
const pull = await applyOfflineSyncSnapshot({
|
|
2356
|
+
root,
|
|
2357
|
+
snapshot: {
|
|
2358
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
2359
|
+
schemaVersion: 1,
|
|
2360
|
+
createdAt: new Date().toISOString(),
|
|
2361
|
+
sourceId: "remote",
|
|
2362
|
+
includeTranscripts: true,
|
|
2363
|
+
files: [incomingFile],
|
|
2364
|
+
},
|
|
2365
|
+
baseFiles: [baseFile],
|
|
2366
|
+
deferredPaths: [relPath],
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
assert.equal(await readUtf8(root, relPath), localContent.toString("utf-8"));
|
|
2370
|
+
assert.equal(pull.upserted, 0);
|
|
2371
|
+
assert.equal(pull.deleted, 0);
|
|
2372
|
+
assert.equal(pull.skipped, 1);
|
|
2373
|
+
assert.equal(pull.conflicts.length, 0);
|
|
2374
|
+
assert.deepEqual(pull.nextBaseFiles, [baseFile]);
|
|
2375
|
+
} finally {
|
|
2376
|
+
await rm(root, { recursive: true, force: true });
|
|
2377
|
+
}
|
|
2378
|
+
});
|
|
2379
|
+
|
|
1326
2380
|
test("offline changeset validation reports client input errors with an offline sync prefix", async () => {
|
|
1327
2381
|
const root = await tempDir("remnic-offline-invalid-changeset");
|
|
1328
2382
|
try {
|