@openlfcp/client 0.1.5 → 0.2.0-beta.10
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/README.md +2 -2
- package/dist/access-recovery.d.ts +69 -0
- package/dist/access-recovery.js +45 -0
- package/dist/batch-status.d.ts +104 -0
- package/dist/batch-status.js +171 -0
- package/dist/claim-journal.d.ts +54 -0
- package/dist/claim-journal.js +52 -0
- package/dist/connection.d.ts +9 -0
- package/dist/connection.js +12 -1
- package/dist/data-unit.js +6 -4
- package/dist/index.d.ts +14 -6
- package/dist/index.js +13 -5
- package/dist/invite.d.ts +42 -3
- package/dist/invite.js +155 -10
- package/dist/outbound.d.ts +25 -3
- package/dist/outbound.js +38 -4
- package/dist/queue.d.ts +4 -0
- package/dist/queue.js +16 -10
- package/dist/receipts.d.ts +60 -0
- package/dist/receipts.js +118 -0
- package/dist/repropose.d.ts +81 -0
- package/dist/repropose.js +221 -0
- package/dist/revoke.d.ts +71 -0
- package/dist/revoke.js +108 -0
- package/dist/section-status.d.ts +94 -0
- package/dist/section-status.js +16 -0
- package/dist/storage.d.ts +46 -0
- package/dist/storage.js +105 -4
- package/dist/sync-client.d.ts +161 -3
- package/dist/sync-client.js +807 -14
- package/dist/typing.d.ts +60 -0
- package/dist/typing.js +110 -0
- package/dist/write-access.d.ts +115 -0
- package/dist/write-access.js +87 -0
- package/package.json +6 -6
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { DataUnitId, ResourceId } from "@openlfcp/core";
|
|
2
|
+
import type { LfcpStorage } from "@openlfcp/storage";
|
|
3
|
+
import type { BatchStatus } from "./batch-status.js";
|
|
4
|
+
import type { AccessState } from "./write-access.js";
|
|
5
|
+
/**
|
|
6
|
+
* The status stream of a Resource (SDK-SECTIONS-INTEGRATION-01 §4, §5,
|
|
7
|
+
* LFCP-02-026): what an editor adapter shows as saved, waiting, accepted
|
|
8
|
+
* or not, and what it knows about units received from others. Every fact
|
|
9
|
+
* comes from evidence the SDK holds; none is derived from a sent frame.
|
|
10
|
+
*/
|
|
11
|
+
/** §4.2: the signal that showed the server lost units it had acknowledged. */
|
|
12
|
+
export type ReofferReason = "unknown-previous" | "have-gap" | "rehost";
|
|
13
|
+
/** §4.3: what became of a unit received from others. */
|
|
14
|
+
export type ReceivedFact = "held" | "waiting" | "refused";
|
|
15
|
+
/**
|
|
16
|
+
* LFCP-02-028: how far this session caught up with the Resource.
|
|
17
|
+
* "current-at-checkpoint" only once the Resource was live (Control, keys
|
|
18
|
+
* and every offered unit received and applied), at `checkedAt`; it stays
|
|
19
|
+
* so offline, as of that check. "unknown" while a Control conflict or a
|
|
20
|
+
* missing key blocks the catch-up.
|
|
21
|
+
*/
|
|
22
|
+
export type CatchUp = "not-started" | "receiving" | "current-at-checkpoint" | "unknown";
|
|
23
|
+
/** §4.3: a section without `ready` is importing. */
|
|
24
|
+
export type SectionState = "ready" | "importing";
|
|
25
|
+
/**
|
|
26
|
+
* §5: one event of a Resource's status stream. `revision` grows by one with
|
|
27
|
+
* each event of that Resource in this SDK session; a consumer that sees a
|
|
28
|
+
* revision other than the next one asks for statusSnapshot.
|
|
29
|
+
*/
|
|
30
|
+
export type StatusEvent = {
|
|
31
|
+
readonly revision: number;
|
|
32
|
+
} & (({
|
|
33
|
+
readonly kind: "batch";
|
|
34
|
+
} & BatchStatus) | {
|
|
35
|
+
readonly kind: "reoffered";
|
|
36
|
+
readonly unitIds: readonly DataUnitId[];
|
|
37
|
+
readonly reason: ReofferReason;
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: "received";
|
|
40
|
+
readonly fact: ReceivedFact;
|
|
41
|
+
readonly unitIds: readonly DataUnitId[];
|
|
42
|
+
/** The admission or merge diagnostic, when refused. */
|
|
43
|
+
readonly diagnostic?: string;
|
|
44
|
+
} | {
|
|
45
|
+
readonly kind: "section-state";
|
|
46
|
+
readonly state: SectionState;
|
|
47
|
+
} | {
|
|
48
|
+
readonly kind: "access";
|
|
49
|
+
readonly access: AccessState;
|
|
50
|
+
} | {
|
|
51
|
+
readonly kind: "catch-up";
|
|
52
|
+
readonly state: CatchUp;
|
|
53
|
+
/** The local time the Resource was last live; null before. */
|
|
54
|
+
readonly checkedAt: number | null;
|
|
55
|
+
}
|
|
56
|
+
/** The Resource was hosted again on this route (§41.1). */
|
|
57
|
+
| {
|
|
58
|
+
readonly kind: "rehost";
|
|
59
|
+
readonly route: string;
|
|
60
|
+
});
|
|
61
|
+
/** The body of a status event, before its revision is assigned. */
|
|
62
|
+
export type StatusEventBody = StatusEvent extends infer E ? E extends {
|
|
63
|
+
readonly revision: number;
|
|
64
|
+
} ? Omit<E, "revision"> : never : never;
|
|
65
|
+
/** §4.3: the received units that are not merged, as stored. */
|
|
66
|
+
export interface ReceivedState {
|
|
67
|
+
readonly held: readonly DataUnitId[];
|
|
68
|
+
readonly waiting: readonly DataUnitId[];
|
|
69
|
+
readonly refused: readonly {
|
|
70
|
+
readonly unitId: DataUnitId;
|
|
71
|
+
readonly diagnostic: string;
|
|
72
|
+
}[];
|
|
73
|
+
}
|
|
74
|
+
/** §5: the complete state of a Resource's status, at `revision`. */
|
|
75
|
+
export interface StatusSnapshot {
|
|
76
|
+
readonly revision: number;
|
|
77
|
+
readonly batches: readonly BatchStatus[];
|
|
78
|
+
readonly received: ReceivedState;
|
|
79
|
+
/** Unknown without a section binding, or before the section exists. */
|
|
80
|
+
readonly section: SectionState | "unknown";
|
|
81
|
+
readonly access: AccessState;
|
|
82
|
+
readonly catchUp: {
|
|
83
|
+
readonly state: CatchUp;
|
|
84
|
+
readonly checkedAt: number | null;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The received units of `resource` that wait or were refused: held behind
|
|
89
|
+
* a change with their actor and sequence (SOP §14.1), waiting for a
|
|
90
|
+
* dependency (the profile's, or the previous unit of their actor, §26.2),
|
|
91
|
+
* refused when merging. Units of this client are never among them.
|
|
92
|
+
*/
|
|
93
|
+
export declare function receivedState(storage: Pick<LfcpStorage, "dataUnits">, resource: ResourceId): Promise<ReceivedState>;
|
|
94
|
+
//# sourceMappingURL=section-status.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The received units of `resource` that wait or were refused: held behind
|
|
3
|
+
* a change with their actor and sequence (SOP §14.1), waiting for a
|
|
4
|
+
* dependency (the profile's, or the previous unit of their actor, §26.2),
|
|
5
|
+
* refused when merging. Units of this client are never among them.
|
|
6
|
+
*/
|
|
7
|
+
export async function receivedState(storage, resource) {
|
|
8
|
+
const ids = async (status) => (await storage.dataUnits.withStatus(resource, status)).map((u) => u.unitId);
|
|
9
|
+
const refused = (await storage.dataUnits.withStatus(resource, "profile-rejected")).map((u) => Object.freeze({ unitId: u.unitId, diagnostic: u.detail ?? "" }));
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
held: Object.freeze(await ids("profile-held")),
|
|
12
|
+
waiting: Object.freeze([...(await ids("profile-pending")), ...(await ids("held"))]),
|
|
13
|
+
refused: Object.freeze(refused),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=section-status.js.map
|
package/dist/storage.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type ResourceDEK } from "@openlfcp/crypto";
|
|
|
3
3
|
import { type CommitResult, type DataUnitRow, type LfcpStorage, type SecretStore, type StorageWrite } from "@openlfcp/storage";
|
|
4
4
|
import { type ChainResult, type SeenRecord, type SeenUnits } from "@openlfcp/wire";
|
|
5
5
|
import { type CreateDataUnitOptions, type CreatedDataUnit } from "./data-unit.js";
|
|
6
|
+
import { type Receipt } from "./receipts.js";
|
|
6
7
|
/**
|
|
7
8
|
* The client code on top of the storage interfaces (LFCP-034), so that a
|
|
8
9
|
* durable adapter (LFCP-035) is all a platform has to write.
|
|
@@ -33,6 +34,23 @@ export declare class StoredSeenUnits implements SeenUnits {
|
|
|
33
34
|
* stored head back to Genesis. Undefined when nothing is stored.
|
|
34
35
|
*/
|
|
35
36
|
export declare function loadControlChain(storage: Pick<LfcpStorage, "control">, resource: ResourceId): Promise<ChainResult | undefined>;
|
|
37
|
+
/**
|
|
38
|
+
* The client's LOCAL Control Chain: the committed chain extended by this
|
|
39
|
+
* client's own queued Control Records that are still live (not yet
|
|
40
|
+
* acknowledged, and not blocked). A writer chains a new record on this
|
|
41
|
+
* local head, not the committed head, so records authored back to back
|
|
42
|
+
* (e.g. two invitations) carry consecutive sequence numbers and a linear
|
|
43
|
+
* `previous` instead of colliding on the committed head (§47,
|
|
44
|
+
* CONTROL_HEAD_MISMATCH). Blocked items are excluded: they will not land as
|
|
45
|
+
* they are, so nothing is chained on them (the re-proposal re-authors them).
|
|
46
|
+
*
|
|
47
|
+
* Returns the committed chain unchanged when there are no live pending
|
|
48
|
+
* records, `undefined` when the Resource has no stored chain, and the
|
|
49
|
+
* committed chain when the pending records do not extend it linearly (they
|
|
50
|
+
* are then left for the re-proposal to sort out, and the caller sees the
|
|
51
|
+
* committed head).
|
|
52
|
+
*/
|
|
53
|
+
export declare function localControlChain(storage: Pick<LfcpStorage, "control" | "outbound">, resource: ResourceId): Promise<ChainResult | undefined>;
|
|
36
54
|
/**
|
|
37
55
|
* Persists a validated linear chain in one atomic batch: its exact records,
|
|
38
56
|
* the head (compare-and-set against `expected`), the epoch history (keeping
|
|
@@ -93,4 +111,32 @@ export declare function createQueuedDataUnit<T>(storage: Pick<LfcpStorage, "acto
|
|
|
93
111
|
*/
|
|
94
112
|
readonly onCreated?: (created: CreatedDataUnit, value: T) => void;
|
|
95
113
|
}, also?: readonly StorageWrite[] | ((created: CreatedDataUnit) => readonly StorageWrite[])): Promise<CreatedDataUnit>;
|
|
114
|
+
/**
|
|
115
|
+
* SDK-SECTIONS-INTEGRATION-01 §3.1 (LFCP-02-025): commits one operation, a
|
|
116
|
+
* batch of a profile's changes, as Data Units with their outbound entries,
|
|
117
|
+
* the caller's writes (`also`: its profile checkpoint) and the receipt, in
|
|
118
|
+
* ONE storage transaction: after a crash either all of it exists or none.
|
|
119
|
+
* The units chain their previous units in order (§26.2).
|
|
120
|
+
*
|
|
121
|
+
* Idempotent per (Resource, operationId) (§3.3): an operation that already
|
|
122
|
+
* has a receipt for the same intents returns it and writes nothing; for
|
|
123
|
+
* different intents it throws OperationIdReusedError. The caller stages
|
|
124
|
+
* its changes first and adopts them only once this resolves.
|
|
125
|
+
*/
|
|
126
|
+
export interface CommitOperationOptions<T> extends Omit<CreateDataUnitOptions<T>, "sequences" | "previousUnitId" | "value"> {
|
|
127
|
+
readonly operationId: string;
|
|
128
|
+
/** The batch's intents, as the caller submitted them (their canonical form is hashed, §3.3). */
|
|
129
|
+
readonly intents: readonly unknown[];
|
|
130
|
+
/** The batch's changes in order; each becomes one Data Unit. */
|
|
131
|
+
readonly values: readonly T[];
|
|
132
|
+
readonly affectedNodeIds: readonly string[];
|
|
133
|
+
/** The document's heads after the batch, sorted, as one string. */
|
|
134
|
+
readonly modelRevision: string;
|
|
135
|
+
/** Called for each sealed unit before the commit (e.g. the profile's recordLocal). */
|
|
136
|
+
readonly onCreated?: (created: CreatedDataUnit, value: T, index: number) => void;
|
|
137
|
+
}
|
|
138
|
+
export declare function commitOperation<T>(storage: Pick<LfcpStorage, "actorSequences" | "commit" | "dataUnits" | "localMarks">, options: CommitOperationOptions<T>, also?: readonly StorageWrite[] | ((units: readonly CreatedDataUnit[]) => readonly StorageWrite[])): Promise<{
|
|
139
|
+
readonly receipt: Receipt;
|
|
140
|
+
readonly committed: boolean;
|
|
141
|
+
}>;
|
|
96
142
|
//# sourceMappingURL=storage.d.ts.map
|
package/dist/storage.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { actorSequence, bytesEqual, hash32, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
2
|
import { dekCommitment, importResourceDEK } from "@openlfcp/crypto";
|
|
3
3
|
import { dekSecretRef, } from "@openlfcp/storage";
|
|
4
|
-
import { parseDataUnit, validateControlChain, } from "@openlfcp/wire";
|
|
4
|
+
import { parseControlRecord, parseDataUnit, validateControlChain, } from "@openlfcp/wire";
|
|
5
|
+
import { receiptIndexWrites, replaceReleasedWrites } from "./batch-status.js";
|
|
5
6
|
import { createDataUnit } from "./data-unit.js";
|
|
7
|
+
import { intentsHash, OperationIdReusedError, receiptOf, receiptWrite, } from "./receipts.js";
|
|
6
8
|
/**
|
|
7
9
|
* The client code on top of the storage interfaces (LFCP-034), so that a
|
|
8
10
|
* durable adapter (LFCP-035) is all a platform has to write.
|
|
@@ -85,6 +87,38 @@ export async function loadControlChain(storage, resource) {
|
|
|
85
87
|
throw new LfcpError("INVALID_CONTROL_CHAIN", `the stored Control Records validate to sequence ${result.state.seq}, not the stored head at ${head.controlSeq} (local corruption)`);
|
|
86
88
|
return result;
|
|
87
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* The client's LOCAL Control Chain: the committed chain extended by this
|
|
92
|
+
* client's own queued Control Records that are still live (not yet
|
|
93
|
+
* acknowledged, and not blocked). A writer chains a new record on this
|
|
94
|
+
* local head, not the committed head, so records authored back to back
|
|
95
|
+
* (e.g. two invitations) carry consecutive sequence numbers and a linear
|
|
96
|
+
* `previous` instead of colliding on the committed head (§47,
|
|
97
|
+
* CONTROL_HEAD_MISMATCH). Blocked items are excluded: they will not land as
|
|
98
|
+
* they are, so nothing is chained on them (the re-proposal re-authors them).
|
|
99
|
+
*
|
|
100
|
+
* Returns the committed chain unchanged when there are no live pending
|
|
101
|
+
* records, `undefined` when the Resource has no stored chain, and the
|
|
102
|
+
* committed chain when the pending records do not extend it linearly (they
|
|
103
|
+
* are then left for the re-proposal to sort out, and the caller sees the
|
|
104
|
+
* committed head).
|
|
105
|
+
*/
|
|
106
|
+
export async function localControlChain(storage, resource) {
|
|
107
|
+
const committed = await loadControlChain(storage, resource);
|
|
108
|
+
if (committed === undefined || committed.kind !== "linear")
|
|
109
|
+
return committed;
|
|
110
|
+
const pending = (await storage.outbound.list(resource))
|
|
111
|
+
.filter((i) => i.kind === "control-record" && i.blocked === null)
|
|
112
|
+
.map((i) => parseControlRecord(i.bytes))
|
|
113
|
+
.sort((a, b) => (a.payload.controlSeq < b.payload.controlSeq ? -1 : 1));
|
|
114
|
+
if (pending.length === 0)
|
|
115
|
+
return committed;
|
|
116
|
+
const extended = validateControlChain([
|
|
117
|
+
...committed.records.map((r) => r.signed.bytes),
|
|
118
|
+
...pending.map((p) => p.signed.bytes),
|
|
119
|
+
]);
|
|
120
|
+
return extended.kind === "linear" ? extended : committed;
|
|
121
|
+
}
|
|
88
122
|
/**
|
|
89
123
|
* Persists a validated linear chain in one atomic batch: its exact records,
|
|
90
124
|
* the head (compare-and-set against `expected`), the epoch history (keeping
|
|
@@ -249,9 +283,13 @@ export async function createQueuedDataUnit(storage, options, also = []) {
|
|
|
249
283
|
}
|
|
250
284
|
async function createQueuedDataUnitNow(storage, options, also, resource, actor) {
|
|
251
285
|
const { onCreated, previousUnitId, ...create } = options;
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
286
|
+
const latest = await latestAcceptedOwnUnit(storage, resource, actor);
|
|
287
|
+
// §26.2: a null previous only when the writer holds no own unit as
|
|
288
|
+
// accepted (before its first, or after a first that went stale); while it
|
|
289
|
+
// holds one, a receiver that accepted it would hold this unit for good.
|
|
290
|
+
if (previousUnitId === null && latest !== null)
|
|
291
|
+
throw new LfcpError("INVALID_STRUCTURE", "the writer still holds an accepted own unit: its next unit names it as previous (§26.2)");
|
|
292
|
+
const previous = previousUnitId !== undefined ? previousUnitId : latest;
|
|
255
293
|
const created = await createDataUnit({
|
|
256
294
|
...create,
|
|
257
295
|
previousUnitId: previous,
|
|
@@ -286,4 +324,67 @@ async function createQueuedDataUnitNow(storage, options, also, resource, actor)
|
|
|
286
324
|
throw new Error(`the local unit was not stored: ${result.reason}`);
|
|
287
325
|
return created;
|
|
288
326
|
}
|
|
327
|
+
export async function commitOperation(storage, options, also = []) {
|
|
328
|
+
const resource = options.view.state.resourceId;
|
|
329
|
+
const actor = options.actor.descriptor.principalId;
|
|
330
|
+
return serialized(storage, `${toHex(resource)}:${toHex(actor)}`, async () => {
|
|
331
|
+
const hash = intentsHash(options.intents);
|
|
332
|
+
const existing = await receiptOf(storage, resource, options.operationId);
|
|
333
|
+
if (existing !== undefined) {
|
|
334
|
+
if (existing.intentsHash !== hash)
|
|
335
|
+
throw new OperationIdReusedError(options.operationId);
|
|
336
|
+
return { receipt: existing, committed: false };
|
|
337
|
+
}
|
|
338
|
+
const { onCreated, operationId, intents, values, affectedNodeIds, modelRevision, ...create } = options;
|
|
339
|
+
void intents;
|
|
340
|
+
const first = await latestAcceptedOwnUnit(storage, resource, actor);
|
|
341
|
+
const created = [];
|
|
342
|
+
let previous = first;
|
|
343
|
+
for (const [i, value] of values.entries()) {
|
|
344
|
+
const unit = await createDataUnit({
|
|
345
|
+
...create,
|
|
346
|
+
value,
|
|
347
|
+
previousUnitId: previous,
|
|
348
|
+
sequences: storage.actorSequences,
|
|
349
|
+
});
|
|
350
|
+
onCreated?.(unit, value, i);
|
|
351
|
+
created.push(unit);
|
|
352
|
+
previous = unit.unitId;
|
|
353
|
+
}
|
|
354
|
+
const receipt = Object.freeze({
|
|
355
|
+
operationId,
|
|
356
|
+
unitIds: Object.freeze(created.map((u) => u.unitId)),
|
|
357
|
+
affectedNodeIds: Object.freeze([...affectedNodeIds]),
|
|
358
|
+
modelRevision,
|
|
359
|
+
intentsHash: hash,
|
|
360
|
+
durable: true,
|
|
361
|
+
});
|
|
362
|
+
const writes = [
|
|
363
|
+
{ op: "expect-previous-unit", resourceId: resource, actor, previous: first },
|
|
364
|
+
];
|
|
365
|
+
for (const unit of created) {
|
|
366
|
+
const row = dataUnitRow(unit.bytes);
|
|
367
|
+
writes.push({ op: "put-data-unit", unit: row, status: "merged", detail: "local", accepted: true }, {
|
|
368
|
+
op: "enqueue",
|
|
369
|
+
item: {
|
|
370
|
+
itemId: hash32(unit.unitId),
|
|
371
|
+
resourceId: row.resourceId,
|
|
372
|
+
kind: "data-unit",
|
|
373
|
+
bytes: unit.bytes,
|
|
374
|
+
attempts: 0,
|
|
375
|
+
lastAttempt: null,
|
|
376
|
+
nextAttempt: null,
|
|
377
|
+
blocked: null,
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
writes.push(
|
|
382
|
+
// A released batch kept for its status under this operation ID is replaced.
|
|
383
|
+
...(await replaceReleasedWrites(storage, resource, options.operationId)), receiptWrite(resource, receipt), ...receiptIndexWrites(resource, receipt), ...(typeof also === "function" ? also(created) : also));
|
|
384
|
+
const result = await storage.commit(writes);
|
|
385
|
+
if (!result.ok)
|
|
386
|
+
throw new Error(`the operation was not stored: ${result.reason}`);
|
|
387
|
+
return { receipt, committed: true };
|
|
388
|
+
});
|
|
389
|
+
}
|
|
289
390
|
//# sourceMappingURL=storage.js.map
|
package/dist/sync-client.d.ts
CHANGED
|
@@ -1,12 +1,52 @@
|
|
|
1
|
-
import { type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type ResourceId } from "@openlfcp/core";
|
|
1
|
+
import { type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
2
|
import { type AgreementKeyPair } from "@openlfcp/crypto";
|
|
3
|
-
import { type LfcpStorage, type SecretStore } from "@openlfcp/storage";
|
|
3
|
+
import { type LfcpStorage, type SecretStore, type StorageWrite } from "@openlfcp/storage";
|
|
4
4
|
import { type ClientConnectionState, type DataProfileCodec, type HaveVector, type Signer } from "@openlfcp/wire";
|
|
5
5
|
import type { ApplyOutcome, DataUnitApplier, EpochReconciliation } from "./apply.js";
|
|
6
6
|
import type { ProfileCheckpointer } from "./checkpoint.js";
|
|
7
7
|
import { type WebSocketFactory } from "./connection.js";
|
|
8
8
|
import type { AckOutcome, NackOutcome, OutboundQueue, StaleOutboundUnit } from "./outbound.js";
|
|
9
|
+
import { type Receipt } from "./receipts.js";
|
|
10
|
+
import { type ReproposeOutcome } from "./repropose.js";
|
|
9
11
|
import { type ResourcePhase } from "./resource-state.js";
|
|
12
|
+
import { type RemainingPath, type RevocationRefusal } from "./revoke.js";
|
|
13
|
+
import { type SectionState, type StatusEvent, type StatusSnapshot } from "./section-status.js";
|
|
14
|
+
import { type AccessState, type WriteAccess } from "./write-access.js";
|
|
15
|
+
/** LFCP-02-060: what revokeAccess did. */
|
|
16
|
+
export type RevokeAccessResult = {
|
|
17
|
+
readonly kind: "queued";
|
|
18
|
+
/** The grants revoked, and those delegated from them that this deactivates (§17.2). */
|
|
19
|
+
readonly revoked: readonly ControlRecordId[];
|
|
20
|
+
readonly deactivated: readonly ControlRecordId[];
|
|
21
|
+
/** The queued Control Records: the revocations, then the Key Epoch if any. */
|
|
22
|
+
readonly recordIds: readonly ControlRecordId[];
|
|
23
|
+
/** The new Data Epoch, or null when it was not rotated. */
|
|
24
|
+
readonly epoch: DataEpoch | null;
|
|
25
|
+
/** The remaining readers who get the new DEK in Key Packages. */
|
|
26
|
+
readonly recipients: readonly PrincipalId[];
|
|
27
|
+
/** Grants this revoker may not revoke: the member keeps access through them. */
|
|
28
|
+
readonly remainingPaths: readonly RemainingPath[];
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: "refused";
|
|
31
|
+
/**
|
|
32
|
+
* offline: the Resource is not live in this session; stale: the
|
|
33
|
+
* validated chain is behind what the server reported; control-pending:
|
|
34
|
+
* Control Records of this client are not committed yet. Nothing is
|
|
35
|
+
* queued: a removal never waits in an offline queue.
|
|
36
|
+
*/
|
|
37
|
+
readonly reason: RevocationRefusal | "offline" | "stale" | "control-pending";
|
|
38
|
+
readonly message: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* SDK-SECTIONS-INTEGRATION-01 §3.6: a batch the SDK refuses before the
|
|
42
|
+
* profile sees it, with the same codes and fields as the profile's
|
|
43
|
+
* refusals. Nothing is written.
|
|
44
|
+
*/
|
|
45
|
+
export declare class CommitRefusedError extends Error {
|
|
46
|
+
readonly code: string;
|
|
47
|
+
readonly intentIndex: number;
|
|
48
|
+
constructor(code: string, message: string, intentIndex: number);
|
|
49
|
+
}
|
|
10
50
|
/**
|
|
11
51
|
* The client sync session (LFCP-039a): one LFCP connection to a server and
|
|
12
52
|
* the §65 machine of every Resource opened on it, orchestrating the parts
|
|
@@ -41,6 +81,37 @@ export interface SnapshotBinding<T> {
|
|
|
41
81
|
load(value: T): void;
|
|
42
82
|
current(): T;
|
|
43
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* SDK-SECTIONS-INTEGRATION-01 §3.1: a batch of intents a Data Profile has
|
|
86
|
+
* validated and changed on a copy of its state (for Shared Sections:
|
|
87
|
+
* SharedSectionsDataProfile.commitBinding().stage).
|
|
88
|
+
*/
|
|
89
|
+
export interface StagedOperation<T> {
|
|
90
|
+
/** The batch's changes in order: one Data Unit each. */
|
|
91
|
+
readonly values: readonly T[];
|
|
92
|
+
readonly affectedNodeIds: readonly string[];
|
|
93
|
+
/** The profile state's revision after the batch. */
|
|
94
|
+
readonly modelRevision: string;
|
|
95
|
+
/** Adopts the batch in the profile state, before the commit is stored. */
|
|
96
|
+
apply(): void;
|
|
97
|
+
/** Undoes apply(): the commit did not happen, or had happened before. */
|
|
98
|
+
revert(): void;
|
|
99
|
+
/** Writes stored with the units: the profile checkpoint that includes them. */
|
|
100
|
+
writes(units: readonly {
|
|
101
|
+
readonly unitId: DataUnitId;
|
|
102
|
+
}[]): readonly StorageWrite[];
|
|
103
|
+
/** Called once the commit is durable, with the units in order. */
|
|
104
|
+
committed(unitIds: readonly DataUnitId[]): void;
|
|
105
|
+
}
|
|
106
|
+
/** How a Resource's Data Profile commits local batches (§3.1). */
|
|
107
|
+
export interface CommitBinding<T> {
|
|
108
|
+
/** The codec of this client's own units. */
|
|
109
|
+
readonly codec: DataProfileCodec<T>;
|
|
110
|
+
/** Validates and changes the batch on a copy; null when it writes nothing. Throws to refuse it. */
|
|
111
|
+
stage(intents: readonly unknown[]): StagedOperation<T> | null;
|
|
112
|
+
/** The section's state (SDK-SECTIONS-INTEGRATION-01 §4.3); undefined before there is a section. */
|
|
113
|
+
section?(): SectionState | undefined;
|
|
114
|
+
}
|
|
44
115
|
/** A Resource to synchronize: its Data Profile applier (and optional checkpoints and Snapshots). */
|
|
45
116
|
export interface ResourceBinding {
|
|
46
117
|
readonly resourceId: ResourceId;
|
|
@@ -48,6 +119,8 @@ export interface ResourceBinding {
|
|
|
48
119
|
readonly checkpointer?: ProfileCheckpointer;
|
|
49
120
|
/** Load an offered Snapshot instead of replaying every unit, and allow publishing. */
|
|
50
121
|
readonly snapshot?: SnapshotBinding<unknown>;
|
|
122
|
+
/** Commit local batches with receipts (commit, receiptOf). */
|
|
123
|
+
readonly commit?: CommitBinding<unknown>;
|
|
51
124
|
}
|
|
52
125
|
/** When to try connecting again after the `attempt`-th failure (1, 2, …): a delay in ms, or null to stop. */
|
|
53
126
|
export type ReconnectPolicy = (attempt: number) => number | null;
|
|
@@ -80,7 +153,8 @@ export interface SyncClientOptions {
|
|
|
80
153
|
readonly dataProfiles?: readonly string[];
|
|
81
154
|
/**
|
|
82
155
|
* Whether to publish a Snapshot of a LIVE Resource now, asked on every
|
|
83
|
-
* tick with the number of units merged since the last one
|
|
156
|
+
* tick with the number of units merged since the last one, received or
|
|
157
|
+
* committed here (a section's only writer reaches it too); no automatic
|
|
84
158
|
* schedule otherwise (publishSnapshot can also be called directly).
|
|
85
159
|
*/
|
|
86
160
|
readonly snapshotPolicy?: (resourceId: ResourceId, unitsSinceLast: number) => boolean;
|
|
@@ -142,6 +216,12 @@ export type SyncEvent = {
|
|
|
142
216
|
readonly type: "ack";
|
|
143
217
|
readonly outcome: AckOutcome;
|
|
144
218
|
}
|
|
219
|
+
/** SDK-SECTIONS-INTEGRATION-01 §5: the Resource's status stream (statusSnapshot after a gap). */
|
|
220
|
+
| {
|
|
221
|
+
readonly type: "status";
|
|
222
|
+
readonly resourceId: ResourceId;
|
|
223
|
+
readonly event: StatusEvent;
|
|
224
|
+
}
|
|
145
225
|
/** A NACK of an outbound object: stale, equivocation alarm, rejected, repropose, … */
|
|
146
226
|
| {
|
|
147
227
|
readonly type: "nack";
|
|
@@ -168,6 +248,34 @@ export type SyncEvent = {
|
|
|
168
248
|
readonly outcome: "hosted" | "refused";
|
|
169
249
|
readonly code?: string;
|
|
170
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* RESOURCE_OPEN was refused with AUTHORIZATION_FAILED although this
|
|
253
|
+
* client's validated chain grants it data/read: the server may have lost
|
|
254
|
+
* Control Records in a restore (LFCP-02-106). "started": the client
|
|
255
|
+
* re-supplies its chain with CONTROL_PUT; "recovered": the server opened
|
|
256
|
+
* the Resource again after it; "ended": no recovery, with the reason (see
|
|
257
|
+
* RecoveryEnd, or "still-refused" when the server holds our chain and
|
|
258
|
+
* still refuses), and the refusal stands.
|
|
259
|
+
*/
|
|
260
|
+
| {
|
|
261
|
+
readonly type: "access-recovery";
|
|
262
|
+
readonly resourceId: ResourceId;
|
|
263
|
+
readonly url: string;
|
|
264
|
+
readonly outcome: "started" | "recovered" | "ended";
|
|
265
|
+
readonly reason?: string;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* This client's own Control Records that a moved head refused
|
|
269
|
+
* (CONTROL_HEAD_MISMATCH) were re-proposed on the current head, or dropped
|
|
270
|
+
* when no longer authorized or meaningful (repropose.ts). One outcome per
|
|
271
|
+
* record; a copied invitation link keeps working, as a re-proposed grant
|
|
272
|
+
* keeps the same Invitation Principal and claim parameters.
|
|
273
|
+
*/
|
|
274
|
+
| {
|
|
275
|
+
readonly type: "repropose";
|
|
276
|
+
readonly resourceId: ResourceId;
|
|
277
|
+
readonly outcomes: readonly ReproposeOutcome[];
|
|
278
|
+
}
|
|
171
279
|
/**
|
|
172
280
|
* The server refused the Resource for good (see ResourceRefusal): it is
|
|
173
281
|
* CLOSED and is not opened again until open() is called for it.
|
|
@@ -212,6 +320,13 @@ export declare class SyncClient {
|
|
|
212
320
|
constructor(options: SyncClientOptions);
|
|
213
321
|
/** Subscribes to session events; returns the unsubscribe function. */
|
|
214
322
|
on(listener: (event: SyncEvent) => void): () => void;
|
|
323
|
+
/**
|
|
324
|
+
* §5: the complete status of `resource` at the stream's current revision:
|
|
325
|
+
* every unreleased batch, the received units that wait or were refused,
|
|
326
|
+
* the section state and write access. Events at or below its revision
|
|
327
|
+
* are already in it.
|
|
328
|
+
*/
|
|
329
|
+
statusSnapshot(resource: ResourceId): Promise<StatusSnapshot>;
|
|
215
330
|
/** Resolves once every message received so far has been handled (tests and shutdown). */
|
|
216
331
|
idle(): Promise<void>;
|
|
217
332
|
get connectionState(): ClientConnectionState;
|
|
@@ -242,6 +357,49 @@ export declare class SyncClient {
|
|
|
242
357
|
* that loads it never skips content the state does not hold. Queued and
|
|
243
358
|
* sent like any object; returns its ID.
|
|
244
359
|
*/
|
|
360
|
+
/**
|
|
361
|
+
* LFCP-02-060: removes `subject`'s access to `resource`. Every active
|
|
362
|
+
* grant naming it that this client may revoke is revoked (§17.3); when it
|
|
363
|
+
* has no read access left, the Data Epoch is rotated with a fresh DEK
|
|
364
|
+
* (§19, member revoked) and the DEK goes in Key Packages to every other
|
|
365
|
+
* remaining reader (§25), so it cannot read new data. Built on the
|
|
366
|
+
* validated head and checked before anything is queued; the records, the
|
|
367
|
+
* Key Packages and (first) the new DEK are written at once, then sent.
|
|
368
|
+
* Refused, with nothing queued, unless the Resource is live and current
|
|
369
|
+
* here and no Control Record of this client is pending.
|
|
370
|
+
*/
|
|
371
|
+
revokeAccess(resource: ResourceId, subject: PrincipalId, options?: {
|
|
372
|
+
readonly rotate?: boolean;
|
|
373
|
+
}): Promise<RevokeAccessResult>;
|
|
374
|
+
/**
|
|
375
|
+
* SDK-SECTIONS-INTEGRATION-01 §3.1: commits a batch of intents on an open
|
|
376
|
+
* Resource and returns its receipt once the batch is durable: its Data
|
|
377
|
+
* Units, outbound entries, the profile checkpoint and the receipt are
|
|
378
|
+
* stored in one transaction, then the batch is sent. An operation that
|
|
379
|
+
* has a receipt returns it for the same intents and writes nothing, and
|
|
380
|
+
* throws OperationIdReusedError for different ones (§3.3). Runs between
|
|
381
|
+
* received units, never during their merge.
|
|
382
|
+
*/
|
|
383
|
+
commit(resource: ResourceId, intents: readonly unknown[], options: {
|
|
384
|
+
readonly operationId: string;
|
|
385
|
+
}): Promise<Receipt>;
|
|
386
|
+
/**
|
|
387
|
+
* SDK-SECTIONS-INTEGRATION-01 §6: whether this client may commit to
|
|
388
|
+
* `resource`, from the validated Control state only: the chain of the
|
|
389
|
+
* last Control sync, or the stored chain validated now. `verifiedAt` is
|
|
390
|
+
* the local time of that validation (the last sync's when the stored
|
|
391
|
+
* chain is still at its head). Unknown without a validated chain.
|
|
392
|
+
*/
|
|
393
|
+
canWrite(resource: ResourceId): Promise<WriteAccess>;
|
|
394
|
+
/**
|
|
395
|
+
* LFCP-02-027: write access with its evidence: the Control sequence it
|
|
396
|
+
* was validated at against the highest one a server reported in this
|
|
397
|
+
* session (a stale view is not current), the effective abilities and
|
|
398
|
+
* the active grant paths giving them, and apart from them the
|
|
399
|
+
* invitations we issued, our Control Records not yet committed (SI14)
|
|
400
|
+
* and an unsettled invitation claim of ours.
|
|
401
|
+
*/
|
|
402
|
+
accessState(resource: ResourceId): Promise<AccessState>;
|
|
245
403
|
publishSnapshot(resource: ResourceId): Promise<Hash32>;
|
|
246
404
|
}
|
|
247
405
|
/**
|