@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.
@@ -0,0 +1,424 @@
1
+ import type { ActorSequence, ControlRecordId, DataEpoch, DataUnitId, Hash32, PrincipalId, ResourceId } from "@openlfcp/core";
2
+ import type { SecretRef } from "./secrets.js";
3
+ import type { ActorSequenceReservation } from "./sequence.js";
4
+ import type { SnapshotSequenceReservation } from "./snapshot-sequence.js";
5
+ /**
6
+ * Local LFCP persistence (LFCP-034): the interfaces a client's durable
7
+ * state goes through. Adapters implement them: InMemoryLfcpStorage for
8
+ * tests, a Node adapter (LFCP-035), Obsidian later (LFCP-059). Nothing here
9
+ * depends on a filesystem, Electron or Obsidian.
10
+ *
11
+ * Rules every adapter keeps:
12
+ *
13
+ * - EXACT BYTES ARE AUTHORITATIVE. Signed objects (Control Records, Data
14
+ * Units, Key Packages, Snapshots) are stored as their exact received or
15
+ * created bytes, under the ID that hashes them. Every other field of a
16
+ * row is a secondary index for lookups; nothing is ever re-encoded from
17
+ * it. Bytes are copied in and out: no caller buffer aliases a stored one.
18
+ * - IMMUTABLE OBJECTS. A stored object's bytes never change; writing the
19
+ * same ID again with other bytes fails the batch.
20
+ * - ATOMIC BATCHES. Everything that must persist together goes into one
21
+ * commit(), which applies every write or none, and checks its
22
+ * preconditions (the expected Control Head, a local unit's previous
23
+ * unit) first.
24
+ * - SEQUENCE SAFETY. Actor and Snapshot sequences come only from their
25
+ * reservation contracts (durable before they resolve). A reserved
26
+ * sequence that a crash leaves unused is abandoned, never reissued, so
27
+ * reserving and then committing the sealed object plus its outbound entry
28
+ * in one batch is safe: no nonce can be used twice.
29
+ * - SECRETS ARE ELSEWHERE. Rows hold SecretRefs only; values live in a
30
+ * SecretStore (secrets.ts).
31
+ */
32
+ /** A Control Record: exact COSE bytes plus its chain position (§13). */
33
+ export interface ControlRecordRow {
34
+ readonly recordId: ControlRecordId;
35
+ readonly resourceId: ResourceId;
36
+ readonly controlSeq: bigint;
37
+ readonly prevControlId: ControlRecordId | null;
38
+ readonly bytes: Uint8Array;
39
+ }
40
+ /** The Resource's current validated Control Head. */
41
+ export interface ControlHeadRow {
42
+ readonly head: ControlRecordId;
43
+ readonly controlSeq: bigint;
44
+ }
45
+ /** A detected Control Chain fork (CONTROL_CONFLICT, §13.2): the competing heads. */
46
+ export interface ControlConflictRow {
47
+ readonly heads: readonly ControlRecordId[];
48
+ }
49
+ /** A Data Epoch as the chain defines it, plus where its DEK is kept. */
50
+ export interface EpochRow {
51
+ readonly epoch: DataEpoch;
52
+ readonly dekCommitment: Hash32;
53
+ readonly openedBy: ControlRecordId;
54
+ readonly closedBy: ControlRecordId | null;
55
+ /** The SecretStore entry of this epoch's DEK, once this client holds it. */
56
+ readonly dekRef: SecretRef | null;
57
+ }
58
+ /** A Data Unit: exact COSE bytes plus its header fields as indexes (§26). */
59
+ export interface DataUnitRow {
60
+ readonly unitId: DataUnitId;
61
+ readonly resourceId: ResourceId;
62
+ readonly dataEpoch: DataEpoch;
63
+ readonly actor: PrincipalId;
64
+ readonly actorSeq: ActorSequence;
65
+ readonly prevDataUnitId: DataUnitId | null;
66
+ readonly controlHead: ControlRecordId;
67
+ readonly bytes: Uint8Array;
68
+ }
69
+ /**
70
+ * Where a Data Unit stands for this client. Units are stored only once
71
+ * their signature verified (or this client created them).
72
+ */
73
+ export type DataUnitStatus =
74
+ /** Signature-valid, still being processed. */
75
+ "seen"
76
+ /** Merged into the Data Profile state (including this client's own units). */
77
+ | "merged"
78
+ /** LFCP-accepted; the profile waits for content it builds on. */
79
+ | "profile-pending"
80
+ /** The actor chain does not link yet (§26.2, G-DP1). */
81
+ | "held"
82
+ /** Beyond an epoch cutoff (STALE_DATA_EPOCH). */
83
+ | "quarantined"
84
+ /** Part of an equivocating pair, kept as evidence (§26.2). */
85
+ | "equivocation"
86
+ /** No DEK, DEK mismatch, AEAD failure or profile decode failure. */
87
+ | "local-failure"
88
+ /** LFCP-accepted, refused when merging. */
89
+ | "profile-rejected"
90
+ /** The Resource's profile is not implemented here. */
91
+ | "profile-unsupported";
92
+ export interface StoredDataUnit extends DataUnitRow {
93
+ readonly status: DataUnitStatus;
94
+ readonly detail: string | null;
95
+ /**
96
+ * Accepted by LFCP (the wire SeenUnits "accepted" mark): replays are
97
+ * duplicates and the actor chain links to it. Cleared by un-accept when
98
+ * a merged unit is taken out again (G-EP7, G-DP5).
99
+ */
100
+ readonly accepted: boolean;
101
+ }
102
+ /** The signature-valid units recorded for one (resource, actor, seq). */
103
+ export interface SeenRecord {
104
+ /** Sorted by bytes; more than one means equivocation. */
105
+ readonly unitIds: readonly DataUnitId[];
106
+ readonly firstSeen: boolean;
107
+ }
108
+ /** A Key Package (§25). Several may exist for one (resource, epoch, recipient). */
109
+ export interface KeyPackageRow {
110
+ /** SHA-256 of the exact bytes. */
111
+ readonly packageId: Hash32;
112
+ readonly resourceId: ResourceId;
113
+ readonly dataEpoch: DataEpoch;
114
+ readonly recipient: PrincipalId;
115
+ readonly sender: PrincipalId;
116
+ readonly bytes: Uint8Array;
117
+ }
118
+ /** A Snapshot (§29) and the metadata needed to pick one for catch-up. */
119
+ export interface SnapshotRow {
120
+ /** SHA-256 of the exact bytes. */
121
+ readonly snapshotId: Hash32;
122
+ readonly resourceId: ResourceId;
123
+ readonly dataEpoch: DataEpoch;
124
+ readonly publisher: PrincipalId;
125
+ readonly snapshotSeq: bigint;
126
+ /** The canonical frontier the Snapshot covers, as its exact CBOR (§28.2). */
127
+ readonly frontier: Uint8Array;
128
+ readonly bytes: Uint8Array;
129
+ }
130
+ /** The Resource's route as the chain defines it (§16, §20). */
131
+ export interface RouteRow {
132
+ readonly routeVersion: bigint;
133
+ readonly endpoints: readonly {
134
+ readonly url: string;
135
+ readonly priority: bigint;
136
+ /** §16 flag bits, when the record carries them. */
137
+ readonly flags?: bigint;
138
+ }[];
139
+ readonly coordinatorUrl: string;
140
+ /** The Control Record that set it. */
141
+ readonly source: ControlRecordId;
142
+ }
143
+ /** Local metadata of a Resource this client follows. */
144
+ export interface ResourceRow {
145
+ readonly resourceId: ResourceId;
146
+ /** The Genesis data_profile (§15). */
147
+ readonly dataProfile: string;
148
+ /** The Principal this client writes as, and where its private keys are. */
149
+ readonly localPrincipal: {
150
+ readonly principalId: PrincipalId;
151
+ readonly signingKeyRef: SecretRef;
152
+ readonly agreementKeyRef: SecretRef;
153
+ } | null;
154
+ /** Application labels (display name, …): public, never secrets. */
155
+ readonly labels: Readonly<Record<string, string>>;
156
+ }
157
+ export type OutboundKind = "control-record" | "data-unit" | "key-package" | "snapshot";
158
+ /**
159
+ * Why an outbound item is no longer sent (LFCP-036). It stays queued and
160
+ * visible until the application discards it.
161
+ *
162
+ * - stale-epoch: beyond a closed epoch's cutoff (§88 step 7, G-EP5); the
163
+ * intent must be applied again as a new unit;
164
+ * - equivocation: the server holds another unit for its (actor, seq): a
165
+ * local-safety alarm;
166
+ * - rejected: refused for good (e.g. AUTHORIZATION_FAILED at its head);
167
+ * - repropose: a Control Record whose expected head moved
168
+ * (CONTROL_HEAD_MISMATCH); the caller builds a new record;
169
+ * - too-large: larger than the server accepts in one message.
170
+ */
171
+ export type OutboundBlock = "stale-epoch" | "equivocation" | "rejected" | "repropose" | "too-large";
172
+ /** An immutable object waiting to be sent (LFCP-036). Removed only when ACKed (or discarded). */
173
+ export interface OutboundItem {
174
+ /** The object's ID (record, unit, package or snapshot ID). */
175
+ readonly itemId: Hash32;
176
+ readonly resourceId: ResourceId;
177
+ readonly kind: OutboundKind;
178
+ /** The exact bytes to send; a retry sends these again, never a re-created object. */
179
+ readonly bytes: Uint8Array;
180
+ readonly attempts: number;
181
+ /** Set by the sender (caller's clock, RFC 3339); null until the first attempt. */
182
+ readonly lastAttempt: string | null;
183
+ /** Not to be sent before this time (caller's clock, RFC 3339); null: now. */
184
+ readonly nextAttempt: string | null;
185
+ /** Set once the item must not be sent again. */
186
+ readonly blocked: {
187
+ readonly reason: OutboundBlock;
188
+ readonly detail: string | null;
189
+ } | null;
190
+ }
191
+ /** Per-Resource sync state that is not derivable from stored objects (LFCP-036). */
192
+ export interface SyncStateRow {
193
+ readonly resourceId: ResourceId;
194
+ /** The most recently ACKed object IDs, newest last (bounded by the writer). */
195
+ readonly recentlyAcked: readonly Hash32[];
196
+ /** The durability the last ACK established (§37 level), or null before any ACK. */
197
+ readonly ackedDurability: bigint | null;
198
+ }
199
+ /**
200
+ * A Data Profile's local state: enough to resume without replaying every
201
+ * unit (e.g. an Automerge full save) and to continue as the same actor
202
+ * (§9: actorSeq becomes the replica's minSeq). `units` maps merged units to
203
+ * the profile's own change references, for a G-EP7 rebuild. The accepted
204
+ * set itself is always reconstructable from the stored units with
205
+ * accepted = true and their epochs' DEKs.
206
+ */
207
+ export interface ProfileCheckpoint {
208
+ readonly resourceId: ResourceId;
209
+ readonly dataProfile: string;
210
+ readonly state: Uint8Array;
211
+ readonly actorSeq: number;
212
+ readonly units: readonly {
213
+ readonly unitId: DataUnitId;
214
+ readonly ref: string;
215
+ }[];
216
+ }
217
+ /** One write of an atomic batch. */
218
+ export type StorageWrite = {
219
+ readonly op: "put-control-records";
220
+ readonly records: readonly ControlRecordRow[];
221
+ } | {
222
+ /** Compare-and-set: fails the batch unless the current head is `expected` (null: none yet). */
223
+ readonly op: "set-control-head";
224
+ readonly resourceId: ResourceId;
225
+ readonly expected: ControlRecordId | null;
226
+ readonly head: ControlHeadRow;
227
+ } | {
228
+ readonly op: "set-control-conflict";
229
+ readonly resourceId: ResourceId;
230
+ readonly conflict: ControlConflictRow | null;
231
+ } | {
232
+ /**
233
+ * Stores the epoch row. A null `dekRef` or `closedBy` never clears a
234
+ * stored one: a chain save that read the rows before a Key Package
235
+ * stored the DEK reference must not erase it, and a DEK reference
236
+ * written from a row read before a Key Epoch closed the epoch must not
237
+ * reopen it (merged inside the commit, so no lost update). Both only
238
+ * ever go from null to set: the stored chain only grows.
239
+ */
240
+ readonly op: "put-epoch";
241
+ readonly resourceId: ResourceId;
242
+ readonly epoch: EpochRow;
243
+ } | {
244
+ /**
245
+ * Compare-and-set for a local unit (§26.2): fails the batch unless
246
+ * `previous` is still `actor`'s latest unit stored as accepted in the
247
+ * Resource (null: none). Two writers that read the same previous unit
248
+ * would otherwise both link to it, and receivers hold the second one
249
+ * forever (PREV_MISMATCH). Checked with set-control-head, before any
250
+ * write.
251
+ */
252
+ readonly op: "expect-previous-unit";
253
+ readonly resourceId: ResourceId;
254
+ readonly actor: PrincipalId;
255
+ readonly previous: DataUnitId | null;
256
+ } | {
257
+ /** Stores the unit if new (exact bytes) and sets its status. */
258
+ readonly op: "put-data-unit";
259
+ readonly unit: DataUnitRow;
260
+ readonly status: DataUnitStatus;
261
+ readonly detail?: string;
262
+ readonly accepted?: boolean;
263
+ } | {
264
+ readonly op: "set-data-unit-status";
265
+ readonly unitId: DataUnitId;
266
+ readonly status: DataUnitStatus;
267
+ readonly detail?: string;
268
+ }
269
+ /** Marks a unit accepted, or un-accepts it. */
270
+ | {
271
+ readonly op: "set-accepted";
272
+ readonly unitId: DataUnitId;
273
+ readonly accepted: boolean;
274
+ } | {
275
+ readonly op: "put-key-package";
276
+ readonly row: KeyPackageRow;
277
+ } | {
278
+ readonly op: "put-snapshot";
279
+ readonly row: SnapshotRow;
280
+ }
281
+ /** Forgets a stored Snapshot (e.g. Snapshot-derived state dropped, SNAP-EP). */
282
+ | {
283
+ readonly op: "delete-snapshot";
284
+ readonly snapshotId: Hash32;
285
+ } | {
286
+ readonly op: "put-resource";
287
+ readonly row: ResourceRow;
288
+ } | {
289
+ readonly op: "put-route";
290
+ readonly resourceId: ResourceId;
291
+ readonly route: RouteRow;
292
+ } | {
293
+ readonly op: "enqueue";
294
+ readonly item: OutboundItem;
295
+ } | {
296
+ /** Changes the given retry fields of a queued item (its bytes never change). */
297
+ readonly op: "update-outbound";
298
+ readonly itemId: Hash32;
299
+ readonly attempts?: number;
300
+ readonly lastAttempt?: string | null;
301
+ readonly nextAttempt?: string | null;
302
+ readonly blocked?: OutboundItem["blocked"];
303
+ } | {
304
+ readonly op: "dequeue";
305
+ readonly itemId: Hash32;
306
+ } | {
307
+ readonly op: "put-profile-checkpoint";
308
+ readonly checkpoint: ProfileCheckpoint;
309
+ } | {
310
+ readonly op: "put-sync-state";
311
+ readonly row: SyncStateRow;
312
+ }
313
+ /** Sets (or, with null, removes) a local mark (LocalMarkReader). */
314
+ | {
315
+ readonly op: "put-local-mark";
316
+ readonly key: string;
317
+ readonly value: string | null;
318
+ };
319
+ export type CommitResult = {
320
+ readonly ok: true;
321
+ }
322
+ /** Nothing was written: a precondition failed. */
323
+ | {
324
+ readonly ok: false;
325
+ readonly reason: "CONTROL_HEAD_MISMATCH";
326
+ readonly resourceId: ResourceId;
327
+ readonly current: ControlRecordId | null;
328
+ }
329
+ /** Nothing was written: another unit of `actor` was accepted since `previous` was read. */
330
+ | {
331
+ readonly ok: false;
332
+ readonly reason: "PREVIOUS_UNIT_MISMATCH";
333
+ readonly resourceId: ResourceId;
334
+ readonly actor: PrincipalId;
335
+ readonly current: DataUnitId | null;
336
+ };
337
+ export interface ControlReader {
338
+ record(recordId: ControlRecordId): Promise<ControlRecordRow | undefined>;
339
+ /** Every stored record of the Resource, by (controlSeq, recordId bytes). */
340
+ records(resource: ResourceId): Promise<ControlRecordRow[]>;
341
+ head(resource: ResourceId): Promise<ControlHeadRow | undefined>;
342
+ conflict(resource: ResourceId): Promise<ControlConflictRow | undefined>;
343
+ /** Epoch history, ascending. */
344
+ epochs(resource: ResourceId): Promise<EpochRow[]>;
345
+ }
346
+ export interface DataUnitReader {
347
+ get(unitId: DataUnitId): Promise<StoredDataUnit | undefined>;
348
+ /** Every unit stored for one (resource, actor, seq): more than one is equivocation. */
349
+ at(resource: ResourceId, actor: PrincipalId, seq: ActorSequence): Promise<StoredDataUnit[]>;
350
+ /** Units of one actor with from <= seq <= to, ascending (anti-entropy). */
351
+ range(resource: ResourceId, actor: PrincipalId, from: ActorSequence, to: ActorSequence): Promise<StoredDataUnit[]>;
352
+ /** Units of the Resource with this status, by (actor, seq, unitId). */
353
+ withStatus(resource: ResourceId, status: DataUnitStatus): Promise<StoredDataUnit[]>;
354
+ /** The accepted unit at (resource, actor, seq), if any. */
355
+ acceptedAt(resource: ResourceId, actor: PrincipalId, seq: ActorSequence): Promise<DataUnitId | undefined>;
356
+ /**
357
+ * Atomically stores a signature-valid unit (exact bytes, status "seen")
358
+ * unless it is stored already, and returns every unit ID recorded for
359
+ * its (resource, actor, seq). The durable form of the wire SeenUnits
360
+ * recordSignatureValid.
361
+ */
362
+ recordSeen(unit: DataUnitRow): Promise<SeenRecord>;
363
+ }
364
+ export interface KeyPackageReader {
365
+ get(packageId: Hash32): Promise<KeyPackageRow | undefined>;
366
+ /** Packages of the Resource, optionally of one epoch and/or recipient, by (epoch, packageId). */
367
+ list(resource: ResourceId, filter?: {
368
+ readonly epoch?: DataEpoch;
369
+ readonly recipient?: PrincipalId;
370
+ }): Promise<KeyPackageRow[]>;
371
+ }
372
+ export interface SnapshotReader {
373
+ get(snapshotId: Hash32): Promise<SnapshotRow | undefined>;
374
+ /** Snapshots of the Resource, optionally of one epoch, by (epoch, publisher, snapshotSeq). */
375
+ list(resource: ResourceId, filter?: {
376
+ readonly epoch?: DataEpoch;
377
+ }): Promise<SnapshotRow[]>;
378
+ }
379
+ export interface ResourceReader {
380
+ get(resource: ResourceId): Promise<ResourceRow | undefined>;
381
+ list(): Promise<ResourceRow[]>;
382
+ route(resource: ResourceId): Promise<RouteRow | undefined>;
383
+ }
384
+ export interface OutboundReader {
385
+ /** Items in enqueue order, optionally of one Resource. */
386
+ list(resource?: ResourceId): Promise<OutboundItem[]>;
387
+ get(itemId: Hash32): Promise<OutboundItem | undefined>;
388
+ }
389
+ export interface ProfileStateReader {
390
+ checkpoint(resource: ResourceId): Promise<ProfileCheckpoint | undefined>;
391
+ }
392
+ export interface SyncStateReader {
393
+ get(resource: ResourceId): Promise<SyncStateRow | undefined>;
394
+ }
395
+ /**
396
+ * Small device-local records the client keeps about its own processing,
397
+ * such as the crash-loop breaker's apply marker. Never protocol state,
398
+ * never secrets: plain strings under string keys.
399
+ */
400
+ export interface LocalMarkReader {
401
+ get(key: string): Promise<string | undefined>;
402
+ /** Every mark whose key starts with `prefix`, by key. */
403
+ list(prefix: string): Promise<{
404
+ readonly key: string;
405
+ readonly value: string;
406
+ }[]>;
407
+ }
408
+ /** Everything a client persists, except secrets (SecretStore). */
409
+ export interface LfcpStorage {
410
+ readonly control: ControlReader;
411
+ readonly dataUnits: DataUnitReader;
412
+ readonly keyPackages: KeyPackageReader;
413
+ readonly snapshots: SnapshotReader;
414
+ readonly resources: ResourceReader;
415
+ readonly outbound: OutboundReader;
416
+ readonly profileState: ProfileStateReader;
417
+ readonly syncState: SyncStateReader;
418
+ readonly localMarks: LocalMarkReader;
419
+ readonly actorSequences: ActorSequenceReservation;
420
+ readonly snapshotSequences: SnapshotSequenceReservation;
421
+ /** Applies every write or none; durable before it resolves. */
422
+ commit(writes: readonly StorageWrite[]): Promise<CommitResult>;
423
+ }
424
+ //# sourceMappingURL=store.d.ts.map
package/dist/store.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=store.js.map
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@openlfcp/storage",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "Storage interfaces for LFCP clients (no adapters).",
5
+ "keywords": [
6
+ "openlfcp",
7
+ "lfcp",
8
+ "local-first",
9
+ "end-to-end-encryption",
10
+ "storage",
11
+ "persistence"
12
+ ],
13
+ "license": "Apache-2.0",
14
+ "homepage": "https://github.com/openlfcp/sdk-ts/tree/main/packages/storage#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/openlfcp/sdk-ts/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/openlfcp/sdk-ts.git",
21
+ "directory": "packages/storage"
22
+ },
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "engines": {
26
+ "node": ">=24"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ },
33
+ "./contract": {
34
+ "types": "./dist/contract.d.ts",
35
+ "import": "./dist/contract.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "!dist/**/*.map",
41
+ "!dist/**/*.tsbuildinfo"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "tag": "next"
46
+ },
47
+ "dependencies": {
48
+ "@openlfcp/core": "^0.1.0-rc.1"
49
+ }
50
+ }