@remnic/core 1.1.15 → 1.1.17
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 +2 -2
- package/dist/access-http.d.ts +1 -1
- package/dist/access-http.js +5 -5
- package/dist/access-mcp.d.ts +1 -1
- package/dist/access-mcp.js +4 -4
- package/dist/access-schema.d.ts +17 -3
- package/dist/access-schema.js +3 -1
- package/dist/{access-service-BCMine1s.d.ts → access-service-DZXc7qwR.d.ts} +11 -1
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +2 -2
- package/dist/{chunk-VWFIQOTJ.js → chunk-66H2DZYB.js} +8 -1
- package/dist/chunk-66H2DZYB.js.map +1 -0
- package/dist/{chunk-BNATB54A.js → chunk-BYACCC5C.js} +3 -3
- package/dist/{chunk-ZYRMKWVW.js → chunk-CDQNR7SV.js} +4 -4
- package/dist/{chunk-HJ2WMBFB.js → chunk-GCR4JFKK.js} +15 -4
- package/dist/chunk-GCR4JFKK.js.map +1 -0
- package/dist/{chunk-GSP6ZKOY.js → chunk-HMZYQPT5.js} +85 -19
- package/dist/chunk-HMZYQPT5.js.map +1 -0
- package/dist/{chunk-5D2G67ZQ.js → chunk-VLQWOGYM.js} +29 -3
- package/dist/chunk-VLQWOGYM.js.map +1 -0
- package/dist/{cli-B71zQ6XK.d.ts → cli-kVwab1_L.d.ts} +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +6 -6
- package/dist/index.d.ts +4 -4
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +1 -1
- package/dist/offline-sync.d.ts +10 -1
- package/dist/offline-sync.js +3 -1
- package/package.json +1 -1
- package/src/access-http.test.ts +73 -0
- package/src/access-http.ts +15 -0
- package/src/access-schema.ts +12 -0
- package/src/access-service-namespace.test.ts +64 -1
- package/src/access-service.ts +44 -0
- package/src/index.ts +1 -0
- package/src/offline-sync.test.ts +174 -0
- package/src/offline-sync.ts +110 -18
- package/dist/chunk-5D2G67ZQ.js.map +0 -1
- package/dist/chunk-GSP6ZKOY.js.map +0 -1
- package/dist/chunk-HJ2WMBFB.js.map +0 -1
- package/dist/chunk-VWFIQOTJ.js.map +0 -1
- /package/dist/{chunk-BNATB54A.js.map → chunk-BYACCC5C.js.map} +0 -0
- /package/dist/{chunk-ZYRMKWVW.js.map → chunk-CDQNR7SV.js.map} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as EngramAccessRecallResponse } from './access-service-
|
|
1
|
+
import { a as EngramAccessRecallResponse } from './access-service-DZXc7qwR.js';
|
|
2
2
|
import { ActionConfidenceRequest } from './access-schema.js';
|
|
3
3
|
import { RecallXraySnapshot } from './recall-xray.js';
|
|
4
4
|
import { ActionConfidenceResult } from './action-confidence.js';
|
package/dist/offline-sync.d.ts
CHANGED
|
@@ -95,6 +95,15 @@ declare function buildOfflineSyncSnapshot(options: {
|
|
|
95
95
|
now?: Date;
|
|
96
96
|
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
97
97
|
}): Promise<OfflineSyncSnapshot>;
|
|
98
|
+
declare function buildOfflineSyncSnapshotForPaths(options: {
|
|
99
|
+
root: string;
|
|
100
|
+
sourceId: string;
|
|
101
|
+
paths: readonly string[];
|
|
102
|
+
includeContent?: boolean;
|
|
103
|
+
includeTranscripts?: boolean;
|
|
104
|
+
now?: Date;
|
|
105
|
+
readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
|
|
106
|
+
}): Promise<OfflineSyncSnapshot>;
|
|
98
107
|
declare function buildOfflineSyncChangeset(options: {
|
|
99
108
|
root: string;
|
|
100
109
|
sourceId: string;
|
|
@@ -133,4 +142,4 @@ declare function offlineSyncStateFromSnapshot(options: {
|
|
|
133
142
|
declare function normalizeOfflineSyncState(input: unknown): OfflineSyncState;
|
|
134
143
|
declare function fileStatesFromSnapshot(snapshot: OfflineSyncSnapshot): OfflineSyncFileState[];
|
|
135
144
|
|
|
136
|
-
export { OFFLINE_SYNC_CHANGESET_FORMAT, OFFLINE_SYNC_SNAPSHOT_FORMAT, OFFLINE_SYNC_STATE_VERSION, type OfflineSyncApplyChangesetResult, type OfflineSyncApplySnapshotResult, type OfflineSyncChange, type OfflineSyncChangeset, type OfflineSyncChangesetSummary, type OfflineSyncConflict, type OfflineSyncFileRecord, type OfflineSyncFileState, type OfflineSyncFileTarget, type OfflineSyncFileWriteTarget, type OfflineSyncSnapshot, type OfflineSyncState, applyOfflineSyncChangeset, applyOfflineSyncSnapshot, buildOfflineSyncChangeset, buildOfflineSyncSnapshot, defaultOfflineSyncStatePath, fileStatesFromSnapshot, normalizeOfflineSyncChangeset, normalizeOfflineSyncSnapshot, normalizeOfflineSyncState, offlineSyncStateFromSnapshot, readOfflineSyncState, summarizeOfflineSyncChangeset, writeOfflineSyncState };
|
|
145
|
+
export { OFFLINE_SYNC_CHANGESET_FORMAT, OFFLINE_SYNC_SNAPSHOT_FORMAT, OFFLINE_SYNC_STATE_VERSION, type OfflineSyncApplyChangesetResult, type OfflineSyncApplySnapshotResult, type OfflineSyncChange, type OfflineSyncChangeset, type OfflineSyncChangesetSummary, type OfflineSyncConflict, type OfflineSyncFileRecord, type OfflineSyncFileState, type OfflineSyncFileTarget, type OfflineSyncFileWriteTarget, type OfflineSyncSnapshot, type OfflineSyncState, applyOfflineSyncChangeset, applyOfflineSyncSnapshot, buildOfflineSyncChangeset, buildOfflineSyncSnapshot, buildOfflineSyncSnapshotForPaths, defaultOfflineSyncStatePath, fileStatesFromSnapshot, normalizeOfflineSyncChangeset, normalizeOfflineSyncSnapshot, normalizeOfflineSyncState, offlineSyncStateFromSnapshot, readOfflineSyncState, summarizeOfflineSyncChangeset, writeOfflineSyncState };
|
package/dist/offline-sync.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
applyOfflineSyncSnapshot,
|
|
7
7
|
buildOfflineSyncChangeset,
|
|
8
8
|
buildOfflineSyncSnapshot,
|
|
9
|
+
buildOfflineSyncSnapshotForPaths,
|
|
9
10
|
defaultOfflineSyncStatePath,
|
|
10
11
|
fileStatesFromSnapshot,
|
|
11
12
|
normalizeOfflineSyncChangeset,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
readOfflineSyncState,
|
|
16
17
|
summarizeOfflineSyncChangeset,
|
|
17
18
|
writeOfflineSyncState
|
|
18
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-HMZYQPT5.js";
|
|
19
20
|
import "./chunk-P7FMDTKL.js";
|
|
20
21
|
import "./chunk-I6K5FBRQ.js";
|
|
21
22
|
import "./chunk-AGZQD76C.js";
|
|
@@ -28,6 +29,7 @@ export {
|
|
|
28
29
|
applyOfflineSyncSnapshot,
|
|
29
30
|
buildOfflineSyncChangeset,
|
|
30
31
|
buildOfflineSyncSnapshot,
|
|
32
|
+
buildOfflineSyncSnapshotForPaths,
|
|
31
33
|
defaultOfflineSyncStatePath,
|
|
32
34
|
fileStatesFromSnapshot,
|
|
33
35
|
normalizeOfflineSyncChangeset,
|
package/package.json
CHANGED
package/src/access-http.test.ts
CHANGED
|
@@ -286,6 +286,79 @@ test("HTTP offline snapshot forwards namespace and transfer options", async () =
|
|
|
286
286
|
}
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
+
test("HTTP offline files forwards namespace and requested paths", async () => {
|
|
290
|
+
const calls: Array<{
|
|
291
|
+
namespace: string | undefined;
|
|
292
|
+
principal: string | undefined;
|
|
293
|
+
includeTranscripts: boolean | undefined;
|
|
294
|
+
paths: string[];
|
|
295
|
+
}> = [];
|
|
296
|
+
const service = {
|
|
297
|
+
offlineSyncFiles: async (options: {
|
|
298
|
+
namespace?: string;
|
|
299
|
+
principal?: string;
|
|
300
|
+
includeTranscripts?: boolean;
|
|
301
|
+
paths: string[];
|
|
302
|
+
}) => {
|
|
303
|
+
calls.push({
|
|
304
|
+
namespace: options.namespace,
|
|
305
|
+
principal: options.principal,
|
|
306
|
+
includeTranscripts: options.includeTranscripts,
|
|
307
|
+
paths: options.paths,
|
|
308
|
+
});
|
|
309
|
+
return {
|
|
310
|
+
namespace: options.namespace ?? "default",
|
|
311
|
+
format: "remnic.offline-sync.snapshot.v1",
|
|
312
|
+
schemaVersion: 1,
|
|
313
|
+
createdAt: new Date("2026-05-21T00:00:00Z").toISOString(),
|
|
314
|
+
sourceId: "remote:test",
|
|
315
|
+
includeTranscripts: options.includeTranscripts !== false,
|
|
316
|
+
files: [],
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
} as unknown as EngramAccessService;
|
|
320
|
+
const server = new EngramAccessHttpServer({
|
|
321
|
+
service,
|
|
322
|
+
port: 0,
|
|
323
|
+
authToken: "test-token",
|
|
324
|
+
principal: "reader",
|
|
325
|
+
adminConsoleEnabled: false,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const status = await server.start();
|
|
329
|
+
try {
|
|
330
|
+
const response = await fetch(
|
|
331
|
+
`http://127.0.0.1:${status.port}/remnic/v1/offline-sync/files`,
|
|
332
|
+
{
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: {
|
|
335
|
+
authorization: "Bearer test-token",
|
|
336
|
+
"content-type": "application/json",
|
|
337
|
+
},
|
|
338
|
+
body: JSON.stringify({
|
|
339
|
+
namespace: "team",
|
|
340
|
+
includeTranscripts: false,
|
|
341
|
+
paths: ["facts/a.md"],
|
|
342
|
+
}),
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
const body = await response.json() as { namespace?: string; includeTranscripts?: boolean; files?: unknown[] };
|
|
346
|
+
|
|
347
|
+
assert.equal(response.status, 200);
|
|
348
|
+
assert.equal(body.namespace, "team");
|
|
349
|
+
assert.equal(body.includeTranscripts, false);
|
|
350
|
+
assert.deepEqual(body.files, []);
|
|
351
|
+
assert.deepEqual(calls, [{
|
|
352
|
+
namespace: "team",
|
|
353
|
+
principal: "reader",
|
|
354
|
+
includeTranscripts: false,
|
|
355
|
+
paths: ["facts/a.md"],
|
|
356
|
+
}]);
|
|
357
|
+
} finally {
|
|
358
|
+
await server.stop();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
289
362
|
test("HTTP offline snapshot rejects invalid boolean query values", async () => {
|
|
290
363
|
let calls = 0;
|
|
291
364
|
const service = {
|
package/src/access-http.ts
CHANGED
|
@@ -626,6 +626,21 @@ export class EngramAccessHttpServer {
|
|
|
626
626
|
return;
|
|
627
627
|
}
|
|
628
628
|
|
|
629
|
+
if (
|
|
630
|
+
req.method === "POST" &&
|
|
631
|
+
(pathname === "/engram/v1/offline-sync/files" || pathname === "/remnic/v1/offline-sync/files")
|
|
632
|
+
) {
|
|
633
|
+
const body = await this.readValidatedBody(req, "offlineSyncFiles");
|
|
634
|
+
const result = await this.service.offlineSyncFiles({
|
|
635
|
+
namespace: this.resolveNamespace(req, body.namespace),
|
|
636
|
+
principal: this.resolveRequestPrincipal(req),
|
|
637
|
+
includeTranscripts: body.includeTranscripts,
|
|
638
|
+
paths: body.paths,
|
|
639
|
+
});
|
|
640
|
+
this.respondJson(res, 200, result);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
629
644
|
if (
|
|
630
645
|
req.method === "POST" &&
|
|
631
646
|
(pathname === "/engram/v1/offline-sync/apply" || pathname === "/remnic/v1/offline-sync/apply")
|
package/src/access-schema.ts
CHANGED
|
@@ -378,6 +378,14 @@ export const offlineSyncApplyRequestSchema = z
|
|
|
378
378
|
path: ["changeset"],
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
export const offlineSyncFilesRequestSchema = z.object({
|
|
382
|
+
namespace: namespaceSchema,
|
|
383
|
+
includeTranscripts: z.boolean().optional(),
|
|
384
|
+
paths: z
|
|
385
|
+
.array(z.string().trim().min(1, "path must be non-empty").max(4096))
|
|
386
|
+
.max(5000, "paths must contain 5000 or fewer entries"),
|
|
387
|
+
});
|
|
388
|
+
|
|
381
389
|
// ---------------------------------------------------------------------------
|
|
382
390
|
// Action confidence
|
|
383
391
|
// ---------------------------------------------------------------------------
|
|
@@ -444,6 +452,7 @@ export type CapsuleExportRequest = z.infer<typeof capsuleExportRequestSchema>;
|
|
|
444
452
|
export type CapsuleImportRequest = z.infer<typeof capsuleImportRequestSchema>;
|
|
445
453
|
export type CapsuleListRequest = z.infer<typeof capsuleListRequestSchema>;
|
|
446
454
|
export type OfflineSyncApplyRequest = z.infer<typeof offlineSyncApplyRequestSchema>;
|
|
455
|
+
export type OfflineSyncFilesRequest = z.infer<typeof offlineSyncFilesRequestSchema>;
|
|
447
456
|
export type ActionConfidenceRequest = z.infer<typeof actionConfidenceRequestSchema>;
|
|
448
457
|
|
|
449
458
|
// ---------------------------------------------------------------------------
|
|
@@ -467,6 +476,7 @@ export type SchemaName =
|
|
|
467
476
|
| "capsuleExport"
|
|
468
477
|
| "capsuleImport"
|
|
469
478
|
| "capsuleList"
|
|
479
|
+
| "offlineSyncFiles"
|
|
470
480
|
| "offlineSyncApply"
|
|
471
481
|
| "actionConfidence";
|
|
472
482
|
|
|
@@ -487,6 +497,7 @@ export type SchemaTypeFor<N extends SchemaName> =
|
|
|
487
497
|
: N extends "capsuleExport" ? CapsuleExportRequest
|
|
488
498
|
: N extends "capsuleImport" ? CapsuleImportRequest
|
|
489
499
|
: N extends "capsuleList" ? CapsuleListRequest
|
|
500
|
+
: N extends "offlineSyncFiles" ? OfflineSyncFilesRequest
|
|
490
501
|
: N extends "offlineSyncApply" ? OfflineSyncApplyRequest
|
|
491
502
|
: N extends "actionConfidence" ? ActionConfidenceRequest
|
|
492
503
|
: never;
|
|
@@ -508,6 +519,7 @@ const schemas: Record<SchemaName, z.ZodTypeAny> = {
|
|
|
508
519
|
capsuleExport: capsuleExportRequestSchema,
|
|
509
520
|
capsuleImport: capsuleImportRequestSchema,
|
|
510
521
|
capsuleList: capsuleListRequestSchema,
|
|
522
|
+
offlineSyncFiles: offlineSyncFilesRequestSchema,
|
|
511
523
|
offlineSyncApply: offlineSyncApplyRequestSchema,
|
|
512
524
|
actionConfidence: actionConfidenceRequestSchema,
|
|
513
525
|
};
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import test from "node:test";
|
|
3
6
|
|
|
4
|
-
import { EngramAccessService } from "./access-service.js";
|
|
7
|
+
import { EngramAccessInputError, EngramAccessService } from "./access-service.js";
|
|
5
8
|
import type { StorageManager } from "./storage.js";
|
|
6
9
|
import type { PluginConfig } from "./types.js";
|
|
7
10
|
|
|
@@ -121,3 +124,63 @@ test("memoryBrowse resolves namespace storage for read principals", async () =>
|
|
|
121
124
|
assert.equal(result.count, 0);
|
|
122
125
|
assert.deepEqual(getStorageCalls, ["team"]);
|
|
123
126
|
});
|
|
127
|
+
|
|
128
|
+
test("offlineSyncFiles reports invalid requested paths as input errors", async () => {
|
|
129
|
+
const { service } = makeService();
|
|
130
|
+
(service as unknown as {
|
|
131
|
+
orchestrator: {
|
|
132
|
+
config: PluginConfig;
|
|
133
|
+
getStorage(namespace: string): Promise<StorageManager>;
|
|
134
|
+
};
|
|
135
|
+
}).orchestrator.getStorage = async () => ({
|
|
136
|
+
dir: os.tmpdir(),
|
|
137
|
+
async readOfflineSyncFile() {
|
|
138
|
+
throw new Error("should not read invalid paths");
|
|
139
|
+
},
|
|
140
|
+
} as unknown as StorageManager);
|
|
141
|
+
|
|
142
|
+
await assert.rejects(
|
|
143
|
+
() =>
|
|
144
|
+
service.offlineSyncFiles({
|
|
145
|
+
namespace: "team",
|
|
146
|
+
principal: "reader",
|
|
147
|
+
paths: ["../escape"],
|
|
148
|
+
}),
|
|
149
|
+
(error: unknown) =>
|
|
150
|
+
error instanceof EngramAccessInputError &&
|
|
151
|
+
/paths\[\]: record path contains unsafe segments/.test(error.message),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("offlineSyncFiles reports symlink requested paths as input errors", async () => {
|
|
156
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "remnic-offline-files-symlink-"));
|
|
157
|
+
try {
|
|
158
|
+
await symlink("/tmp", path.join(root, "linked"));
|
|
159
|
+
const { service } = makeService();
|
|
160
|
+
(service as unknown as {
|
|
161
|
+
orchestrator: {
|
|
162
|
+
config: PluginConfig;
|
|
163
|
+
getStorage(namespace: string): Promise<StorageManager>;
|
|
164
|
+
};
|
|
165
|
+
}).orchestrator.getStorage = async () => ({
|
|
166
|
+
dir: root,
|
|
167
|
+
async readOfflineSyncFile() {
|
|
168
|
+
throw new Error("should not read symlink paths");
|
|
169
|
+
},
|
|
170
|
+
} as unknown as StorageManager);
|
|
171
|
+
|
|
172
|
+
await assert.rejects(
|
|
173
|
+
() =>
|
|
174
|
+
service.offlineSyncFiles({
|
|
175
|
+
namespace: "team",
|
|
176
|
+
principal: "reader",
|
|
177
|
+
paths: ["linked"],
|
|
178
|
+
}),
|
|
179
|
+
(error: unknown) =>
|
|
180
|
+
error instanceof EngramAccessInputError &&
|
|
181
|
+
/buildOfflineSyncSnapshotForPaths: record path targets a symlink/.test(error.message),
|
|
182
|
+
);
|
|
183
|
+
} finally {
|
|
184
|
+
await rm(root, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
});
|
package/src/access-service.ts
CHANGED
|
@@ -138,6 +138,7 @@ import {
|
|
|
138
138
|
import {
|
|
139
139
|
applyOfflineSyncChangeset,
|
|
140
140
|
buildOfflineSyncSnapshot,
|
|
141
|
+
buildOfflineSyncSnapshotForPaths,
|
|
141
142
|
type OfflineSyncApplyChangesetResult,
|
|
142
143
|
type OfflineSyncSnapshot,
|
|
143
144
|
} from "./offline-sync.js";
|
|
@@ -616,6 +617,13 @@ export interface EngramAccessOfflineSyncSnapshotRequest {
|
|
|
616
617
|
includeContent?: boolean;
|
|
617
618
|
}
|
|
618
619
|
|
|
620
|
+
export interface EngramAccessOfflineSyncFilesRequest {
|
|
621
|
+
namespace?: string;
|
|
622
|
+
principal?: string;
|
|
623
|
+
includeTranscripts?: boolean;
|
|
624
|
+
paths: string[];
|
|
625
|
+
}
|
|
626
|
+
|
|
619
627
|
export interface EngramAccessOfflineSyncApplyRequest {
|
|
620
628
|
namespace?: string;
|
|
621
629
|
principal?: string;
|
|
@@ -626,6 +634,10 @@ export interface EngramAccessOfflineSyncSnapshotResponse extends OfflineSyncSnap
|
|
|
626
634
|
namespace: string;
|
|
627
635
|
}
|
|
628
636
|
|
|
637
|
+
export interface EngramAccessOfflineSyncFilesResponse extends OfflineSyncSnapshot {
|
|
638
|
+
namespace: string;
|
|
639
|
+
}
|
|
640
|
+
|
|
629
641
|
export interface EngramAccessOfflineSyncApplyResponse extends OfflineSyncApplyChangesetResult {
|
|
630
642
|
namespace: string;
|
|
631
643
|
}
|
|
@@ -5568,6 +5580,38 @@ export class EngramAccessService {
|
|
|
5568
5580
|
};
|
|
5569
5581
|
}
|
|
5570
5582
|
|
|
5583
|
+
async offlineSyncFiles(
|
|
5584
|
+
options: EngramAccessOfflineSyncFilesRequest,
|
|
5585
|
+
): Promise<EngramAccessOfflineSyncFilesResponse> {
|
|
5586
|
+
const resolvedNamespace = this.resolveReadableNamespace(options.namespace, options.principal);
|
|
5587
|
+
const storage = await this.orchestrator.getStorage(resolvedNamespace);
|
|
5588
|
+
const storageHash = createHash("sha256").update(storage.dir).digest("hex").slice(0, 16);
|
|
5589
|
+
try {
|
|
5590
|
+
const snapshot = await buildOfflineSyncSnapshotForPaths({
|
|
5591
|
+
root: storage.dir,
|
|
5592
|
+
sourceId: `remnic:${resolvedNamespace}:${storageHash}`,
|
|
5593
|
+
paths: options.paths,
|
|
5594
|
+
includeContent: true,
|
|
5595
|
+
includeTranscripts: options.includeTranscripts !== false,
|
|
5596
|
+
readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
|
|
5597
|
+
});
|
|
5598
|
+
return {
|
|
5599
|
+
namespace: resolvedNamespace,
|
|
5600
|
+
...snapshot,
|
|
5601
|
+
};
|
|
5602
|
+
} catch (error) {
|
|
5603
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5604
|
+
if (
|
|
5605
|
+
message.startsWith("paths[]:") ||
|
|
5606
|
+
message.startsWith("buildOfflineSyncSnapshotForPaths: record path ") ||
|
|
5607
|
+
message.startsWith("offline sync snapshot path is excluded:")
|
|
5608
|
+
) {
|
|
5609
|
+
throw new EngramAccessInputError(message);
|
|
5610
|
+
}
|
|
5611
|
+
throw error;
|
|
5612
|
+
}
|
|
5613
|
+
}
|
|
5614
|
+
|
|
5571
5615
|
async offlineSyncApply(
|
|
5572
5616
|
options: EngramAccessOfflineSyncApplyRequest,
|
|
5573
5617
|
): Promise<EngramAccessOfflineSyncApplyResponse> {
|
package/src/index.ts
CHANGED
package/src/offline-sync.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
applyOfflineSyncSnapshot,
|
|
10
11
|
buildOfflineSyncChangeset,
|
|
11
12
|
buildOfflineSyncSnapshot,
|
|
13
|
+
buildOfflineSyncSnapshotForPaths,
|
|
12
14
|
} from "./offline-sync.js";
|
|
13
15
|
import { isEncryptedFile } from "./secure-store/secure-fs.js";
|
|
14
16
|
import { StorageManager } from "./storage.js";
|
|
@@ -38,6 +40,9 @@ test("offline snapshot captures source-of-truth files and excludes private/inter
|
|
|
38
40
|
await write(root, ".offline-sync/state/local.json", "state");
|
|
39
41
|
await write(root, "state/fact-hashes.txt", "derived");
|
|
40
42
|
await write(root, "state/fact-hashes.ready", "v1");
|
|
43
|
+
await write(root, "state/lcm.sqlite", "live db");
|
|
44
|
+
await write(root, "state/lcm.sqlite-shm", "live shm");
|
|
45
|
+
await write(root, "state/lcm.sqlite-wal", "live wal");
|
|
41
46
|
|
|
42
47
|
const snapshot = await buildOfflineSyncSnapshot({
|
|
43
48
|
root,
|
|
@@ -67,6 +72,51 @@ test("offline snapshot captures source-of-truth files and excludes private/inter
|
|
|
67
72
|
}
|
|
68
73
|
});
|
|
69
74
|
|
|
75
|
+
test("offline sync excludes live LCM sqlite artifacts without deleting existing local copies", async () => {
|
|
76
|
+
const root = await tempDir("remnic-offline-lcm-sqlite");
|
|
77
|
+
try {
|
|
78
|
+
await write(root, "facts/a.md", "alpha");
|
|
79
|
+
await write(root, "state/lcm.sqlite", "live db");
|
|
80
|
+
await write(root, "state/lcm.sqlite-shm", "live shm");
|
|
81
|
+
await write(root, "state/lcm.sqlite-wal", "live wal");
|
|
82
|
+
|
|
83
|
+
const snapshot = await buildOfflineSyncSnapshot({
|
|
84
|
+
root,
|
|
85
|
+
sourceId: "remote",
|
|
86
|
+
includeContent: true,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
assert.deepEqual(snapshot.files.map((file) => file.path), ["facts/a.md"]);
|
|
90
|
+
await assert.rejects(
|
|
91
|
+
() =>
|
|
92
|
+
buildOfflineSyncSnapshotForPaths({
|
|
93
|
+
root,
|
|
94
|
+
sourceId: "remote",
|
|
95
|
+
paths: ["state/lcm.sqlite"],
|
|
96
|
+
includeContent: true,
|
|
97
|
+
}),
|
|
98
|
+
/offline sync snapshot path is excluded: state\/lcm\.sqlite/,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const oldDb = Buffer.from("old live db");
|
|
102
|
+
const pull = await applyOfflineSyncSnapshot({
|
|
103
|
+
root,
|
|
104
|
+
snapshot,
|
|
105
|
+
baseFiles: [{
|
|
106
|
+
path: "state/lcm.sqlite",
|
|
107
|
+
sha256: createHash("sha256").update(oldDb).digest("hex"),
|
|
108
|
+
bytes: oldDb.byteLength,
|
|
109
|
+
mtimeMs: 0,
|
|
110
|
+
}],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
assert.equal(pull.deleted, 0);
|
|
114
|
+
assert.equal(await readUtf8(root, "state/lcm.sqlite"), "live db");
|
|
115
|
+
} finally {
|
|
116
|
+
await rm(root, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
70
120
|
test("offline changeset pushes local edits when the remote is still at the shared base", async () => {
|
|
71
121
|
const remote = await tempDir("remnic-offline-remote");
|
|
72
122
|
const local = await tempDir("remnic-offline-local");
|
|
@@ -106,6 +156,130 @@ test("offline changeset pushes local edits when the remote is still at the share
|
|
|
106
156
|
}
|
|
107
157
|
});
|
|
108
158
|
|
|
159
|
+
test("offline changeset only carries content for changed local files", async () => {
|
|
160
|
+
const local = await tempDir("remnic-offline-changeset-content");
|
|
161
|
+
try {
|
|
162
|
+
await write(local, "facts/unchanged.md", "same");
|
|
163
|
+
await write(local, "facts/changed.md", "before");
|
|
164
|
+
const base = await buildOfflineSyncSnapshot({
|
|
165
|
+
root: local,
|
|
166
|
+
sourceId: "remote",
|
|
167
|
+
includeContent: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await write(local, "facts/changed.md", "after");
|
|
171
|
+
await write(local, "facts/empty.md", "");
|
|
172
|
+
const changeset = await buildOfflineSyncChangeset({
|
|
173
|
+
root: local,
|
|
174
|
+
sourceId: "laptop",
|
|
175
|
+
baseFiles: base.files,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(
|
|
179
|
+
changeset.changes.map((change) => change.path),
|
|
180
|
+
["facts/changed.md", "facts/empty.md"],
|
|
181
|
+
);
|
|
182
|
+
const empty = changeset.changes.find((change) => change.path === "facts/empty.md");
|
|
183
|
+
assert.equal(empty?.type, "upsert");
|
|
184
|
+
if (empty?.type === "upsert") {
|
|
185
|
+
assert.equal(empty.file.contentBase64, "");
|
|
186
|
+
}
|
|
187
|
+
assert.equal(JSON.stringify(changeset).includes("same"), false);
|
|
188
|
+
} finally {
|
|
189
|
+
await rm(local, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("offline pull accepts metadata-only snapshots when files are unchanged", async () => {
|
|
194
|
+
const remote = await tempDir("remnic-offline-metadata-remote");
|
|
195
|
+
const local = await tempDir("remnic-offline-metadata-local");
|
|
196
|
+
try {
|
|
197
|
+
await write(remote, "facts/shared.md", "base");
|
|
198
|
+
const initial = await buildOfflineSyncSnapshot({
|
|
199
|
+
root: remote,
|
|
200
|
+
sourceId: "remote",
|
|
201
|
+
includeContent: true,
|
|
202
|
+
});
|
|
203
|
+
const firstPull = await applyOfflineSyncSnapshot({
|
|
204
|
+
root: local,
|
|
205
|
+
snapshot: initial,
|
|
206
|
+
});
|
|
207
|
+
const metadataOnly = await buildOfflineSyncSnapshot({
|
|
208
|
+
root: remote,
|
|
209
|
+
sourceId: "remote",
|
|
210
|
+
includeContent: false,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const secondPull = await applyOfflineSyncSnapshot({
|
|
214
|
+
root: local,
|
|
215
|
+
snapshot: metadataOnly,
|
|
216
|
+
baseFiles: firstPull.nextBaseFiles,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
assert.equal(secondPull.conflicts.length, 0);
|
|
220
|
+
assert.equal(secondPull.upserted, 0);
|
|
221
|
+
assert.equal(secondPull.skipped, 1);
|
|
222
|
+
} finally {
|
|
223
|
+
await rm(remote, { recursive: true, force: true });
|
|
224
|
+
await rm(local, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("offline pull applies snapshots with content only for remote-changed files", async () => {
|
|
229
|
+
const remote = await tempDir("remnic-offline-partial-remote");
|
|
230
|
+
const local = await tempDir("remnic-offline-partial-local");
|
|
231
|
+
try {
|
|
232
|
+
await write(remote, "facts/shared.md", "base");
|
|
233
|
+
await write(remote, "facts/stable.md", "unchanged");
|
|
234
|
+
const initial = await buildOfflineSyncSnapshot({
|
|
235
|
+
root: remote,
|
|
236
|
+
sourceId: "remote",
|
|
237
|
+
includeContent: true,
|
|
238
|
+
});
|
|
239
|
+
const firstPull = await applyOfflineSyncSnapshot({
|
|
240
|
+
root: local,
|
|
241
|
+
snapshot: initial,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await write(remote, "facts/shared.md", "remote edit");
|
|
245
|
+
const metadataOnly = await buildOfflineSyncSnapshot({
|
|
246
|
+
root: remote,
|
|
247
|
+
sourceId: "remote",
|
|
248
|
+
includeContent: false,
|
|
249
|
+
});
|
|
250
|
+
const changedContent = await buildOfflineSyncSnapshotForPaths({
|
|
251
|
+
root: remote,
|
|
252
|
+
sourceId: "remote",
|
|
253
|
+
paths: ["facts/shared.md"],
|
|
254
|
+
includeContent: true,
|
|
255
|
+
});
|
|
256
|
+
const contentByPath = new Map(
|
|
257
|
+
changedContent.files.map((file) => [file.path, file.contentBase64]),
|
|
258
|
+
);
|
|
259
|
+
const hydrated = {
|
|
260
|
+
...metadataOnly,
|
|
261
|
+
files: metadataOnly.files.map((file) => {
|
|
262
|
+
const contentBase64 = contentByPath.get(file.path);
|
|
263
|
+
return contentBase64 === undefined ? file : { ...file, contentBase64 };
|
|
264
|
+
}),
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const secondPull = await applyOfflineSyncSnapshot({
|
|
268
|
+
root: local,
|
|
269
|
+
snapshot: hydrated,
|
|
270
|
+
baseFiles: firstPull.nextBaseFiles,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
assert.equal(secondPull.upserted, 1);
|
|
274
|
+
assert.equal(secondPull.conflicts.length, 0);
|
|
275
|
+
assert.equal(await readUtf8(local, "facts/shared.md"), "remote edit");
|
|
276
|
+
assert.equal(await readUtf8(local, "facts/stable.md"), "unchanged");
|
|
277
|
+
} finally {
|
|
278
|
+
await rm(remote, { recursive: true, force: true });
|
|
279
|
+
await rm(local, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
109
283
|
test("offline pull preserves local edits when both sides changed since the base", async () => {
|
|
110
284
|
const remote = await tempDir("remnic-offline-conflict-remote");
|
|
111
285
|
const local = await tempDir("remnic-offline-conflict-local");
|