@serfab/cadre-core 1.19.0 → 1.19.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.
Files changed (64) hide show
  1. package/dist/cadre-node.d.ts +65 -2
  2. package/dist/cadre-node.js +114 -2
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-read-retry.d.ts +19 -16
  5. package/dist/control-read-retry.js +22 -54
  6. package/dist/control-read-retry.js.map +1 -1
  7. package/dist/control-retry.d.ts +50 -6
  8. package/dist/control-retry.js +53 -6
  9. package/dist/control-retry.js.map +1 -1
  10. package/dist/control-write-retry.d.ts +24 -16
  11. package/dist/control-write-retry.js +58 -30
  12. package/dist/control-write-retry.js.map +1 -1
  13. package/dist/edge-node/config.d.ts +8 -2
  14. package/dist/edge-node/config.js +5 -1
  15. package/dist/edge-node/config.js.map +1 -1
  16. package/dist/edge-node/edge-join.js +2 -0
  17. package/dist/edge-node/edge-join.js.map +1 -1
  18. package/dist/edge-node/edge-node.d.ts +10 -0
  19. package/dist/edge-node/edge-node.js +52 -8
  20. package/dist/edge-node/edge-node.js.map +1 -1
  21. package/dist/edge-node/options.d.ts +11 -2
  22. package/dist/edge-node/options.js +11 -4
  23. package/dist/edge-node/options.js.map +1 -1
  24. package/dist/edge-node/platform.d.ts +15 -3
  25. package/dist/edge-node/strands.js +3 -2
  26. package/dist/edge-node/strands.js.map +1 -1
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.js +6 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/strand-database.d.ts +56 -0
  31. package/dist/strand-database.js +87 -0
  32. package/dist/strand-database.js.map +1 -1
  33. package/dist/strand-instance-manager.d.ts +67 -2
  34. package/dist/strand-instance-manager.js +105 -4
  35. package/dist/strand-instance-manager.js.map +1 -1
  36. package/dist/strand-membership-reconciler.d.ts +13 -1
  37. package/dist/strand-membership-reconciler.js +18 -7
  38. package/dist/strand-membership-reconciler.js.map +1 -1
  39. package/dist/strand-membership-writer.d.ts +16 -2
  40. package/dist/strand-membership-writer.js +54 -8
  41. package/dist/strand-membership-writer.js.map +1 -1
  42. package/dist/strand-offline.d.ts +131 -0
  43. package/dist/strand-offline.js +142 -0
  44. package/dist/strand-offline.js.map +1 -0
  45. package/dist/types.d.ts +35 -0
  46. package/dist/types.js.map +1 -1
  47. package/package.json +8 -8
  48. package/src/cadre-node.ts +11456 -11329
  49. package/src/control-read-retry.ts +22 -58
  50. package/src/control-retry.ts +54 -6
  51. package/src/control-write-retry.ts +58 -30
  52. package/src/edge-node/config.ts +11 -2
  53. package/src/edge-node/edge-join.ts +1 -0
  54. package/src/edge-node/edge-node.ts +55 -6
  55. package/src/edge-node/options.ts +20 -4
  56. package/src/edge-node/platform.ts +15 -3
  57. package/src/edge-node/strands.ts +3 -2
  58. package/src/index.ts +917 -898
  59. package/src/strand-database.ts +466 -352
  60. package/src/strand-instance-manager.ts +2210 -2092
  61. package/src/strand-membership-reconciler.ts +24 -7
  62. package/src/strand-membership-writer.ts +61 -14
  63. package/src/strand-offline.ts +247 -0
  64. package/src/types.ts +2960 -2926
@@ -120,6 +120,15 @@
120
120
  * written nothing, and the pass retries on the ladder. A busy refusal is never read as a
121
121
  * dead invitation, and a busy burn leaves the invitation staged for the next pass.
122
122
  *
