@uns-kit/core 3.0.45 → 3.0.47

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.
Files changed (30) hide show
  1. package/MIGRATIONS.md +31 -0
  2. package/dist/runtime/catalog-publication-client.d.ts +21 -0
  3. package/dist/runtime/catalog-publication-client.d.ts.map +1 -0
  4. package/dist/runtime/catalog-publication-client.js +92 -0
  5. package/dist/runtime/catalog-publication-client.js.map +1 -0
  6. package/dist/runtime/catalog-publication.d.ts +97 -0
  7. package/dist/runtime/catalog-publication.d.ts.map +1 -0
  8. package/dist/runtime/catalog-publication.js +185 -0
  9. package/dist/runtime/catalog-publication.js.map +1 -0
  10. package/dist/runtime/retained-store-copy.d.mts +3 -0
  11. package/dist/runtime/retained-store-copy.mjs +133 -0
  12. package/dist/runtime/retained-store-guide.md +44 -2
  13. package/dist/runtime/retained-store-job.d.mts +1 -0
  14. package/dist/runtime/retained-store-job.mjs +28 -0
  15. package/dist/runtime/retained-store-storage.d.mts +7 -0
  16. package/dist/runtime/retained-store-storage.mjs +66 -21
  17. package/dist/runtime/retained-store.d.ts +12 -3
  18. package/dist/runtime/retained-store.d.ts.map +1 -1
  19. package/dist/runtime/retained-store.js +74 -19
  20. package/dist/runtime/retained-store.js.map +1 -1
  21. package/dist/runtime/service-access-guide.md +130 -0
  22. package/dist/runtime/service-access.d.ts +23 -0
  23. package/dist/runtime/service-access.d.ts.map +1 -0
  24. package/dist/runtime/service-access.js +62 -0
  25. package/dist/runtime/service-access.js.map +1 -0
  26. package/dist/runtime/service-access.test.d.ts +2 -0
  27. package/dist/runtime/service-access.test.d.ts.map +1 -0
  28. package/dist/runtime/service-access.test.js +57 -0
  29. package/dist/runtime/service-access.test.js.map +1 -0
  30. package/package.json +14 -1
@@ -1,7 +1,8 @@
1
1
  # Retained stores: opt-in same-host adoption
2
2
 
3
- Prepared in UNS Kit 3.0.45; npm publication and coordinated Controller/Runtime
4
- releases are separate steps. Existing applications do not change behavior.
3
+ Retained-store adoption starts in UNS Kit 3.0.45. The 3.0.46 extension adds
4
+ explicit copying between local mounts and verification of recovered stores.
5
+ Managed activation requires coordinated Controller and Runtime releases. Existing applications do not change behavior.
5
6
  This is a filesystem capability, separate from the small JSON
6
7
  handover-state contract and MQTT output persistence. It does not change the
7
8
  default handover protocol, enable disk queues, or transfer data between hosts.
@@ -120,3 +121,44 @@ cannot be used as a complete queue restore. Restore retains store/owner/generati
120
121
  identity at the same logical root on another offline volume, and leaves an applying
121
122
  fence until complete. Known private partial staging files may be re-created only
122
123
  from the verified snapshot; differing published work is never overwritten.
