@solarpunkltd/swarm-id 0.0.0-stage → 0.8.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 (111) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +86 -2
  3. package/dist/bus/account-bus.d.ts +104 -0
  4. package/dist/bus/account-delta.d.ts +52 -0
  5. package/dist/bus/bus-context.d.ts +7 -0
  6. package/dist/bus/envelope.d.ts +9 -0
  7. package/dist/bus/messages.d.ts +241 -0
  8. package/dist/bus/presence.d.ts +62 -0
  9. package/dist/bus/signaling-transport.d.ts +79 -0
  10. package/dist/chunk/bmt.d.ts +16 -0
  11. package/dist/chunk/cac.d.ts +17 -0
  12. package/dist/chunk/constants.d.ts +10 -0
  13. package/dist/chunk/encrypted-cac.d.ts +47 -0
  14. package/dist/chunk/encryption.d.ts +85 -0
  15. package/dist/chunk/index.d.ts +5 -0
  16. package/dist/chunks/chat-binding-DlH_ZkTn.js +23 -0
  17. package/dist/chunks/chat-binding-DlH_ZkTn.js.map +1 -0
  18. package/dist/errors.d.ts +35 -0
  19. package/dist/index.d.ts +27 -0
  20. package/dist/internal.d.ts +49 -0
  21. package/dist/internal.esm.js +2 -0
  22. package/dist/internal.esm.js.map +1 -0
  23. package/dist/proxy/act/act.d.ts +58 -0
  24. package/dist/proxy/act/crypto.d.ts +46 -0
  25. package/dist/proxy/act/grantee-list.d.ts +81 -0
  26. package/dist/proxy/act/history.d.ts +182 -0
  27. package/dist/proxy/act/index.d.ts +114 -0
  28. package/dist/proxy/app-stamp-limits.d.ts +62 -0
  29. package/dist/proxy/chunk-upload-stream.d.ts +37 -0
  30. package/dist/proxy/chunking.d.ts +15 -0
  31. package/dist/proxy/collection.d.ts +38 -0
  32. package/dist/proxy/download-data.d.ts +27 -0
  33. package/dist/proxy/errors.d.ts +8 -0
  34. package/dist/proxy/feed-manifest.d.ts +60 -0
  35. package/dist/proxy/feeds/epochs/async-finder.d.ts +70 -0
  36. package/dist/proxy/feeds/epochs/epoch.d.ts +79 -0
  37. package/dist/proxy/feeds/epochs/finder.d.ts +60 -0
  38. package/dist/proxy/feeds/epochs/index.d.ts +30 -0
  39. package/dist/proxy/feeds/epochs/types.d.ts +110 -0
  40. package/dist/proxy/feeds/epochs/updater.d.ts +76 -0
  41. package/dist/proxy/feeds/epochs/utils.d.ts +21 -0
  42. package/dist/proxy/feeds/index.d.ts +4 -0
  43. package/dist/proxy/feeds/sequence/async-finder.d.ts +14 -0
  44. package/dist/proxy/feeds/sequence/finder.d.ts +11 -0
  45. package/dist/proxy/feeds/sequence/index.d.ts +22 -0
  46. package/dist/proxy/feeds/sequence/types.d.ts +82 -0
  47. package/dist/proxy/feeds/sequence/updater.d.ts +20 -0
  48. package/dist/proxy/index.d.ts +5 -0
  49. package/dist/proxy/manifest-builder.d.ts +164 -0
  50. package/dist/proxy/mantaray.d.ts +76 -0
  51. package/dist/proxy/soc-restamp-slots.d.ts +48 -0
  52. package/dist/proxy/stamp-chunk.d.ts +45 -0
  53. package/dist/proxy/stamp-worker-handler.d.ts +32 -0
  54. package/dist/proxy/stamp-worker-pool.d.ts +52 -0
  55. package/dist/proxy/stamp-worker.d.ts +1 -0
  56. package/dist/proxy/types.d.ts +38 -0
  57. package/dist/proxy/upload.d.ts +207 -0
  58. package/dist/schemas.d.ts +665 -0
  59. package/dist/stamp-worker.iife.js +1 -0
  60. package/dist/storage/debounced-uploader.d.ts +61 -0
  61. package/dist/storage/utilization-store.d.ts +113 -0
  62. package/dist/swarm-id-client.d.ts +1114 -0
  63. package/dist/swarm-id-proxy.d.ts +1031 -0
  64. package/dist/swarm-id.esm.js +2 -0
  65. package/dist/swarm-id.esm.js.map +1 -0
  66. package/dist/sync/batch-write-coordinator.d.ts +347 -0
  67. package/dist/sync/device-roster.d.ts +59 -0
  68. package/dist/sync/device-state.d.ts +199 -0
  69. package/dist/sync/fold-account-from-swarm.d.ts +53 -0
  70. package/dist/sync/index.d.ts +11 -0
  71. package/dist/sync/merge-snapshot.d.ts +57 -0
  72. package/dist/sync/partition-intent.d.ts +218 -0
  73. package/dist/sync/partition-lease.d.ts +531 -0
  74. package/dist/sync/partition-lock.d.ts +218 -0
  75. package/dist/sync/partition-state.d.ts +287 -0
  76. package/dist/sync/session-check.d.ts +29 -0
  77. package/dist/sync/store-interfaces.d.ts +48 -0
  78. package/dist/sync/sync-account.d.ts +45 -0
  79. package/dist/sync/timing-constants.d.ts +32 -0
  80. package/dist/sync/types.d.ts +18 -0
  81. package/dist/types.d.ts +3007 -0
  82. package/dist/utils/account-state-snapshot.d.ts +43 -0
  83. package/dist/utils/active-devices.d.ts +45 -0
  84. package/dist/utils/avatar.d.ts +30 -0
  85. package/dist/utils/batch-utilization.d.ts +840 -0
  86. package/dist/utils/batch-write-lock.d.ts +12 -0
  87. package/dist/utils/bee-error.d.ts +11 -0
  88. package/dist/utils/chat-binding.d.ts +113 -0
  89. package/dist/utils/coalesced-task.d.ts +28 -0
  90. package/dist/utils/collection.d.ts +6 -0
  91. package/dist/utils/connection-info.d.ts +11 -0
  92. package/dist/utils/constants.d.ts +28 -0
  93. package/dist/utils/device-id.d.ts +109 -0
  94. package/dist/utils/hex.d.ts +31 -0
  95. package/dist/utils/json-rpc.d.ts +34 -0
  96. package/dist/utils/key-derivation.d.ts +124 -0
  97. package/dist/utils/lock-soc.d.ts +35 -0
  98. package/dist/utils/postage-contract.d.ts +143 -0
  99. package/dist/utils/postage-stamp-association.d.ts +39 -0
  100. package/dist/utils/promise.d.ts +37 -0
  101. package/dist/utils/sealed-values.d.ts +23 -0
  102. package/dist/utils/seed-encryption.d.ts +17 -0
  103. package/dist/utils/site-allow-list.d.ts +54 -0
  104. package/dist/utils/stamp-lifespan.d.ts +37 -0
  105. package/dist/utils/storage-managers.d.ts +62 -0
  106. package/dist/utils/storage-probe.d.ts +40 -0
  107. package/dist/utils/tag.d.ts +11 -0
  108. package/dist/utils/ttl.d.ts +120 -0
  109. package/dist/utils/url.d.ts +47 -0
  110. package/dist/utils/versioned-storage.d.ts +183 -0
  111. package/package.json +91 -3
