@openlfcp/storage 0.2.0-beta.5 → 0.2.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/contract.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { actorSequence, bytesEqual, controlRecordId, dataEpoch, dataUnitId, hash32, LfcpError, principalId, resourceId, toHex, } from "@openlfcp/core";
2
+ import { deleteUnsentResource } from "./delete-resource.js";
2
3
  import { dekSecretRef, isSecretRef, principalKeySecretRef } from "./secrets.js";
3
4
  // ---------------------------------------------------------------------------
4
5
  // assertions (no test framework)
@@ -508,6 +509,144 @@ export function runStorageContract(t, name, open) {
508
509
  if (!bytesEqual(record(0, null).bytes, (await r.control.records(R))[0]?.bytes ?? new Uint8Array()))
509
510
  throw new Error("exact bytes");
510
511
  });
512
+ /** Everything a client stores of R before sending it, and another Resource R2. */
513
+ const unsent = () => [
514
+ { op: "put-resource", row: resourceRow },
515
+ { op: "put-route", resourceId: R, route },
516
+ { op: "put-control-records", records: [record(0, null), record(1, 0)] },
517
+ {
518
+ op: "set-control-head",
519
+ resourceId: R,
520
+ expected: null,
521
+ head: { head: record(1, 0).recordId, controlSeq: 1n },
522
+ },
523
+ { op: "set-control-conflict", resourceId: R, conflict: { heads: [record(1, 0).recordId] } },
524
+ ...epochRows.map((epoch) => ({ op: "put-epoch", resourceId: R, epoch })),
525
+ { op: "put-data-unit", unit: unit(1, 1n), status: "merged", accepted: true },
526
+ { op: "put-key-package", row: keyPackage(1) },
527
+ { op: "put-snapshot", row: snapshot },
528
+ {
529
+ op: "enqueue",
530
+ item: {
531
+ itemId: hash32(id(160)),
532
+ resourceId: R,
533
+ kind: "control-record",
534
+ bytes: Uint8Array.of(1),
535
+ attempts: 0,
536
+ lastAttempt: null,
537
+ nextAttempt: null,
538
+ blocked: null,
539
+ },
540
+ },
541
+ { op: "put-profile-checkpoint", checkpoint },
542
+ { op: "put-sync-state", row: { resourceId: R, recentlyAcked: [], ackedDurability: null } },
543
+ { op: "put-resource", row: { ...resourceRow, resourceId: R2 } },
544
+ {
545
+ op: "enqueue",
546
+ item: {
547
+ itemId: hash32(id(161)),
548
+ resourceId: R2,
549
+ kind: "control-record",
550
+ bytes: Uint8Array.of(2),
551
+ attempts: 3,
552
+ lastAttempt: "2026-10-05T12:00:00Z",
553
+ nextAttempt: null,
554
+ blocked: null,
555
+ },
556
+ },
557
+ { op: "put-local-mark", key: "apply:x", value: "1" },
558
+ ];
559
+ test("deletes a Resource never sent, every row of it at once; others, marks and counters stay", async ({ storage: s, }) => {
560
+ const reserved = await s.actorSequences.reserveNext(R, ALICE);
561
+ await ok(s, unsent(), "stored");
562
+ await ok(s, [{ op: "delete-resource", resourceId: R }], "deleted");
563
+ eq(await s.resources.get(R), undefined, "resource");
564
+ eq(await s.resources.route(R), undefined, "route");
565
+ eq(await s.control.records(R), [], "records");
566
+ eq(await s.control.head(R), undefined, "head");
567
+ eq(await s.control.conflict(R), undefined, "conflict");
568
+ eq(await s.control.epochs(R), [], "epochs");
569
+ eq(await s.dataUnits.get(unit(1, 1n).unitId), undefined, "unit");
570
+ eq(await s.dataUnits.withStatus(R, "merged"), [], "units by status");
571
+ eq(await s.keyPackages.list(R), [], "key packages");
572
+ eq(await s.snapshots.list(R), [], "snapshots");
573
+ eq(await s.outbound.list(R), [], "outbound");
574
+ eq(await s.profileState.checkpoint(R), undefined, "checkpoint");
575
+ eq(await s.syncState.get(R), undefined, "sync state");
576
+ eq((await s.resources.list()).map((r) => r.resourceId), [R2], "the other Resource");
577
+ eq((await s.outbound.list(R2)).length, 1, "its outbound item");
578
+ eq(await s.localMarks.get("apply:x"), "1", "local marks");
579
+ const next = await s.actorSequences.reserveNext(R, ALICE);
580
+ if (next <= reserved)
581
+ throw new Error(`actor sequence reused: ${next} after ${reserved}`);
582
+ await ok(s, [{ op: "delete-resource", resourceId: R }], "deleting again is harmless");
583
+ });
584
+ test("refuses to delete a Resource that may have been sent, writing nothing of the batch", async ({ storage: s, }) => {
585
+ await ok(s, unsent(), "stored");
586
+ const cases = [
587
+ [
588
+ {
589
+ op: "update-outbound",
590
+ itemId: hash32(id(160)),
591
+ attempts: 1,
592
+ lastAttempt: "2026-10-05T12:00:00Z",
593
+ },
594
+ "attempted",
595
+ ],
596
+ [
597
+ {
598
+ op: "put-sync-state",
599
+ row: { resourceId: R, recentlyAcked: [hash32(id(160))], ackedDurability: 1n },
600
+ },
601
+ "acked",
602
+ ],
603
+ [
604
+ { op: "put-data-unit", unit: unit(2, 1n, BOB), status: "merged", accepted: true },
605
+ "received",
606
+ ],
607
+ ];
608
+ for (const [shown, detail] of cases) {
609
+ const h = await s.commit([shown]);
610
+ eq(h, { ok: true }, `stored: ${detail}`);
611
+ const refused = await s.commit([
612
+ { op: "put-local-mark", key: "batch", value: "written" },
613
+ { op: "delete-resource", resourceId: R },
614
+ ]);
615
+ eq(refused, { ok: false, reason: "RESOURCE_SENT", resourceId: R, detail }, detail);
616
+ eq(await s.localMarks.get("batch"), undefined, `nothing of the batch: ${detail}`);
617
+ eq((await s.resources.get(R))?.resourceId, R, `still stored: ${detail}`);
618
+ // Undone for the next case (a unit cannot be removed: it comes last).
619
+ if (detail === "attempted")
620
+ await ok(s, [{ op: "update-outbound", itemId: hash32(id(160)), attempts: 0, lastAttempt: null }], "undone");
621
+ if (detail === "acked")
622
+ await ok(s, [
623
+ {
624
+ op: "put-sync-state",
625
+ row: { resourceId: R, recentlyAcked: [], ackedDurability: null },
626
+ },
627
+ ], "undone");
628
+ }
629
+ });
630
+ test("deleteUnsentResource deletes the Resource's keys, never the Principal's", async ({ storage: s, secrets, }) => {
631
+ await ok(s, unsent(), "stored");
632
+ const keys = [
633
+ dekSecretRef(R, E0),
634
+ dekSecretRef(R, E1),
635
+ principalKeySecretRef(ALICE, "signing"),
636
+ ];
637
+ for (const k of keys)
638
+ await secrets.put(k, Uint8Array.of(42));
639
+ eq(await deleteUnsentResource(s, secrets, R), { ok: true }, "deleted");
640
+ eq(await secrets.get(dekSecretRef(R, E0)), undefined, "epoch 0 key");
641
+ eq(await secrets.get(dekSecretRef(R, E1)), undefined, "epoch 1 key");
642
+ eq(await secrets.get(principalKeySecretRef(ALICE, "signing")), Uint8Array.of(42), "Principal key");
643
+ // A Resource that may have been sent: refused, its keys kept.
644
+ await ok(s, unsent(), "stored again");
645
+ await ok(s, [{ op: "put-data-unit", unit: unit(2, 1n, BOB), status: "merged", accepted: true }], "received");
646
+ await secrets.put(dekSecretRef(R, E1), Uint8Array.of(7));
647
+ eq(await deleteUnsentResource(s, secrets, R), { ok: false, reason: "RESOURCE_SENT", resourceId: R, detail: "received" }, "refused");
648
+ eq(await secrets.get(dekSecretRef(R, E1)), Uint8Array.of(7), "key kept");
649
+ });
511
650
  });
