@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.
- package/dist/cadre-node.d.ts +65 -2
- package/dist/cadre-node.js +114 -2
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-read-retry.d.ts +19 -16
- package/dist/control-read-retry.js +22 -54
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +50 -6
- package/dist/control-retry.js +53 -6
- package/dist/control-retry.js.map +1 -1
- package/dist/control-write-retry.d.ts +24 -16
- package/dist/control-write-retry.js +58 -30
- package/dist/control-write-retry.js.map +1 -1
- package/dist/edge-node/config.d.ts +8 -2
- package/dist/edge-node/config.js +5 -1
- package/dist/edge-node/config.js.map +1 -1
- package/dist/edge-node/edge-join.js +2 -0
- package/dist/edge-node/edge-join.js.map +1 -1
- package/dist/edge-node/edge-node.d.ts +10 -0
- package/dist/edge-node/edge-node.js +52 -8
- package/dist/edge-node/edge-node.js.map +1 -1
- package/dist/edge-node/options.d.ts +11 -2
- package/dist/edge-node/options.js +11 -4
- package/dist/edge-node/options.js.map +1 -1
- package/dist/edge-node/platform.d.ts +15 -3
- package/dist/edge-node/strands.js +3 -2
- package/dist/edge-node/strands.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/strand-database.d.ts +56 -0
- package/dist/strand-database.js +87 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +67 -2
- package/dist/strand-instance-manager.js +105 -4
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +13 -1
- package/dist/strand-membership-reconciler.js +18 -7
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +16 -2
- package/dist/strand-membership-writer.js +54 -8
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-offline.d.ts +131 -0
- package/dist/strand-offline.js +142 -0
- package/dist/strand-offline.js.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/cadre-node.ts +11456 -11329
- package/src/control-read-retry.ts +22 -58
- package/src/control-retry.ts +54 -6
- package/src/control-write-retry.ts +58 -30
- package/src/edge-node/config.ts +11 -2
- package/src/edge-node/edge-join.ts +1 -0
- package/src/edge-node/edge-node.ts +55 -6
- package/src/edge-node/options.ts +20 -4
- package/src/edge-node/platform.ts +15 -3
- package/src/edge-node/strands.ts +3 -2
- package/src/index.ts +917 -898
- package/src/strand-database.ts +466 -352
- package/src/strand-instance-manager.ts +2210 -2092
- package/src/strand-membership-reconciler.ts +24 -7
- package/src/strand-membership-writer.ts +61 -14
- package/src/strand-offline.ts +247 -0
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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) =>
|
|
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 —
|
|
716
|
-
+ '
|
|
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 —
|
|
765
|
-
|
|
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
|
|
740
|
-
`insert into Strand.Invite (Key, Expiration)
|
|
741
|
-
with context ManagerKey =
|
|
742
|
-
values (
|
|
743
|
-
|
|
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
|
|
941
|
-
*
|
|
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
|
|
977
|
-
`insert into Strand.CancelledInvite (InviteKey)
|
|
978
|
-
with context ManagerKey =
|
|
979
|
-
values (
|
|
980
|
-
|
|
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
|
+
}
|