@serfab/cadre-core 1.3.0 → 1.5.0

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 (90) hide show
  1. package/README.md +5 -3
  2. package/dist/cadre-node.d.ts +70 -34
  3. package/dist/cadre-node.js +197 -60
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-database.d.ts +24 -10
  6. package/dist/control-database.js +48 -16
  7. package/dist/control-database.js.map +1 -1
  8. package/dist/control-formation-recorder.d.ts +4 -4
  9. package/dist/control-formation-recorder.js +6 -6
  10. package/dist/control-formation-recorder.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +12 -0
  12. package/dist/control-read-retry.js +12 -0
  13. package/dist/control-read-retry.js.map +1 -1
  14. package/dist/control-write-retry.d.ts +5 -4
  15. package/dist/control-write-retry.js +24 -7
  16. package/dist/control-write-retry.js.map +1 -1
  17. package/dist/index.d.ts +5 -4
  18. package/dist/index.js +10 -5
  19. package/dist/index.js.map +1 -1
  20. package/dist/link-budget.d.ts +182 -0
  21. package/dist/link-budget.js +195 -0
  22. package/dist/link-budget.js.map +1 -0
  23. package/dist/membership-connection-gater.d.ts +18 -5
  24. package/dist/membership-connection-gater.js +50 -10
  25. package/dist/membership-connection-gater.js.map +1 -1
  26. package/dist/peer-dial.d.ts +36 -17
  27. package/dist/peer-dial.js +37 -17
  28. package/dist/peer-dial.js.map +1 -1
  29. package/dist/peer-join-backfill.d.ts +58 -4
  30. package/dist/peer-join-backfill.js +165 -20
  31. package/dist/peer-join-backfill.js.map +1 -1
  32. package/dist/relay-reservation.d.ts +59 -11
  33. package/dist/relay-reservation.js +169 -43
  34. package/dist/relay-reservation.js.map +1 -1
  35. package/dist/seed-bootstrap.js +2 -1
  36. package/dist/seed-bootstrap.js.map +1 -1
  37. package/dist/storage-scope.d.ts +50 -12
  38. package/dist/storage-scope.js +85 -12
  39. package/dist/storage-scope.js.map +1 -1
  40. package/dist/strand-first-sync-gate.d.ts +43 -5
  41. package/dist/strand-first-sync-gate.js +55 -7
  42. package/dist/strand-first-sync-gate.js.map +1 -1
  43. package/dist/strand-formation-manager.js +2 -1
  44. package/dist/strand-formation-manager.js.map +1 -1
  45. package/dist/strand-id.d.ts +35 -0
  46. package/dist/strand-id.js +46 -0
  47. package/dist/strand-id.js.map +1 -0
  48. package/dist/strand-instance-manager.d.ts +27 -2
  49. package/dist/strand-instance-manager.js +75 -15
  50. package/dist/strand-instance-manager.js.map +1 -1
  51. package/dist/strand-membership-reconciler.d.ts +200 -45
  52. package/dist/strand-membership-reconciler.js +302 -86
  53. package/dist/strand-membership-reconciler.js.map +1 -1
  54. package/dist/strand-membership-writer.d.ts +3 -2
  55. package/dist/strand-membership-writer.js +67 -90
  56. package/dist/strand-membership-writer.js.map +1 -1
  57. package/dist/strand-network-config.d.ts +3 -3
  58. package/dist/strand-network-config.js +3 -3
  59. package/dist/strand-solicitation.js +2 -1
  60. package/dist/strand-solicitation.js.map +1 -1
  61. package/dist/strand-watcher.d.ts +45 -0
  62. package/dist/strand-watcher.js +74 -13
  63. package/dist/strand-watcher.js.map +1 -1
  64. package/dist/types.d.ts +190 -24
  65. package/dist/types.js +73 -4
  66. package/dist/types.js.map +1 -1
  67. package/package.json +8 -8
  68. package/src/cadre-node.ts +7445 -7302
  69. package/src/control-database.ts +48 -16
  70. package/src/control-formation-recorder.ts +6 -6
  71. package/src/control-read-retry.ts +214 -202
  72. package/src/control-write-retry.ts +24 -7
  73. package/src/index.ts +29 -2
  74. package/src/link-budget.ts +217 -0
  75. package/src/membership-connection-gater.ts +56 -11
  76. package/src/peer-dial.ts +39 -17
  77. package/src/peer-join-backfill.ts +192 -23
  78. package/src/relay-reservation.ts +1119 -951
  79. package/src/seed-bootstrap.ts +2 -1
  80. package/src/storage-scope.ts +163 -78
  81. package/src/strand-first-sync-gate.ts +55 -7
  82. package/src/strand-formation-manager.ts +647 -646
  83. package/src/strand-id.ts +48 -0
  84. package/src/strand-instance-manager.ts +1516 -1440
  85. package/src/strand-membership-reconciler.ts +334 -92
  86. package/src/strand-membership-writer.ts +1962 -1987
  87. package/src/strand-network-config.ts +250 -250
  88. package/src/strand-solicitation.ts +500 -499
  89. package/src/strand-watcher.ts +388 -325
  90. package/src/types.ts +192 -23