512
651
  }
513
652
  //# sourceMappingURL=contract.js.map
@@ -0,0 +1,14 @@
1
+ import type { ResourceId } from "@openlfcp/core";
2
+ import { type SecretStore } from "./secrets.js";
3
+ import type { CommitResult, LfcpStorage } from "./store.js";
4
+ /**
5
+ * Forgets a Resource this client made and never sent (a share given up
6
+ * before it was hosted): every stored row of it in one atomic commit
7
+ * (delete-resource), then the secrets only it used, its Data Epoch keys.
8
+ * The local Principal's keys are kept: other Resources use them. Refused,
9
+ * with nothing deleted, when the Resource may have been sent
10
+ * (RESOURCE_SENT). The rows go first: a crash before the keys are deleted
11
+ * leaves keys nothing refers to, never a Resource without its keys.
12
+ */
13
+ export declare function deleteUnsentResource(storage: LfcpStorage, secrets: SecretStore, resource: ResourceId): Promise<CommitResult>;
14
+ //# sourceMappingURL=delete-resource.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { dekSecretRef } from "./secrets.js";
2
+ /**
3
+ * Forgets a Resource this client made and never sent (a share given up
4
+ * before it was hosted): every stored row of it in one atomic commit
5
+ * (delete-resource), then the secrets only it used, its Data Epoch keys.
6
+ * The local Principal's keys are kept: other Resources use them. Refused,
7
+ * with nothing deleted, when the Resource may have been sent
8
+ * (RESOURCE_SENT). The rows go first: a crash before the keys are deleted
9
+ * leaves keys nothing refers to, never a Resource without its keys.
10
+ */
11
+ export async function deleteUnsentResource(storage, secrets, resource) {
12
+ const epochs = await storage.control.epochs(resource);
13
+ const refs = new Set(epochs.map((e) => e.dekRef ?? dekSecretRef(resource, e.epoch)));
14
+ const result = await storage.commit([{ op: "delete-resource", resourceId: resource }]);
15
+ if (!result.ok)
16
+ return result;
17
+ for (const ref of refs)
18
+ await secrets.delete(ref);
19
+ return result;
20
+ }
21
+ //# sourceMappingURL=delete-resource.js.map
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /** Name of this package. */
2
2
  export declare const PACKAGE = "@openlfcp/storage";