@@ -0,0 +1,347 @@
1
+ /**
2
+ * `BatchWriteCoordinator` — the write path for one account+batch.
3
+ *
4
+ * Owns everything needed to **write to a shared postage batch as a partition
5
+ * holder**, and nothing about client communication:
6
+ * - the cross-tab Web Lock (`withBatchWriteLock`),
7
+ * - the partition-lease lifecycle (acquire / refresh / yield / demote),
8
+ * - stamper-state flush under the lock.
9
+ *
10
+ * Two consumers share it:
11
+ * - the proxy iframe — long-lived (`mode: "persistent"`): eager
12
+ * `startLease()` + a refresh timer; an upload that can't get a partition
13
+ * blocks for a slot and then throws.
14
+ * - `sync-account` — one-off (`mode: "oneshot"`): no refresh timer; a write
15
+ * claims a free/expired partition once and throws `PartitionContendedError`
16
+ * when every partition is held by a live foreign device, so the caller
17
+ * skips. The lease then lapses by TTL.
18
+ *
19
+ * The lock SOC on Swarm (`partition-lock.ts`) stays the cross-device authority;
20
+ * this class is the lifecycle layer above `PartitionLease`. See
21
+ * `docs/BatchWriteCoordinator.md`.
22
+ *
23
+ * ## Displacement-during-upload race (issue #336)
24
+ *
25
+ * `refreshTick` runs on a bare `setInterval`, NOT under the write lock, so it
26
+ * can detect that a peer took our partition while an upload is mid-`stamp()`.
27
+ * The fix is two-phase: on confirmed displacement we call
28
+ * `stamper.invalidateLease()` **immediately** (partition stays bound →
29
+ * `leaseStale=true` → the in-flight `stamp()` throws `PartitionLeaseLostError`
30
+ * and the corrupting upload aborts), and only *then* run the
31
+ * unbind/clear cleanup **under the write lock** so no `stamp()` runs in the
32
+ * window between invalidate and unbind. Lock-synchronising the demote alone is
33
+ * insufficient — it would merely order the demote after an upload that already
34
+ * corrupted the peer's slot space.
35
+ */
36
+ import { Bee, PrivateKey } from "@ethersphere/bee-js";
37
+ import { UtilizationAwareStamper } from "../utils/batch-utilization";
38
+ import type { PartitionLeaseStateSnapshot } from "./partition-lease";
39
+ import type { UploadTarget } from "../proxy/upload";
40
+ import type { StampWorkerPool } from "../proxy/stamp-worker-pool";
41
+ /** Max time the acquire spends polling for a slot when all are held. */
42
+ export declare const SLOT_WAIT_TIMEOUT_MS = 30000;
43
+ /**
44
+ * Thrown by `withWrite` in `wait: "skip"` mode when every partition is held by
45
+ * a live foreign device, i.e. there is genuinely no slot to claim. The caller
46
+ * (`sync-account`) catches this and skips the publish — distinct from an
47
+ * operational error (missing stamper, a lock-SOC write that threw), which
48
+ * propagates so it can be logged as an error rather than as contention.
49
+ */
50
+ export declare class PartitionContendedError extends Error {
51
+ readonly accountId?: string | undefined;
52
+ constructor(message?: string, accountId?: string | undefined);
53
+ }
54
+ /**
55
+ * A lock-SOC payload means this device has been displaced only if it names a
56
+ * *different*, *live* device. A missing/unreadable payload (e.g. a Bee 500),
57
+ * our own id, the release sentinel, or an *expired* foreign holder are all NOT
58
+ * displacement — the holder keeps its lease and retries. Pure (no instance
59
+ * state) so it can be unit-tested directly; mirrors the proxy's former
60
+ * `isDisplaced`.
61
+ */
62
+ export declare function isDisplaced(payload: {
63
+ holderDeviceId: string;
64
+ leasedUntil: number;
65
+ } | undefined, now: number, selfDeviceId: string): boolean;
66
+ /** Long-lived (proxy) vs one-off (`sync-account`) lease behaviour. */
67
+ export type CoordinatorMode = "persistent" | "oneshot";
68
+ /** Per-transition lease snapshot handed to `onLeaseChange`. */
69
+ export interface LeaseChangeInfo {
70
+ currentPartition: number | undefined;
71
+ isReadOnly: boolean;
72
+ }
73
+ export interface WithWriteOptions {
74
+ useWorkers?: boolean;
75
+ workerCount?: number;
76
+ /** Override the mode default: persistent → "block", oneshot → "skip". */
77
+ wait?: "block" | "skip";
78
+ }
79
+ export interface BatchWriteCoordinatorDeps {
80
+ bee: Bee;
81
+ /** Postage batch id (hex) — the `withBatchWriteLock` key. */
82
+ batchId: string;
83
+ /** Already created + account-bound by the caller; the coordinator only
84
+ * binds/unbinds the held partition on it. */
85
+ stamper: UtilizationAwareStamper;
86
+ /** This device's identity, captured once by the caller. */
87
+ deviceId: string;
88
+ /**
89
+ * Current known device IDs for this account (from the synced device
90
+ * registry). Read fresh per acquire so it reflects late-announced devices.
91
+ * Drives the Phase-2 intent round that lets disjoint-gateway contenders for
92
+ * a free partition agree on one winner (see partition-intent.ts). Omit for
93
+ * single-device / legacy callers — acquire falls back to guard+TTL only.
94
+ */
95
+ knownDeviceIds?: () => string[];
96
+ /**
97
+ * Pull the latest device registry from Swarm into local state before a fresh
98
+ * acquire, so `knownDeviceIds` reflects a peer that signed in after this device
99
+ * created the account (otherwise the claimer is "blind" — never reads the
100
+ * peer's beacon — and dual-acquires). Best-effort; the provider throttles.
101
+ * Omit for node/oneshot callers.
102
+ */
103
+ refreshKnownDeviceIds?: () => Promise<void>;
104
+ /**
105
+ * Gateway-propagation tuning for the intent round (optional overrides; default
106
+ * to the `INTENT_*` constants). Lets the proxy pass runtime-tunable values.
107
+ */
108
+ intentReadTimeoutMs?: number;
109
+ intentGuardWindowMs?: number;
110
+ intentGuardPollMs?: number;
111
+ accountId: string;
112
+ /** Owns the per-partition lock SOCs (derived from the account key). */
113
+ backupSigner: PrivateKey;
114
+ swarmEncryptionKey: Uint8Array;
115
+ partitionCount: number;
116
+ mode: CoordinatorMode;
117
+ /** Persistent mode only: read/write the local lease-cache hint. */
118
+ readLeaseCache?: () => PartitionLeaseStateSnapshot | undefined;
119
+ writeLeaseCache?: (snap: PartitionLeaseStateSnapshot | undefined) => void;
120
+ /** Flush stamper bucket state after a write (proxy: `saveStamperState`). */
121
+ flushStamperState?: () => Promise<void>;
122
+ /** Build/reuse a parallel-signing worker pool (proxy: `getOrCreateWorkerPool`). */
123
+ getWorkerPool?: (count?: number) => Promise<StampWorkerPool | undefined>;
124
+ /** Fired on every partition / read-only transition. */
125
+ onLeaseChange?: (info: LeaseChangeInfo) => void;
126
+ /** Fired when a partition is (re)acquired (phase-5 publish trigger). */
127
+ onLeaseAcquired?: (partition: number) => void;
128
+ /**
129
+ * Fired each slot-wait poll round while a blocking acquire finds every
130
+ * partition held by live peers. The proxy broadcasts a `lease-request` on
131
+ * the account bus so an idle live holder yields immediately instead of
132
+ * after `IDLE_YIELD_MS` (docs/Account-Bus.md, bus-accelerated leases).
133
+ */
134
+ onSlotWait?: () => void;
135
+ /**
136
+ * Fired after every release of a held partition — the idle-timer yield, a
137
+ * yield for a peer's `lease-request` (`requestId` echoes it), and teardown —
138
+ * so announcing it on the bus is the coordinator's doing, not something each
139
+ * release path remembers. The proxy publishes `lease-released`.
140
+ */
141
+ onLeaseReleased?: (partition: number, requestId?: string) => void;
142
+ }
143
+ /**
144
+ * Minimum idle time before a peer's `lease-request` makes this holder yield.
145
+ * Much shorter than `IDLE_YIELD_MS` (the unprompted timer yield) — the request
146
+ * proves a peer is actively waiting — but non-zero so a holder mid-burst
147
+ * (between two uploads) doesn't ping-pong its partition away.
148
+ */
149
+ export declare const PEER_YIELD_MIN_IDLE_MS = 3000;
150
+ /**
151
+ * A `lease-released` reaches a waiter over the bus well before the released
152
+ * slot's sentinel write is readable from its node, so the scan it wakes often
153
+ * still reads the old holder (#724). Rather than sleep out a poll interval on
154
+ * that — or re-ask, which draws a fresh request id nobody can stand down
155
+ * against, so a second holder yields for the same waiter — the wait re-scans
156
+ * this many times at this interval, asking nobody.
157
+ */
158
+ export declare const ANNOUNCED_RESCAN_INTERVAL_MS = 1500;
159
+ export declare const ANNOUNCED_RESCAN_ROUNDS = 3;
160
+ export declare class BatchWriteCoordinator {
161
+ private readonly deps;
162
+ /** Active partition-lease; undefined when no lease is held (read-only mode,
163
+ * or single-partition legacy accounts). */
164
+ private partitionLease;
165
+ private partitionRefreshTimer;
166
+ private readOnly;
167
+ /** Set when the next write should (re)acquire a partition: cold start,
168
+ * after a demote, or while read-only. */
169
+ private pendingAcquire;
170
+ /** Wall-clock ms of the last lock-SOC validation (acquire / refresh). */
171
+ private lastLeaseValidatedAt;
172
+ /** Wall-clock ms of the last upload activity; drives the idle-yield. */
173
+ private lastLeaseActivityAt;
174
+ /** Uploads currently executing under the write lock; the refresh tick only
175
+ * yields an idle lease when this is 0. */
176
+ private activeUploadCount;
177
+ /** Wall-clock ms the state-pointer heartbeat has been failing since
178
+ * (undefined = healthy). The heartbeat is best-effort per tick, but a
179
+ * PERSISTENT failure streak walks the last durable pointer out of the
180
+ * takeover lookup span while lock renewals keep extending `leasedUntil` —
181
+ * a takeover would then zero-seed over acked slots. Once the streak
182
+ * exceeds one pointer epoch, `refreshTick` demotes. Reset on a successful
183
+ * heartbeat and whenever the lease binding changes. */
184
+ private pointerHeartbeatFailingSince;
185
+ /** The live slot-wait's state; undefined outside slot-wait. Held as ONE
186
+ * object per wait, and every touch goes through this field, so a DETACHED
187
+ * wait — `ensureLease` races the poll against a `withTimeout`, and the
188
+ * loser keeps running — owns a different object and cannot reach into the
189
+ * live wait's wake or flag. */
190
+ private slotWait;
191
+ /** Set by `teardown()`. Once disposed the coordinator must never re-acquire a
192
+ * partition or arm a refresh timer: an in-flight `withWrite` (e.g. a deferred
193
+ * publish, or a normal upload) can outlive a disconnect/sign-out, and without
194
+ * this guard its `ensureLease` would re-lease the slot and start a detached
195
+ * interval — a ghost lease the proxy can no longer tear down. */
196
+ private disposed;
197
+ /** Bumped whenever held-lease state is reset (`pauseLeaseBackgroundWork`,
198
+ * `finalizeDemote`, `yieldIdleLease`, `teardown`). An `acquire()` that began
199
+ * under an older epoch must not commit (bind / start timers) when it
200
+ * resolves — `Promise.race` cannot cancel a losing acquire, whose
201
+ * continuation would otherwise resurrect a lease the coordinator already
202
+ * cleared. */
203
+ private leaseEpoch;
204
+ constructor(deps: BatchWriteCoordinatorDeps);
205
+ /** Current held partition, or undefined (read-only / legacy / not yet acquired). */
206
+ get currentPartition(): number | undefined;
207
+ /** True when every partition is held by a live peer and this device cannot write. */
208
+ get isReadOnly(): boolean;
209
+ /** The account's partition split — the modulus the proxy ranks holders by
210
+ * when electing which one answers a peer's `lease-request`. */
211
+ get partitionCount(): number;
212
+ /** The bound stamper — exposed for the proxy's `buildConnectionInfo`
213
+ * (appKey / uploadMode). */
214
+ get stamperRef(): UtilizationAwareStamper;
215
+ /** The account this coordinator (and its batch/lease) was built for —
216
+ * exposed so the proxy can refuse to publish a snapshot assembled for a
217
+ * different account through this coordinator's batch. */
218
+ get accountId(): string;
219
+ /** The batch this coordinator's lease lives on — lanes are per (batch,
220
+ * partition), so the proxy routes bus lease messages by both (#589). */
221
+ get batchId(): string;
222
+ /**
223
+ * The single write entry point. Takes the cross-tab Web Lock, ensures a held
224
+ * partition (acquire / slot-wait, or skip-with-throw), runs `op` against a
225
+ * stamper upload target, and flushes stamper state. Subsidised mode is NOT a
226
+ * coordinator concern — the proxy only calls this in stamper mode.
227
+ */
228
+ withWrite<T>(op: (target: UploadTarget) => Promise<T>, opts?: WithWriteOptions): Promise<T>;
229
+ /** Persistent mode: eagerly acquire a partition + start the refresh timer in
230
+ * the background so the first upload doesn't pay the acquire latency. */
231
+ startLease(): void;
232
+ /**
233
+ * Tear down all lease background work and release the held partition
234
+ * (best-effort) so peers see this device vacate promptly. Used on sign-out /
235
+ * disconnect. Never throws.
236
+ */
237
+ teardown(): void;
238
+ /**
239
+ * Ensure a partition is held for the current write. Runs under the write
240
+ * lock (called from `withWrite`/`startLease`). Re-arms a pending acquire
241
+ * when no lease is held or we are read-only, then either acquires (with
242
+ * slot-wait in "block" mode, or once-then-throw in "skip" mode) or runs the
243
+ * throttled freshness check on the held lease.
244
+ */
245
+ private ensureLease;
246
+ /**
247
+ * Acquire a partition via the lock SOC — the single source of truth. The
248
+ * local cache is only a hint (`hydrate`); `acquire` always re-reads and
249
+ * reconciles against the lock SOCs before binding.
250
+ */
251
+ private acquire;
252
+ /**
253
+ * Wait-for-slot wrapper around `acquire`. When the initial acquire returns
254
+ * read-only (every partition held by a live foreign holder), poll every
255
+ * `LEASE_REFRESH_MS` up to `SLOT_WAIT_TIMEOUT_MS`.
256
+ */
257
+ private acquireWithSlotWait;
258
+ private pollForSlot;
259
+ private sleepUntilWoken;
260
+ /**
261
+ * Push the held lease's local expiry to the stamper so an in-flight `stamp()`
262
+ * fences itself the moment the lease lapses locally — the write-side
263
+ * complement of the reverse-clobber ack guard (Postage-Batch-Partitioning.md
264
+ * §12, "ack-safe, not write-safe"). Call wherever `leasedUntil` moves while we
265
+ * hold: bind and every renewal. `undefined` (no lease) disables the fence.
266
+ */
267
+ private syncStamperLeaseDeadline;
268
+ /**
269
+ * Whether the held lease is within {@link LEASE_SKEW_MARGIN_MS} of its local
270
+ * expiry (or already lapsed). When true the freshness check must re-read the
271
+ * lock SOC even inside the throttle window — the reverse-clobber guard.
272
+ */
273
+ private leaseNearExpiry;
274
+ /**
275
+ * Throttled lock-SOC freshness check on the held lease. Runs under the write
276
+ * lock (via `ensureLease`). Demotes (and throws) when a different live device
277
+ * now holds our partition.
278
+ */
279
+ private ensureLeaseStillValid;
280
+ /**
281
+ * Phase 1 of demotion (the race fix): mark the bound lease stale so an
282
+ * in-flight `stamp()` throws `PartitionLeaseLostError` and aborts before
283
+ * writing to a slot a peer now owns. The partition stays bound (so the
284
+ * breaker can fire); the unbind happens in `finalizeDemote` under the lock.
285
+ */
286
+ private signalLeaseLost;
287
+ /**
288
+ * Phase 2 of demotion: drop the claim on the current partition. MUST run
289
+ * under the write lock when called outside a held lock (see `refreshTick`),
290
+ * so no `stamp()` runs between `invalidateLease` and `unbindPartition`.
291
+ */
292
+ private finalizeDemote;
293
+ /**
294
+ * Periodic refresh: re-run the lock protocol on the held partition every
295
+ * `LEASE_REFRESH_MS`. Runs on a bare interval (NOT under the write lock) so
296
+ * it can detect displacement mid-upload — see the race-fix note in the class
297
+ * doc. Persistent mode only.
298
+ */
299
+ private startRefreshTimer;
300
+ private refreshTick;
301
+ /**
302
+ * Voluntarily give up the held partition after it has gone idle. Keeps the
303
+ * coordinator able to re-acquire on the next write (re-arm via `ensureLease`).
304
+ * MUST run under the write lock (see the idle-yield note in `refreshTick`).
305
+ */
306
+ private yieldIdleLease;
307
+ /**
308
+ * Wake a slot-waiting acquire immediately (a peer announced a release over
309
+ * the account bus). No-op outside slot-wait; the woken loop still claims
310
+ * through the lock SOCs, so a spurious wake costs one extra read round.
311
+ */
312
+ notifySlotMaybeFree(): void;
313
+ /**
314
+ * Whether a peer's `lease-request` would presently be answered — a partition
315
+ * held, nothing in flight, and idle for `PEER_YIELD_MIN_IDLE_MS`. The proxy
316
+ * checks it before taking a rank in the yield election, so a holder mid-burst
317
+ * doesn't occupy the front of the order only to decline in silence.
318
+ *
319
+ * Advisory: `yieldForPeer` re-checks under the write lock, where an upload
320
+ * that slipped in between can still turn the answer into a no-op.
321
+ */
322
+ get canYieldForPeer(): boolean;
323
+ /**
324
+ * A waiting peer asked for a slot (bus `lease-request`): release the held
325
+ * partition NOW if it is safe — nothing in flight and no write within
326
+ * `PEER_YIELD_MIN_IDLE_MS`. Runs the exact idle-yield path (under the write
327
+ * lock, with the same re-checks), so the Swarm release protocol and counter
328
+ * flushes are identical to a timer yield. Returns the released partition,
329
+ * or undefined when nothing was (or could safely be) yielded; the release
330
+ * is announced through `onLeaseReleased` with the `requestId` it answers.
331
+ */
332
+ yieldForPeer(requestId?: string): Promise<number | undefined>;
333
+ /**
334
+ * Stop lease background work and drop the in-memory lease + cache. Used when
335
+ * the acquire path times out. A still-valid cached lease is re-adopted before
336
+ * that path runs, so reaching here means there is no live lease to preserve.
337
+ */
338
+ private pauseLeaseBackgroundWork;
339
+ private buildStamperTarget;
340
+ /** Take the cross-tab Web Lock, run `op`, then flush stamper state. */
341
+ private lockAndFlush;
342
+ /** Take the cross-tab Web Lock without the stamper flush (lease mutations). */
343
+ private lock;
344
+ /** Post-acquisition guard: a multi-device account must hold a partition. */
345
+ private ensureHeldForUpload;
346
+ private emitLeaseChange;
347
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Device roster (Phase 3a hardening) — append-only discovery log.
3
+ *
4
+ * Replaces the single shared *mutable* registry doc, which a device could
5
+ * **clobber** on a high-latency/caching gateway: it read-merge-overwrote the
6
+ * whole doc, so a device that couldn't yet see a peer's just-written entry
7
+ * dropped it. (See the gateway finding in the Phase 3a implementation log.)
8
+ *
9
+ * The roster is a **sequential append-only feed** (`swarm-id-roster-v1:<acct>`):
10
+ * each device appends ONLY its own membership record (`Device`) at the next free
11
+ * index. A device never overwrites a peer's slot, so concurrent announces can't
12
+ * clobber an existing entry — the worst case is two devices racing the same
13
+ * fresh index, where one append is lost and re-added on its next sync (existing
14
+ * entries are untouched). Readers scan all entries and fold by `deviceId`
15
+ * (reusing `mergeDevicesList`, so removals/resurrections converge by Phase 1
16
+ * LWW). Account-level immutables (publicKey, createdAt, partitionCount) live on
17
+ * the robust per-device state feed (`device-state.ts`), not here.
18
+ *
19
+ * Each entry is an encrypted blob (privacy parity with the old registry)
20
+ * referenced by the sequential SOC, reusing `uploadData`/`downloadDataWithChunkAPI`.
21
+ */
22
+ import { Bee, EthAddress, Identifier, PrivateKey, Topic } from "@ethersphere/bee-js";
23
+ import { type Device } from "../schemas";
24
+ import { type UploadTarget } from "../proxy/upload";
25
+ export declare const ROSTER_TOPIC_PREFIX = "swarm-id-roster-v1";
26
+ /** Test-only: forget every memoised roster length (in-memory module state). */
27
+ export declare function resetRosterScanCache(): void;
28
+ /** Thrown by {@link readRoster} when it cannot distinguish an empty roster from
29
+ * an outage (index 0 stays inconclusive after the retry, nothing folded).
30
+ * Callers must NOT treat this as "no devices" — see {@link ensureInRoster}. */
31
+ export declare class RosterScanInconclusiveError extends Error {
32
+ constructor();
33
+ }
34
+ export declare function rosterTopic(accountId: string): Topic;
35
+ export declare function rosterIdentifier(topic: Topic, index: bigint): Identifier;
36
+ /**
37
+ * Scan the roster and fold to the latest record per `deviceId` (Phase 1
38
+ * `mergeDevicesList`: removals/resurrections converge by LWW). Returns `[]` when
39
+ * the feed is empty (nothing published yet).
40
+ */
41
+ export declare function readRoster(opts: {
42
+ bee: Bee;
43
+ accountId: string;
44
+ owner: EthAddress;
45
+ }): Promise<Device[]>;
46
+ /**
47
+ * Ensure this device is in the roster with current membership. Appends only when
48
+ * the device is absent or its `removedAt` state differs (a new sign-in, a
49
+ * removal, or a resurrection) — keeping the roster a rare, append-only write.
50
+ */
51
+ export declare function ensureInRoster(opts: {
52
+ bee: Bee;
53
+ accountKey: PrivateKey;
54
+ owner: EthAddress;
55
+ encryptionKey: string;
56
+ accountId: string;
57
+ device: Device;
58
+ target: UploadTarget;
59
+ }): Promise<void>;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Per-device account-state feed (Phase 3a).
3
+ *
4
+ * Each device publishes its own **full current account view** to its OWN feed
5
+ * (`swarm-id-devstate-v1:<accountId>:<deviceId>`). Because a device writes only
6
+ * its own feed there is no shared SOC address and no cross-device write
7
+ * contention — the `publishAccountState` merge-read-rewrite + `verifyWon` dance
8
+ * is gone. Convergence happens at READ time: `foldAccount` merges every device's
9
+ * latest view with the Phase 1 merge primitives.
10
+ *
11
+ * Device membership (the `deviceId` set) lives in the append-only device roster
12
+ * (`device-roster.ts`), not here; account-level immutables (publicKey, createdAt,
13
+ * partitionCount) ride this robust per-device feed so the fold reconstructs them
14
+ * without a shared doc. Scalar account fields carry per-field LWW clocks so a
15
+ * concurrent change to a different scalar on another device is not dropped
16
+ * (docs/Account-State.md, "Convergence rules").
17
+ */
18
+ import { Bee, BatchId, EthAddress, PrivateKey, Topic } from "@ethersphere/bee-js";
19
+ import { z } from "zod";
20
+ import { type AccountStateSnapshot, type ConnectedApp, type Device, type PostageStamp } from "../schemas";
21
+ import { type UploadTarget } from "../proxy/upload";
22
+ import type { SyncResult } from "./types";
23
+ export declare const DEVICE_STATE_TOPIC_PREFIX = "swarm-id-devstate-v1";
24
+ /** Account settings carried in a device view (mirrors `account.settings`). */
25
+ declare const SettingsSchema: z.ZodObject<{
26
+ appSessionDuration: z.ZodOptional<z.ZodNumber>;
27
+ }, z.core.$strip>;
28
+ export type AccountSettings = z.infer<typeof SettingsSchema>;
29
+ /**
30
+ * One device's view of the account. `connectedApps`/`postageStamps` carry their
31
+ * Phase 1 LWW/tombstone clocks; scalars carry their own per-field `at`.
32
+ */
33
+ export declare const DeviceStateSnapshotSchemaV1: z.ZodObject<{
34
+ version: z.ZodLiteral<1>;
35
+ accountId: z.ZodString;
36
+ deviceId: z.ZodString;
37
+ timestamp: z.ZodNumber;
38
+ connectedApps: z.ZodArray<z.ZodObject<{
39
+ appUrl: z.ZodString;
40
+ appName: z.ZodString;
41
+ lastConnectedAt: z.ZodNumber;
42
+ appIcon: z.ZodOptional<z.ZodString>;
43
+ appDescription: z.ZodOptional<z.ZodString>;
44
+ connectedUntil: z.ZodOptional<z.ZodNumber>;
45
+ appSecret: z.ZodOptional<z.ZodString>;
46
+ postageStampBatchID: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<BatchId, string>>>;
47
+ postageStampBatchIDAt: z.ZodOptional<z.ZodNumber>;
48
+ revokedAt: z.ZodOptional<z.ZodNumber>;
49
+ disconnectedAt: z.ZodOptional<z.ZodNumber>;
50
+ chatBinding: z.ZodOptional<z.ZodObject<{
51
+ message: z.ZodString;
52
+ signature: z.ZodString;
53
+ }, z.core.$strip>>;
54
+ }, z.core.$strip>>;
55
+ postageStamps: z.ZodArray<z.ZodObject<{
56
+ batchID: z.ZodPipe<z.ZodString, z.ZodTransform<BatchId, string>>;
57
+ signerKey: z.ZodPipe<z.ZodString, z.ZodTransform<PrivateKey, string>>;
58
+ name: z.ZodOptional<z.ZodString>;
59
+ nameUpdatedAt: z.ZodOptional<z.ZodNumber>;
60
+ utilization: z.ZodNumber;
61
+ usable: z.ZodBoolean;
62
+ depth: z.ZodNumber;
63
+ amount: z.ZodPipe<z.ZodString, z.ZodTransform<bigint, string>>;
64
+ bucketDepth: z.ZodNumber;
65
+ blockNumber: z.ZodNumber;
66
+ immutableFlag: z.ZodBoolean;
67
+ exists: z.ZodBoolean;
68
+ batchTTL: z.ZodOptional<z.ZodNumber>;
69
+ createdAt: z.ZodNumber;
70
+ updatedAt: z.ZodOptional<z.ZodNumber>;
71
+ deletedAt: z.ZodOptional<z.ZodNumber>;
72
+ }, z.core.$strip>>;
73
+ accountName: z.ZodObject<{
74
+ value: z.ZodString;
75
+ at: z.ZodNumber;
76
+ }, z.core.$strip>;
77
+ defaultPostageStampBatchID: z.ZodObject<{
78
+ value: z.ZodOptional<z.ZodString>;
79
+ at: z.ZodNumber;
80
+ }, z.core.$strip>;
81
+ settings: z.ZodObject<{
82
+ value: z.ZodOptional<z.ZodObject<{
83
+ appSessionDuration: z.ZodOptional<z.ZodNumber>;
84
+ }, z.core.$strip>>;
85
+ at: z.ZodNumber;
86
+ }, z.core.$strip>;
87
+ accountPublicKey: z.ZodString;
88
+ accountCreatedAt: z.ZodNumber;
89
+ partitionCount: z.ZodDefault<z.ZodNumber>;
90
+ }, z.core.$strip>;
91
+ export type DeviceStateSnapshot = z.infer<typeof DeviceStateSnapshotSchemaV1>;
92
+ /** The per-device view a device contributes (mutable collections/scalars + account immutables). */
93
+ export interface DeviceStateView {
94
+ connectedApps: ConnectedApp[];
95
+ postageStamps: PostageStamp[];
96
+ accountName: {
97
+ value: string;
98
+ at: number;
99
+ };
100
+ defaultPostageStampBatchID: {
101
+ value: string | undefined;
102
+ at: number;
103
+ };
104
+ settings: {
105
+ value: AccountSettings | undefined;
106
+ at: number;
107
+ };
108
+ accountPublicKey: string;
109
+ accountCreatedAt: number;
110
+ partitionCount: number;
111
+ }
112
+ /** Folded account state — the shape `applyRefreshed` / the proxy consume. */
113
+ export interface FoldedAccount {
114
+ devices: Device[];
115
+ connectedApps: ConnectedApp[];
116
+ postageStamps: PostageStamp[];
117
+ accountName: string;
118
+ defaultPostageStampBatchID: BatchId | undefined;
119
+ settings: AccountSettings | undefined;
120
+ accountNameAt: number;
121
+ defaultStampAt: number;
122
+ settingsAt: number;
123
+ createdAt: number;
124
+ publicKey: string;
125
+ partitionCount: number;
126
+ }
127
+ /**
128
+ * Build this device's `DeviceStateView` from the local account snapshot — the
129
+ * single derivation shared by both publish paths (`sync-account` and the proxy).
130
+ *
131
+ * Each scalar carries its own per-field LWW clock. A field that was never
132
+ * explicitly edited (no `*At` clock on the snapshot) falls back to the account's
133
+ * STABLE `createdAt` — NEVER a fresh `Date.now()`. Re-stamping an unchanged
134
+ * name / default-stamp / settings with a new timestamp on every publish would
135
+ * let a device that never touched the field clobber a peer's genuine concurrent
136
+ * edit under per-field LWW (docs/Account-State.md, "Convergence rules").
137
+ * `createdAt` is identical across devices and
138
+ * predates every edit, so any real change always wins.
139
+ */
140
+ export declare function accountStateToDeviceView(snapshot: AccountStateSnapshot): DeviceStateView;
141
+ export declare function deviceStateTopic(accountId: string, deviceId: string): Topic;
142
+ export declare function serializeDeviceState(accountId: string, deviceId: string, view: DeviceStateView): Record<string, unknown>;
143
+ export declare function deserializeDeviceState(data: Uint8Array): DeviceStateSnapshot;
144
+ /**
145
+ * Write this device's view to its own feed. No merge / no verifyWon — the feed
146
+ * is owned exclusively by this device. Runs inside the caller's
147
+ * `BatchWriteCoordinator.withWrite` (partition lease + Web Lock already held),
148
+ * which supplies `target`.
149
+ */
150
+ export declare function writeDeviceState(opts: {
151
+ accountId: string;
152
+ deviceId: string;
153
+ accountKey: PrivateKey;
154
+ encryptionKey: string;
155
+ view: DeviceStateView;
156
+ target: UploadTarget;
157
+ }): Promise<{
158
+ reference: string;
159
+ chunkAddresses: Uint8Array[];
160
+ }>;
161
+ /**
162
+ * Write this device's view AND ensure it's in the append-only device roster. The
163
+ * single write entry point for both `sync-account` (UI-triggered) and the proxy
164
+ * (publish-on-acquire). `ensureInRoster` appends only when this device is absent
165
+ * or its removed-state changed — keeping the roster a rare, clobber-free write.
166
+ */
167
+ export declare function publishDeviceState(opts: {
168
+ bee: Bee;
169
+ accountId: string;
170
+ device: Device;
171
+ accountKey: PrivateKey;
172
+ owner: EthAddress;
173
+ encryptionKey: string;
174
+ view: DeviceStateView;
175
+ target: UploadTarget;
176
+ /** Per-chunk utilisation hook (mirrors the old publish path). */
177
+ onChunksUploaded?: (chunkAddresses: Uint8Array[]) => Promise<void>;
178
+ }): Promise<SyncResult>;
179
+ /**
180
+ * Read a device's latest view, or `undefined` when its feed is empty/unreachable.
181
+ */
182
+ export declare function readLatestDeviceState(opts: {
183
+ bee: Bee;
184
+ accountId: string;
185
+ deviceId: string;
186
+ owner: EthAddress;
187
+ }): Promise<DeviceStateSnapshot | undefined>;
188
+ /**
189
+ * Merge every device's latest view into one account state, reusing the Phase 1
190
+ * primitives for the collections and per-field LWW for the scalars. The device
191
+ * set (`rosterDevices`) comes from the append-only roster (discovery); account
192
+ * immutables come from any device view (identical across devices).
193
+ *
194
+ * Equivalent by construction to today's `mergeSnapshotWithRemote` over the same
195
+ * data (see the differential test) — convergence and deletion semantics are
196
+ * unchanged from Phase 1.
197
+ */
198
+ export declare function foldAccount(views: DeviceStateSnapshot[], rosterDevices: Device[], accountId: string): FoldedAccount;
199
+ export {};
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Read entry point for Phase 3a: reconstruct account state from Swarm by
3
+ * scanning the append-only device roster (discovery) and folding every device's
4
+ * latest state feed. Replaces the single shared-snapshot fetch used by restore
5
+ * / refresh / the proxy.
6
+ */
7
+ import { Bee, EthAddress } from "@ethersphere/bee-js";
8
+ import { type FoldedAccount } from "./device-state";
9
+ import type { SyncedAccount, Device } from "../schemas";
10
+ export interface FoldAccountResult {
11
+ account: FoldedAccount;
12
+ devices: Device[];
13
+ }
14
+ /**
15
+ * Project a folded account onto the `SyncedAccount` record — the single place
16
+ * the field list lives so a new `SyncedAccount`/`FoldedAccount` field can't
17
+ * be silently dropped by a hand-written copy on the sign-in path. `id` and
18
+ * `derivationKey` aren't in the fold (they come from the recovering wallet), so
19
+ * the caller supplies them.
20
+ *
21
+ * `foldAccountFromSwarm` shallow-FREEZES its result arrays so coalesced callers
22
+ * can't corrupt each other's shared view; a persisted `SyncedAccount` outlives
23
+ * that window and is mutated in place downstream, so the arrays are COPIED here.
24
+ */
25
+ export declare function foldedToSyncedAccount(opts: {
26
+ id: EthAddress;
27
+ derivationKey: string;
28
+ account: FoldedAccount;
29
+ lastModified?: number;
30
+ }): SyncedAccount;
31
+ /**
32
+ * Derive the feed owner + encryption key from the account derivation key, read
33
+ * the roster, fold all device-state feeds. Returns `undefined` when the roster
34
+ * is empty (no device has published — equivalent to today's "no-backup").
35
+ *
36
+ * Concurrent calls for the same account share one in-flight read (see
37
+ * `inFlightFolds`); `bee` is intentionally keyed OUT — every client reads the
38
+ * same feeds. Coalescing across two DIFFERENT `bee` endpoints (e.g. a local
39
+ * cluster and a public gateway mid-transition) is safe, not a bug: a fold is a
40
+ * point-in-time, eventually-consistent LWW/CRDT snapshot (roster + device-state
41
+ * both fold last-writer-wins by their per-field `at` clocks — see
42
+ * `merge-snapshot.ts` / `device-state.ts`). No caller assumes a fold reflects a
43
+ * specific endpoint's live view, so a coalesced caller receiving the other
44
+ * endpoint's equally-valid snapshot is indistinguishable from ordinary gateway
45
+ * staleness — which the LWW fold already tolerates everywhere, and the next
46
+ * (uncoalesced, entry-cleared) fold re-reads and re-converges. Do NOT add `bee`
47
+ * to the key to "fix" this; it would only defeat the coalescing.
48
+ */
49
+ export declare function foldAccountFromSwarm(opts: {
50
+ bee: Bee;
51
+ derivationKey: string;
52
+ accountId: string;
53
+ }): Promise<FoldAccountResult | undefined>;
@@ -0,0 +1,11 @@
1
+ export { deriveAccountDerivationKey, deriveSwarmEncryptionKey, derivePostageSignerKey, } from "../utils/key-derivation";
2
+ export { mergeSnapshotWithRemote, mergeConnectedApps, mergePostageStamps, mergeDevicesList, snapshotContainsContribution, } from "./merge-snapshot";
3
+ export { createSyncAccount } from "./sync-account";
4
+ export type { SyncAccountOptions, SyncAccountFunction } from "./sync-account";
5
+ export { deviceStateTopic, accountStateToDeviceView, writeDeviceState, publishDeviceState, readLatestDeviceState, foldAccount, deserializeDeviceState, DeviceStateSnapshotSchemaV1, DEVICE_STATE_TOPIC_PREFIX, } from "./device-state";
6
+ export type { DeviceStateSnapshot, DeviceStateView, FoldedAccount, AccountSettings, } from "./device-state";
7
+ export { rosterTopic, readRoster, ensureInRoster, ROSTER_TOPIC_PREFIX, } from "./device-roster";
8
+ export { foldAccountFromSwarm, foldedToSyncedAccount, } from "./fold-account-from-swarm";
9
+ export type { FoldAccountResult } from "./fold-account-from-swarm";
10
+ export type { AccountsStoreInterface, PostageStampsStoreInterface, StamperOptions, FlushableStamper, } from "./store-interfaces";
11
+ export type { SyncResult } from "./types";