@openlfcp/storage 0.1.0-rc.1
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/LICENSE +201 -0
- package/README.md +27 -0
- package/dist/contract.d.ts +30 -0
- package/dist/contract.js +513 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/memory.d.ts +69 -0
- package/dist/memory.js +367 -0
- package/dist/secrets.d.ts +53 -0
- package/dist/secrets.js +60 -0
- package/dist/sequence.d.ts +55 -0
- package/dist/sequence.js +55 -0
- package/dist/snapshot-sequence.d.ts +39 -0
- package/dist/snapshot-sequence.js +34 -0
- package/dist/store.d.ts +424 -0
- package/dist/store.js +2 -0
- package/package.json +50 -0
package/dist/contract.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { actorSequence, bytesEqual, controlRecordId, dataEpoch, dataUnitId, hash32, LfcpError, principalId, resourceId, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { dekSecretRef, isSecretRef, principalKeySecretRef } from "./secrets.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// assertions (no test framework)
|
|
5
|
+
/** A canonical rendering: bigints and bytes spelled out, object keys sorted (rows from any adapter compare equal). */
|
|
6
|
+
const show = (v) => JSON.stringify(v, (_k, x) => {
|
|
7
|
+
if (typeof x === "bigint")
|
|
8
|
+
return `${x}n`;
|
|
9
|
+
if (x instanceof Uint8Array)
|
|
10
|
+
return `0x${toHex(x)}`;
|
|
11
|
+
if (x !== null && typeof x === "object" && !Array.isArray(x))
|
|
12
|
+
return Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
13
|
+
return x;
|
|
14
|
+
});
|
|
15
|
+
function eq(actual, expected, what) {
|
|
16
|
+
const [a, e] = [show(actual), show(expected)];
|
|
17
|
+
if (a !== e)
|
|
18
|
+
throw new Error(`${what}: expected ${e}, got ${a}`);
|
|
19
|
+
}
|
|
20
|
+
async function rejectsWith(p, code, what) {
|
|
21
|
+
try {
|
|
22
|
+
await p;
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof LfcpError && e.code === code)
|
|
26
|
+
return;
|
|
27
|
+
throw new Error(`${what}: expected ${code}, got ${String(e)}`);
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`${what}: expected ${code}, but it resolved`);
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// fixtures
|
|
33
|
+
const id = (b) => new Uint8Array(32).fill(b);
|
|
34
|
+
const R = resourceId(id(1));
|
|
35
|
+
const R2 = resourceId(id(2));
|
|
36
|
+
const ALICE = principalId(id(10));
|
|
37
|
+
const BOB = principalId(id(11));
|
|
38
|
+
const E0 = dataEpoch(0n);
|
|
39
|
+
const E1 = dataEpoch(1n);
|
|
40
|
+
const record = (n, prev) => ({
|
|
41
|
+
recordId: controlRecordId(id(100 + n)),
|
|
42
|
+
resourceId: R,
|
|
43
|
+
controlSeq: BigInt(n),
|
|
44
|
+
prevControlId: prev === null ? null : controlRecordId(id(100 + prev)),
|
|
45
|
+
bytes: Uint8Array.of(0xd2, n, 0, 0xff, 0x80, 1, 2, 3),
|
|
46
|
+
});
|
|
47
|
+
const unit = (n, seq, actor = ALICE, prev = null) => ({
|
|
48
|
+
unitId: dataUnitId(id(150 + n)),
|
|
49
|
+
resourceId: R,
|
|
50
|
+
dataEpoch: E0,
|
|
51
|
+
actor,
|
|
52
|
+
actorSeq: actorSequence(seq),
|
|
53
|
+
prevDataUnitId: prev === null ? null : dataUnitId(id(150 + prev)),
|
|
54
|
+
controlHead: controlRecordId(id(100)),
|
|
55
|
+
bytes: Uint8Array.of(0xd2, 0x84, n, Number(seq), 0, 0xff),
|
|
56
|
+
});
|
|
57
|
+
const keyPackage = (n, epoch = E0, recipient = BOB) => ({
|
|
58
|
+
packageId: hash32(id(200 + n)),
|
|
59
|
+
resourceId: R,
|
|
60
|
+
dataEpoch: epoch,
|
|
61
|
+
recipient,
|
|
62
|
+
sender: ALICE,
|
|
63
|
+
bytes: Uint8Array.of(0xd2, 0x25, n, 0),
|
|
64
|
+
});
|
|
65
|
+
const snapshot = {
|
|
66
|
+
snapshotId: hash32(id(210)),
|
|
67
|
+
resourceId: R,
|
|
68
|
+
dataEpoch: E1,
|
|
69
|
+
publisher: ALICE,
|
|
70
|
+
snapshotSeq: 3n,
|
|
71
|
+
frontier: Uint8Array.of(0x81, 0x83),
|
|
72
|
+
bytes: Uint8Array.of(0xd2, 0x29, 0),
|
|
73
|
+
};
|
|
74
|
+
const route = {
|
|
75
|
+
routeVersion: 2n,
|
|
76
|
+
endpoints: [
|
|
77
|
+
{ url: "wss://a.example.test", priority: 0n },
|
|
78
|
+
{ url: "wss://b.example.test", priority: 1n, flags: 0n },
|
|
79
|
+
],
|
|
80
|
+
coordinatorUrl: "wss://a.example.test",
|
|
81
|
+
source: controlRecordId(id(103)),
|
|
82
|
+
};
|
|
83
|
+
const resourceRow = {
|
|
84
|
+
resourceId: R,
|
|
85
|
+
dataProfile: "org.openlfcp.shared-objects.v1",
|
|
86
|
+
localPrincipal: {
|
|
87
|
+
principalId: ALICE,
|
|
88
|
+
signingKeyRef: principalKeySecretRef(ALICE, "signing"),
|
|
89
|
+
agreementKeyRef: principalKeySecretRef(ALICE, "agreement"),
|
|
90
|
+
},
|
|
91
|
+
labels: { name: "Project Alpha" },
|
|
92
|
+
};
|
|
93
|
+
const checkpoint = {
|
|
94
|
+
resourceId: R,
|
|
95
|
+
dataProfile: resourceRow.dataProfile,
|
|
96
|
+
state: Uint8Array.of(0x85, 0x6f, 0x4a, 0x83, 0),
|
|
97
|
+
actorSeq: 4,
|
|
98
|
+
units: [{ unitId: dataUnitId(id(151)), ref: "abc" }],
|
|
99
|
+
};
|
|
100
|
+
const epochRows = [
|
|
101
|
+
{
|
|
102
|
+
epoch: E0,
|
|
103
|
+
dekCommitment: hash32(id(30)),
|
|
104
|
+
openedBy: controlRecordId(id(100)),
|
|
105
|
+
closedBy: controlRecordId(id(103)),
|
|
106
|
+
dekRef: null,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
epoch: E1,
|
|
110
|
+
dekCommitment: hash32(id(31)),
|
|
111
|
+
openedBy: controlRecordId(id(103)),
|
|
112
|
+
closedBy: null,
|
|
113
|
+
dekRef: dekSecretRef(R, E1),
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
async function ok(s, writes, what) {
|
|
117
|
+
eq(await s.commit(writes), { ok: true }, what);
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
export function runStorageContract(t, name, open) {
|
|
121
|
+
const test = (title, fn) => t.it(title, async () => {
|
|
122
|
+
const h = await open();
|
|
123
|
+
try {
|
|
124
|
+
await fn(h);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await h.close();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
t.describe(`LfcpStorage contract: ${name}`, () => {
|
|
131
|
+
test("keeps Control Records' exact bytes and sets the head by compare-and-set", async ({ storage: s, }) => {
|
|
132
|
+
const genesis = record(0, null);
|
|
133
|
+
const grant = record(1, 0);
|
|
134
|
+
await ok(s, [
|
|
135
|
+
{ op: "put-control-records", records: [grant, genesis] },
|
|
136
|
+
{
|
|
137
|
+
op: "set-control-head",
|
|
138
|
+
resourceId: R,
|
|
139
|
+
expected: null,
|
|
140
|
+
head: { head: genesis.recordId, controlSeq: 0n },
|
|
141
|
+
},
|
|
142
|
+
], "first batch");
|
|
143
|
+
eq(await s.control.record(genesis.recordId), genesis, "record");
|
|
144
|
+
eq((await s.control.records(R)).map((r) => r.controlSeq), [0n, 1n], "records in order");
|
|
145
|
+
eq(await s.control.head(R), { head: genesis.recordId, controlSeq: 0n }, "head");
|
|
146
|
+
const stale = await s.commit([
|
|
147
|
+
{ op: "put-control-records", records: [record(2, 1)] },
|
|
148
|
+
{
|
|
149
|
+
op: "set-control-head",
|
|
150
|
+
resourceId: R,
|
|
151
|
+
expected: null,
|
|
152
|
+
head: { head: grant.recordId, controlSeq: 1n },
|
|
153
|
+
},
|
|
154
|
+
]);
|
|
155
|
+
eq(stale, { ok: false, reason: "CONTROL_HEAD_MISMATCH", resourceId: R, current: genesis.recordId }, "stale head");
|
|
156
|
+
eq((await s.control.records(R)).length, 2, "nothing of the failed batch written");
|
|
157
|
+
await ok(s, [
|
|
158
|
+
{
|
|
159
|
+
op: "set-control-head",
|
|
160
|
+
resourceId: R,
|
|
161
|
+
expected: genesis.recordId,
|
|
162
|
+
head: { head: grant.recordId, controlSeq: 1n },
|
|
163
|
+
},
|
|
164
|
+
], "advance");
|
|
165
|
+
eq((await s.control.head(R))?.controlSeq, 1n, "advanced");
|
|
166
|
+
await rejectsWith(s.commit([
|
|
167
|
+
{ op: "put-control-records", records: [record(5, 4)] },
|
|
168
|
+
{ op: "put-control-records", records: [{ ...genesis, bytes: Uint8Array.of(9) }] },
|
|
169
|
+
]), "INVALID_STRUCTURE", "same ID, other bytes");
|
|
170
|
+
eq(await s.control.record(genesis.recordId), genesis, "unchanged");
|
|
171
|
+
eq(await s.control.record(record(5, 4).recordId), undefined, "nothing of the refused batch written");
|
|
172
|
+
eq(await s.control.records(R2), [], "other Resource");
|
|
173
|
+
});
|
|
174
|
+
test("keeps a Control conflict and the epoch history with DEK references", async ({ storage: s, }) => {
|
|
175
|
+
const heads = [controlRecordId(id(120)), controlRecordId(id(121))];
|
|
176
|
+
await ok(s, [
|
|
177
|
+
{ op: "set-control-conflict", resourceId: R, conflict: { heads } },
|
|
178
|
+
{ op: "put-epoch", resourceId: R, epoch: epochRows[1] },
|
|
179
|
+
{ op: "put-epoch", resourceId: R, epoch: epochRows[0] },
|
|
180
|
+
], "conflict and epochs");
|
|
181
|
+
eq(await s.control.conflict(R), { heads }, "conflict");
|
|
182
|
+
eq(await s.control.epochs(R), epochRows, "epochs ascending");
|
|
183
|
+
await ok(s, [{ op: "set-control-conflict", resourceId: R, conflict: null }], "clear");
|
|
184
|
+
eq(await s.control.conflict(R), undefined, "cleared");
|
|
185
|
+
});
|
|
186
|
+
test("never clears a stored DEK reference with a null one (a chain save after a Key Package)", async ({ storage: s, }) => {
|
|
187
|
+
await ok(s, [{ op: "put-epoch", resourceId: R, epoch: epochRows[1] }], "with ref");
|
|
188
|
+
// The same epoch rewritten by a chain save that read the row before the reference existed.
|
|
189
|
+
const closed = {
|
|
190
|
+
...epochRows[1],
|
|
191
|
+
closedBy: controlRecordId(id(104)),
|
|
192
|
+
dekRef: null,
|
|
193
|
+
};
|
|
194
|
+
await ok(s, [{ op: "put-epoch", resourceId: R, epoch: closed }], "null ref");
|
|
195
|
+
eq(await s.control.epochs(R), [{ ...closed, dekRef: dekSecretRef(R, E1) }], "ref kept, row updated");
|
|
196
|
+
});
|
|
197
|
+
test("keeps local marks, lists them by prefix and removes them with null", async ({ storage: s, }) => {
|
|
198
|
+
eq(await s.localMarks.get("applying:a"), undefined, "none yet");
|
|
199
|
+
await ok(s, [
|
|
200
|
+
{ op: "put-local-mark", key: "applying:b", value: "2" },
|
|
201
|
+
{ op: "put-local-mark", key: "applying:a", value: "1" },
|
|
202
|
+
{ op: "put-local-mark", key: "other", value: "x" },
|
|
203
|
+
], "set");
|
|
204
|
+
eq(await s.localMarks.get("applying:a"), "1", "get");
|
|
205
|
+
eq(await s.localMarks.list("applying:"), [
|
|
206
|
+
{ key: "applying:a", value: "1" },
|
|
207
|
+
{ key: "applying:b", value: "2" },
|
|
208
|
+
], "by prefix, by key");
|
|
209
|
+
await ok(s, [{ op: "put-local-mark", key: "applying:a", value: null }], "removed");
|
|
210
|
+
eq(await s.localMarks.get("applying:a"), undefined, "gone");
|
|
211
|
+
eq((await s.localMarks.list("")).length, 2, "the others stay");
|
|
212
|
+
});
|
|
213
|
+
test("refuses a local unit whose previous unit is no longer the actor's latest accepted one", async ({ storage: s, }) => {
|
|
214
|
+
const expect = (previous, actor = ALICE) => ({
|
|
215
|
+
op: "expect-previous-unit",
|
|
216
|
+
resourceId: R,
|
|
217
|
+
actor,
|
|
218
|
+
previous: previous === null ? null : dataUnitId(id(150 + previous)),
|
|
219
|
+
});
|
|
220
|
+
const put = (u, accepted) => ({ op: "put-data-unit", unit: u, status: "merged", accepted });
|
|
221
|
+
await ok(s, [expect(null), put(unit(1, 1n), true)], "first unit, none before");
|
|
222
|
+
// Units of another actor, and units not accepted, do not count.
|
|
223
|
+
await ok(s, [put(unit(9, 1n, BOB), true), put(unit(3, 3n), false)], "others");
|
|
224
|
+
await ok(s, [expect(1), put(unit(2, 2n, ALICE, 1), true)], "names the latest");
|
|
225
|
+
// A second writer that read unit 1 as the latest: nothing is stored.
|
|
226
|
+
eq(await s.commit([expect(1), put(unit(4, 4n, ALICE, 1), true)]), {
|
|
227
|
+
ok: false,
|
|
228
|
+
reason: "PREVIOUS_UNIT_MISMATCH",
|
|
229
|
+
resourceId: R,
|
|
230
|
+
actor: ALICE,
|
|
231
|
+
current: dataUnitId(id(152)),
|
|
232
|
+
}, "superseded");
|
|
233
|
+
eq(await s.dataUnits.get(dataUnitId(id(154))), undefined, "nothing written");
|
|
234
|
+
eq(await s.commit([expect(null, BOB)]), {
|
|
235
|
+
ok: false,
|
|
236
|
+
reason: "PREVIOUS_UNIT_MISMATCH",
|
|
237
|
+
resourceId: R,
|
|
238
|
+
actor: BOB,
|
|
239
|
+
current: dataUnitId(id(159)),
|
|
240
|
+
}, "null after an accepted unit");
|
|
241
|
+
});
|
|
242
|
+
test("never reopens a closed epoch with a null closedBy (a DEK reference from an older row)", async ({ storage: s, }) => {
|
|
243
|
+
const open = { ...epochRows[1], dekRef: null };
|
|
244
|
+
await ok(s, [{ op: "put-epoch", resourceId: R, epoch: open }], "open");
|
|
245
|
+
const closedBy = controlRecordId(id(104));
|
|
246
|
+
await ok(s, [{ op: "put-epoch", resourceId: R, epoch: { ...open, closedBy } }], "closed");
|
|
247
|
+
// A Key Package's DEK reference, written from the row read before the close.
|
|
248
|
+
await ok(s, [{ op: "put-epoch", resourceId: R, epoch: { ...open, dekRef: dekSecretRef(R, E1) } }], "ref from the older row");
|
|
249
|
+
eq(await s.control.epochs(R), [{ ...open, closedBy, dekRef: dekSecretRef(R, E1) }], "still closed, ref stored");
|
|
250
|
+
});
|
|
251
|
+
test("keeps Data Unit bytes, treats a repeat as a duplicate and exposes an equivocating pair", async ({ storage: s, }) => {
|
|
252
|
+
const a = unit(1, 1n);
|
|
253
|
+
eq(await s.dataUnits.recordSeen(a), { unitIds: [a.unitId], firstSeen: true }, "first");
|
|
254
|
+
eq(await s.dataUnits.recordSeen(a), { unitIds: [a.unitId], firstSeen: false }, "repeat");
|
|
255
|
+
const stored = await s.dataUnits.get(a.unitId);
|
|
256
|
+
eq([stored?.bytes, stored?.status, stored?.accepted, stored?.detail], [a.bytes, "seen", false, null], "stored");
|
|
257
|
+
eq(stored?.actorSeq, 1n, "header index");
|
|
258
|
+
const twin = unit(2, 1n);
|
|
259
|
+
eq((await s.dataUnits.recordSeen(twin)).unitIds.map(toHex), [a.unitId, twin.unitId].map(toHex), "both IDs at the tuple");
|
|
260
|
+
eq((await s.dataUnits.at(R, ALICE, actorSequence(1n))).length, 2, "at");
|
|
261
|
+
await rejectsWith(s.dataUnits.recordSeen({ ...a, bytes: Uint8Array.of(1) }), "INVALID_STRUCTURE", "other bytes");
|
|
262
|
+
});
|
|
263
|
+
test("marks, finds and un-accepts accepted units, and indexes them by status and range", async ({ storage: s, }) => {
|
|
264
|
+
const [u1, u2, u3] = [unit(1, 1n), unit(2, 2n, ALICE, 1), unit(3, 3n, ALICE, 2)];
|
|
265
|
+
await ok(s, [
|
|
266
|
+
{ op: "put-data-unit", unit: u1, status: "merged", accepted: true },
|
|
267
|
+
{ op: "put-data-unit", unit: u2, status: "merged", accepted: true },
|
|
268
|
+
{ op: "put-data-unit", unit: u3, status: "held", detail: "GAP" },
|
|
269
|
+
{ op: "put-data-unit", unit: unit(4, 1n, BOB), status: "merged" },
|
|
270
|
+
], "units");
|
|
271
|
+
eq(await s.dataUnits.acceptedAt(R, ALICE, actorSequence(2n)), u2.unitId, "acceptedAt");
|
|
272
|
+
eq((await s.dataUnits.withStatus(R, "held")).map((u) => u.detail), ["GAP"], "withStatus");
|
|
273
|
+
eq((await s.dataUnits.range(R, ALICE, actorSequence(2n), actorSequence(3n))).map((u) => u.actorSeq), [2n, 3n], "range");
|
|
274
|
+
eq((await s.dataUnits.withStatus(R, "merged")).length, 3, "merged");
|
|
275
|
+
await ok(s, [
|
|
276
|
+
{ op: "set-accepted", unitId: u2.unitId, accepted: false },
|
|
277
|
+
{
|
|
278
|
+
op: "set-data-unit-status",
|
|
279
|
+
unitId: u2.unitId,
|
|
280
|
+
status: "quarantined",
|
|
281
|
+
detail: "BEYOND_CUTOFF",
|
|
282
|
+
},
|
|
283
|
+
], "un-accept");
|
|
284
|
+
eq(await s.dataUnits.acceptedAt(R, ALICE, actorSequence(2n)), undefined, "un-accepted");
|
|
285
|
+
const u2s = await s.dataUnits.get(u2.unitId);
|
|
286
|
+
eq([u2s?.status, u2s?.accepted, u2s?.detail, u2s?.bytes], ["quarantined", false, "BEYOND_CUTOFF", u2.bytes], "status kept bytes");
|
|
287
|
+
// put-data-unit of a stored unit keeps its accepted mark unless given.
|
|
288
|
+
await ok(s, [{ op: "put-data-unit", unit: u1, status: "profile-pending" }], "status only");
|
|
289
|
+
eq((await s.dataUnits.get(u1.unitId))?.accepted, true, "accepted kept");
|
|
290
|
+
await rejectsWith(s.commit([
|
|
291
|
+
{ op: "set-data-unit-status", unitId: u1.unitId, status: "equivocation" },
|
|
292
|
+
{ op: "set-accepted", unitId: dataUnitId(id(99)), accepted: false },
|
|
293
|
+
]), "INVALID_STRUCTURE", "unknown unit");
|
|
294
|
+
eq((await s.dataUnits.get(u1.unitId))?.status, "profile-pending", "nothing of the refused batch written");
|
|
295
|
+
});
|
|
296
|
+
test("keeps several Key Packages for one (resource, epoch, recipient)", async ({ storage: s, }) => {
|
|
297
|
+
await ok(s, [1, 2, 3].map((n) => ({ op: "put-key-package", row: keyPackage(n) })), "three");
|
|
298
|
+
await ok(s, [
|
|
299
|
+
{ op: "put-key-package", row: keyPackage(4, E1) },
|
|
300
|
+
{ op: "put-key-package", row: keyPackage(5, E0, ALICE) },
|
|
301
|
+
{ op: "put-key-package", row: keyPackage(1) },
|
|
302
|
+
], "more, one repeated");
|
|
303
|
+
eq((await s.keyPackages.list(R, { epoch: E0, recipient: BOB })).length, 3, "per recipient");
|
|
304
|
+
eq(await s.keyPackages.list(R, { epoch: E1 }), [keyPackage(4, E1)], "per epoch");
|
|
305
|
+
eq((await s.keyPackages.list(R)).length, 5, "all");
|
|
306
|
+
eq(await s.keyPackages.get(keyPackage(2).packageId), keyPackage(2), "get");
|
|
307
|
+
eq(await s.keyPackages.list(R2), [], "other Resource");
|
|
308
|
+
});
|
|
309
|
+
test("keeps Snapshot bytes and their selection metadata", async ({ storage: s }) => {
|
|
310
|
+
await ok(s, [{ op: "put-snapshot", row: snapshot }], "snapshot");
|
|
311
|
+
eq(await s.snapshots.get(snapshot.snapshotId), snapshot, "get");
|
|
312
|
+
eq(await s.snapshots.list(R, { epoch: E1 }), [snapshot], "list");
|
|
313
|
+
eq(await s.snapshots.list(R, { epoch: E0 }), [], "other epoch");
|
|
314
|
+
await ok(s, [{ op: "delete-snapshot", snapshotId: snapshot.snapshotId }], "delete");
|
|
315
|
+
eq(await s.snapshots.get(snapshot.snapshotId), undefined, "deleted");
|
|
316
|
+
await ok(s, [{ op: "delete-snapshot", snapshotId: snapshot.snapshotId }], "deleting again is harmless");
|
|
317
|
+
});
|
|
318
|
+
test("keeps routes, Resource metadata, outbound items and profile checkpoints", async ({ storage: s, }) => {
|
|
319
|
+
const item = {
|
|
320
|
+
itemId: hash32(id(160)),
|
|
321
|
+
resourceId: R,
|
|
322
|
+
kind: "data-unit",
|
|
323
|
+
bytes: Uint8Array.of(7, 0),
|
|
324
|
+
attempts: 0,
|
|
325
|
+
lastAttempt: null,
|
|
326
|
+
nextAttempt: null,
|
|
327
|
+
blocked: null,
|
|
328
|
+
};
|
|
329
|
+
const second = { ...item, itemId: hash32(id(161)), kind: "key-package" };
|
|
330
|
+
const third = { ...item, itemId: hash32(id(159)), resourceId: R2 };
|
|
331
|
+
await ok(s, [
|
|
332
|
+
{ op: "put-route", resourceId: R, route },
|
|
333
|
+
{ op: "put-resource", row: resourceRow },
|
|
334
|
+
{ op: "enqueue", item },
|
|
335
|
+
{ op: "enqueue", item: second },
|
|
336
|
+
{ op: "enqueue", item: third },
|
|
337
|
+
{ op: "put-profile-checkpoint", checkpoint },
|
|
338
|
+
], "batch");
|
|
339
|
+
eq(await s.resources.route(R), route, "route");
|
|
340
|
+
eq(await s.resources.get(R), resourceRow, "resource");
|
|
341
|
+
eq(await s.resources.list(), [resourceRow], "resources");
|
|
342
|
+
eq((await s.outbound.list(R)).map((o) => o.itemId), [item.itemId, second.itemId], "enqueue order");
|
|
343
|
+
eq((await s.outbound.list()).length, 3, "all outbound");
|
|
344
|
+
await ok(s, [
|
|
345
|
+
{
|
|
346
|
+
op: "update-outbound",
|
|
347
|
+
itemId: item.itemId,
|
|
348
|
+
attempts: 1,
|
|
349
|
+
lastAttempt: "2026-10-05T12:00:00Z",
|
|
350
|
+
nextAttempt: "2026-10-05T12:00:30Z",
|
|
351
|
+
},
|
|
352
|
+
{ op: "dequeue", itemId: second.itemId },
|
|
353
|
+
{
|
|
354
|
+
op: "update-outbound",
|
|
355
|
+
itemId: third.itemId,
|
|
356
|
+
blocked: { reason: "stale-epoch", detail: "BEYOND_CUTOFF" },
|
|
357
|
+
},
|
|
358
|
+
], "attempt, block and dequeue");
|
|
359
|
+
eq(await s.outbound.list(R), [
|
|
360
|
+
{
|
|
361
|
+
...item,
|
|
362
|
+
attempts: 1,
|
|
363
|
+
lastAttempt: "2026-10-05T12:00:00Z",
|
|
364
|
+
nextAttempt: "2026-10-05T12:00:30Z",
|
|
365
|
+
},
|
|
366
|
+
], "after");
|
|
367
|
+
eq(await s.outbound.get(third.itemId), { ...third, blocked: { reason: "stale-epoch", detail: "BEYOND_CUTOFF" } }, "blocked, other fields kept");
|
|
368
|
+
await ok(s, [{ op: "update-outbound", itemId: third.itemId, blocked: null, nextAttempt: null }], "unblock");
|
|
369
|
+
eq((await s.outbound.get(third.itemId))?.blocked, null, "unblocked");
|
|
370
|
+
await rejectsWith(s.commit([{ op: "update-outbound", itemId: hash32(id(170)), attempts: 1 }]), "INVALID_STRUCTURE", "unknown item");
|
|
371
|
+
const sync = {
|
|
372
|
+
resourceId: R,
|
|
373
|
+
recentlyAcked: [hash32(id(161)), hash32(id(160))],
|
|
374
|
+
ackedDurability: 2n,
|
|
375
|
+
};
|
|
376
|
+
eq(await s.syncState.get(R), undefined, "no sync state yet");
|
|
377
|
+
await ok(s, [{ op: "put-sync-state", row: sync }], "sync state");
|
|
378
|
+
eq(await s.syncState.get(R), sync, "sync state");
|
|
379
|
+
await ok(s, [{ op: "put-sync-state", row: { ...sync, recentlyAcked: [], ackedDurability: null } }], "replace");
|
|
380
|
+
eq(await s.syncState.get(R), { ...sync, recentlyAcked: [], ackedDurability: null }, "replaced");
|
|
381
|
+
eq(await s.profileState.checkpoint(R), checkpoint, "checkpoint");
|
|
382
|
+
await ok(s, [{ op: "put-profile-checkpoint", checkpoint: { ...checkpoint, actorSeq: 5 } }], "replace");
|
|
383
|
+
eq((await s.profileState.checkpoint(R))?.actorSeq, 5, "replaced");
|
|
384
|
+
});
|
|
385
|
+
test("reserves actor and Snapshot sequences without ever repeating one", async ({ storage: s, }) => {
|
|
386
|
+
const all = await Promise.all([1, 2, 3, 4].map(() => s.actorSequences.reserveNext(R, ALICE)));
|
|
387
|
+
eq([...all].sort(), [1n, 2n, 3n, 4n], "concurrent reservations");
|
|
388
|
+
eq(await s.actorSequences.reserveNext(R, BOB), 1n, "per principal");
|
|
389
|
+
eq(await s.actorSequences.reserveNext(R2, ALICE), 1n, "per resource");
|
|
390
|
+
eq(await s.snapshotSequences.reserveNext(R, E1, ALICE), 1n, "snapshot");
|
|
391
|
+
eq(await s.snapshotSequences.reserveNext(R, E1, ALICE), 2n, "snapshot next");
|
|
392
|
+
eq(await s.snapshotSequences.reserveNext(R, E0, ALICE), 1n, "per epoch");
|
|
393
|
+
});
|
|
394
|
+
test("fails closed when a reservation counter is behind the Principal's own stored objects", async ({ storage: s, }) => {
|
|
395
|
+
// Units of ALICE up to sequence 3 exist, but no reservation was recorded:
|
|
396
|
+
// lost or damaged sequence state. Reserving must refuse, not hand out 1.
|
|
397
|
+
await ok(s, [{ op: "put-data-unit", unit: unit(3, 3n), status: "merged", accepted: true }], "unit at 3");
|
|
398
|
+
await rejectsWith(s.actorSequences.reserveNext(R, ALICE), "SEQUENCE_REUSE", "actor sequence behind");
|
|
399
|
+
eq(await s.actorSequences.reserveNext(R, BOB), 1n, "another Principal is unaffected");
|
|
400
|
+
await ok(s, [{ op: "put-snapshot", row: snapshot }], "Snapshot at sequence 3");
|
|
401
|
+
await rejectsWith(s.snapshotSequences.reserveNext(R, E1, ALICE), "SEQUENCE_REUSE", "Snapshot sequence behind");
|
|
402
|
+
eq(await s.snapshotSequences.reserveNext(R, E0, ALICE), 1n, "another epoch is unaffected");
|
|
403
|
+
});
|
|
404
|
+
test("no caller buffer aliases a stored one, in or out", async ({ storage: s }) => {
|
|
405
|
+
const u = unit(1, 1n);
|
|
406
|
+
const bytes = u.bytes;
|
|
407
|
+
await ok(s, [{ op: "put-data-unit", unit: u, status: "merged", accepted: true }], "unit");
|
|
408
|
+
bytes[0] = 0xff;
|
|
409
|
+
u.unitId[0] = 0;
|
|
410
|
+
const read = await s.dataUnits.get(dataUnitId(id(151)));
|
|
411
|
+
eq(read?.bytes[0], 0xd2, "write side");
|
|
412
|
+
(read?.bytes)[0] = 0xee;
|
|
413
|
+
eq((await s.dataUnits.get(dataUnitId(id(151))))?.bytes[0], 0xd2, "read side");
|
|
414
|
+
});
|
|
415
|
+
test("gives the same reads for the same writes", async ({ storage: s }) => {
|
|
416
|
+
await ok(s, [
|
|
417
|
+
{ op: "put-data-unit", unit: unit(3, 2n, BOB), status: "merged" },
|
|
418
|
+
{ op: "put-data-unit", unit: unit(1, 1n), status: "merged" },
|
|
419
|
+
{ op: "put-data-unit", unit: unit(2, 1n, BOB), status: "merged" },
|
|
420
|
+
{ op: "put-control-records", records: [record(1, 0), record(0, null)] },
|
|
421
|
+
], "writes");
|
|
422
|
+
eq((await s.dataUnits.withStatus(R, "merged")).map((u) => `${toHex(u.actor).slice(0, 2)}:${u.actorSeq}`), ["0a:1", "0b:1", "0b:2"], "by (actor, seq)");
|
|
423
|
+
eq((await s.control.records(R)).map((r) => r.controlSeq), [0n, 1n], "records by seq");
|
|
424
|
+
});
|
|
425
|
+
test("SecretStore: a reference names a secret without containing it; values are copied", async ({ secrets, }) => {
|
|
426
|
+
const ref = dekSecretRef(R, E1);
|
|
427
|
+
const dek = Uint8Array.from({ length: 32 }, (_, i) => 200 - i);
|
|
428
|
+
eq(isSecretRef(ref), true, "ref");
|
|
429
|
+
eq(ref.includes(toHex(dek)), false, "no value in the ref");
|
|
430
|
+
eq(await secrets.get(ref), undefined, "absent");
|
|
431
|
+
await secrets.put(ref, dek);
|
|
432
|
+
dek[0] = 0;
|
|
433
|
+
const got = await secrets.get(ref);
|
|
434
|
+
eq(got?.[0], 200, "copied in");
|
|
435
|
+
got[1] = 0;
|
|
436
|
+
eq((await secrets.get(ref))?.[1], 199, "copied out");
|
|
437
|
+
await secrets.put(ref, Uint8Array.of(1));
|
|
438
|
+
eq(await secrets.get(ref), Uint8Array.of(1), "replaced");
|
|
439
|
+
await secrets.delete(ref);
|
|
440
|
+
eq(await secrets.get(ref), undefined, "deleted");
|
|
441
|
+
await secrets.delete(ref);
|
|
442
|
+
eq("list" in secrets, false, "no enumeration");
|
|
443
|
+
eq(String(secrets).includes("200"), false, "no value in String()");
|
|
444
|
+
});
|
|
445
|
+
test("durable adapters: everything committed survives a reopen", async (h) => {
|
|
446
|
+
if (h.reopen === undefined)
|
|
447
|
+
return; // the in-memory adapter forgets by design
|
|
448
|
+
const s = h.storage;
|
|
449
|
+
const u = unit(1, 1n);
|
|
450
|
+
// Reserve first, as writers do (a stored unit ahead of its reservation fails closed).
|
|
451
|
+
const seqs = [
|
|
452
|
+
await s.actorSequences.reserveNext(R, ALICE),
|
|
453
|
+
await s.actorSequences.reserveNext(R, ALICE),
|
|
454
|
+
];
|
|
455
|
+
eq(seqs, [1n, 2n], "reserved");
|
|
456
|
+
eq(await s.snapshotSequences.reserveNext(R, E1, BOB), 1n, "snapshot reserved");
|
|
457
|
+
await ok(s, [
|
|
458
|
+
{ op: "put-control-records", records: [record(0, null), record(1, 0)] },
|
|
459
|
+
{
|
|
460
|
+
op: "set-control-head",
|
|
461
|
+
resourceId: R,
|
|
462
|
+
expected: null,
|
|
463
|
+
head: { head: record(1, 0).recordId, controlSeq: 1n },
|
|
464
|
+
},
|
|
465
|
+
...epochRows.map((epoch) => ({ op: "put-epoch", resourceId: R, epoch })),
|
|
466
|
+
{ op: "put-route", resourceId: R, route },
|
|
467
|
+
{ op: "put-resource", row: resourceRow },
|
|
468
|
+
{ op: "put-data-unit", unit: u, status: "merged", accepted: true },
|
|
469
|
+
{ op: "put-key-package", row: keyPackage(1) },
|
|
470
|
+
{ op: "put-snapshot", row: snapshot },
|
|
471
|
+
{
|
|
472
|
+
op: "enqueue",
|
|
473
|
+
item: {
|
|
474
|
+
itemId: hash32(id(160)),
|
|
475
|
+
resourceId: R,
|
|
476
|
+
kind: "data-unit",
|
|
477
|
+
bytes: Uint8Array.of(7),
|
|
478
|
+
attempts: 0,
|
|
479
|
+
lastAttempt: null,
|
|
480
|
+
nextAttempt: "2026-10-05T12:01:00Z",
|
|
481
|
+
blocked: { reason: "repropose", detail: null },
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
op: "put-sync-state",
|
|
486
|
+
row: { resourceId: R, recentlyAcked: [hash32(id(160))], ackedDurability: 3n },
|
|
487
|
+
},
|
|
488
|
+
{ op: "put-profile-checkpoint", checkpoint },
|
|
489
|
+
], "everything");
|
|
490
|
+
await h.secrets.put(dekSecretRef(R, E1), Uint8Array.of(42));
|
|
491
|
+
const again = await h.reopen();
|
|
492
|
+
const r = again.storage;
|
|
493
|
+
eq(await r.control.head(R), { head: record(1, 0).recordId, controlSeq: 1n }, "head");
|
|
494
|
+
eq((await r.control.records(R)).map((x) => x.bytes), [record(0, null).bytes, record(1, 0).bytes], "records");
|
|
495
|
+
eq(await r.control.epochs(R), epochRows, "epochs");
|
|
496
|
+
eq(await r.resources.route(R), route, "route");
|
|
497
|
+
eq(await r.resources.get(R), resourceRow, "resource");
|
|
498
|
+
eq(await r.dataUnits.get(u.unitId), { ...u, status: "merged", detail: null, accepted: true }, "unit");
|
|
499
|
+
eq(await r.keyPackages.get(keyPackage(1).packageId), keyPackage(1), "key package");
|
|
500
|
+
eq(await r.snapshots.get(snapshot.snapshotId), snapshot, "snapshot");
|
|
501
|
+
eq((await r.outbound.list()).length, 1, "outbound");
|
|
502
|
+
eq(await r.profileState.checkpoint(R), checkpoint, "checkpoint");
|
|
503
|
+
eq((await r.outbound.list()).map((o) => [o.nextAttempt, o.blocked]), [["2026-10-05T12:01:00Z", { reason: "repropose", detail: null }]], "outbound retry state");
|
|
504
|
+
eq(await r.syncState.get(R), { resourceId: R, recentlyAcked: [hash32(id(160))], ackedDurability: 3n }, "sync state");
|
|
505
|
+
eq(await r.actorSequences.reserveNext(R, ALICE), 3n, "no sequence reuse after reopen");
|
|
506
|
+
eq(await r.snapshotSequences.reserveNext(R, E1, BOB), 2n, "no snapshot sequence reuse after reopen");
|
|
507
|
+
eq(await again.secrets.get(dekSecretRef(R, E1)), Uint8Array.of(42), "secret");
|
|
508
|
+
if (!bytesEqual(record(0, null).bytes, (await r.control.records(R))[0]?.bytes ?? new Uint8Array()))
|
|
509
|
+
throw new Error("exact bytes");
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
//# sourceMappingURL=contract.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Name of this package. */
|
|
2
|
+
export declare const PACKAGE = "@openlfcp/storage";
|
|
3
|
+
export { InMemoryLfcpStorage } from "./memory.js";
|
|
4
|
+
export { dekSecretRef, InMemorySecretStore, isSecretRef, principalKeySecretRef, type SecretKind, type SecretRef, type SecretStore, secretRef, } from "./secrets.js";
|
|
5
|
+
export { type ActorSequenceReservation, InMemoryActorSequenceReservation, nextActorSequence, SequenceReuseGuard, } from "./sequence.js";
|
|
6
|
+
export { InMemorySnapshotSequenceReservation, SnapshotSequenceGuard, type SnapshotSequenceReservation, } from "./snapshot-sequence.js";
|
|
7
|
+
export type { CommitResult, ControlConflictRow, ControlHeadRow, ControlReader, ControlRecordRow, DataUnitReader, DataUnitRow, DataUnitStatus, EpochRow, KeyPackageReader, KeyPackageRow, LfcpStorage, LocalMarkReader, OutboundBlock, OutboundItem, OutboundKind, OutboundReader, ProfileCheckpoint, ProfileStateReader, ResourceReader, ResourceRow, RouteRow, SeenRecord, SnapshotReader, SnapshotRow, StorageWrite, StoredDataUnit, SyncStateReader, SyncStateRow, } from "./store.js";
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Name of this package. */
|
|
2
|
+
export const PACKAGE = "@openlfcp/storage";
|
|
3
|
+
export { InMemoryLfcpStorage } from "./memory.js";
|
|
4
|
+
export { dekSecretRef, InMemorySecretStore, isSecretRef, principalKeySecretRef, secretRef, } from "./secrets.js";
|
|
5
|
+
export { InMemoryActorSequenceReservation, nextActorSequence, SequenceReuseGuard, } from "./sequence.js";
|
|
6
|
+
export { InMemorySnapshotSequenceReservation, SnapshotSequenceGuard, } from "./snapshot-sequence.js";
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type ActorSequence, type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type ActorSequenceReservation } from "./sequence.js";
|
|
3
|
+
import { type SnapshotSequenceReservation } from "./snapshot-sequence.js";
|
|
4
|
+
import type { CommitResult, ControlConflictRow, ControlHeadRow, ControlRecordRow, DataUnitRow, DataUnitStatus, EpochRow, KeyPackageRow, LfcpStorage, OutboundItem, ProfileCheckpoint, ResourceRow, RouteRow, SeenRecord, SnapshotRow, StorageWrite, StoredDataUnit, SyncStateRow } from "./store.js";
|
|
5
|
+
/**
|
|
6
|
+
* FOR TESTS AND DEVELOPMENT ONLY: an LfcpStorage in process memory, lost on
|
|
7
|
+
* restart, with the same semantic contracts as a durable adapter (exact
|
|
8
|
+
* bytes copied in and out, immutable objects, all-or-nothing batches with
|
|
9
|
+
* their preconditions). Deterministic: no clock, no randomness, ordered
|
|
10
|
+
* results.
|
|
11
|
+
*/
|
|
12
|
+
export declare class InMemoryLfcpStorage implements LfcpStorage {
|
|
13
|
+
#private;
|
|
14
|
+
/** Reservations fail closed when the counter is behind this Principal's own stored objects (§9, §29). */
|
|
15
|
+
readonly actorSequences: ActorSequenceReservation;
|
|
16
|
+
readonly snapshotSequences: SnapshotSequenceReservation;
|
|
17
|
+
commit(writes: readonly StorageWrite[]): Promise<CommitResult>;
|
|
18
|
+
readonly control: {
|
|
19
|
+
record: (id: ControlRecordId) => Promise<ControlRecordRow | undefined>;
|
|
20
|
+
records: (resource: ResourceId) => Promise<ControlRecordRow[]>;
|
|
21
|
+
head: (resource: ResourceId) => Promise<ControlHeadRow | undefined>;
|
|
22
|
+
conflict: (resource: ResourceId) => Promise<ControlConflictRow | undefined>;
|
|
23
|
+
epochs: (resource: ResourceId) => Promise<EpochRow[]>;
|
|
24
|
+
};
|
|
25
|
+
readonly dataUnits: {
|
|
26
|
+
get: (id: DataUnitId) => Promise<StoredDataUnit | undefined>;
|
|
27
|
+
at: (resource: ResourceId, actor: PrincipalId, seq: ActorSequence) => Promise<StoredDataUnit[]>;
|
|
28
|
+
range: (resource: ResourceId, actor: PrincipalId, from: ActorSequence, to: ActorSequence) => Promise<StoredDataUnit[]>;
|
|
29
|
+
withStatus: (resource: ResourceId, status: DataUnitStatus) => Promise<StoredDataUnit[]>;
|
|
30
|
+
acceptedAt: (resource: ResourceId, actor: PrincipalId, seq: ActorSequence) => Promise<DataUnitId | undefined>;
|
|
31
|
+
recordSeen: (unit: DataUnitRow) => Promise<SeenRecord>;
|
|
32
|
+
};
|
|
33
|
+
readonly keyPackages: {
|
|
34
|
+
get: (id: Hash32) => Promise<KeyPackageRow | undefined>;
|
|
35
|
+
list: (resource: ResourceId, filter?: {
|
|
36
|
+
readonly epoch?: DataEpoch;
|
|
37
|
+
readonly recipient?: PrincipalId;
|
|
38
|
+
}) => Promise<KeyPackageRow[]>;
|
|
39
|
+
};
|
|
40
|
+
readonly snapshots: {
|
|
41
|
+
get: (id: Hash32) => Promise<SnapshotRow | undefined>;
|
|
42
|
+
list: (resource: ResourceId, filter?: {
|
|
43
|
+
readonly epoch?: DataEpoch;
|
|
44
|
+
}) => Promise<SnapshotRow[]>;
|
|
45
|
+
};
|
|
46
|
+
readonly resources: {
|
|
47
|
+
get: (resource: ResourceId) => Promise<ResourceRow | undefined>;
|
|
48
|
+
list: () => Promise<ResourceRow[]>;
|
|
49
|
+
route: (resource: ResourceId) => Promise<RouteRow | undefined>;
|
|
50
|
+
};
|
|
51
|
+
readonly outbound: {
|
|
52
|
+
list: (resource?: ResourceId) => Promise<OutboundItem[]>;
|
|
53
|
+
get: (id: Hash32) => Promise<OutboundItem | undefined>;
|
|
54
|
+
};
|
|
55
|
+
readonly profileState: {
|
|
56
|
+
checkpoint: (resource: ResourceId) => Promise<ProfileCheckpoint | undefined>;
|
|
57
|
+
};
|
|
58
|
+
readonly syncState: {
|
|
59
|
+
get: (resource: ResourceId) => Promise<SyncStateRow | undefined>;
|
|
60
|
+
};
|
|
61
|
+
readonly localMarks: {
|
|
62
|
+
get: (key: string) => Promise<string | undefined>;
|
|
63
|
+
list: (prefix: string) => Promise<{
|
|
64
|
+
key: string;
|
|
65
|
+
value: string;
|
|
66
|
+
}[]>;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=memory.d.ts.map
|