3
+ export { deleteUnsentResource } from "./delete-resource.js";
3
4
  export { LOCAL_STATE_SCHEME, type LocalStateCipher, type LocalStateDiagnostics, type LocalStateEvent, LocalStateKeyring, type LocalStateMeta, localStateAad, localStateKeyRef, type OpenedLocal, type ResealRow, reseal, } from "./local-state.js";
4
5
  export { InMemoryLfcpStorage } from "./memory.js";
5
6
  export { dekSecretRef, InMemorySecretStore, isSecretRef, principalKeySecretRef, type SecretKind, type SecretRef, type SecretStore, secretRef, } from "./secrets.js";
6
7
  export { type ActorSequenceReservation, InMemoryActorSequenceReservation, nextActorSequence, SequenceReuseGuard, } from "./sequence.js";
7
8
  export { InMemorySnapshotSequenceReservation, SnapshotSequenceGuard, type SnapshotSequenceReservation, } from "./snapshot-sequence.js";
8
- 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";
9
+ export type { CommitResult, ControlConflictRow, ControlHeadRow, ControlReader, ControlRecordRow, DataUnitReader, DataUnitRow, DataUnitStatus, EpochRow, KeyPackageReader, KeyPackageRow, LfcpStorage, LocalMarkReader, OutboundBlock, OutboundItem, OutboundKind, OutboundReader, ProfileCheckpoint, ProfileStateReader, ResourceReader, ResourceRow, ResourceSentDetail, RouteRow, SeenRecord, SnapshotReader, SnapshotRow, StorageWrite, StoredDataUnit, SyncStateReader, SyncStateRow, } from "./store.js";
10
+ export { resourceSent } from "./store.js";
9
11
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  /** Name of this package. */
2
2
  export const PACKAGE = "@openlfcp/storage";
3
+ export { deleteUnsentResource } from "./delete-resource.js";
3
4
  export { LOCAL_STATE_SCHEME, LocalStateKeyring, localStateAad, localStateKeyRef, reseal, } from "./local-state.js";
4
5
  export { InMemoryLfcpStorage } from "./memory.js";
5
6
  export { dekSecretRef, InMemorySecretStore, isSecretRef, principalKeySecretRef, secretRef, } from "./secrets.js";
