@openlfcp/client 0.2.0-beta.9 → 0.2.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.
@@ -100,7 +100,7 @@ export interface StagedOperation<T> {
100
100
  writes(units: readonly {
101
101
  readonly unitId: DataUnitId;
102
102
  }[]): readonly StorageWrite[];
103
- /** Called once the commit is durable, with the units in order. */
103
+ /** Called once the commit is durable, with the units in order; revert() is never called after it. */
104
104
  committed(unitIds: readonly DataUnitId[]): void;
105
105
  }
106
106
  /** How a Resource's Data Profile commits local batches (§3.1). */
@@ -375,7 +375,10 @@ export declare class SyncClient {
375
375
  * SDK-SECTIONS-INTEGRATION-01 §3.1: commits a batch of intents on an open
376
376
  * Resource and returns its receipt once the batch is durable: its Data
377
377
  * Units, outbound entries, the profile checkpoint and the receipt are
378
- * stored in one transaction, then the batch is sent. An operation that
378
+ * stored in one transaction, then the batch is sent. Once it is stored
379
+ * the batch stands: a send that fails leaves it queued for the next
380
+ * connection, and a throwing listener is reported as an error event;
381
+ * neither reverts it or fails the commit (LFCP-03-034). An operation that
379
382
  * has a receipt returns it for the same intents and writes nothing, and
380
383
  * throws OperationIdReusedError for different ones (§3.3). Runs between
381
384
  * received units, never during their merge.
@@ -103,6 +103,8 @@ export class SyncClient {
103
103
  #sentAt = new Map();
104
104
  #requestTimeoutMs;
105
105
  #listeners = new Set();
106
+ /** #emit is reporting a listener's failure: a failure in that report is dropped. */
107
+ #reportingListenerError = false;
106
108
  /** Re-proposal attempts per Control Record (item ID hex), bounded; cleared on ACK (repropose.ts). */
107
109
  #reproposeAttempts = new Map();
108
110
  #antiEntropyMs;
@@ -145,9 +147,42 @@ export class SyncClient {
145
147
  this.#listeners.add(listener);
146
148
  return () => this.#listeners.delete(listener);
147
149
  }
150
+ /**
151
+ * LFCP-03-034: every listener gets the event, and a listener that throws
152
+ * never interrupts the SDK's own work (a commit, an ACK, received units,
153
+ * a loaded Snapshot): its failure is reported as an INTERNAL error event,
154
+ * and a failure while reporting one is dropped (no recursion). An engine
155
+ * trap is the exception and is thrown on, as before: reached from message
156
+ * handling (#serial) it stops the session; a call such as commit,
157
+ * publishSnapshot or revokeAccess rejects with it, and the session is not
158
+ * stopped.
159
+ */
148
160
  #emit(event) {
161
+ let failed;
162
+ let trap;
149
163
  for (const l of this.#listeners)
150
- l(event);
164
+ try {
165
+ l(event);
166
+ }
167
+ catch (error) {
168
+ if (isEngineTrap(error))
169
+ trap ??= { error };
170
+ else
171
+ failed ??= { error };
172
+ }
173
+ if (trap !== undefined)
174
+ throw trap.error;
175
+ if (failed === undefined || this.#reportingListenerError)
176
+ return;
177
+ this.#reportingListenerError = true;
178
+ try {
179
+ const e = failed.error;
180
+ const resourceId = "resourceId" in event ? event.resourceId : undefined;
181
+ this.#error("INTERNAL", `a listener of a ${event.type} event failed: ${e instanceof Error ? e.message : String(e)}`, resourceId);
182
+ }
183
+ finally {
184
+ this.#reportingListenerError = false;
185
+ }
151
186
  }
152
187
  // -------------------------------------------------------------------------
153
188
  // Status (SDK-SECTIONS-INTEGRATION-01 §4, §5; LFCP-02-026)
@@ -1398,8 +1433,9 @@ export class SyncClient {
1398
1433
  ]);
1399
1434
  if (!r.ok)
1400
1435
  throw new Error(`the revocation was not queued: ${r.reason}`);
1401
- await this.#refreshAccess(ctx);
1402
- await this.#flush(ctx);
1436
+ // Queued: the revocation stands whatever the report or the send does.
1437
+ await this.#afterStored(resource, () => this.#refreshAccess(ctx));
1438
+ await this.#afterStored(resource, () => this.#flush(ctx));
1403
1439
  return Object.freeze({
1404
1440
  kind: "queued",
1405
1441
  revoked: plan.revoked,
@@ -1414,7 +1450,10 @@ export class SyncClient {
1414
1450
  * SDK-SECTIONS-INTEGRATION-01 §3.1: commits a batch of intents on an open
1415
1451
  * Resource and returns its receipt once the batch is durable: its Data
1416
1452
  * Units, outbound entries, the profile checkpoint and the receipt are
1417
- * stored in one transaction, then the batch is sent. An operation that
1453
+ * stored in one transaction, then the batch is sent. Once it is stored
1454
+ * the batch stands: a send that fails leaves it queued for the next
1455
+ * connection, and a throwing listener is reported as an error event;
1456
+ * neither reverts it or fails the commit (LFCP-03-034). An operation that
1418
1457
  * has a receipt returns it for the same intents and writes nothing, and
1419
1458
  * throws OperationIdReusedError for different ones (§3.3). Runs between
1420
1459
  * received units, never during their merge.
@@ -1552,8 +1591,9 @@ export class SyncClient {
1552
1591
  await this.#guardCreate(ctx, view, intents);
1553
1592
  const staged = binding.stage(intents);
1554
1593
  staged?.apply();
1594
+ let stored;
1555
1595
  try {
1556
- const { receipt, committed } = await commitOperation(storage, {
1596
+ stored = await commitOperation(storage, {
1557
1597
  view,
1558
1598
  controlHead: view.state.head,
1559
1599
  actor: this.#o.signer,
@@ -1565,23 +1605,51 @@ export class SyncClient {
1565
1605
  affectedNodeIds: staged?.affectedNodeIds ?? [],
1566
1606
  modelRevision: staged?.modelRevision ?? "",
1567
1607
  }, (units) => staged?.writes(units) ?? []);
1568
- if (!committed)
1569
- staged?.revert();
1570
- else
1571
- staged?.committed(receipt.unitIds);
1572
- if (committed) {
1573
- // Our own units grow the history a Snapshot covers, as received ones do.
1574
- ctx.unitsSinceSnapshot += receipt.unitIds.length;
1575
- await this.#reportBatches(R, [receipt.operationId]);
1576
- this.#refreshSection(ctx);
1577
- }
1578
- await this.#flush(ctx);
1579
- return receipt;
1580
1608
  }
1581
1609
  catch (e) {
1610
+ // Not stored: the batch did not happen.
1582
1611
  staged?.revert();
1583
1612
  throw e;
1584
1613
  }
1614
+ const { receipt, committed } = stored;
1615
+ // The operation had a receipt already (a concurrent commit of it): this
1616
+ // staging did not happen.
1617
+ if (!committed)
1618
+ staged?.revert();
1619
+ // LFCP-03-034: from here the batch is durable and will be sent; nothing
1620
+ // below may undo it in the profile state or fail the commit. A failed
1621
+ // step is reported, and a failed send means the queue sends it later.
1622
+ else {
1623
+ await this.#afterStored(R, () => staged?.committed(receipt.unitIds));
1624
+ // Our own units grow the history a Snapshot covers, as received ones do.
1625
+ ctx.unitsSinceSnapshot += receipt.unitIds.length;
1626
+ await this.#afterStored(R, () => this.#reportBatches(R, [receipt.operationId]));
1627
+ await this.#afterStored(R, () => this.#refreshSection(ctx));
1628
+ }
1629
+ await this.#afterStored(R, () => this.#flush(ctx));
1630
+ return receipt;
1631
+ }
1632
+ /**
1633
+ * LFCP-03-034: a step after a local write is durable (the profile's
1634
+ * bookkeeping, status reports, the send). Its failure never undoes the
1635
+ * write or fails the call that made it: a lost connection leaves the
1636
+ * write in the outbound queue for the next one, and anything else (the
1637
+ * profile's committed() throwing, say) is reported as an error event, as
1638
+ * #emit reports a throwing listener. An engine trap is thrown on: the
1639
+ * call rejects with it.
1640
+ */
1641
+ async #afterStored(resource, step) {
1642
+ try {
1643
+ await step();
1644
+ }
1645
+ catch (e) {
1646
+ if (isEngineTrap(e))
1647
+ throw e;
1648
+ if (e instanceof ConnectionNotReady)
1649
+ return;
1650
+ // #emit does not throw a listener's failure: the report cannot undo the write.
1651
+ this.#error("INTERNAL", `a step after a durable local write failed; the write stands (${e instanceof Error ? e.message : String(e)})`, resource);
1652
+ }
1585
1653
  }
1586
1654
  async publishSnapshot(resource) {
1587
1655
  const ctx = this.#resources.get(toHex(resource));
@@ -1622,8 +1690,9 @@ export class SyncClient {
1622
1690
  value: binding.current(),
1623
1691
  });
1624
1692
  ctx.unitsSinceSnapshot = 0;
1625
- this.#emit({ type: "snapshot-published", resourceId: R, snapshotId: created.snapshotId });
1626
- await this.#flush(ctx);
1693
+ // Queued: the Snapshot stands whatever a listener or the send does.
1694
+ await this.#afterStored(R, () => this.#emit({ type: "snapshot-published", resourceId: R, snapshotId: created.snapshotId }));
1695
+ await this.#afterStored(R, () => this.#flush(ctx));
1627
1696
  return created.snapshotId;
1628
1697
  }
1629
1698
  async #dataRound(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/client",
3
- "version": "0.2.0-beta.9",
3
+ "version": "0.2.1",
4
4
  "description": "LFCP client: session, Control Plane and Data Plane synchronization.",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -39,12 +39,12 @@
39
39
  ],
40
40
  "publishConfig": {
41
41
  "access": "public",
42
- "tag": "beta"
42
+ "tag": "latest"
43
43
  },
44
44
  "dependencies": {
45
- "@openlfcp/core": "^0.2.0-beta.9",
46
- "@openlfcp/storage": "^0.2.0-beta.9",
47
- "@openlfcp/wire": "^0.2.0-beta.9",
48
- "@openlfcp/crypto": "^0.2.0-beta.9"
45
+ "@openlfcp/core": "^0.2.1",
46
+ "@openlfcp/storage": "^0.2.1",
47
+ "@openlfcp/wire": "^0.2.1",
48
+ "@openlfcp/crypto": "^0.2.1"
49
49
  }
50
50
  }