124
+
125
+ ## Cross-mount transfer and recovery verification in 3.0.46
126
+
127
+ Same-filesystem rename remains the default.
128
+ For initial relocation to a separately mounted Runtime volume, the host may
129
+ explicitly select `transfer: "copy-verified"`. Stop the source cleanly **before**
130
+ reviewing this transport. Review pins content hashes, aggregate byte/file counts,
131
+ resource identities, both installed artifacts, configuration and supervisor epoch.
132
+ There is no automatic `EXDEV` fallback or caller-selected filesystem path.
133
+
134
+ The host uses `prepareRetainedStoreMoveAsync`, `applyRetainedStoreMoveAsync` and
135
+ `prepareRetainedStoreReturnAsync`. Bounded worker jobs perform opaque hashing and
136
+ copying while the host event loop can serve health and recovery reads. Persist the
137
+ complete plan before mutation and hold the existing maintenance/lifecycle fences
138
+ through the durable outcome. Jobs have a 128MiB old-generation heap and a 300s
139
+ bound; uncertainty always requires reconciliation of the same operation.
140
+
141
+ Copy stages and fsyncs each declared resource on its receiving mount, verifies
142
+ its exact contents and atomically publishes it. Source files remain unchanged and
143
+ fenced; allow space for both copies. Known incomplete scratch can be resumed from
144
+ the unchanged reviewed source. Unknown entries, links, changed source bytes and
145
+ differing published files are preserved and refused. The existing limits remain
146
+ 32 stores, 100,000 entries, 64GiB aggregate files and 32GiB per file.
147
+
148
+ Return copies the **current** stopped store back. If current work differs from
149
+ the original, the original is first preserved as
150
+ `.retained-original-<store UUID>-<resource ID>` beside its legacy path. The retired
151
+ stable copy remains inert and is included by full backup; it cannot acquire a
152
+ runtime lease. No cleanup, resource merge, conversion or cross-host transfer is
153
+ implied. Runtime disk capacity and the application's destination acknowledgement
154
+ semantics remain independent operational requirements.
155
+
156
+ Offline restore now retains a verified local snapshot manifest. The data-only
157
+ `verify-restored` / `verify-root` actions rehash exact declared payloads under an
158
+ exclusive root lease before Runtime activation. A completed restore retry never
159
+ replaces newer application data. Full RTT stage/activation/completion binds the
160
+ verified root, signed backup digest and local manifest digest into its protected
161
+ receipts. Missing, excluded, unfinished or altered storage cannot enable bound
162
+ owners. After application work advances, do not activate an old snapshot: preserve
163
+ current storage and use a fresh reviewed owner transition. Historic operation
164
+ plans pin original physical files and are not rebased after a full restore.
@@ -0,0 +1 @@
1
+ export function retainedStoreJob(job: unknown, onProgress?: (id: string) => void): Promise<unknown>;
@@ -0,0 +1,28 @@
1
+ import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
2
+ import { inspectRetainedCopy, applyRetainedCopy } from "./retained-store-copy.mjs";
3
+ if (!isMainThread) {
4
+ const job = workerData;
5
+ if (job.action === "inspect") parentPort.postMessage({ done: true, result: inspectRetainedCopy(job.path, job.kind, job.limits) });
6
+ else if (job.action === "apply") {
7
+ applyRetainedCopy(job.plan, job.direction, job.limits, id => parentPort.postMessage({ progress: id }));
8
+ parentPort.postMessage({ done: true });
9
+ } else throw Error("Unknown retained storage job");
10
+ }
11
+ export function retainedStoreJob(job, onProgress) {
12
+ if (!isMainThread) throw Error("Nested retained storage job");
13
+ return new Promise((resolve, reject) => {
14
+ const worker = new Worker(new URL(import.meta.url), { workerData: job, execArgv: [], env: { NODE_NO_WARNINGS: "1" }, resourceLimits: { maxOldGenerationSizeMb: 128 } });
15
+ let done = false;
16
+ const timer = setTimeout(() => finish(Error("Retained storage job timed out; reconcile the same operation")), 300000);
17
+ const finish = (error, result) => {
18
+ if (done) return; done = true; clearTimeout(timer);
19
+ void worker.terminate().finally(() => error ? reject(error) : resolve(result));
20
+ };
21
+ worker.on("message", message => {
22
+ if (message.progress) { try { onProgress?.(message.progress); } catch (error) { finish(error); } }
23
+ if (message.done) finish(undefined, message.result);
24
+ });
25
+ worker.once("error", error => finish(error));
26
+ worker.once("exit", () => { if (!done) finish(Error("Retained storage job exited without confirmation; reconcile the same operation")); });
27
+ });
28
+ }
@@ -15,3 +15,10 @@ export function restoreRetained(
15
15
  afterPublish?: (id: string) => void,
16
16
  ): { state: string; stores: number };
17
17
  export function available(root: string): void;
18
+ export function owned(path: string, directory?: boolean): import("node:fs").Stats;
19
+ export function sha(path: string): string;
20
+ export function sync(path: string): void;
21
+ export function relative(path: string): boolean;
22
+ export function checkLimit(limits: unknown): void;
23
+ export function verifyRestoredRetained(source:string,root:string,backupDigest:string,limits:unknown):{state:string;stores:number;backupDigest:string;manifestDigest:string;root:string};
24
+ export function verifyRetainedRecoveryRoot(root:string,backupDigest:string,manifestDigest:string,limits:unknown):{state:string;stores:number;backupDigest:string;manifestDigest:string;root:string};
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
5
 
6
6
  export const RESTORE_FILE = "retained-store-restore.json";
7
+ export const RESTORE_MANIFEST = "retained-store-restore-manifest.json";
7
8
  export const DECLARATION = "store-resources.json";
8
9
  export const OPERATION = "store-operation.json";
9
10
  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -21,7 +22,7 @@ export function safePath(p) {
21
22
  if (!path.isAbsolute(p) || path.normalize(p) !== p) throw Error("Unsafe retained path");
22
23
  for (let a = p; a !== path.dirname(a); a = path.dirname(a)) if (entry(a)?.isSymbolicLink()) throw Error("Linked retained path");
23
24
  }