6
7
  export { InMemoryActorSequenceReservation, nextActorSequence, SequenceReuseGuard, } from "./sequence.js";
7
8
  export { InMemorySnapshotSequenceReservation, SnapshotSequenceGuard, } from "./snapshot-sequence.js";
9
+ export { resourceSent } from "./store.js";
8
10
  //# sourceMappingURL=index.js.map
package/dist/memory.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ActorSequence, type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
2
  import { type ActorSequenceReservation } from "./sequence.js";
3
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";
4
+ import { type CommitResult, type ControlConflictRow, type ControlHeadRow, type ControlRecordRow, type DataUnitRow, type DataUnitStatus, type EpochRow, type KeyPackageRow, type LfcpStorage, type OutboundItem, type ProfileCheckpoint, type ResourceRow, type RouteRow, type SeenRecord, type SnapshotRow, type StorageWrite, type StoredDataUnit, type SyncStateRow } from "./store.js";
5
5
  /**
6
6
  * FOR TESTS AND DEVELOPMENT ONLY: an LfcpStorage in process memory, lost on
7
7
  * restart, with the same semantic contracts as a durable adapter (exact
package/dist/memory.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { bytesEqual, LfcpError, toHex, } from "@openlfcp/core";
2
2
  import { InMemoryActorSequenceReservation } from "./sequence.js";
3
3
  import { InMemorySnapshotSequenceReservation, } from "./snapshot-sequence.js";
4
+ import { resourceSent, } from "./store.js";
4
5
  /** A deep copy: every Uint8Array copied, everything frozen. Nothing stored aliases a caller's buffer. */
5
6
  function own(value) {
6
7
  if (value instanceof Uint8Array)
@@ -156,6 +157,25 @@ function apply(s, w) {
156
157
  else
157
158
  s.marks.set(w.key, w.value);
158
159
  return;
160
+ case "delete-resource": {
161
+ const r = hex(w.resourceId);
162
+ const of = (row) => hex(row.resourceId) === r;
163
+ for (const map of [s.records, s.units, s.keyPackages, s.snapshots, s.outbound])
164
+ for (const [k, v] of map)
165
+ if (of(v))
166
+ map.delete(k);
167
+ for (const map of [
168
+ s.heads,
169
+ s.conflicts,
170
+ s.epochs,
171
+ s.resources,
172
+ s.routes,
173
+ s.checkpoints,
174
+ s.syncStates,
175
+ ])
176
+ map.delete(r);
177
+ return;
178
+ }
159
179
  }
160
180
  }
161
181
  const pickUnit = (u) => ({
@@ -230,6 +250,32 @@ export class InMemoryLfcpStorage {
230
250
  current: current === null ? null : own(current),
231
251
  }));
232
252
  }