123
+ * A write refused because the strand's offline queue holds transactions
124
+ * (`StrandOfflineQueueBusyError`, docs/strands.md → "Offline mode") is a busy refusal too: it
125
+ * wrote nothing, and the ladder retries it until the queue drains. A membership commit queued
126
+ * behind an app commit anyway (the race that refusal leaves open) can fail with Optimystic's
127
+ * `OfflineCommitTimeoutError`, which is an unclassified failure here: a redemption keeps the
128
+ * invitation and retries, and a binding is retried by the next pass. Each pass reads the state
129
+ * before it writes (the member row, then the binding, which is insert-if-absent), so a write that
130
+ * landed after all (`sent: true`) is found by that pass rather than written twice.
131
+ *
123
132
  * ## Half-committed join
124
133
  *
125
134
  * On a networked strand optimystic commits `Member` and `ConsumedInvite` as separate
@@ -144,6 +153,7 @@ import type { Ed25519KeyPair } from './ed25519-key.js';
144
153
  import type { StrandMembershipInvite } from './types.js';
145
154
  import { strandMemberKeyPair } from './strand-member-key.js';
146
155
  import {
156
+ StrandOfflineQueueBusyError,
147
157
  StrandTransactionBusyError,
148
158
  burnInvite,
149
159
  consumeInvite,
@@ -231,7 +241,10 @@ export interface HalfCommittedJoin {
231
241
  /** Where {@link classifyConsumeFailure} routes a failed `consumeInvite` or `burnInvite`. */
232
242
  export type ConsumeFailure =
233
243
  | HalfCommittedJoin
234
- /** The app had a transaction open, so nothing was tried — keep the invitation, retry. */
244
+ /**
245
+ * The app had a transaction open, or the strand's offline queue held transactions, so nothing
246
+ * was tried — keep the invitation, retry.
247
+ */
235
248
  | { kind: 'busy' }
236
249
  /** The strand is sealed — terminal for a non-member; a member's leftover invitation is merely dead. */
237
250
  | { kind: 'sealed' }
@@ -289,9 +302,13 @@ function halfCommittedJoin(error: unknown): HalfCommittedJoin | undefined {
289
302
  return undefined;
290
303
  }
291
304
 
292
- /** Whether a writer refused because another transaction was open (see "Sharing the database with the app"). */
305
+ /**
306
+ * Whether a writer refused having written nothing, because another transaction was open or the
307
+ * offline queue held transactions (see "Sharing the database with the app").
308
+ */
293
309
  function isTransactionBusy(error: unknown): boolean {
294
- return causeChain(error).some((link) => link instanceof StrandTransactionBusyError);
310
+ return causeChain(error).some((link) =>
311
+ link instanceof StrandTransactionBusyError || link instanceof StrandOfflineQueueBusyError);
295
312
  }
296
313
 
297
314
  function errorMessage(error: unknown): string {
@@ -712,8 +729,8 @@ export class StrandMembershipReconciler {
712
729
  const failure = classifyConsumeFailure(error);
713
730
  switch (failure.kind) {
714
731
  case 'busy':
715
- log('[%s] burning the leftover invitation deferred — the app has a transaction open; it stays staged '
716
- + 'for the next pass', this.deps.label);
732
+ log('[%s] burning the leftover invitation deferred — nothing was tried; it stays staged for the next '
733
+ + 'pass: %s', this.deps.label, errorMessage(error));
717
734
  return;
718
735
  case 'sealed':
719
736
  case 'dead-invite':
@@ -761,8 +778,8 @@ export class StrandMembershipReconciler {
761
778
  const failure = classifyConsumeFailure(error);
762
779
  switch (failure.kind) {
763
780
  case 'busy':
764
- log('[%s] redeeming the staged invitation deferred — the app has a transaction open on the strand '
765
- + 'database; retrying next tick', this.deps.label);
781
+ log('[%s] redeeming the staged invitation deferred — nothing was tried; retrying next tick: %s',
782
+ this.deps.label, errorMessage(error));
766
783
  return;
767
784
  case 'half-committed':
768
785
  this.reportHalfCommittedJoin(failure, error, invite);
@@ -3,6 +3,7 @@ import { toString as uint8ArrayToString } from 'uint8arrays';
3
3
  import { TransactionActiveError } from '@quereus/quereus';
4
4
  import type { Database, SqlValue } from '@quereus/quereus';
5
5
  import { digest, sign, verify, generatePrivateKey, getPublicKey, randomBytes } from '@optimystic/quereus-plugin-crypto';
6
+ import { offlineControllerOf } from '@serfab/quereus-plugin-sereus';
6
7
  import type { SAppConfig } from './types.js';
7
8
  import type { Ed25519KeyPair } from './ed25519-key.js';
8
9
  import { canonicalDatetime } from './canonical-datetime.js';
@@ -146,6 +147,31 @@ export class StrandTransactionBusyError extends Error {
146
147
  }
147
148
  }
148
149
 
150
+ /**
151
+ * A membership write refused because the strand's offline queue holds transactions its members
152
+ * have not taken yet (docs/strands.md → "Offline mode"). Every `Strand` table is network-acknowledged, and a
153
+ * network-acknowledged commit made while transactions are queued waits for them to settle while
154
+ * holding Quereus's execution lock, so every other statement on the strand database, the app's
155
+ * included, would wait with it for up to the plugin's `networkCommitTimeoutMs`. Nothing was
156
+ * written. Raised whether or not the write would have joined an open transaction: joining would
157
+ * make the app's own commit wait the same way.
158
+ */
159
+ export class StrandOfflineQueueBusyError extends Error {
160
+ /** The queued transactions the write would have waited behind, in queue order. */
161
+ readonly transactionIds: readonly string[];
162
+
163
+ constructor(transactionIds: readonly string[]) {
164
+ super(
165
+ `The strand's offline queue holds ${transactionIds.length} transaction(s) its members have not taken yet `
166
+ + `(${transactionIds.join(', ')}). A membership write is acknowledged by the strand's members and would `
167
+ + 'wait behind them, holding the strand database, so nothing was written; await '
168
+ + 'strand.database.offline.whenDrained() and retry.',
169
+ );
170
+ this.name = 'StrandOfflineQueueBusyError';
171
+ this.transactionIds = transactionIds;
172
+ }
173
+ }
174
+
149
175
  /** Options every transactional membership writer accepts. */
150
176
  export interface StrandWriteOptions {
151
177
  /**
@@ -207,6 +233,8 @@ function combineStatements(...fragments: StrandStatements[]): StrandStatements {
207
233
  * autocommit statement is merely in flight, which would make a background writer refuse
208
234
  * spuriously and a composing caller run its statements as separate autocommits.
209
235
  *
236
+ * - The strand's offline queue holds transactions: {@link StrandOfflineQueueBusyError}, before
237
+ * anything runs, every writer and every option alike (see {@link refuseBehindOfflineQueue}).
210
238
  * - No transaction was open: the batch commits or fails as a unit, leaving nothing open.
211
239
  * - One was already open, so the batch refuses with `TransactionActiveError` having run
212
240
  * nothing. With `joinOpenTransaction: false` that becomes a
@@ -219,6 +247,7 @@ async function execStrandTransaction(
219
247
  statements: StrandStatements,
220
248
  options?: StrandWriteOptions,
221
249
  ): Promise<void> {
250
+ refuseBehindOfflineQueue(db);
222
251
  try {
223
252
  await db.exec(statements.sql, statements.params, { transaction: true });
224
253
  return;
@@ -244,6 +273,24 @@ async function execStrandTransaction(
244
273
  await db.exec(statements.sql, statements.params);
245
274
  }
246
275
 
276
+ /**
277
+ * Throw {@link StrandOfflineQueueBusyError} while the strand's offline queue lists any
278
+ * transaction. `entries()` also lists the journaled ones a restore has not put back yet, and a
279
+ * network-acknowledged commit waits behind those too.
280
+ *
281
+ * NOTE: racy by nature: an app commit queued between this check and the batch's `exec` makes the
282
+ * membership commit wait behind it, for up to `networkCommitTimeoutMs`, holding the execution
283
+ * lock. That is upstream's bounded wait in the rare case. If a frozen strand database is ever
284
+ * traced to a membership write that slipped past this check, hold the app's writes during
285
+ * membership commits instead.
286
+ */
287
+ function refuseBehindOfflineQueue(db: Database): void {
288
+ const queued = offlineControllerOf(db)?.entries() ?? [];
289
+ if (queued.length > 0) {
290
+ throw new StrandOfflineQueueBusyError(queued.map((entry) => entry.transactionId));
291
+ }
292
+ }
293
+
247
294
  /**
248
295
  * The `Strand.Revocation` tombstone insert that retires a deleted row's StampId.
249
296
  *
@@ -736,12 +783,12 @@ export async function issueInvite(db: Database, params: IssueInviteParams): Prom
736
783
  const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
737
784
  const inviteSignature = signStrandPayload(payload, invitePrivateKey);
738
785
 
739
- await db.exec(
740
- `insert into Strand.Invite (Key, Expiration)
741
- with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
742
- values (?, ?)`,
743
- [managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn],
744
- );
786
+ await execStrandTransaction(db, {
787
+ sql: `insert into Strand.Invite (Key, Expiration)
788
+ with context ManagerKey = :managerKey, ManagerSignature = :managerSignature, InviteSignature = :inviteSignature
789
+ values (:inviteKey, :expiration);`,
790
+ params: { managerKey: managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expiration: expirationColumn },
791
+ });
745
792
  log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
746
793
 
747
794
  return { inviteKey, invitePrivateKey };
@@ -937,8 +984,8 @@ export interface CancelInviteParams {
937
984
  * `digest('Strand.CancelledInvite', 'cancel', InviteKey)`. The digest binds the exact
938
985
  * invite key, so an approval minted for one invitation cannot cancel another.
939
986
  *
940
- * One row, one statement — no transaction needed. `CancelledInvite.Authorized` is a
941
- * deferred (subquery-bearing) check, so it fires at this statement's own auto-commit.
987
+ * One row, one statement. `CancelledInvite.Authorized` is a deferred (subquery-bearing)
988
+ * check, so it fires at the batch's commit, or at the caller's when it joins an open one.
942
989
  *
943
990
  * Insert-if-absent, matching {@link registerMemberPeer}'s and {@link revokeMember}'s
944
991
  * restart-safe shape: a repeat cancellation logs and returns rather than throwing on the
@@ -973,12 +1020,12 @@ export async function cancelInvite(db: Database, params: CancelInviteParams): Pr
973
1020
  ['Strand.CancelledInvite', 'cancel', inviteKey],
974
1021
  managerKeyPair.privateKeyB64,
975
1022
  );
976
- await db.exec(
977
- `insert into Strand.CancelledInvite (InviteKey)
978
- with context ManagerKey = ?, ManagerSignature = ?
979
- values (?)`,
980
- [managerKeyPair.publicKeyB64, signature, inviteKey],
981
- );
1023
+ await execStrandTransaction(db, {
1024
+ sql: `insert into Strand.CancelledInvite (InviteKey)
1025
+ with context ManagerKey = :managerKey, ManagerSignature = :managerSignature
1026
+ values (:inviteKey);`,
1027
+ params: { managerKey: managerKeyPair.publicKeyB64, managerSignature: signature, inviteKey },
1028
+ });
982
1029
  log('Cancelled invite %s by manager %s', inviteKey, managerKeyPair.publicKeyB64);
983
1030
  }
984
1031
 
@@ -0,0 +1,247 @@
1
+ import debug from 'debug';
2
+ import { EntryNotDiscardableError, UnknownEntryError } from '@optimystic/db-core';
3
+ import type { IKVStore } from '@optimystic/db-p2p';
4
+ import type {
5
+ AppSchemaOutcome, OfflineModeState, QueuedTransactionStatus, SereusPluginResult, StrandOfflineController,
6
+ } from '@serfab/quereus-plugin-sereus';
7
+
8
+ const log = debug('sereus:cadre:strand-offline');
9
+
10
+ /**
11
+ * Which acknowledgement a strand's transactions get from the moment it is set: `'offline'`, a
12
+ * commit resolves once the transaction is queued on this machine; `'online'`, once the strand's
13
+ * members have taken it.
14
+ */
15
+ export type StrandOfflineMode = 'online' | 'offline';
16
+
17
+ /** `CadreNodeConfig.strandOffline`: what a node needs to offer offline mode on its strands. */
18
+ export interface StrandOfflineConfig {
19
+ /**
20
+ * The journal for one strand: called once per database build with the strand id (a storage scope
21
+ * key). Queued transactions are saved in it before their commit resolves, so they survive a
22
+ * restart. A throw or rejection fails the build, as a storage provider's does.
23
+ *
24
+ * Nothing in cadre-core or Optimystic closes the store, and every rebuild (a wake, a retry while
25
+ * no seed peer answers) calls this again, so return the same store for the same scope and close
26
+ * it yourself after the node stops.
27
+ */
28
+ journal: (scope: string) => IKVStore | Promise<IKVStore>;
29
+ /** The mode every strand starts in until the app sets one. Default `'online'`. */
30
+ initialMode?: StrandOfflineMode;
31
+ }
32
+
33
+ /**
34
+ * `StrandDatabase.offline`: the strand connection's offline queue, less the mode switch, which
35
+ * goes through `CadreNode.setStrandOfflineMode` so that it outlives the database. Listeners taken
36
+ * here die with the database; the `CadreNode` events do not.
37
+ */
38
+ export type StrandOfflineQueue = Pick<StrandOfflineController,
39
+ 'queueState' | 'entries' | 'status' | 'outcomes' | 'acknowledge' | 'discard' | 'whenDrained' | 'onStatus' | 'onQueueState'>;
40
+
41
+ /**
42
+ * `StrandDatabase.appSchemaState()`: whether the database runs the `App` schema its launch was
43
+ * given. `'waiting-for-queue'` while queued transactions hold the apply back (the database runs the
44
+ * earlier schema); `'failed'` when a later retry failed for another reason.
45
+ */
46
+ export type StrandAppSchemaState = 'applied' | 'waiting-for-queue' | 'failed';
47
+
48
+ /** `strand:offline-status`: one status of one queued transaction. */
49
+ export interface StrandOfflineStatus {
50
+ readonly strandId: string;
51
+ readonly status: QueuedTransactionStatus;
52
+ }
53
+
54
+ /** `strand:queue-state`: the queue's state, for a status line. */
55
+ export interface StrandQueueState {
56
+ readonly strandId: string;
57
+ readonly state: OfflineModeState;
58
+ }
59
+
60
+ /** `strand:app-schema-deferred`: the queued transactions that held the `App` schema apply back. */
61
+ export interface StrandAppSchemaDeferred {
62
+ readonly strandId: string;
63
+ readonly transactionIds: readonly string[];
64
+ }
65
+
66
+ /**
67
+ * Where a strand database reports its offline queue and its app-schema apply. Retained with the
68
+ * launch config and handed to every database built for the launch, so the reports outlive
69
+ * hibernation and every other rebuild.
70
+ */
71
+ export interface StrandOfflineListeners {
72
+ onOfflineStatus?: (event: StrandOfflineStatus) => void;
73
+ onQueueState?: (event: StrandQueueState) => void;
74
+ onAppSchemaDeferred?: (event: StrandAppSchemaDeferred) => void;
75
+ onAppSchemaApplied?: (event: { strandId: string }) => void;
76
+ onAppSchemaFailed?: (event: { strandId: string; error: Error }) => void;
77
+ }
78
+
79
+ /** Thrown by `CadreNode.setStrandOfflineMode` on a node configured without `strandOffline`. */
80
+ export class StrandOfflineUnavailableError extends Error {
81
+ readonly strandId: string;
82
+
83
+ constructor(strandId: string) {
84
+ super(
85
+ `Cannot put strand ${strandId} in offline mode: this node has no offline journal ` +
86
+ '(CadreNodeConfig.strandOffline is not configured), and a queue lost with the process would ' +
87
+ 'break the promise a local commit makes.'
88
+ );
89
+ this.name = 'StrandOfflineUnavailableError';
90
+ this.strandId = strandId;
91
+ }
92
+ }
93
+
94
+ /** Options for leaving a strand: `CadreNode.forgetJoinedStrand` and `CadreNode.unpublishStrand`. */
95
+ export interface StrandLeaveOptions {
96
+ /**
97
+ * Withdraw the strand's queued offline transactions that were never sent, then leave. Without
98
+ * it a leave refuses with {@link StrandQueueNotEmptyError} while the queue holds any: the app was
99
+ * told each of them was acknowledged, so dropping them is the user's choice to make.
100
+ */
101
+ discardQueued?: boolean;
102
+ }
103
+
104
+ /**
105
+ * Thrown by a leave ({@link StrandLeaveOptions}) while the strand's offline queue holds
106
+ * transactions and `discardQueued` was not given. Nothing was left or removed.
107
+ */
108
+ export class StrandQueueNotEmptyError extends Error {
109
+ readonly strandId: string;
110
+ /** The queued transactions, by the `StampId()` each one's statements returned. */
111
+ readonly transactionIds: readonly string[];
112
+
113
+ constructor(strandId: string, transactionIds: readonly string[]) {
114
+ super(
115
+ `Cannot leave strand ${strandId}: its offline queue holds ${transactionIds.length} transaction(s) its ` +
116
+ `members have not taken yet (${transactionIds.join(', ')}), so nothing was left or removed. Show the user ` +
117
+ 'the unsent changes, then leave again with { discardQueued: true } to drop them, or once ' +
118
+ 'strand.database.offline.whenDrained() resolves.'
119
+ );
120
+ this.name = 'StrandQueueNotEmptyError';
121
+ this.strandId = strandId;
122
+ this.transactionIds = transactionIds;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Withdraw every transaction in `queue` that was never sent, for a leave the app confirmed
128
+ * ({@link StrandLeaveOptions.discardQueued}). Returns the ones Optimystic refuses to withdraw
129
+ * (`EntryNotDiscardableError`): each was sent, or is being sent or restored, and the strand's
130
+ * members may already hold it, so it stays in the journal. One that settled meanwhile, or failed
131
+ * with a transaction it depended on, is already gone.
132
+ */
133
+ export function discardQueued(queue: Pick<StrandOfflineController, 'entries' | 'discard'>): string[] {
134
+ const kept: string[] = [];
135
+ for (const { transactionId } of queue.entries()) {
136
+ try {
137
+ queue.discard(transactionId);
138
+ } catch (error) {
139
+ if (error instanceof EntryNotDiscardableError) {
140
+ kept.push(transactionId);
141
+ } else if (!(error instanceof UnknownEntryError)) {
142
+ throw error;
143
+ }
144
+ }
145
+ }
146
+ return kept;
147
+ }
148
+
149
+ /**
150
+ * Whether a status shows the queue sending, which counts as strand activity for hibernation. A
151
+ * transaction waiting for its cohort (`queued`) is not, or an offline phone would never hibernate.
152
+ */
153
+ // NOTE: a send that keeps failing transiently while the cohort reads as reachable is re-tried at
154
+ // least every 60 s (Optimystic's re-check cap), so such a strand never idles. If a phone is found
155
+ // kept awake that way, count only `committed`, `partially-committed` and `failed`.
156
+ export function isSendingStatus(status: QueuedTransactionStatus): boolean {
157
+ return status.kind === 'submitting' || status.kind === 'committed'
158
+ || status.kind === 'partially-committed' || status.kind === 'failed';
159
+ }
160
+
161
+ /**
162
+ * What one strand database reports about its offline queue and its app-schema apply. Built once
163
+ * `connectToStrand` has returned (a restored transaction's `queued` status was delivered during its
164
+ * `hydrate`, before this subscribes: {@link published} reports the queue's state for that reason)
165
+ * and released first when the database closes, so nothing from a closing connection reaches the app.
166
+ *
167
+ * App-schema events begin at publication, when the app gets the database: a retry that landed
168
+ * before then is not announced, and one that failed before then is announced at publication.
169
+ */
170
+ export class StrandOfflineReport {
171
+ private appSchema: StrandAppSchemaState;
172
+ private appSchemaError: Error | undefined;
173
+ private wasPublished = false;
174
+ private released = false;
175
+ private readonly unsubscribe: () => void;
176
+
177
+ constructor(
178
+ private readonly strandId: string,
179
+ private readonly listeners: StrandOfflineListeners,
180
+ private readonly result: Pick<SereusPluginResult, 'offline' | 'appSchema' | 'appSchemaDeferredBy'>
181
+ ) {
182
+ // `appSchema` is absent without an `App` schema (a storage replica), which has nothing to defer.
183
+ this.appSchema = result.appSchemaDeferredBy ? 'waiting-for-queue' : 'applied';
184
+ const queue = result.offline;
185
+ const unsubscribeStatus = queue?.onStatus((status) => listeners.onOfflineStatus?.({ strandId, status }));
186
+ const unsubscribeState = queue?.onQueueState((state) => listeners.onQueueState?.({ strandId, state }));
187
+ this.unsubscribe = () => {
188
+ unsubscribeStatus?.();
189
+ unsubscribeState?.();
190
+ };
191
+ if (result.appSchemaDeferredBy) {
192
+ void result.appSchema?.then((outcome) => this.settleAppSchema(outcome)).catch((error: unknown) => {
193
+ log('strand %s: reporting the deferred app schema apply failed: %O', strandId, error);
194
+ });
195
+ }
196
+ }
197
+
198
+ appSchemaState(): StrandAppSchemaState {
199
+ return this.appSchema;
200
+ }
201
+
202
+ /** The database was handed to the app: report the queue's state, and an apply still waiting or already failed. */
203
+ published(): void {
204
+ if (this.released || this.wasPublished) {
205
+ return;
206
+ }
207
+ this.wasPublished = true;
208
+ const queue = this.result.offline;
209
+ if (queue) {
210
+ this.listeners.onQueueState?.({ strandId: this.strandId, state: queue.queueState() });
211
+ }
212
+ if (this.appSchema === 'waiting-for-queue') {
213
+ this.listeners.onAppSchemaDeferred?.({ strandId: this.strandId, transactionIds: this.result.appSchemaDeferredBy ?? [] });
214
+ } else if (this.appSchemaError) {
215
+ this.reportAppSchemaFailed(this.appSchemaError);
216
+ }
217
+ }
218
+
219
+ release(): void {
220
+ this.released = true;
221
+ this.unsubscribe();
222
+ }
223
+
224
+ /** A cancelled retry (the database closed first) reports nothing. */
225
+ private settleAppSchema(outcome: AppSchemaOutcome): void {
226
+ if (this.released || outcome.state === 'cancelled') {
227
+ return;
228
+ }
229
+ if (outcome.state === 'applied') {
230
+ this.appSchema = 'applied';
231
+ if (this.wasPublished) {
232
+ this.listeners.onAppSchemaApplied?.({ strandId: this.strandId });
233
+ }
234
+ return;
235
+ }
236
+ this.appSchema = 'failed';
237
+ this.appSchemaError = outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error));
238
+ log('strand %s: the deferred app schema apply failed; the database keeps the earlier schema: %O', this.strandId, outcome.error);
239
+ if (this.wasPublished) {
240
+ this.reportAppSchemaFailed(this.appSchemaError);
241
+ }
242
+ }
243
+
244
+ private reportAppSchemaFailed(error: Error): void {
245
+ this.listeners.onAppSchemaFailed?.({ strandId: this.strandId, error });
246
+ }
247
+ }