@uns-kit/core 3.0.44 → 3.0.46
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/MIGRATIONS.md +31 -0
- package/README.md +14 -0
- package/dist/runtime/retained-store-copy.d.mts +3 -0
- package/dist/runtime/retained-store-copy.mjs +133 -0
- package/dist/runtime/retained-store-examples/index-worker.manifest.json +22 -0
- package/dist/runtime/retained-store-examples/measurement-worker.manifest.json +22 -0
- package/dist/runtime/retained-store-guide.md +164 -0
- package/dist/runtime/retained-store-job.d.mts +1 -0
- package/dist/runtime/retained-store-job.mjs +28 -0
- package/dist/runtime/retained-store-storage-contract.json +9 -0
- package/dist/runtime/retained-store-storage.d.mts +24 -0
- package/dist/runtime/retained-store-storage.mjs +538 -0
- package/dist/runtime/retained-store.d.ts +135 -0
- package/dist/runtime/retained-store.d.ts.map +1 -0
- package/dist/runtime/retained-store.js +428 -0
- package/dist/runtime/retained-store.js.map +1 -0
- package/package.json +10 -1
package/MIGRATIONS.md
CHANGED
|
@@ -9,6 +9,37 @@ Agents must inspect the application's existing ownership and shutdown flow
|
|
|
9
9
|
before editing it. The examples below describe the intended behavior, not a
|
|
10
10
|
mechanical search-and-replace operation.
|
|
11
11
|
|
|
12
|
+
## 3.0.46 - Verified retained-store copy and recovery
|
|
13
|
+
|
|
14
|
+
When crossing `<3.0.46` to `>=3.0.46`, existing rename and unbound consumers
|
|
15
|
+
remain compatible. An explicit
|
|
16
|
+
`transfer: "copy-verified"` adds stopped-source review and bounded asynchronous
|
|
17
|
+
opaque copying between local mounts. Use the same operation/checksum after
|
|
18
|
+
interruption; return current work and retain the original copy. Full Runtime
|
|
19
|
+
recovery requires the matching verified offline retained root before RTT owners
|
|
20
|
+
can be activated. See `docs/retained-store-adoption.md`, bundled as the core
|
|
21
|
+
retained-store guide. No application-specific Controller logic, queue conversion,
|
|
22
|
+
new default handover protocol or automatic disk persistence is introduced.
|
|
23
|
+
|
|
24
|
+
## 3.0.45 - Opt-in retained-store storage protocol
|
|
25
|
+
|
|
26
|
+
When crossing `<3.0.45` to `>=3.0.45`, ordinary applications keep their current
|
|
27
|
+
behavior. Applications adopting bound
|
|
28
|
+
retained stores must declare `storageProtocol: 1`, resolve only the shared resource
|
|
29
|
+
paths, acquire `openRetainedStoreLease(binding)` before opening payload writers,
|
|
30
|
+
and release it only after final writer cleanup. Do not infer support from a Kit
|
|
31
|
+
version or default an old omitted capability to supported. Classify manifest
|
|
32
|
+
resources explicitly as backup payload or metadata when needed.
|
|
33
|
+
|
|
34
|
+
Read the [bundled guide](dist/runtime/retained-store-guide.md) and the two
|
|
35
|
+
unrelated manifests in `dist/runtime/retained-store-examples/`. The source guide
|
|
36
|
+
is `docs/retained-store-adoption.md` in the SDK repository.
|
|
37
|
+
Managed use requires the Runtime's external retained-store root. Older local
|
|
38
|
+
candidate bindings/layouts need reviewed initialization/relocation; the SDK never
|
|
39
|
+
silently changes their absolute roots or converts application data. Snapshot
|
|
40
|
+
requires stopped owners. Preserve original volumes and handle unknown outcomes
|
|
41
|
+
through the same operation ID before restarting any owner.
|
|
42
|
+
|
|
12
43
|
## 3.0.44 - Bounded operator JWKS recovery
|
|
13
44
|
|
|
14
45
|
When crossing `<3.0.44` to `>=3.0.44`, ordinary application handlers, standalone
|
package/README.md
CHANGED
|
@@ -29,6 +29,20 @@ npm install @uns-kit/core
|
|
|
29
29
|
- **MQTT helpers** — resilient publishers, topic builders, throttled queues, and handover support.
|
|
30
30
|
- **gRPC gateway** — infrastructure to bridge Python workers into the UNS message fabric.
|
|
31
31
|
|
|
32
|
+
## Optional retained stores (3.0.45)
|
|
33
|
+
|
|
34
|
+
Applications can explicitly declare stable opaque resources for reviewed
|
|
35
|
+
same-host adoption, offline backup and recovery. Import the shared contract from
|
|
36
|
+
`@uns-kit/core/runtime/retained-store.js`; hold its store lease before opening
|
|
37
|
+
payload writers. Ordinary applications retain their existing behavior.
|
|
38
|
+
|
|
39
|
+
Read the [bundled guide](dist/runtime/retained-store-guide.md) and manifests in
|
|
40
|
+
`dist/runtime/retained-store-examples/`, or the
|
|
41
|
+
[source guide](https://github.com/uns-openhub/uns-kit/blob/master/docs/retained-store-adoption.md).
|
|
42
|
+
Runtime mounts, Controller orchestration and helper releases must also support
|
|
43
|
+
the declared capability. The SDK version alone grants no permissions and does
|
|
44
|
+
not move data, enable queues or change the handover protocol.
|
|
45
|
+
|
|
32
46
|
## Basic usage
|
|
33
47
|
|
|
34
48
|
```ts
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type RetainedCopyFingerprint = { digest: string; bytes: number; entries: number };
|
|
2
|
+
export function inspectRetainedCopy(root: string, kind: "file" | "directory", limits: unknown): RetainedCopyFingerprint;
|
|
3
|
+
export function applyRetainedCopy(plan: unknown, direction: "adopt" | "return", limits: unknown, afterMove?: (id: string) => void, onFile?: (file: string) => void): void;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { safePath, owned, sha, sync, entry, relative, checkLimit } from "./retained-store-storage.mjs";
|
|
5
|
+
|
|
6
|
+
function inventory(root, kind, limits) {
|
|
7
|
+
checkLimit(limits);
|
|
8
|
+
const entries = [];
|
|
9
|
+
let bytes = 0;
|
|
10
|
+
function visit(file, rel, depth) {
|
|
11
|
+
if (depth > limits.maximumDepth || (rel && !relative(rel)) || entries.length >= limits.maximumEntries) throw Error("Retained copy path/count limit");
|
|
12
|
+
const stat = entry(file);
|
|
13
|
+
if (!stat) throw Error("Retained copy source is missing");
|
|
14
|
+
if (stat.isDirectory()) {
|
|
15
|
+
owned(file, true);
|
|
16
|
+
entries.push({ path: rel, type: "directory" });
|
|
17
|
+
for (const name of fs.readdirSync(file).sort()) visit(path.join(file, name), rel ? rel + "/" + name : name, depth + 1);
|
|
18
|
+
} else {
|
|
19
|
+
const value = owned(file);
|
|
20
|
+
bytes += value.size;
|
|
21
|
+
if (bytes > limits.maximumBytes || value.size > limits.maximumFileBytes) throw Error("Retained copy byte limit");
|
|
22
|
+
entries.push({ path: rel, type: "file", size: value.size, sha256: sha(file) });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
safePath(root);
|
|
26
|
+
if ((kind === "directory") !== !!entry(root)?.isDirectory()) throw Error("Retained copy resource kind differs");
|
|
27
|
+
visit(root, "", 0);
|
|
28
|
+
return { entries, fingerprint: { digest: createHash("sha256").update(JSON.stringify(entries)).digest("hex"), bytes, entries: entries.length } };
|
|
29
|
+
}
|
|
30
|
+
export function inspectRetainedCopy(root, kind, limits) { return inventory(root, kind, limits).fingerprint; }
|
|
31
|
+
function same(a, b) { return a?.digest === b?.digest && a?.bytes === b?.bytes && a?.entries === b?.entries; }
|
|
32
|
+
function assertFingerprint(file, move, limits) {
|
|
33
|
+
const stat = entry(file);
|
|
34
|
+
if (!stat || stat.dev !== move.device || stat.ino !== move.inode || !same(inspectRetainedCopy(file, move.kind, limits), move.content)) throw Error("Retained copy source changed after review");
|
|
35
|
+
}
|
|
36
|
+
function privateStage(stage) {
|
|
37
|
+
if (!entry(stage)) { fs.mkdirSync(stage, { mode: 0o700 }); sync(path.dirname(stage)); }
|
|
38
|
+
if (owned(stage, true).mode & 0o077) throw Error("Retained copy staging must be private");
|
|
39
|
+
}
|
|
40
|
+
function checkStage(stage, entries) {
|
|
41
|
+
const allowed = new Set(["payload", ...entries.map(e => e.path ? "payload/" + e.path : "payload")]);
|
|
42
|
+
function visit(dir, prefix) {
|
|
43
|
+
for (const name of fs.readdirSync(dir)) {
|
|
44
|
+
const rel = prefix ? prefix + "/" + name : name, file = path.join(dir, name);
|
|
45
|
+
if (!allowed.has(rel)) throw Error("Unexpected retained copy staging entry");
|
|
46
|
+
const stat = entry(file);
|
|
47
|
+
owned(file, !!stat?.isDirectory());
|
|
48
|
+
if (stat.isDirectory()) visit(file, rel);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
visit(stage, "");
|
|
52
|
+
}
|
|
53
|
+
function copyFile(source, destination, expected) {
|
|
54
|
+
if (entry(destination)) owned(destination);
|
|
55
|
+
const input = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
56
|
+
let output;
|
|
57
|
+
try {
|
|
58
|
+
output = fs.openSync(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW, 0o600);
|
|
59
|
+
const h = createHash("sha256"), buffer = Buffer.alloc(65536);
|
|
60
|
+
let count = 0, size;
|
|
61
|
+
while ((size = fs.readSync(input, buffer, 0, buffer.length, null))) {
|
|
62
|
+
count += size;
|
|
63
|
+
if (count > expected.size) throw Error("Retained copy source grew");
|
|
64
|
+
h.update(buffer.subarray(0, size));
|
|
65
|
+
let offset = 0;
|
|
66
|
+
while (offset < size) offset += fs.writeSync(output, buffer, offset, size - offset);
|
|
67
|
+
}
|
|
68
|
+
fs.fchmodSync(output, 0o600); fs.fsyncSync(output);
|
|
69
|
+
if (count !== expected.size || h.digest("hex") !== expected.sha256) throw Error("Retained copy source changed while reading");
|
|
70
|
+
} finally { fs.closeSync(input); if(output!==undefined)fs.closeSync(output); }
|
|
71
|
+
}
|
|
72
|
+
function copyClosed(source, destination, stage, move, limits, onFile) {
|
|
73
|
+
assertFingerprint(source, move, limits);
|
|
74
|
+
const snapshot = inventory(source, move.kind, limits);
|
|
75
|
+
if (entry(destination)) {
|
|
76
|
+
if (!same(inspectRetainedCopy(destination, move.kind, limits), move.content)) throw Error("Published retained copy differs; preserve for review");
|
|
77
|
+
if (entry(stage)) { checkStage(stage, snapshot.entries); fs.rmSync(stage, { recursive: true }); sync(path.dirname(stage)); }
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
privateStage(stage); checkStage(stage, snapshot.entries);
|
|
81
|
+
const payload = path.join(stage, "payload");
|
|
82
|
+
for (const item of snapshot.entries) {
|
|
83
|
+
const from = item.path ? path.join(source, item.path) : source, to = item.path ? path.join(payload, item.path) : payload;
|
|
84
|
+
if (item.type === "directory") {
|
|
85
|
+
if (!entry(to)) fs.mkdirSync(to, { mode: 0o700 });
|
|
86
|
+
if (owned(to, true).mode & 0o077) throw Error("Retained stage directory must be private");
|
|
87
|
+
} else {
|
|
88
|
+
if (entry(to) && owned(to).size === item.size && sha(to) === item.sha256) continue;
|
|
89
|
+
copyFile(from, to, item); onFile?.(item.path);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
assertFingerprint(source, move, limits);
|
|
93
|
+
if (!same(inspectRetainedCopy(payload, move.kind, limits), move.content)) throw Error("Retained staged copy differs");
|
|
94
|
+
for (const item of [...snapshot.entries].reverse()) if (item.type === "directory") sync(item.path ? path.join(payload, item.path) : payload);
|
|
95
|
+
fs.renameSync(payload, destination); sync(stage); sync(path.dirname(destination));
|
|
96
|
+
fs.rmdirSync(stage); sync(path.dirname(stage));
|
|
97
|
+
}
|
|
98
|
+
export function applyRetainedCopy(plan, direction, limits, afterMove, onFile) {
|
|
99
|
+
checkLimit(limits); safePath(plan.source); safePath(plan.directory);
|
|
100
|
+
for (const move of plan.moves) {
|
|
101
|
+
const legacy = path.join(plan.source, move.relative), stable = path.join(plan.directory, move.id);
|
|
102
|
+
const stage = direction === "adopt" ? path.join(plan.directory, ".retained-copy-" + move.id) : path.join(plan.source, ".retained-return-" + plan.storeId + "-" + move.id);
|
|
103
|
+
if (direction === "adopt") copyClosed(legacy, stable, stage, move, limits, onFile);
|
|
104
|
+
else {
|
|
105
|
+
assertFingerprint(stable, move, limits);
|
|
106
|
+
const original = plan.originalMoves?.find(m => m.id === move.id), saved = path.join(plan.source, ".retained-original-" + plan.storeId + "-" + move.id);
|
|
107
|
+
if (entry(legacy) && !same(inspectRetainedCopy(legacy, move.kind, limits), move.content)) {
|
|
108
|
+
if (!original || entry(saved)) throw Error("Return would overwrite unreviewed legacy work");
|
|
109
|
+
assertFingerprint(legacy, original, limits);
|
|
110
|
+
fs.renameSync(legacy, saved); sync(path.dirname(legacy));
|
|
111
|
+
}
|
|
112
|
+
if (entry(saved)) {
|
|
113
|
+
if (!original) throw Error("Unreviewed original retained copy");
|
|
114
|
+
assertFingerprint(saved, original, limits);
|
|
115
|
+
}
|
|
116
|
+
copyClosed(stable, legacy, stage, move, limits, onFile);
|
|
117
|
+
}
|
|
118
|
+
afterMove?.(move.id);
|
|
119
|
+
}
|
|
120
|
+
if (direction === "return") {
|
|
121
|
+
for (const original of plan.originalMoves ?? []) {
|
|
122
|
+
const stage = path.join(plan.directory, ".retained-copy-" + original.id);
|
|
123
|
+
if (!entry(stage)) continue;
|
|
124
|
+
const legacy = path.join(plan.source, original.relative);
|
|
125
|
+
if (!plan.moves.some(m => m.id === original.id)) assertFingerprint(legacy, original, limits);
|
|
126
|
+
const reference = plan.moves.some(m => m.id === original.id) ? path.join(plan.directory, original.id) : legacy;
|
|
127
|
+
const snapshot = inventory(reference, original.kind, limits);
|
|
128
|
+
checkStage(stage, snapshot.entries);
|
|
129
|
+
// Only incomplete scratch is discarded; all original bytes remain proven.
|
|
130
|
+
fs.rmSync(stage, { recursive: true }); sync(plan.directory);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"protocol": 1,
|
|
3
|
+
"format": "text-index-v1",
|
|
4
|
+
"resources": [
|
|
5
|
+
{
|
|
6
|
+
"id": "queue",
|
|
7
|
+
"path": "index-data",
|
|
8
|
+
"kind": "directory",
|
|
9
|
+
"required": true,
|
|
10
|
+
"backup": "payload"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "replay",
|
|
14
|
+
"path": "maintenance.json",
|
|
15
|
+
"kind": "file",
|
|
16
|
+
"required": false,
|
|
17
|
+
"backup": "metadata"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"legacyVersions": [],
|
|
21
|
+
"storageProtocol": 1
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"protocol": 1,
|
|
3
|
+
"format": "measurement-journal-v1",
|
|
4
|
+
"resources": [
|
|
5
|
+
{
|
|
6
|
+
"id": "queue",
|
|
7
|
+
"path": "pending-events",
|
|
8
|
+
"kind": "directory",
|
|
9
|
+
"required": true,
|
|
10
|
+
"backup": "payload"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "policy",
|
|
14
|
+
"path": "destination-policy.json",
|
|
15
|
+
"kind": "file",
|
|
16
|
+
"required": false,
|
|
17
|
+
"backup": "metadata"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"legacyVersions": ["1.0.0"],
|
|
21
|
+
"storageProtocol": 1
|
|
22
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Retained stores: opt-in same-host adoption
|
|
2
|
+
|
|
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.
|
|
6
|
+
This is a filesystem capability, separate from the small JSON
|
|
7
|
+
handover-state contract and MQTT output persistence. It does not change the
|
|
8
|
+
default handover protocol, enable disk queues, or transfer data between hosts.
|
|
9
|
+
|
|
10
|
+
## Ownership
|
|
11
|
+
|
|
12
|
+
The SDK owns the versioned manifest, binding, safe path validation and resumable
|
|
13
|
+
same-filesystem rename helpers. Controller owns authenticated review, launch
|
|
14
|
+
checks, reservations, durable operation state and managed lifecycle fences.
|
|
15
|
+
The application owns data schema compatibility, its exclusive writer/lease,
|
|
16
|
+
maintenance semantics, replay and database acknowledgements. Runtime/tools own
|
|
17
|
+
durable mounts and backup/restore coverage. No Controller package-name branches
|
|
18
|
+
or execution of application code are required.
|
|
19
|
+
|
|
20
|
+
Publish `retained-store.manifest.json` at the installed package root. Give each
|
|
21
|
+
opaque resource a stable ID, a relative legacy path, kind and required flag.
|
|
22
|
+
Declare a format only when both versions can safely open the same data. An
|
|
23
|
+
explicit legacy version list permits sources without a manifest; it is a
|
|
24
|
+
compatibility promise by the application, not an automatic schema conversion.
|
|
25
|
+
Keep keys, tokens and startup configuration outside resource declarations.
|
|
26
|
+
|
|
27
|
+
The two example manifests in `examples/retained-store/` deliberately describe
|
|
28
|
+
different applications/formats. Whole data directories include SQLite WAL/SHM
|
|
29
|
+
sidecars without the Controller reading SQLite or interpreting payloads.
|
|
30
|
+
The npm core package includes this guide at `dist/runtime/retained-store-guide.md`
|
|
31
|
+
and the same manifests at `dist/runtime/retained-store-examples/`.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import {
|
|
35
|
+
readRetainedStoreManifest,
|
|
36
|
+
resolveRetainedStoreResources,
|
|
37
|
+
openRetainedStoreLease,
|
|
38
|
+
} from '@uns-kit/core/runtime/retained-store.js';
|
|
39
|
+
|
|
40
|
+
const resolved = resolveRetainedStoreResources(instanceDirectory,
|
|
41
|
+
readRetainedStoreManifest(packageDirectory),
|
|
42
|
+
{ rttNode: packageName, version: packageVersion, instanceId });
|
|
43
|
+
const lease = resolved.binding ? openRetainedStoreLease(resolved.binding) : undefined;
|
|
44
|
+
// Now open resolved payload resources and acquire application database leases.
|
|
45
|
+
// Keep the shared lease until writers close; call lease?.close() on shutdown.
|
|
46
|
+
// An attached store enters application maintenance before replay.
|
|
47
|
+
const dataDirectory = resolved.resources.queue;
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A binding is private and names an opaque store UUID, generation, operation,
|
|
51
|
+
owner, reviewed configuration digest and stable directory. The shared store
|
|
52
|
+
metadata must match it exactly. A stale owner or fenced instance fails closed.
|
|
53
|
+
The helper does not acquire the application's database lock or authorize an
|
|
54
|
+
operator. Do not bypass either check with an application-specific fallback.
|
|
55
|
+
|
|
56
|
+
## Host procedure
|
|
57
|
+
|
|
58
|
+
1. Resolve installed package/instance IDs server-side. Require matching portable
|
|
59
|
+
configuration, compatible declarations, exact compiled artifact fingerprints,
|
|
60
|
+
an unoccupied target and one filesystem. Preview never creates storage.
|
|
61
|
+
2. Persist the reviewed selection/digest and, before moving, commit operation
|
|
62
|
+
intent. Require the reviewed source launch's clean physical exit according to
|
|
63
|
+
the supervisor; target must also be stopped. Reserve both managed instances.
|
|
64
|
+
3. Use `prepareRetainedStoreMove` and `applyRetainedStoreMove(plan, 'adopt')`.
|
|
65
|
+
Persist the complete plan before applying it. Moves are atomic per resource,
|
|
66
|
+
fsynced, inode/device pinned and resumable; there is no copy or overwrite
|
|
67
|
+
fallback. Several resources do not form one filesystem transaction.
|
|
68
|
+
4. Commit the binding/current owner and durable outcome before releasing the
|
|
69
|
+
receiving instance fence. Start only the new owner, initially in maintenance.
|
|
70
|
+
5. After stopping that owner, capture `prepareRetainedStoreReturn(plan)` and
|
|
71
|
+
persist return intent. Return the *current* resources, including new optional
|
|
72
|
+
or atomically replaced control files. Never restore an old queue snapshot.
|
|
73
|
+
|
|
74
|
+
Reconcile a timeout by reading/repeating the same operation UUID. A different
|
|
75
|
+
request is not a substitute for recovery. Keep the old-side fence after success.
|
|
76
|
+
A direct manual launch of an older binary outside Controller can ignore a fence;
|
|
77
|
+
this protocol protects managed lifecycle and participating SDK consumers.
|
|
78
|
+
|
|
79
|
+
## Deliberate limits
|
|
80
|
+
|
|
81
|
+
POSIX same-user, same-filesystem storage only. No cross-host transport, resource
|
|
82
|
+
merging, schema conversion, automatic deletion, retention finalization, or
|
|
83
|
+
exactly-once destination guarantee. Unknown write acknowledgements keep their
|
|
84
|
+
existing application semantics. Launches, configurations and artifacts changed
|
|
85
|
+
after review are rejected. An absent target requires the existing installation
|
|
86
|
+
workflow first.
|
|
87
|
+
|
|
88
|
+
Before production release, Runtime and backup tools must explicitly cover the
|
|
89
|
+
stable root, including a queue-excluding backup policy and full restore proof.
|
|
90
|
+
Moving a directory outside the previous archive layout is not backup coverage.
|
|
91
|
+
Consumers must be checked again against published, versioned registry packages;
|
|
92
|
+
a local file tarball is development evidence only.
|
|
93
|
+
|
|
94
|
+
## Runtime storage and backup extension
|
|
95
|
+
|
|
96
|
+
A bound application must now explicitly declare `storageProtocol: 1` in its
|
|
97
|
+
manifest and acquire `openRetainedStoreLease(binding)` before opening payloads.
|
|
98
|
+
Keep that handle until final writer shutdown and close it then; the SDK also pins
|
|
99
|
+
live leases process-wide, bounded to32. This is a participation contract, not an
|
|
100
|
+
inference from an installed SDK version. Older unbound applications keep their
|
|
101
|
+
legacy behavior. Bound sources/targets lacking the declaration are rejected by
|
|
102
|
+
managed startup/adoption instead of being guessed safe for offline capture.
|
|
103
|
+
The storage program has been tested on Node 24 with POSIX local filesystems;
|
|
104
|
+
application payload formats remain opaque to it.
|
|
105
|
+
|
|
106
|
+
The trusted Runtime root is `UNS_RETAINED_STORE_ROOT`; managed adoption requires
|
|
107
|
+
it, and an existing binding must match it exactly. No automatic rebasing of old
|
|
108
|
+
absolute bindings is performed. `store-resources.json` records the declaration,
|
|
109
|
+
including optional per-resource `backup: "payload" | "metadata"` (default payload).
|
|
110
|
+
An incomplete store operation or root restore blocks startup. A returned legacy
|
|
111
|
+
layout leaves a validated empty-store retirement marker, rather than making old
|
|
112
|
+
bindings appear live.
|
|
113
|
+
|
|
114
|
+
The shared `retained-store-storage.mjs` program supplies leases, offline capture
|
|
115
|
+
and retryable restoration without importing application modules. Runtime tools
|
|
116
|
+
vendor its exact bytes/limits with provenance. Capture requires all bound owners
|
|
117
|
+
stopped; output/input journals retain their existing distinct online protocols.
|
|
118
|
+
The full RTT backup includes the extra capsule inside its existing signed,
|
|
119
|
+
age-encrypted payload. Queue-excluding capture reports excluded payloads and
|
|
120
|
+
cannot be used as a complete queue restore. Restore retains store/owner/generation
|
|
121
|
+
identity at the same logical root on another offline volume, and leaves an applying
|
|
122
|
+
fence until complete. Known private partial staging files may be re-created only
|
|
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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function rootLease(root: string, exclusive?: boolean, restoring?: boolean): () => void;
|
|
2
|
+
export function maintenanceLease(directory: string): () => void;
|
|
3
|
+
export function runtimeLease(binding: unknown): { close: () => void };
|
|
4
|
+
export function captureRetained(
|
|
5
|
+
root: string,
|
|
6
|
+
destination: string,
|
|
7
|
+
mode: string,
|
|
8
|
+
limits: unknown,
|
|
9
|
+
): { state: string; stores: number; files: number; payloadBytes: number };
|
|
10
|
+
export function restoreRetained(
|
|
11
|
+
source: string,
|
|
12
|
+
root: string,
|
|
13
|
+
backupDigest: string,
|
|
14
|
+
limits: unknown,
|
|
15
|
+
afterPublish?: (id: string) => void,
|
|
16
|
+
): { state: string; stores: number };
|
|
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};
|