253
+ for (const w of writes) {
254
+ if (w.op !== "delete-resource")
255
+ continue;
256
+ const r = hex(w.resourceId);
257
+ const detail = resourceSent({
258
+ local: next.resources.get(r)?.localPrincipal?.principalId ?? null,
259
+ outbound: [...next.outbound.values()].filter((o) => hex(o.resourceId) === r),
260
+ syncState: next.syncStates.get(r),
261
+ actors: [
262
+ ...[...next.units.values()].filter((u) => hex(u.resourceId) === r).map((u) => u.actor),
263
+ ...[...next.keyPackages.values()]
264
+ .filter((k) => hex(k.resourceId) === r)
265
+ .map((k) => k.sender),
266
+ ...[...next.snapshots.values()]
267
+ .filter((x) => hex(x.resourceId) === r)
268
+ .map((x) => x.publisher),
269
+ ],
270
+ });
271
+ if (detail !== null)
272
+ return Promise.resolve(Object.freeze({
273
+ ok: false,
274
+ reason: "RESOURCE_SENT",
275
+ resourceId: own(w.resourceId),
276
+ detail,
277
+ }));
278
+ }
233
279
  for (const w of writes) {
234
280
  if (w.op !== "expect-previous-unit")
235
281
  continue;
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ActorSequence, ControlRecordId, DataEpoch, DataUnitId, Hash32, PrincipalId, ResourceId } from "@openlfcp/core";
1
+ import { type ActorSequence, type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
2
  import type { SecretRef } from "./secrets.js";
3
3
  import type { ActorSequenceReservation } from "./sequence.js";
4
4
  import type { SnapshotSequenceReservation } from "./snapshot-sequence.js";
@@ -321,6 +321,20 @@ export type StorageWrite = {
321
321
  readonly op: "put-local-mark";
322
322
  readonly key: string;
323
323
  readonly value: string | null;
324
+ } | {
325
+ /**
326
+ * Forgets a Resource this client made and never sent: its row, route,
327
+ * Control Records, head and conflict, epochs, Data Units, Key
328
+ * Packages, Snapshots, outbound items, profile checkpoint and sync
329
+ * state. Fails the batch (RESOURCE_SENT) when anything of it may have
330
+ * left this device or come from elsewhere: an outbound item attempted,
331
+ * an ACK recorded, or a Data Unit, Key Package or Snapshot of another
332
+ * Principal stored. The actor and Snapshot sequence counters are kept
333
+ * (a number is never reused). Secrets are the caller's
334
+ * (deleteUnsentResource deletes the Resource's DEKs).
335
+ */
336
+ readonly op: "delete-resource";
337
+ readonly resourceId: ResourceId;
324
338
  };
325
339
  export type CommitResult = {
326
340
  readonly ok: true;
@@ -339,7 +353,29 @@ export type CommitResult = {
339
353
  readonly resourceId: ResourceId;
340
354
  readonly actor: PrincipalId;
341
355
  readonly current: DataUnitId | null;
356
+ }
357
+ /** Nothing was written: the Resource to delete may have been sent (delete-resource). */
358
+ | {
359
+ readonly ok: false;
360
+ readonly reason: "RESOURCE_SENT";
361
+ readonly resourceId: ResourceId;
362
+ /** What shows it: "attempted", "acked" or "received". */
363
+ readonly detail: ResourceSentDetail;
342
364
  };
365
+ /** Why a Resource counts as sent (delete-resource). */
366
+ export type ResourceSentDetail = "attempted" | "acked" | "received";
367
+ /**
368
+ * Why `resource` counts as sent, from what is stored of it, or null: an
369
+ * outbound item attempted, an ACK recorded, or a Data Unit, Key Package or
370
+ * Snapshot of a Principal other than its local one. Shared by the adapters
371
+ * (delete-resource).
372
+ */
373
+ export declare function resourceSent(stored: {
374
+ readonly local: PrincipalId | null;
375
+ readonly outbound: readonly OutboundItem[];
376
+ readonly syncState: SyncStateRow | undefined;
377
+ readonly actors: readonly PrincipalId[];
378
+ }): ResourceSentDetail | null;
343
379
  export interface ControlReader {
344
380
  record(recordId: ControlRecordId): Promise<ControlRecordRow | undefined>;
345
381
  /** Every stored record of the Resource, by (controlSeq, recordId bytes). */
package/dist/store.js CHANGED
@@ -1,2 +1,19 @@
1
- export {};
1
+ import { bytesEqual, } from "@openlfcp/core";
2
+ /**
3
+ * Why `resource` counts as sent, from what is stored of it, or null: an
4
+ * outbound item attempted, an ACK recorded, or a Data Unit, Key Package or
5
+ * Snapshot of a Principal other than its local one. Shared by the adapters
6
+ * (delete-resource).
7
+ */
8
+ export function resourceSent(stored) {
9
+ if (stored.outbound.some((o) => o.attempts > 0 || o.lastAttempt !== null))
10
+ return "attempted";
11
+ const s = stored.syncState;
12
+ if (s !== undefined && (s.recentlyAcked.length > 0 || s.ackedDurability !== null))
13
+ return "acked";
14
+ const local = stored.local;
15
+ if (stored.actors.some((a) => local === null || !bytesEqual(a, local)))
16
+ return "received";
17
+ return null;
18
+ }
2
19
  //# sourceMappingURL=store.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/storage",
3
- "version": "0.2.0-beta.5",
3
+ "version": "0.2.0-beta.7",
4
4
  "description": "Storage interfaces for LFCP clients (no adapters).",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -45,6 +45,6 @@
45
45
  "tag": "beta"
46
46
  },
47
47
  "dependencies": {
48
- "@openlfcp/core": "^0.2.0-beta.5"
48
+ "@openlfcp/core": "^0.2.0-beta.7"
49
49
  }
50
50
  }