24
- function owned(p, directory = false) {
25
+ export function owned(p, directory = false) {
25
26
  safePath(p);
26
27
  const s = entry(p);
27
28
  if (!s || s.uid !== process.getuid?.() || s.isSymbolicLink() || (directory ? !s.isDirectory() : !s.isFile() || s.nlink !== 1))
@@ -38,7 +39,7 @@ export function readJSON(p, limit = 131072) {
38
39
  if (s.size > limit) throw Error("Retained metadata limit");
39
40
  return JSON.parse(fs.readFileSync(p, "utf8"));
40
41
  }
41
- function sync(p) {
42
+ export function sync(p) {
42
43
  const fd = fs.openSync(p, "r");
43
44
  try {
44
45
  fs.fsyncSync(fd);
@@ -147,7 +148,7 @@ export function runtimeLease(binding) {
147
148
  throw e;
148
149
  }
149
150
  }
150
- function sha(file) {
151
+ export function sha(file) {
151
152
  const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
152
153
  const h = createHash("sha256"),
153
154
  buffer = Buffer.alloc(65536);
@@ -159,7 +160,7 @@ function sha(file) {
159
160
  fs.closeSync(fd);
160
161
  }
161
162
  }
162
- function relative(p) {
163
+ export function relative(p) {
163
164
  return (
164
165
  typeof p === "string" &&
165
166
  p.length <= 4096 &&
@@ -212,7 +213,7 @@ function declaration(value, format) {
212
213
  }
213
214
  return value;
214
215
  }
215
- function checkLimit(limits) {
216
+ export function checkLimit(limits) {
216
217
  if (
217
218
  limits.schemaVersion !== 1 ||
218
219
  limits.maximumStores !== 32 ||
@@ -258,7 +259,7 @@ export function captureRetained(root, destination, mode, limits) {
258
259
  }
259
260
  const names = fs.readdirSync(root).sort();
260
261
  for (const id of names) {
261
- if (["retained-root-lease.sqlite", RESTORE_FILE].includes(id)) continue;
262
+ if (["retained-root-lease.sqlite", RESTORE_FILE,RESTORE_MANIFEST].includes(id)) continue;
262
263
  if (!uuid.test(id) || manifest.stores.length >= limits.maximumStores) throw Error("Unexpected retained root entry");
263
264
  const dir = path.join(root, id);
264
265
  privateRoot(dir);
@@ -267,30 +268,25 @@ export function captureRetained(root, destination, mode, limits) {
267
268
  if (entry(path.join(dir, OPERATION))) throw Error("Retained operation is incomplete");
268
269
  const b = binding(readJSON(path.join(dir, "store.json")), root, id);
269
270
  const d = declaration(readJSON(path.join(dir, DECLARATION)), b.format);
270
- if (entry(path.join(dir, "store-returned.json"))) {
271
- if (fs.readdirSync(dir).some((n) => !["store.json", DECLARATION, "store-lease.sqlite", "store-returned.json"].includes(n)))
272
- throw Error("Undeclared returned-store entry");
271
+ const retired = !!entry(path.join(dir, "store-returned.json"));
272
+ if (retired) {
273
273
  const r = readJSON(path.join(dir, "store-returned.json"));
274
- if (r.operationId !== b.operationId || r.generation !== b.generation || d.resources.some((x) => entry(path.join(dir, x.id))))
274
+ if (r.operationId !== b.operationId || r.generation !== b.generation)
275
275
  throw Error("Uncertain returned store");
276
- fs.mkdirSync(path.join(destination, id), { mode: 0o700 });
277
- manifest.entries.push({ path: id, type: "directory" });
278
- for (const name of ["store.json", DECLARATION, "store-returned.json"]) copy(path.join(dir, name), id + "/" + name);
279
- manifest.stores.push({ binding: b, storeId: id, generation: b.generation, owner: b.owner, format: b.format, resources: [], retired: true });
280
- continue;
281
276
  }
282
- const allowed = new Set(["store.json", DECLARATION, "store-lease.sqlite", ...d.resources.map((x) => x.id)]);
277
+ const allowed = new Set(["store.json", DECLARATION, "store-lease.sqlite", ...(retired?["store-returned.json"]:[]), ...d.resources.map((x) => x.id)]);
283
278
  if (fs.readdirSync(dir).some((n) => !allowed.has(n))) throw Error("Undeclared retained entry");
284
279
  fs.mkdirSync(path.join(destination, id), { mode: 0o700 });
285
280
  manifest.entries.push({ path: id, type: "directory" });
286
281
  copy(path.join(dir, "store.json"), id + "/store.json");
287
282
  copy(path.join(dir, DECLARATION), id + "/" + DECLARATION);
283
+ if(retired)copy(path.join(dir,"store-returned.json"),id+"/store-returned.json");
288
284
  const resources = [];
289
285
  for (const r of d.resources) {
290
286
  const p = path.join(dir, r.id),
291
287
  s = entry(p);
292
288
  if (!s) {
293
- if (r.required) throw Error("Required retained resource missing");
289
+ if (r.required && !retired) throw Error("Required retained resource missing");
294
290
  continue;
295
291
  }
296
292
  if ((r.kind === "directory") !== s.isDirectory()) throw Error("Retained resource kind mismatch");
@@ -298,7 +294,7 @@ export function captureRetained(root, destination, mode, limits) {
298
294
  resources.push({ id: r.id, backup: r.backup ?? "payload", included });
299
295
  if (included) copy(p, id + "/" + r.id);
300
296
  }
301
- manifest.stores.push({ binding: b, storeId: id, generation: b.generation, owner: b.owner, format: b.format, resources });
297
+ manifest.stores.push({ binding: b, storeId: id, generation: b.generation, owner: b.owner, format: b.format, resources, ...(retired?{retired:true}:{}) });
302
298
  }
303
299
  const raw = JSON.stringify(manifest);
304
300
  if (Buffer.byteLength(raw) > limits.maximumManifestBytes) throw Error("Retained manifest limit");
@@ -363,7 +359,7 @@ function verifySnapshot(source, limits) {
363
359
  if (stores.size !== m.stores.length || m.entries.some((e) => !stores.has(e.path.split("/")[0]))) throw Error("Undeclared snapshot store");
364
360
  for (const st of m.stores) {
365
361
  const b = binding(readJSON(path.join(source, st.storeId, "store.json")), m.sourceRoot, st.storeId);
366
- declaration(readJSON(path.join(source, st.storeId, DECLARATION)), b.format);
362
+ const declared=declaration(readJSON(path.join(source, st.storeId, DECLARATION)), b.format);
367
363
  if (st.retired) {
368
364
  const r = readJSON(path.join(source, st.storeId, "store-returned.json"));
369
365
  if (
@@ -371,7 +367,7 @@ function verifySnapshot(source, limits) {
371
367
  r.generation !== b.generation ||
372
368
  m.entries.some(
373
369
  (e) =>
374
- e.path.startsWith(st.storeId + "/") && !["store.json", DECLARATION, "store-returned.json"].includes(e.path.slice(st.storeId.length + 1)),
370
+ e.path.startsWith(st.storeId + "/") && !["store.json", DECLARATION, "store-returned.json",...declared.resources.map(r=>r.id)].includes(e.path.slice(st.storeId.length + 1).split("/")[0]),
375
371
  )
376
372
  )
377
373
  throw Error("Invalid retired-store receipt");
@@ -397,6 +393,7 @@ export function restoreRetained(source, root, backupDigest, limits, afterPublish
397
393
  if (new Set(ids).size !== ids.length || ids.some((id) => !uuid.test(id))) throw Error("Invalid snapshot store identities");
398
394
  if (marker?.state === "complete") {
399
395
  for (const id of ids) binding(readJSON(path.join(root, id, "store.json")), root, id);
396
+ persistRecoveryManifest(source,root,manifestDigest);
400
397
  return { state: "complete", stores: ids.length };
401
398
  }
402
399
  if (!marker) {
@@ -404,10 +401,11 @@ export function restoreRetained(source, root, backupDigest, limits, afterPublish
404
401
  marker = { protocol: 1, state: "applying", backupDigest, manifestDigest };
405
402
  atomicJSON(markerFile, marker);
406
403
  }
404
+ persistRecoveryManifest(source,root,manifestDigest);
407
405
  const stage = path.join(root, "retained-restore-stage");
408
406
  if (!entry(stage)) fs.mkdirSync(stage, { mode: 0o700 });
409
407
  privateRoot(stage);
410
- const allowed = new Set(["retained-root-lease.sqlite", RESTORE_FILE, "retained-restore-stage", ...ids]);
408
+ const allowed = new Set(["retained-root-lease.sqlite", RESTORE_FILE,RESTORE_MANIFEST, "retained-restore-stage", ...ids]);
411
409
  if (fs.readdirSync(root).some((n) => !allowed.has(n))) throw Error("Unexpected restore target entry");
412
410
  for (const e of m.entries) {
413
411
  const id = e.path.split("/")[0],
@@ -486,8 +484,55 @@ export function retainedStorageMain() {
486
484
  ? captureRetained(root, out, modeOrDigest, limits)
487
485
  : action === "restore"
488
486
  ? restoreRetained(root, out, modeOrDigest, limits)
487
+ : action === "verify-restored"
488
+ ? verifyRestoredRetained(root,out,modeOrDigest,limits)
489
+ : action === "verify-root"
490
+ ? verifyRetainedRecoveryRoot(root,modeOrDigest,out,limits)
489
491
  : (() => {
490
492
  throw Error("Unknown retained operation");
491
493
  })();
492
494
  process.stdout.write(JSON.stringify(result));
493
495
  }
496
+
497
+ /** Offline proof tied to one verified backup, used before an RTT tree can activate. */
498
+ export function verifyRestoredRetained(source,root,backupDigest,limits){
499
+ checkLimit(limits);
500
+ const m=verifySnapshot(source,limits);
501
+ if(m.mode!=="include"||m.sourceRoot!==root||!hash.test(backupDigest))throw Error("Retained recovery scope differs");
502
+ return verifyRetainedRecoveryRoot(root,backupDigest,sha(path.join(source,"manifest.json")),limits);
503
+ }
504
+ function persistRecoveryManifest(source,root,digest){
505
+ const target=path.join(root,RESTORE_MANIFEST);
506
+ const temporary=target+".part";
507
+ if(entry(temporary)){owned(temporary);fs.unlinkSync(temporary);sync(root);}
508
+ if(entry(target)){if(sha(target)!==digest)throw Error("Retained recovery manifest differs");return;}
509
+ try{fs.copyFileSync(path.join(source,"manifest.json"),temporary,fs.constants.COPYFILE_EXCL);fs.chmodSync(temporary,0o600);sync(temporary);if(sha(temporary)!==digest)throw Error("Retained recovery manifest changed");fs.renameSync(temporary,target);sync(root);}finally{if(entry(temporary))fs.unlinkSync(temporary);}
510
+ }
511
+ export function verifyRetainedRecoveryRoot(root,backupDigest,digest,limits){
512
+ checkLimit(limits);
513
+ if(!hash.test(backupDigest)||!hash.test(digest))throw Error("Invalid retained recovery proof");
514
+ const release=rootLease(root,true,true);
515
+ try{
516
+ const marker=readJSON(path.join(root,RESTORE_FILE));
517
+ if(marker.state!=="complete"||marker.backupDigest!==backupDigest||marker.manifestDigest!==digest)throw Error("Matching completed retained recovery is required");
518
+ const file=path.join(root,RESTORE_MANIFEST);
519
+ if(sha(file)!==digest)throw Error("Retained recovery manifest differs");
520
+ const m=readJSON(file,limits.maximumManifestBytes);
521
+ if(m.schemaVersion!==1||m.kind!=="uns-retained-store-snapshot"||m.mode!=="include"||m.sourceRoot!==root||!Array.isArray(m.stores)||m.stores.length>limits.maximumStores||!Array.isArray(m.entries)||m.entries.length>limits.maximumEntries)throw Error("Invalid retained recovery manifest");
522
+ const ids=m.stores.map(s=>s.storeId);
523
+ if(new Set(ids).size!==ids.length||ids.some(id=>!uuid.test(id)))throw Error("Invalid recovered stores");
524
+ if(fs.readdirSync(root).some(n=>!["retained-root-lease.sqlite",RESTORE_FILE,RESTORE_MANIFEST,...ids].includes(n)))throw Error("Unexpected retained recovery root entry");
525
+ const seen=new Set();let total=0;
526
+ for(const e of m.entries){
527
+ if(!relative(e.path)||!ids.includes(e.path.split('/')[0])||seen.has(e.path)||!["file","directory"].includes(e.type))throw Error("Invalid retained recovery path");seen.add(e.path);
528
+ const p=path.join(root,e.path),s=owned(p,e.type==="directory");
529
+ if(e.type==="file"){total+=s.size;if(total>limits.maximumBytes||s.size>limits.maximumFileBytes||s.size!==e.size||sha(p)!==e.sha256)throw Error("Restored retained payload changed before activation");}
530
+ }
531
+ for(const id of ids){
532
+ const prefix=id+"/",dir=path.join(root,id),allowed=new Set(m.entries.filter(e=>e.path.startsWith(prefix)).map(e=>e.path.slice(prefix.length)));
533
+ if(entry(path.join(dir,"store-lease.sqlite"))){owned(path.join(dir,"store-lease.sqlite"));allowed.add("store-lease.sqlite");}
534
+ exactTree(dir,allowed);
535
+ }
536
+ return{state:"verified",stores:ids.length,backupDigest,manifestDigest:digest,root};
537
+ }finally{release();}
538
+ }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import { z } from "zod";
3
+ import { type RetainedCopyFingerprint } from "./retained-store-copy.mjs";
3
4
  export declare const RETAINED_STORE_MANIFEST = "retained-store.manifest.json";
4
5
  export declare const RETAINED_STORE_BINDING = "retained-store-binding.json";
5
6
  export declare const RETAINED_STORE_GUARD = "retained-store-guard.json";
@@ -45,6 +46,7 @@ export type ResourceMove = {
45
46
  kind: "file" | "directory";
46
47
  device: number;
47
48
  inode: number;
49
+ content?: RetainedCopyFingerprint;
48
50
  };
49
51
  export type StoreMovePlan = {
50
52
  storeId: string;
@@ -54,6 +56,8 @@ export type StoreMovePlan = {
54
56
  directory: string;
55
57
  slots: RetainedStoreManifest["resources"];
56
58
  moves: ResourceMove[];
59
+ transfer?: "copy-verified";
60
+ originalMoves?: ResourceMove[];
57
61
  };
58
62
  export declare function retainedStoreDigest(value: unknown): string;
59
63
  export declare function retainedStoreEntry(file: string): fs.Stats | undefined;
@@ -62,10 +66,10 @@ export declare function readRetainedStoreJson(file: string): unknown;
62
66
  export declare function syncRetainedDirectory(directory: string): void;
63
67
  export declare function writeRetainedStoreJson(file: string, value: unknown): void;
64
68
  export declare function readRetainedStoreManifest(packageDirectory: string): RetainedStoreManifest;
65
- export declare function prepareRetainedStoreMove(source: string, root: string, storeId: string, manifest: RetainedStoreManifest): StoreMovePlan;
69
+ export declare function prepareRetainedStoreMove(source: string, root: string, storeId: string, manifest: RetainedStoreManifest, transfer?: "copy-verified", deferContent?: boolean): StoreMovePlan;
66
70
  /** Caller durably records the complete plan and holds its resource reservation first.
67
71
  * Each rename is atomic; the multi-resource operation is deliberately resumable. */
68
- export declare function applyRetainedStoreMove(plan: StoreMovePlan, direction: "adopt" | "return", afterMove?: (id: string) => void): void;
72
+ export declare function applyRetainedStoreMove(plan: StoreMovePlan, direction: "adopt" | "return", afterMove?: (id: string) => void, onFile?: (file: string) => void, validateOnly?: boolean): void;
69
73
  export declare function writeRetainedStoreBinding(instanceDirectory: string, binding: StoreBinding): void;
70
74
  export declare function loadRetainedStoreBinding(instanceDirectory: string, operationId?: string): StoreBinding | undefined;
71
75
  /** No application payload/schema inspection is performed by this helper. */
@@ -80,6 +84,7 @@ export declare const retainedStoreSelectionSchema: z.ZodObject<{
80
84
  sourceInstanceId: z.ZodString;
81
85
  targetVersion: z.ZodString;
82
86
  targetInstanceId: z.ZodString;
87
+ transfer: z.ZodOptional<z.ZodLiteral<"copy-verified">>;
83
88
  }, z.core.$strict>;
84
89
  export declare const retainedStoreRequestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
85
90
  action: z.ZodLiteral<"review">;
@@ -90,6 +95,7 @@ export declare const retainedStoreRequestSchema: z.ZodDiscriminatedUnion<[z.ZodO
90
95
  sourceInstanceId: z.ZodString;
91
96
  targetVersion: z.ZodString;
92
97
  targetInstanceId: z.ZodString;
98
+ transfer: z.ZodOptional<z.ZodLiteral<"copy-verified">>;
93
99
  }, z.core.$strict>;
94
100
  }, z.core.$strict>, z.ZodObject<{
95
101
  action: z.ZodLiteral<"get">;
@@ -104,12 +110,15 @@ export declare const retainedStoreRequestSchema: z.ZodDiscriminatedUnion<[z.ZodO
104
110
  }, z.core.$strict>], "action">;
105
111
  export type RetainedStoreRequest = z.infer<typeof retainedStoreRequestSchema>;
106
112
  /** Capture mutable control files from the current stopped owner before persisting return intent. */
107
- export declare function prepareRetainedStoreReturn(plan: StoreMovePlan): StoreMovePlan;
113
+ export declare function prepareRetainedStoreReturn(plan: StoreMovePlan, deferContent?: boolean): StoreMovePlan;
108
114
  export declare function retainedStoreRoot(defaultBase: string, configured?: string): string;
109
115
  export declare function openRetainedStoreLease(binding: StoreBinding): {
110
116
  close: () => void;
111
117
  };
112
118
  export declare function openRetainedStoreMaintenance(directory: string): () => void;
119
+ export declare function prepareRetainedStoreMoveAsync(source: string, root: string, id: string, manifest: RetainedStoreManifest, transfer?: "copy-verified"): Promise<StoreMovePlan>;
120
+ export declare function prepareRetainedStoreReturnAsync(plan: StoreMovePlan): Promise<StoreMovePlan>;
121
+ export declare function applyRetainedStoreMoveAsync(plan: StoreMovePlan, direction: "adopt" | "return", afterMove?: (id: string) => void): Promise<void>;
113
122
  export declare const retainedStoreBackupSchema: z.ZodObject<{
114
123
  schemaVersion: z.ZodLiteral<1>;
115
124
  state: z.ZodEnum<{
@@ -1 +1 @@
1
- {"version":3,"file":"retained-store.d.ts","sourceRoot":"","sources":["../../src/runtime/retained-store.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"retained-store.d.ts","sourceRoot":"","sources":["../../src/runtime/retained-store.ts"],"names":[],"mappings":"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"}
@@ -3,6 +3,9 @@ import * as fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { available, maintenanceLease, runtimeLease } from "./retained-store-storage.mjs";
6
+ import { inspectRetainedCopy, applyRetainedCopy } from "./retained-store-copy.mjs";
7
+ import { retainedStoreJob } from "./retained-store-job.mjs";
8
+ const copyLimits = () => JSON.parse(fs.readFileSync(new URL("./retained-store-storage-contract.json", import.meta.url), "utf8"));
6
9
  const segment = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/);
7
10
  const relative = z
8
11
  .string()
@@ -137,7 +140,7 @@ export function writeRetainedStoreJson(file, value) {
137
140
  export function readRetainedStoreManifest(packageDirectory) {
138
141
  return retainedStoreManifestSchema.parse(readRetainedStoreJson(path.join(packageDirectory, RETAINED_STORE_MANIFEST)));
139
142
  }
140
- export function prepareRetainedStoreMove(source, root, storeId, manifest) {
143
+ export function prepareRetainedStoreMove(source, root, storeId, manifest, transfer, deferContent = false) {
141
144
  z.uuid().parse(storeId);
142
145
  retainedStoreManifestSchema.parse(manifest);
143
146
  assertRetainedStorePath(source);
@@ -151,8 +154,10 @@ export function prepareRetainedStoreMove(source, root, storeId, manifest) {
151
154
  const rootStat = existingRoot ?? fs.statSync(path.dirname(root)), sourceStat = fs.statSync(source);
152
155
  if (root === source || root.startsWith(source + path.sep) || source.startsWith(root + path.sep))
153
156
  throw Error("Nested retained store roots");
154
- if (rootStat.dev !== sourceStat.dev)
155
- throw Error("Retained store requires same filesystem; copy is not permitted");
157
+ if (transfer !== undefined && transfer !== "copy-verified")
158
+ throw Error("Unsupported retained transfer");
159
+ if (!transfer && rootStat.dev !== sourceStat.dev)
160
+ throw Error("Retained store requires same filesystem; review an explicit verified copy");
156
161
  const moves = [];
157
162
  for (const resource of manifest.resources) {
158
163
  const file = path.join(source, resource.path);
@@ -164,13 +169,16 @@ export function prepareRetainedStoreMove(source, root, storeId, manifest) {
164
169
  continue;
165
170
  }
166
171
  if (s.uid !== process.getuid?.() ||
167
- s.dev !== rootStat.dev ||
172
+ (!transfer && s.dev !== rootStat.dev) ||
168
173
  s.isSymbolicLink() ||
169
174
  (resource.kind === "file" ? !s.isFile() || s.nlink !== 1 : !s.isDirectory()))
170
175
  throw Error("Unsafe retained resource");
171
- moves.push({ id: resource.id, relative: resource.path, kind: resource.kind, device: s.dev, inode: s.ino });
176
+ moves.push({ id: resource.id, relative: resource.path, kind: resource.kind, device: s.dev, inode: s.ino,
177
+ ...(transfer && !deferContent ? { content: inspectRetainedCopy(file, resource.kind, copyLimits()) } : {}) });
172
178
  }
173
- return { storeId, generation: randomUUID(), format: manifest.format, source, directory, slots: manifest.resources, moves };
179
+ if (transfer && (moves.reduce((n, m) => n + (m.content?.bytes ?? 0), 0) > copyLimits().maximumBytes || moves.reduce((n, m) => n + (m.content?.entries ?? 0), 0) > copyLimits().maximumEntries))
180
+ throw Error("Retained copy aggregate limit");
181
+ return { storeId, generation: randomUUID(), format: manifest.format, source, directory, slots: manifest.resources, moves, ...(transfer ? { transfer } : {}) };
174
182
  }
175
183
  function matches(file, move) {
176
184
  assertRetainedStorePath(file);
@@ -184,7 +192,8 @@ function matches(file, move) {
184
192
  }
185
193
  /** Caller durably records the complete plan and holds its resource reservation first.
186
194
  * Each rename is atomic; the multi-resource operation is deliberately resumable. */
187
- export function applyRetainedStoreMove(plan, direction, afterMove) {
195
+ export function applyRetainedStoreMove(plan, direction, afterMove, onFile, validateOnly = false) {
196
+ const resourceMove = z.object({ id: segment, relative, kind: z.enum(["file", "directory"]), device: z.number().int().nonnegative(), inode: z.number().int().nonnegative(), content: z.object({ digest: z.string().regex(/^[a-f0-9]{64}$/), bytes: z.number().int().nonnegative().max(copyLimits().maximumBytes), entries: z.number().int().positive().max(copyLimits().maximumEntries) }).strict().optional() }).strict();
188
197
  z.object({
189
198
  storeId: z.uuid(),
190
199
  generation: z.uuid(),
@@ -202,17 +211,9 @@ export function applyRetainedStoreMove(plan, direction, afterMove) {
202
211
  })
203
212
  .strict())
204
213
  .max(16),
205
- moves: z
206
- .array(z
207
- .object({
208
- id: segment,
209
- relative,
210
- kind: z.enum(["file", "directory"]),
211
- device: z.number().int().nonnegative(),
212
- inode: z.number().int().nonnegative(),
213
- })
214
- .strict())
215
- .max(16),
214
+ moves: z.array(resourceMove).max(16),
215
+ transfer: z.literal("copy-verified").optional(),
216
+ originalMoves: z.array(resourceMove).max(16).optional(),
216
217
  })
217
218
  .strict()
218
219
  .parse(plan);
@@ -234,6 +235,20 @@ export function applyRetainedStoreMove(plan, direction, afterMove) {
234
235
  const directoryStat = fs.lstatSync(plan.directory);
235
236
  if (!directoryStat.isDirectory() || directoryStat.uid !== process.getuid?.() || directoryStat.mode & 0o077)
236
237
  throw Error("Unsafe retained store directory");
238
+ if (plan.transfer) {
239
+ if (plan.moves.some(m => !m.content) || plan.originalMoves?.some(m => !m.content))
240
+ throw Error("Verified copy requires reviewed content fingerprints");
241
+ for (const entries of [plan.moves, plan.originalMoves ?? []])
242
+ if (entries.reduce((n, m) => n + (m.content?.bytes ?? 0), 0) > copyLimits().maximumBytes || entries.reduce((n, m) => n + (m.content?.entries ?? 0), 0) > copyLimits().maximumEntries)
243
+ throw Error("Retained copy aggregate limit");
244
+ if ((plan.originalMoves ?? []).some(m => !manifest.resources.some(r => r.id === m.id && r.path === m.relative && r.kind === m.kind)))
245
+ throw Error("Original copy differs from declared resources");
246
+ if (!validateOnly)
247
+ applyRetainedCopy(plan, direction, copyLimits(), afterMove, onFile);
248
+ return;
249
+ }
250
+ if (plan.originalMoves)
251
+ throw Error("Original copy receipt requires verified transfer");
237
252
  for (const move of plan.moves) {
238
253
  const legacy = path.join(plan.source, move.relative), stable = path.join(plan.directory, move.id);
239
254
  const [from, to] = direction === "adopt" ? [legacy, stable] : [stable, legacy];
@@ -314,6 +329,7 @@ export const retainedStoreSelectionSchema = z
314
329
  sourceInstanceId: segment,
315
330
  targetVersion: segment,
316
331
  targetInstanceId: segment,
332
+ transfer: z.literal("copy-verified").optional(),
317
333
  })
318
334
  .strict()
319
335
  .refine((x) => x.sourceVersion !== x.targetVersion || x.sourceInstanceId !== x.targetInstanceId, "Source and target must differ");
@@ -323,8 +339,25 @@ export const retainedStoreRequestSchema = z.discriminatedUnion("action", [
323
339
  z.object({ action: z.enum(["adopt", "return"]), id: z.uuid(), reviewedChecksum: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
324
340
  ]);
325
341
  /** Capture mutable control files from the current stopped owner before persisting return intent. */
326
- export function prepareRetainedStoreReturn(plan) {
342
+ export function prepareRetainedStoreReturn(plan, deferContent = false) {
327
343
  const moves = [];
344
+ if (plan.transfer) {
345
+ for (const slot of plan.slots) {
346
+ const stable = path.join(plan.directory, slot.id), legacy = path.join(plan.source, slot.path), a = retainedStoreEntry(stable), b = retainedStoreEntry(legacy);
347
+ if (a) {
348
+ assertRetainedStorePath(stable);
349
+ moves.push({ id: slot.id, relative: slot.path, kind: slot.kind, device: a.dev, inode: a.ino, ...(!deferContent ? { content: inspectRetainedCopy(stable, slot.kind, copyLimits()) } : {}) });
350
+ }
351
+ else if (b) {
352
+ const original = plan.moves.find(m => m.id === slot.id);
353
+ if (!original || !matches(legacy, original) || (!deferContent && retainedStoreDigest(inspectRetainedCopy(legacy, slot.kind, copyLimits())) !== retainedStoreDigest(original.content)))
354
+ throw Error("Original retained copy changed");
355
+ }
356
+ else if (slot.required)
357
+ throw Error("Current retained resource missing");
358
+ }
359
+ return { ...plan, moves, originalMoves: plan.moves };
360
+ }
328
361
  for (const slot of plan.slots) {
329
362
  const stable = path.join(plan.directory, slot.id), legacy = path.join(plan.source, slot.path);
330
363
  assertRetainedStorePath(stable);
@@ -359,6 +392,28 @@ export function openRetainedStoreLease(binding) {
359
392
  export function openRetainedStoreMaintenance(directory) {
360
393
  return maintenanceLease(directory);
361
394
  }
395
+ export async function prepareRetainedStoreMoveAsync(source, root, id, manifest, transfer) {
396
+ const plan = prepareRetainedStoreMove(source, root, id, manifest, transfer, !!transfer);
397
+ if (transfer)
398
+ for (const move of plan.moves)
399
+ move.content = await retainedStoreJob({ action: "inspect", path: path.join(source, move.relative), kind: move.kind, limits: copyLimits() });
400
+ return plan;
401
+ }
402
+ export async function prepareRetainedStoreReturnAsync(plan) {
403
+ const back = prepareRetainedStoreReturn(plan, !!plan.transfer);
404
+ if (plan.transfer)
405
+ for (const move of back.moves)
406
+ move.content = await retainedStoreJob({ action: "inspect", path: path.join(plan.directory, move.id), kind: move.kind, limits: copyLimits() });
407
+ return back;
408
+ }
409
+ export async function applyRetainedStoreMoveAsync(plan, direction, afterMove) {
410
+ if (!plan.transfer) {
411
+ applyRetainedStoreMove(plan, direction, afterMove);
412
+ return;
413
+ }
414
+ applyRetainedStoreMove(plan, direction, undefined, undefined, true);
415
+ await retainedStoreJob({ action: "apply", plan, direction, limits: copyLimits() }, afterMove);
416
+ }
362
417
  export const retainedStoreBackupSchema = z
363
418
  .object({
364
419
  schemaVersion: z.literal(1),