@@ -811,7 +811,8 @@ export class SeedBootstrapService {
811
811
  // (`dialPeerAddrs`), so an owner whose first address never answers neither
812
812
  // stalls seed application nor goes undialed at its other addresses.
813
813
  // NOTE: `handleSeedStream` acks only after this loop, so an unreachable owner
814
- // can hold the ack for up to `dialBudget.totalMs` (30 s) each, past a sender's
814
+ // can hold the ack for up to `dialBudget.totalMs` each — 32 s at the default declared link
815
+ // round trip, more on a host that declared a slower one (`link-budget.ts`) — past a sender's
815
816
  // 10 s `seedDeliverTimeoutMs`; the seed is still applied. If senders start
816
817
  // reporting accepted seeds as timed out, ack before dialing.
817
818
  const selfPeerId = this.libp2pNode.peerId?.toString();
@@ -1,78 +1,163 @@
1
- /**
2
- * storage-scope.ts — the scope keys `CadreNodeConfig.storage.provider` is called with.
3
- *
4
- * A provider is a factory cadre-core invokes once per **scope**: once for the control
5
- * database and once for each strand. A strand's scope key is its strand id; the control
6
- * database's scope key is what this module mints.
7
- *
8
- * WHY THE CONTROL SCOPE CARRIES THE PARTY ID. The control database holds the party's
9
- * own records — its strands, owner keys, peers, invitations, revocations. Two parties
10
- * on one device must therefore not share one control store, or a node started for
11
- * party B reads party A's rows as if party A had written them for B. Every other
12
- * party-scoped store in this codebase already knows this: `PersistentTrustedOwnerStore`,
13
- * `PersistentBootstrapPeerStore` and `PersistentEnrolledMachineStore` each take a
14
- * `partyId` and fail closed on a mismatch. Putting the party id in the key here means
15
- * no embedder has to know it belongs there, and none can get it wrong.
16
- *
17
- * WHY IT IS ENCODED. A party id is arbitrary text — nothing in cadre-core validates
18
- * its shape, and the React Native reference app lets a user type one into Settings.
19
- * Scope keys reach real namespaces unescaped: cadre-cli builds `${config.path}/${scope}`,
20
- * React Native a LevelDB filename, the browser an IndexedDB database name. A party id
21
- * containing `/` or `..` would escape the CLI's storage directory. Base64url keeps every
22
- * key inside `[A-Za-z0-9._-]` — the charset embedders already rely on, and the same
23
- * encoding the React Native app's `secureStoreKeySegment` uses on a party id for the
24
- * same reason.
25
- *
26
- * THE STRAND ARM OF THAT CHARSET RULE IS NOT YET ENFORCED. The strand scope key is
27
- * `StrandRow.Id`, and the ids cadre-core itself mints are `strand-<digits>-<base36>`
28
- * / `strand-<hex>`, which satisfy the charset. But a strand row replicated into the
29
- * control database by another node in the party carries whatever id THAT node wrote,
30
- * and nothing validates its shape before `resolveStrandStorage` hands it to the
31
- * provider. See `tickets/backlog/bug-strand-scope-key-charset-unenforced`.
32
- */
33
-
34
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
35
-
36
- /**
37
- * Prefix marking a scope key as a control database rather than a strand.
38
- *
39
- * Not exported: `controlStorageScope` mints these keys and `isControlStorageScope`
40
- * recognizes them, which is the whole surface a caller needs.
41
- */
42
- const CONTROL_SCOPE_PREFIX = 'control-';
43
-
44
- /**
45
- * The storage scope key for a party's control database.
46
- *
47
- * Returns `control-` followed by the base64url encoding of `partyId`'s UTF-8 bytes,
48
- * so the whole key stays within `[A-Za-z0-9._-]` and is safe to use directly as a
49
- * file name, directory name or database name. See the module comment for why the
50
- * encoding is load-bearing rather than decorative.
51
- *
52
- * To read a party id back off a device — from, say, a LevelDB file named
53
- * `sereus-control-MTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1` — in a browser or
54
- * Node console, with no dependency on this package:
55
- * ```js
56
- * const b64 = key.slice('control-'.length).replace(/-/g, '+').replace(/_/g, '/');
57
- * new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))
58
- * ```
59
- * The `TextDecoder` step is not optional: `atob` alone yields one character per BYTE,
60
- * so a party id with any non-ASCII character in it decodes to mojibake.
61
- */
62
- export function controlStorageScope(partyId: string): string {
63
- return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url');
64
- }
65
-
66
- /**
67
- * Whether a scope key names a control database rather than a strand.
68
- *
69
- * A prefix test. Safe as one against the strand ids cadre-core mints, which are
70
- * `strand-`-prefixed (`strand-formation-manager.ts`, `strand-solicitation.ts`,
71
- * `control-formation-recorder.ts`) and so can never collide. It is NOT a security
72
- * boundary: a strand id replicated in from another node is unvalidated text that
73
- * could begin `control-`, which is one more reason to close the gap the module
74
- * comment names.
75
- */
76
- export function isControlStorageScope(scope: string): boolean {
77
- return scope.startsWith(CONTROL_SCOPE_PREFIX);
78
- }
1
+ /**
2
+ * storage-scope.ts — the scope keys `CadreNodeConfig.storage.provider` is called with.
3
+ *
4
+ * A provider is a factory cadre-core invokes once per **scope**: once for the control
5
+ * database and once for each strand. A strand's scope key is its strand id; the control
6
+ * database's scope key is what this module mints.
7
+ *
8
+ * WHY THE CONTROL SCOPE CARRIES THE PARTY ID. The control database holds the party's
9
+ * own records — its strands, owner keys, peers, invitations, revocations. Two parties
10
+ * on one device must therefore not share one control store, or a node started for
11
+ * party B reads party A's rows as if party A had written them for B. Every other
12
+ * party-scoped store in this codebase already knows this: `PersistentTrustedOwnerStore`,
13
+ * `PersistentBootstrapPeerStore` and `PersistentEnrolledMachineStore` each take a
14
+ * `partyId` and fail closed on a mismatch. Putting the party id in the key here means
15
+ * no embedder has to know it belongs there, and none can get it wrong.
16
+ *
17
+ * WHY IT IS ENCODED. A party id is arbitrary text — nothing in cadre-core validates
18
+ * its shape, and the React Native reference app lets a user type one into Settings.
19
+ * Scope keys reach real namespaces unescaped: cadre-cli builds `${config.path}/${scope}`,
20
+ * React Native a LevelDB filename, the browser an IndexedDB database name. A party id
21
+ * containing `/` or `..` would escape the CLI's storage directory. Base64url keeps every
22
+ * key inside `[A-Za-z0-9._-]` — the charset embedders already rely on, and the same
23
+ * encoding the React Native app's `secureStoreKeySegment` uses on a party id for the
24
+ * same reason.
25
+ *
26
+ * THE STRAND ARM OF THAT CHARSET RULE IS ENFORCED HERE. A strand's scope key is
27
+ * `StrandRow.Id`, and a strand row replicated into the control database by another
28
+ * node in the party carries whatever id THAT node wrote. `assertStrandScopeKey`
29
+ * (below) is the check that makes the charset true of strand keys too; it runs
30
+ * unconditionally at the top of `StrandInstanceManager.startStrand`, so no strand id
31
+ * reaches an embedder's provider — or a libp2p protocol prefix — unvalidated. Ids
32
+ * cadre-core mints come from `strand-id.ts`, which is built on the same predicate.
33
+ */
34
+
35
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
36
+
37
+ /**
38
+ * Prefix marking a scope key as a control database rather than a strand.
39
+ *
40
+ * Not exported: `controlStorageScope` mints these keys and `isControlStorageScope`
41
+ * recognizes them, which is the whole surface a caller needs.
42
+ */
43
+ const CONTROL_SCOPE_PREFIX = 'control-';
44
+
45
+ /**
46
+ * The storage scope key for a party's control database.
47
+ *
48
+ * Returns `control-` followed by the base64url encoding of `partyId`'s UTF-8 bytes,
49
+ * so the whole key stays within `[A-Za-z0-9._-]` and is safe to use directly as a
50
+ * file name, directory name or database name. See the module comment for why the
51
+ * encoding is load-bearing rather than decorative.
52
+ *
53
+ * To read a party id back off a device — from, say, a LevelDB file named
54
+ * `sereus-control-MTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1` — in a browser or
55
+ * Node console, with no dependency on this package:
56
+ * ```js
57
+ * const b64 = key.slice('control-'.length).replace(/-/g, '+').replace(/_/g, '/');
58
+ * new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))
59
+ * ```
60
+ * The `TextDecoder` step is not optional: `atob` alone yields one character per BYTE,
61
+ * so a party id with any non-ASCII character in it decodes to mojibake.
62
+ */
63
+ export function controlStorageScope(partyId: string): string {
64
+ return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url');
65
+ }
66
+
67
+ /**
68
+ * Whether a scope key names a control database rather than a strand.
69
+ *
70
+ * A prefix test, and a sound one: a strand scope key can never begin `control-`,
71
+ * because {@link isValidStrandScopeKey} rejects that prefix and
72
+ * {@link assertStrandScopeKey} runs on every strand launch.
73
+ */
74
+ export function isControlStorageScope(scope: string): boolean {
75
+ return scope.startsWith(CONTROL_SCOPE_PREFIX);
76
+ }
77
+
78
+ /**
79
+ * The longest strand scope key accepted.
80
+ *
81
+ * The longest id cadre-core mints is 39 characters (`strand-` plus the 32 hex
82
+ * characters of `randomBytes(128, 'hex')` — 128 BITS, so 16 bytes), so this leaves
83
+ * ample room while keeping a key clear of the 255-BYTE limit every mainstream
84
+ * filesystem puts on one path component. The charset is ASCII, so characters and
85
+ * bytes are the same count here.
86
+ */
87
+ const MAX_STRAND_SCOPE_KEY_LENGTH = 128;
88
+
89
+ /** The charset every scope key stays within — see the module comment. */
90
+ const SCOPE_KEY_CHARSET = /^[A-Za-z0-9._-]+$/;
91
+
92
+ /**
93
+ * Whether a strand id is usable as a storage scope key and as a libp2p network name.
94
+ *
95
+ * A strand id is not minted locally in the general case — a strand row replicates in
96
+ * from another node in the party carrying whatever id THAT node wrote — and both
97
+ * places it lands turn it straight into a name: the embedder's storage provider
98
+ * (a directory under cadre-cli's storage path, a LevelDB filename, an IndexedDB
99
+ * database name) and the strand node's protocol prefix `/optimystic/strand-<id>`.
100
+ *
101
+ * Valid means all of:
102
+ * - non-empty, and at most {@link MAX_STRAND_SCOPE_KEY_LENGTH} characters;
103
+ * - within `[A-Za-z0-9._-]`, so no separator, drive letter, NUL or non-ASCII text;
104
+ * - not `.` or `..`, which the charset admits but every filesystem reads as a
105
+ * directory rather than a name;
106
+ * - not `control-`-prefixed, which would let a strand's store masquerade as a
107
+ * party's control database (see {@link isControlStorageScope}).
108
+ */
109
+ export function isValidStrandScopeKey(strandId: string): boolean {
110
+ return strandId.length > 0
111
+ && strandId.length <= MAX_STRAND_SCOPE_KEY_LENGTH
112
+ && SCOPE_KEY_CHARSET.test(strandId)
113
+ && strandId !== '.'
114
+ && strandId !== '..'
115
+ && !isControlStorageScope(strandId);
116
+ }
117
+
118
+ /**
119
+ * Thrown when a strand id cannot be used as a storage scope key or network name.
120
+ *
121
+ * NOT retryable: the id is a property of the strand row, so every later attempt on
122
+ * the same row fails identically. Callers that poll — `CadreNode.handleStrandAdded`
123
+ * via `StrandWatcher` — suppress the strand rather than scheduling a retry.
124
+ */
125
+ export class InvalidStrandIdError extends Error {
126
+ readonly strandId: string;
127
+
128
+ constructor(strandId: string) {
129
+ super(
130
+ `Strand id ${JSON.stringify(strandId)} cannot be used as a storage scope key: ` +
131
+ `a strand id must be 1-${MAX_STRAND_SCOPE_KEY_LENGTH} characters within [A-Za-z0-9._-], ` +
132
+ "must not be '.' or '..', and must not begin 'control-'. " +
133
+ 'This strand was created by a node that does not mint conforming ids; it cannot be ' +
134
+ 'started here, because the id becomes a file, directory or database name.'
135
+ );
136
+ this.name = 'InvalidStrandIdError';
137
+ this.strandId = strandId;
138
+ }
139
+ }
140
+
141
+ /** {@link isValidStrandScopeKey} as an assertion, throwing {@link InvalidStrandIdError}. */
142
+ export function assertStrandScopeKey(strandId: string): void {
143
+ if (!isValidStrandScopeKey(strandId)) {
144
+ throw new InvalidStrandIdError(strandId);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The charset half of the rule on its own, for the CONTROL key.
150
+ *
151
+ * {@link assertStrandScopeKey} cannot serve here: it rejects the `control-` prefix by
152
+ * design. Asserted at the seam that calls the provider (`CadreNode.resolveControlStorage`)
153
+ * rather than inside {@link controlStorageScope}, so the guarantee belongs to the call
154
+ * that hands a key out and not to one particular way of minting one.
155
+ */
156
+ export function assertScopeKeyCharset(scope: string): void {
157
+ if (!SCOPE_KEY_CHARSET.test(scope)) {
158
+ throw new Error(
159
+ `Storage scope key ${JSON.stringify(scope)} leaves the [A-Za-z0-9._-] charset ` +
160
+ 'embedders rely on to use it directly as a file, directory or database name.'
161
+ );
162
+ }
163
+ }
@@ -64,12 +64,42 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
64
64
 
65
65
  /**
66
66
  * How long `CadreNode.addStrand` waits for a joining machine's first sync before rejecting
67
- * with {@link StrandAwaitingFirstSyncError}. Measured: over a direct connection the
68
- * joiner reads the founder's rows about 1.3 s after `addStrand` would previously have
69
- * resolved; a relayed path is slower, and a phone that just redeemed an invitation has the
70
- * host reachable moments ago, so 30 s is generous without leaving an app hanging.
67
+ * with {@link StrandAwaitingFirstSyncError}.
68
+ *
69
+ * Measured over a DIRECT connection (2026-09-16): the joiner reads the founder's rows about
70
+ * 1.3 s after `addStrand` would previously have resolved. That is still the fast case, and it
71
+ * is not what this budget has to cover.
72
+ *
73
+ * Measured over a RELAYED SLOW LINK (2026-09-26): one Windows developer machine, two
74
+ * relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
75
+ * 900 ms one-way per-frame outbound delay applied to every websocket by
76
+ * `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
77
+ * in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
78
+ * (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
79
+ * (`docs/testing.md` → "Link latency"). A round trip therefore costs about
80
+ * 1.8 s. Time from `addStrand` to writable, for a machine holding nothing of the strand yet:
81
+ * 23, 27, 31 and 41 s over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42
82
+ * and 46 s over three runs at a 5000 ms one. **The second band is the one in force**: sereus
83
+ * declares 5000 ms (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`),
84
+ * because 1000 ms is shorter than one round trip on that link and leaves every cohort read with
85
+ * no answer to corroborate against. Widening that deadline makes a consult against a peer that
86
+ * cannot answer cost longer, and this phase runs several of those — which is why the two bands
87
+ * are both recorded here, and why a change to either number has to be weighed against the
88
+ * other. 120 s clears the worst sample of the band in force by about 2.6x. The previous 30 s
89
+ * default sat INSIDE the first band, so roughly half of those joins were rejected as "not
90
+ * writable yet" while the sync was progressing normally and went on to complete.
91
+ *
92
+ * NOTE: accepted tradeoff — what 120 s costs. This wait is what an app's `addStrand` sits in
93
+ * before it is told "not yet", so a strand none of whose members is reachable at all takes two
94
+ * minutes to report instead of thirty seconds. The slow report was weighed against refusing
95
+ * joins that were working, and the refusal is the worse failure. The cost is bounded — the
96
+ * rejection is retryable, the strand stays launched and keeps probing, and `strand:writable`
97
+ * fires the moment the sync lands, so an app that listens for the event rather than awaiting
98
+ * the call is unaffected either way. Revisit if the gate ever learns whether ANY other member
99
+ * is connected: "no peer at all" could then be reported at once and this budget would only
100
+ * ever be spent on a sync that is actually in progress.
71
101
  */
72
- export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 30_000;
102
+ export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 120_000;
73
103
 
74
104
  /** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
75
105
  export interface StrandFirstSyncConfig {
@@ -242,8 +272,7 @@ export class StrandFirstSyncGate {
242
272
  private async probe(): Promise<void> {
243
273
  if (this.stopped || this.opened || this.probing) return;
244
274
  this.probing = true;
245
- const held = await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label)
246
- .finally(() => { this.probing = false; });
275
+ const held = await this.probeHeld();
247
276
  // Re-check after the await: a release or a force-open may have landed mid-read.
248
277
  if (this.stopped || this.opened) return;
249
278
  if (!held) {
@@ -255,4 +284,23 @@ export class StrandFirstSyncGate {
255
284
  log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
256
285
  this.deps.onHeaderHeld();
257
286
  }
287
+
288
+ /**
289
+ * One probe's answer, with a throw from OUTSIDE {@link strandFirstSyncComplete}'s own read
290
+ * guards — the `App` schema lookup, or `getDatabase()` itself — reported as "not yet" like
291
+ * any failing read. The loop is scheduled from `void this.probe()`, so an escaping rejection
292
+ * would be unhandled AND leave nothing scheduled: the strand would stay gated for the whole
293
+ * `timeoutMs` and never recover, which is the one failure this gate must not have.
294
+ */
295
+ private async probeHeld(): Promise<boolean> {
296
+ try {
297
+ return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
298
+ } catch (error) {
299
+ log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
300
+ error instanceof Error ? error.message : String(error));
301
+ return false;
302
+ } finally {
303
+ this.probing = false;
304
+ }
305
+ }
258
306
  }