@thomasfosterau/legation-browser 0.0.0-stage → 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,1109 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
2
+ import { IndexedDb } from "@effect/platform-browser";
3
+ import { Client, ClientStore, Identity, StoreError, TransportError } from "@thomasfosterau/legation";
4
+ import * as Layer from "effect/Layer";
5
+ import { AtomicKeyValue, ClientEngine, ClientView, EngineHost, EngineHost as EngineHost$1, HttpCache, Mirror, Mirror as Mirror$1, MirrorChannel, MirrorProtocol, MirrorProtocol as MirrorProtocol$1, PushPoke, Subscriptions, Subset, Telemetry } from "@thomasfosterau/legation/internal";
6
+ import * as Arr from "effect/Array";
7
+ import * as Effect from "effect/Effect";
8
+ import * as P from "effect/Predicate";
9
+ import * as Result from "effect/Result";
10
+ import { KeyValueStore } from "effect/persistence";
11
+ import * as Queue from "effect/Queue";
12
+ import * as Exit from "effect/Exit";
13
+ import * as Ref from "effect/Ref";
14
+ import * as Scope from "effect/Scope";
15
+ import * as Stream from "effect/Stream";
16
+ import * as SubscriptionRef from "effect/SubscriptionRef";
17
+ import * as Clock from "effect/Clock";
18
+ import * as Duration from "effect/Duration";
19
+ import * as Option from "effect/Option";
20
+ import * as Metric from "effect/Metric";
21
+ import * as Schedule from "effect/Schedule";
22
+ import * as Schema from "effect/Schema";
23
+ // src/internal/IndexedDbKeyValue.ts
24
+ /**
25
+ * The platform-compatible IndexedDB key-value seam under {@link IndexedDbClientStore} (#393). Not part of
26
+ * the package's public surface: the store and the tests import it from here, and nothing re-exports it.
27
+ *
28
+ * It opens the platform's database without a version, keeps one connection per layer, and gives the
29
+ * store a `KeyValueStore` whose records the platform's `BrowserKeyValueStore` reads back, plus the
30
+ * `AtomicKeyValue` batch over the same entries store.
31
+ */
32
+ /**
33
+ * The database and object store this module reads and writes: the database
34
+ * platform-browser's `BrowserKeyValueStore.layerIndexedDb` (4.0.2) also uses,
35
+ * by default `effect_key_value_store`, with an `entries` store keyed by `key`
36
+ * whose records are `{ key, value }`. Both are written here in the same layout,
37
+ * so the store and the atomic batch read one database.
38
+ *
39
+ * The database is opened without a version (#393). The platform's layer opens
40
+ * at version 1, which fails with a `VersionError` on a database a later version
41
+ * created, and that failure kills the layer. Opening at whatever version exists
42
+ * keeps the layer alive. A database without the `entries` store cannot be fixed
43
+ * from here (creating it needs a version upgrade), so its reads and its batch
44
+ * fail with an error that names it.
45
+ */
46
+ const PLATFORM_DATABASE = "effect_key_value_store";
47
+ const ENTRIES_STORE = "entries";
48
+ /**
49
+ * What every call on a store whose database another connection upgraded fails with (#393). The
50
+ * connection is closed by then, so the call cannot run; the message says why.
51
+ */
52
+ const UPGRADED_MESSAGE = "the IndexedDB database was upgraded by another connection; reload to continue";
53
+ /**
54
+ * Open the platform's database at the version it has, creating the entries
55
+ * store only when the database is new. The connection closes itself when
56
+ * another connection asks for a newer version, rather than blocking that
57
+ * connection's upgrade (`onversionchange`); after that, its calls fail with
58
+ * {@link UPGRADED_MESSAGE}.
59
+ */
60
+ const openDatabase = (database) => Effect.gen(function* () {
61
+ const idb = (yield* IndexedDb.IndexedDb).indexedDB;
62
+ return yield* Effect.callback((resume) => {
63
+ const fail = (cause) => resume(Effect.fail(new StoreError({
64
+ message: "Failed to open the IndexedDB store",
65
+ cause
66
+ })));
67
+ const opened = Result.try(() => idb.open(database));
68
+ if (Result.isFailure(opened)) {
69
+ fail(opened.failure);
70
+ return;
71
+ }
72
+ const request = opened.success;
73
+ request.onupgradeneeded = () => {
74
+ const db = request.result;
75
+ if (!db.objectStoreNames.contains(ENTRIES_STORE)) db.createObjectStore(ENTRIES_STORE, { keyPath: "key" });
76
+ };
77
+ request.onsuccess = () => {
78
+ const connection = {
79
+ db: request.result,
80
+ upgraded: false
81
+ };
82
+ connection.db.onversionchange = () => {
83
+ connection.upgraded = true;
84
+ connection.db.close();
85
+ };
86
+ resume(Effect.succeed(connection));
87
+ };
88
+ request.onerror = () => fail(request.error);
89
+ });
90
+ });
91
+ /**
92
+ * Abort `transaction`, ignoring the `InvalidStateError` an already-finished
93
+ * transaction throws: there is nothing left to abort then.
94
+ */
95
+ const abortQuietly = (transaction) => {
96
+ Result.try(() => transaction.abort());
97
+ };
98
+ /** A clear error for a database that lacks the entries store (see the module note above). */
99
+ const missingEntriesStore = (db, method, key) => new KeyValueStore.KeyValueStoreError({
100
+ method,
101
+ message: `IndexedDB database "${db.name}" has no "${ENTRIES_STORE}" object store`,
102
+ ...key === void 0 ? {} : { key }
103
+ });
104
+ /**
105
+ * One request in its own transaction, resolved when the transaction completes,
106
+ * so a write is durable before the caller continues. A failed or aborted
107
+ * transaction fails the call.
108
+ */
109
+ const runRequest = (connection, mode, failure, evaluate) => Effect.callback((resume) => {
110
+ let settled = false;
111
+ let result;
112
+ const finish = (effect) => {
113
+ if (settled) return;
114
+ settled = true;
115
+ resume(effect);
116
+ };
117
+ const fail = (cause) => finish(Effect.fail(new KeyValueStore.KeyValueStoreError({
118
+ ...failure,
119
+ message: connection.upgraded ? UPGRADED_MESSAGE : failure.message,
120
+ cause
121
+ })));
122
+ if (connection.upgraded) {
123
+ finish(Effect.fail(new KeyValueStore.KeyValueStoreError({
124
+ method: failure.method,
125
+ message: UPGRADED_MESSAGE,
126
+ ...failure.key === void 0 ? {} : { key: failure.key }
127
+ })));
128
+ return;
129
+ }
130
+ const db = connection.db;
131
+ if (!db.objectStoreNames.contains(ENTRIES_STORE)) {
132
+ finish(Effect.fail(missingEntriesStore(db, failure.method, failure.key)));
133
+ return;
134
+ }
135
+ const began = Result.try(() => db.transaction(ENTRIES_STORE, mode));
136
+ if (Result.isFailure(began)) {
137
+ fail(began.failure);
138
+ return;
139
+ }
140
+ const transaction = began.success;
141
+ transaction.oncomplete = () => finish(Effect.succeed(result));
142
+ transaction.onerror = () => fail(transaction.error);
143
+ transaction.onabort = () => fail(transaction.error);
144
+ const issued = Result.try(() => {
145
+ const request = evaluate(transaction.objectStore(ENTRIES_STORE));
146
+ request.onsuccess = () => {
147
+ result = request.result;
148
+ };
149
+ request.onerror = () => fail(request.error);
150
+ });
151
+ if (Result.isFailure(issued)) {
152
+ fail(issued.failure);
153
+ abortQuietly(transaction);
154
+ }
155
+ return Effect.sync(() => {
156
+ if (settled) return;
157
+ settled = true;
158
+ abortQuietly(transaction);
159
+ });
160
+ });
161
+ /**
162
+ * The platform's `KeyValueStore` semantics over the database {@link openDatabase}
163
+ * opened: a string value reads back as a string, a `Uint8Array` as bytes, and
164
+ * every call is its own transaction, as the platform's store makes them.
165
+ */
166
+ const keyValueOver = (connection) => {
167
+ return KeyValueStore.make({
168
+ get: (key) => runRequest(connection, "readonly", {
169
+ method: "get",
170
+ message: "Failed to get value from backing store",
171
+ key
172
+ }, (store) => store.get(key)).pipe(Effect.map((found) => P.isString(found?.value) ? found.value : void 0)),
173
+ getUint8Array: (key) => runRequest(connection, "readonly", {
174
+ method: "getUint8Array",
175
+ message: "Failed to get value from backing store",
176
+ key
177
+ }, (store) => store.get(key)).pipe(Effect.map((found) => found?.value instanceof Uint8Array ? found.value : void 0)),
178
+ has: (key) => runRequest(connection, "readonly", {
179
+ method: "has",
180
+ message: "Failed to check key in backing store",
181
+ key
182
+ }, (store) => store.count(key)).pipe(Effect.map((count) => count > 0)),
183
+ set: (key, value) => runRequest(connection, "readwrite", {
184
+ method: "set",
185
+ message: "Failed to set value in backing store",
186
+ key
187
+ }, (store) => store.put({
188
+ key,
189
+ value
190
+ })).pipe(Effect.asVoid),
191
+ remove: (key) => runRequest(connection, "readwrite", {
192
+ method: "remove",
193
+ message: "Failed to remove value from backing store",
194
+ key
195
+ }, (store) => store.delete(key)).pipe(Effect.asVoid),
196
+ clear: runRequest(connection, "readwrite", {
197
+ method: "clear",
198
+ message: "Failed to clear backing store"
199
+ }, (store) => store.clear()).pipe(Effect.asVoid),
200
+ size: runRequest(connection, "readonly", {
201
+ method: "size",
202
+ message: "Failed to get backing store size"
203
+ }, (store) => store.count()),
204
+ keys: runRequest(connection, "readonly", {
205
+ method: "keys",
206
+ message: "Failed to list keys in backing store"
207
+ }, (store) => store.getAllKeys()).pipe(Effect.map((keys) => keys.filter(P.isString)))
208
+ });
209
+ };
210
+ /**
211
+ * Commit `ops` in one `readwrite` transaction on the entries store: IndexedDB
212
+ * commits a transaction whole or not at all, so a failed or interrupted batch
213
+ * leaves the store as it was.
214
+ */
215
+ const commitOps = (connection, ops) => Effect.callback((resume) => {
216
+ let settled = false;
217
+ const finish = (effect) => {
218
+ if (settled) return;
219
+ settled = true;
220
+ resume(effect);
221
+ };
222
+ const fail = (cause) => finish(Effect.fail(new StoreError({
223
+ message: connection.upgraded ? UPGRADED_MESSAGE : "Failed to commit an atomic batch to IndexedDB",
224
+ cause
225
+ })));
226
+ if (connection.upgraded) {
227
+ finish(Effect.fail(new StoreError({ message: UPGRADED_MESSAGE })));
228
+ return;
229
+ }
230
+ const db = connection.db;
231
+ if (!db.objectStoreNames.contains(ENTRIES_STORE)) {
232
+ finish(Effect.fail(new StoreError({ message: `Cannot commit an atomic batch: IndexedDB database "${db.name}" has no "${ENTRIES_STORE}" object store` })));
233
+ return;
234
+ }
235
+ const began = Result.try(() => db.transaction(ENTRIES_STORE, "readwrite"));
236
+ if (Result.isFailure(began)) {
237
+ fail(began.failure);
238
+ return;
239
+ }
240
+ const transaction = began.success;
241
+ const abort = () => abortQuietly(transaction);
242
+ transaction.oncomplete = () => finish(Effect.void);
243
+ transaction.onerror = () => fail(transaction.error);
244
+ transaction.onabort = () => fail(transaction.error);
245
+ const issued = Result.try(() => {
246
+ const entries = transaction.objectStore(ENTRIES_STORE);
247
+ Arr.forEach(ops, (op) => {
248
+ if (op._tag === "Set") entries.put({
249
+ key: op.key,
250
+ value: op.value
251
+ });
252
+ else entries.delete(op.key);
253
+ });
254
+ });
255
+ if (Result.isFailure(issued)) {
256
+ fail(issued.failure);
257
+ abort();
258
+ }
259
+ return Effect.sync(() => {
260
+ if (!settled) {
261
+ settled = true;
262
+ abort();
263
+ }
264
+ });
265
+ });
266
+ /**
267
+ * The platform `KeyValueStore` over IndexedDB, opened without a version (see
268
+ * the module note above). Keys are the full keys the store holds.
269
+ */
270
+ const keyValueLayer = (database) => Layer.effect(KeyValueStore.KeyValueStore)(Effect.gen(function* () {
271
+ const connection = yield* Effect.acquireRelease(openDatabase(database ?? PLATFORM_DATABASE), (opened) => Effect.sync(() => opened.db.close())).pipe(Effect.orDie);
272
+ return keyValueOver(connection);
273
+ }));
274
+ /**
275
+ * Every string entry in the entries store, read in one `readonly` transaction (#392): a single
276
+ * request, so the records are one state of the database and a batch another connection commits is
277
+ * either wholly in them or wholly absent. The platform store writes only strings for the keys this
278
+ * layer reads, so other values are left out.
279
+ */
280
+ const readEntries = (connection) => runRequest(connection, "readonly", {
281
+ method: "getAll",
282
+ message: "Failed to read the IndexedDB store"
283
+ }, (store) => store.getAll()).pipe(Effect.map((records) => records.flatMap((record) => P.isString(record.value) ? [{
284
+ key: record.key,
285
+ value: record.value
286
+ }] : [])), Effect.mapError((error) => new StoreError({
287
+ message: error.message,
288
+ cause: error
289
+ })));
290
+ /**
291
+ * The atomic batch over the same IndexedDB database as the `KeyValueStore`
292
+ * (#393), with its one-transaction read of the entries (#392). Keys arrive in
293
+ * full, namespace prefix included, as the store holds them, so the batch needs
294
+ * no knowledge of the prefix.
295
+ */
296
+ const atomicKeyValueLayer = (database) => Layer.effect(AtomicKeyValue.AtomicKeyValue)(Effect.gen(function* () {
297
+ const connection = yield* Effect.acquireRelease(openDatabase(database ?? PLATFORM_DATABASE), (opened) => Effect.sync(() => opened.db.close())).pipe(Effect.orDie);
298
+ return AtomicKeyValue.AtomicKeyValue.of({
299
+ apply: (ops) => Arr.isReadonlyArrayEmpty(ops) ? Effect.void : commitOps(connection, ops),
300
+ readAll: readEntries(connection)
301
+ });
302
+ }));
303
+ // src/IndexedDbClientStore.ts
304
+ /**
305
+ * IndexedDbClientStore
306
+ *
307
+ * The durable browser ClientStore (ADR-0010): core's KeyValueStore-backed
308
+ * store over platform-browser's IndexedDB KeyValueStore. Every engine
309
+ * state transition lands in IndexedDB, so a killed service worker (or
310
+ * closed tab in main-thread hosting) rehydrates losslessly.
311
+ *
312
+ * A durable replica on a browser is a durable replica on a **device**, which
313
+ * is where the identity boundary earns its keep (#212, CONTEXT.md): a
314
+ * multi-tenant app on a shared device syncs one user's private rows
315
+ * legitimately — the server's auth filter said they could have them — and
316
+ * then the next user opens the app to the freshest thing on the machine.
317
+ * There are two ways to hold that line, and both are here:
318
+ *
319
+ * - {@link layerWindow} with an `identity`: **one replica, reset on change.**
320
+ * The store stamps the fingerprint and compares it on open; a mismatch
321
+ * quarantines the unpushed journal under the superseded fingerprint and
322
+ * drops rows and cookie before anything can read them.
323
+ * - {@link layerNamespaced}: **one replica per identity.** The fingerprint
324
+ * derives the key prefix, so two principals never share a keyspace and
325
+ * nothing needs resetting.
326
+ *
327
+ * A version change from another connection (a newer app build upgrading the
328
+ * database, or a deletion) closes this store's connections, since they would
329
+ * otherwise block it. From then on every call fails with an error that says
330
+ * the database was upgraded by another connection and asks for a reload. The
331
+ * store never reopens itself, so a reload is the way back.
332
+ *
333
+ * Which to choose: namespace when the device has a small number of regular
334
+ * users who switch back and forth (each keeps a warm replica, so switching
335
+ * back costs no re-pull, at the price of holding both on disk); reset when
336
+ * sign-ins are one-off or unbounded — a kiosk, a shared library machine —
337
+ * where accumulating a replica per visitor is the worse trade.
338
+ */
339
+ var IndexedDbClientStore_exports = /* @__PURE__ */ __exportAll({
340
+ layer: () => layer,
341
+ layerNamespaced: () => layerNamespaced,
342
+ layerWindow: () => layerWindow,
343
+ namespacedPrefix: () => namespacedPrefix
344
+ });
345
+ /** The store options that reach core, absent keys left absent. */
346
+ const keyValueOptions = (options) => ({
347
+ ...options?.prefix === void 0 ? {} : { prefix: options.prefix },
348
+ ...options?.identity === void 0 ? {} : { identity: options.identity }
349
+ });
350
+ /**
351
+ * ClientStore over IndexedDB. Requires the `IndexedDb` primitives service;
352
+ * use {@link layerWindow} where browser globals are available, or provide
353
+ * `IndexedDb` yourself (tests use fake-indexeddb).
354
+ *
355
+ * A pull and a push acknowledgement commit as one IndexedDB transaction (#393),
356
+ * so a durable pull writes its rows once and writes no release record.
357
+ */
358
+ const layer = (options) => ClientStore.layerKeyValue(keyValueOptions(options)).pipe(Layer.provide(Layer.merge(keyValueLayer(options?.database), atomicKeyValueLayer(options?.database))));
359
+ /** Self-contained layer using the browser's global `indexedDB`. */
360
+ const layerWindow = (options) => layer(options).pipe(Layer.provide(IndexedDb.layerWindow));
361
+ /**
362
+ * The key prefix {@link layerNamespaced} derives for a fingerprint:
363
+ * core's `"legation/"` namespace with a percent-encoded identity segment
364
+ * under it, so two principals' replicas cannot collide and neither can
365
+ * collide with an un-namespaced one.
366
+ */
367
+ const namespacedPrefix = (identity) => `legation/identity/${Identity.keySegment(identity)}/`;
368
+ /**
369
+ * The namespacing alternative to resetting (#212): one replica per identity
370
+ * in a single IndexedDB database, keyed by fingerprint.
371
+ *
372
+ * A principal's rows are unreachable from another principal's store because
373
+ * they are under a different key prefix — no comparison, no reset, and a
374
+ * user who switches back finds their replica still warm. The fingerprint is
375
+ * still stamped, so a store reached through this layer keeps the same
376
+ * guarantee if it is ever opened un-namespaced.
377
+ *
378
+ * The cost is disk: every identity that has ever signed in on this device
379
+ * keeps a replica until something clears it. Prefer {@link layerWindow} with
380
+ * an `identity` where sign-ins are unbounded.
381
+ *
382
+ * Pass `database` to isolate at the database level instead of the key level
383
+ * — a heavier boundary (a whole IndexedDB database per identity, deletable
384
+ * in one call) for apps that want it.
385
+ */
386
+ const layerNamespaced = (identity, options) => layerWindow({
387
+ ...options?.database === void 0 ? {} : { database: options.database },
388
+ prefix: namespacedPrefix(identity),
389
+ identity
390
+ });
391
+ // src/BroadcastMirrorChannel.ts
392
+ /**
393
+ * BroadcastMirrorChannel
394
+ *
395
+ * The browser binding of the `MirrorChannel` seam: schema-defined mirror
396
+ * protocol messages over a `BroadcastChannel` name. Pair one in the engine
397
+ * host with one per client context (tab, window, worker) — BroadcastChannel
398
+ * semantics mean a side never receives its own posts.
399
+ */
400
+ var BroadcastMirrorChannel_exports = /* @__PURE__ */ __exportAll({ make: () => make$1 });
401
+ /** A {@link MirrorChannel.MirrorChannel} over a `BroadcastChannel` name. */
402
+ const make$1 = (name) => Effect.gen(function* () {
403
+ const channel = yield* Effect.acquireRelease(Effect.sync(() => new BroadcastChannel(name)), (ch) => Effect.sync(() => ch.close()));
404
+ return {
405
+ post: (message) => Effect.try({
406
+ try: () => channel.postMessage(message),
407
+ catch: (cause) => new TransportError({
408
+ message: `BroadcastChannel post failed`,
409
+ cause
410
+ })
411
+ }),
412
+ subscribe: Effect.gen(function* () {
413
+ const queue = yield* Queue.unbounded();
414
+ yield* Effect.acquireRelease(Effect.sync(() => {
415
+ const listener = (event) => {
416
+ Queue.offerUnsafe(queue, event.data);
417
+ };
418
+ channel.addEventListener("message", listener);
419
+ return listener;
420
+ }), (listener) => Effect.sync(() => channel.removeEventListener("message", listener)));
421
+ return { take: Queue.take(queue).pipe(Effect.orDie) };
422
+ })
423
+ };
424
+ });
425
+ // src/ServiceWorkerHost.ts
426
+ var ServiceWorkerHost_exports = /* @__PURE__ */ __exportAll({
427
+ defaultChannelName: () => defaultChannelName,
428
+ run: () => run$1,
429
+ start: () => start
430
+ });
431
+ /** The default channel name; Mirror setups should use the same one. */
432
+ const defaultChannelName = "legation";
433
+ /**
434
+ * Start the host: serve mirrors over the broadcast channel and start the
435
+ * engine's sync loop (pull on poke, push+pull on reconnect), then return
436
+ * the {@link Host}. Both the host and `runSync` fork their fibers into the
437
+ * surrounding scope, so the caller must keep that scope open for as long
438
+ * as it serves (`Effect.never`, as {@link run} does); closing it stops
439
+ * serving.
440
+ */
441
+ const start = (options = {}) => Effect.gen(function* () {
442
+ const { channelName = defaultChannelName, registry, nodes, urls, backgroundSync, pushPoke } = options;
443
+ const engine = yield* ClientEngine.ClientEngine;
444
+ const channel = yield* make$1(channelName);
445
+ const host = yield* EngineHost$1.run({
446
+ channel,
447
+ engine,
448
+ ...registry === void 0 ? {} : { registry },
449
+ ...nodes === void 0 ? {} : { nodes },
450
+ ...urls === void 0 ? {} : { urls }
451
+ });
452
+ yield* engine.runSync.pipe(Effect.ignore);
453
+ if (backgroundSync !== void 0) yield* backgroundSync.serve(engine);
454
+ if (pushPoke !== void 0) yield* pushPoke.serve(engine);
455
+ return {
456
+ engine,
457
+ registrations: host.registrations
458
+ };
459
+ });
460
+ /**
461
+ * Run the host: {@link start}, then serve forever. NEVER returns — both the
462
+ * host and `runSync` fork their fibers into the surrounding scope, so
463
+ * returning would let a caller's `Effect.scoped` tear the whole host down
464
+ * immediately. Interrupt it / close the scope to stop serving.
465
+ */
466
+ const run$1 = (options = {}) => start(options).pipe(Effect.andThen(Effect.never));
467
+ // src/ElectedHost.ts
468
+ var ElectedHost_exports = /* @__PURE__ */ __exportAll({ run: () => run });
469
+ /** Resolve the ambient `navigator.locks`, `undefined` where it (or `navigator`) doesn't exist. */
470
+ const globalLocks = () => globalThis.navigator?.locks;
471
+ /**
472
+ * Wait to be granted `name` (Web Lock, exclusive mode), then hold it until
473
+ * this effect's scope closes. While waiting, interruption aborts the
474
+ * pending request (`LockManager.request`'s own `signal` option) instead of
475
+ * leaving it queued forever.
476
+ */
477
+ const electLeader = (locks, name) => Effect.acquireRelease(Effect.callback((resume, signal) => {
478
+ locks.request(name, {
479
+ mode: "exclusive",
480
+ signal
481
+ }, () => new Promise((resolveHeld) => {
482
+ resume(Effect.succeed(() => resolveHeld()));
483
+ })).catch(() => void 0);
484
+ }), (release) => Effect.sync(release), { interruptible: true }).pipe(Effect.asVoid);
485
+ /**
486
+ * A `ClientView` over a `SubscriptionRef` of the currently-active view:
487
+ * one-shot calls (`query`/`mutate`/…) always hit whatever is current;
488
+ * `observe`/`observeNamed`/`related` re-subscribe themselves against the
489
+ * new view the moment it changes (`Stream.switchMap`, tearing the old
490
+ * subscription down first — a mirror's own `unsubscribe`/interrupt path);
491
+ * explicit pins (`subscribe`/`subscribeQuery`) have no ongoing stream to
492
+ * redo that for them, so this keeps its own ledger and replays it onto
493
+ * every newly-active view.
494
+ */
495
+ const switchingView = (viewRef) => Effect.gen(function* () {
496
+ const pinnedSubsets = yield* Ref.make(/* @__PURE__ */ new Map());
497
+ const pinnedNamed = yield* Ref.make(/* @__PURE__ */ new Map());
498
+ yield* Effect.forkScoped(Stream.runForEach(Stream.drop(SubscriptionRef.changes(viewRef), 1), (view) => Effect.gen(function* () {
499
+ const subsets = yield* Ref.get(pinnedSubsets);
500
+ const named = yield* Ref.get(pinnedNamed);
501
+ yield* Effect.forEach(subsets.values(), (subset) => view.subscribe(subset).pipe(Effect.ignore), { discard: true });
502
+ yield* Effect.forEach(named.values(), (entry) => view.subscribeQuery(entry.name, entry.args).pipe(Effect.ignore), { discard: true });
503
+ })).pipe(Effect.ignore));
504
+ const current = SubscriptionRef.get(viewRef);
505
+ const withCurrent = (f) => Effect.flatMap(current, f);
506
+ return {
507
+ query: (subset) => withCurrent((view) => view.query(subset)),
508
+ observe: (subset) => SubscriptionRef.changes(viewRef).pipe(Stream.switchMap((view) => view.observe(subset))),
509
+ observeChanges: (subset, changeOptions) => SubscriptionRef.changes(viewRef).pipe(Stream.switchMap((view) => view.observeChanges(subset, changeOptions))),
510
+ subscribe: (subset) => Ref.update(pinnedSubsets, (map) => new Map(map).set(Subset.hash(subset), subset)).pipe(Effect.andThen(withCurrent((view) => view.subscribe(subset)))),
511
+ unsubscribe: (subset) => Ref.update(pinnedSubsets, (map) => {
512
+ const next = new Map(map);
513
+ next.delete(Subset.hash(subset));
514
+ return next;
515
+ }).pipe(Effect.andThen(withCurrent((view) => view.unsubscribe(subset)))),
516
+ subscribeQuery: (name, args) => Ref.update(pinnedNamed, (map) => new Map(map).set(MirrorProtocol$1.namedKey(name, args), {
517
+ name,
518
+ args
519
+ })).pipe(Effect.andThen(withCurrent((view) => view.subscribeQuery(name, args)))),
520
+ unsubscribeQuery: (name, args) => Ref.update(pinnedNamed, (map) => {
521
+ const next = new Map(map);
522
+ next.delete(MirrorProtocol$1.namedKey(name, args));
523
+ return next;
524
+ }).pipe(Effect.andThen(withCurrent((view) => view.unsubscribeQuery(name, args)))),
525
+ queryNamed: (name, args) => withCurrent((view) => view.queryNamed(name, args)),
526
+ pulled: (key) => withCurrent((view) => view.pulled(key)),
527
+ observeNamed: (name, args) => SubscriptionRef.changes(viewRef).pipe(Stream.switchMap((view) => view.observeNamed(name, args))),
528
+ related: (registry, collectionName, subset, edges) => Effect.succeed(SubscriptionRef.changes(viewRef).pipe(Stream.switchMap((view) => Stream.unwrap(view.related(registry, collectionName, subset, edges))))),
529
+ pinEdges: (registry, collectionName, subset, edges) => Effect.gen(function* () {
530
+ const scope = yield* Effect.scope;
531
+ const held = yield* Ref.make(void 0);
532
+ yield* Effect.forkIn(Stream.runForEach(SubscriptionRef.changes(viewRef), (view) => Effect.gen(function* () {
533
+ const previous = yield* Ref.getAndSet(held, void 0);
534
+ if (previous !== void 0) yield* Scope.close(previous, Exit.void);
535
+ const child = yield* Scope.fork(scope);
536
+ yield* view.pinEdges(registry, collectionName, subset, edges).pipe(Scope.provide(child));
537
+ yield* Ref.set(held, child);
538
+ })).pipe(Effect.ignore), scope);
539
+ }),
540
+ read: (subsets) => withCurrent((view) => view.read === void 0 ? Effect.fail(new TransportError({ message: "ElectedHost: no fallthrough read available on this tier" })) : view.read(subsets)),
541
+ mutate: (mutator, args) => withCurrent((view) => view.mutate(mutator, args)),
542
+ mutateWithAck: (mutator, args) => withCurrent((view) => view.mutateWithAck(mutator, args)),
543
+ mutationStates: SubscriptionRef.changes(viewRef).pipe(Stream.switchMap((view) => view.mutationStates))
544
+ };
545
+ });
546
+ /**
547
+ * Elect this tab into hosting `clientGroupId`'s engine — full main-thread
548
+ * host (ADR-0010) while the lock is held, a mirror over the same channel
549
+ * otherwise — and hand back one `Client` that works unchanged across the
550
+ * transition, plus a `role` stream for observability.
551
+ */
552
+ const run = (options) => Effect.gen(function* () {
553
+ const channelName = options.channelName ?? "legation";
554
+ const locks = options.locks ?? globalLocks();
555
+ const mirrorScope = yield* Scope.make();
556
+ const mirrorChannel = yield* make$1(channelName).pipe(Scope.provide(mirrorScope));
557
+ const mirror = yield* Mirror$1.make({
558
+ channel: mirrorChannel,
559
+ mutators: options.mutations
560
+ }).pipe(Scope.provide(mirrorScope));
561
+ const viewRef = yield* SubscriptionRef.make(mirror);
562
+ const roleRef = yield* SubscriptionRef.make("mirror");
563
+ const becomeHost = Effect.gen(function* () {
564
+ const hostClientId = options.hostClientId ?? `host-${options.clientGroupId}`;
565
+ const engine = Subscriptions.counted(yield* ClientEngine.make({
566
+ clientId: hostClientId,
567
+ clientGroupId: options.clientGroupId,
568
+ mutators: options.mutations,
569
+ ...options.queries === void 0 ? {} : { queries: options.queries },
570
+ ...options.syncLoop === void 0 ? {} : { syncLoop: options.syncLoop }
571
+ }).pipe(Effect.provide(options.dependencies)));
572
+ const hostChannel = yield* make$1(channelName);
573
+ yield* EngineHost$1.run({
574
+ channel: hostChannel,
575
+ engine,
576
+ nodes: options.nodes,
577
+ ...options.registry === void 0 ? {} : { registry: options.registry },
578
+ ...options.urls === void 0 ? {} : { urls: options.urls }
579
+ });
580
+ yield* Effect.forkScoped(engine.runSync.pipe(Effect.ignore));
581
+ yield* SubscriptionRef.set(viewRef, ClientView.fromEngine(engine));
582
+ yield* SubscriptionRef.set(roleRef, "host");
583
+ yield* Scope.close(mirrorScope, Exit.void);
584
+ });
585
+ if (locks === void 0) {
586
+ yield* Effect.logWarning("ElectedHost: navigator.locks is unavailable — becoming host immediately. This is only correct for a single tab; see the README's topology docs.");
587
+ yield* becomeHost;
588
+ } else yield* Effect.forkScoped(electLeader(locks, options.clientGroupId).pipe(Effect.andThen(becomeHost), Effect.tapCause((cause) => Effect.logError("ElectedHost: failed to become host", cause)), Effect.ignore));
589
+ const view = yield* switchingView(viewRef);
590
+ return {
591
+ client: Client.fromView(options.nodes, view, options.urls === void 0 ? {} : { urls: options.urls }),
592
+ role: SubscriptionRef.changes(roleRef)
593
+ };
594
+ });
595
+ // src/CacheApiHttpCache.ts
596
+ /**
597
+ * CacheApiHttpCache
598
+ *
599
+ * The browser implementation of core's HTTP cache module (ADR-0008 narrows
600
+ * ADR-0004's stale-marking semantics to exactly this module): a
601
+ * ResponseCacheStore over the Cache API, plus the stale-while-revalidate
602
+ * fetch handler service workers wire into their fetch events.
603
+ *
604
+ * Cache metadata (freshness window, tags) travels in a response header on
605
+ * the stored copy, so entries are self-describing and survive across
606
+ * service-worker restarts. Structural `CacheLike` typing keeps the module
607
+ * testable without browser globals.
608
+ */
609
+ var CacheApiHttpCache_exports = /* @__PURE__ */ __exportAll({
610
+ fetchHandler: () => fetchHandler,
611
+ make: () => make,
612
+ metaHeader: () => metaHeader,
613
+ open: () => open
614
+ });
615
+ /** Header carrying the serialized CachedResponseMeta on stored copies. */
616
+ const metaHeader = "x-legation-cache-meta";
617
+ const storeError = (message) => (cause) => new StoreError({
618
+ message,
619
+ cause
620
+ });
621
+ const tryCache = (message, run) => Effect.tryPromise({
622
+ try: run,
623
+ catch: storeError(message)
624
+ });
625
+ /** Decode a stored meta header; `None` when it is malformed (foreign or corrupt). */
626
+ const parseMeta = Option.liftThrowable((raw) => JSON.parse(decodeURIComponent(raw)));
627
+ const readMeta = (response) => {
628
+ const raw = response.headers.get(metaHeader);
629
+ if (raw === null) return void 0;
630
+ return Option.getOrUndefined(parseMeta(raw));
631
+ };
632
+ /** ResponseCacheStore over one named Cache API cache. */
633
+ const make = (cache) => ({
634
+ match: (request) => tryCache(`Cache match failed for ${request.url}`, () => cache.match(request)).pipe(Effect.map((response) => {
635
+ if (response === void 0) return void 0;
636
+ const meta = readMeta(response);
637
+ return meta === void 0 ? void 0 : {
638
+ response,
639
+ meta
640
+ };
641
+ })),
642
+ put: (request, response, meta) => tryCache(`Cache put failed for ${request.url}`, async () => {
643
+ const headers = new Headers(response.headers);
644
+ headers.set(metaHeader, encodeURIComponent(JSON.stringify(meta)));
645
+ const stored = new Response(response.body, {
646
+ status: response.status,
647
+ statusText: response.statusText,
648
+ headers
649
+ });
650
+ await cache.put(request, stored);
651
+ }),
652
+ delete: (request) => Effect.asVoid(tryCache(`Cache delete failed for ${request.url}`, () => cache.delete(request))),
653
+ invalidateTags: (tags) => Effect.gen(function* () {
654
+ const requests = yield* tryCache("Cache enumeration failed", () => cache.keys());
655
+ yield* Effect.forEach(requests, (request) => Effect.gen(function* () {
656
+ const response = yield* tryCache(`Cache match failed for ${request.url}`, () => cache.match(request));
657
+ const meta = response === void 0 ? void 0 : readMeta(response);
658
+ if (meta !== void 0 && meta.tags.some((tag) => tags.includes(tag))) yield* tryCache(`Cache delete failed for ${request.url}`, () => cache.delete(request));
659
+ }), {
660
+ concurrency: 1,
661
+ discard: true
662
+ });
663
+ })
664
+ });
665
+ /** Open a named cache (defaults to the global `caches`). */
666
+ const open = (name, cacheStorage) => tryCache(`Failed to open cache "${name}"`, () => (cacheStorage ?? globalThis.caches).open(name)).pipe(Effect.map(make));
667
+ /**
668
+ * Build the stale-while-revalidate handler: fresh entries are served from
669
+ * cache, stale-but-unexpired entries are served immediately with a
670
+ * background revalidation, and expired or missing entries reload from the
671
+ * network (caching ok GET responses).
672
+ */
673
+ const fetchHandler = (options) => {
674
+ const network = (request) => Effect.tryPromise({
675
+ try: () => (options.fetch ?? globalThis.fetch)(request),
676
+ catch: (cause) => new TransportError({
677
+ message: `Fetch failed for ${request.url}`,
678
+ cause
679
+ })
680
+ });
681
+ const fetchAndStore = (request, policy) => Effect.gen(function* () {
682
+ const response = yield* network(request);
683
+ if (!response.ok) return response;
684
+ const now = yield* Clock.currentTimeMillis;
685
+ const timeToLive = policy.timeToLive;
686
+ const meta = {
687
+ url: request.url,
688
+ storedAt: now,
689
+ staleAt: now + Duration.toMillis(policy.staleTime),
690
+ expiresAt: timeToLive === void 0 ? void 0 : now + Duration.toMillis(timeToLive),
691
+ tags: policy.tags ?? []
692
+ };
693
+ yield* options.store.put(request, response.clone(), meta);
694
+ return response;
695
+ });
696
+ return (request) => Effect.gen(function* () {
697
+ const policy = request.method === "GET" ? options.policy(request) : void 0;
698
+ if (policy === void 0) return {
699
+ response: yield* network(request),
700
+ revalidate: void 0
701
+ };
702
+ const cached = yield* options.store.match(request);
703
+ const now = yield* Clock.currentTimeMillis;
704
+ if (cached !== void 0) {
705
+ const { meta, response } = cached;
706
+ if (now < meta.staleAt) return {
707
+ response,
708
+ revalidate: void 0
709
+ };
710
+ if (meta.expiresAt === void 0 || now < meta.expiresAt) return {
711
+ response,
712
+ revalidate: Effect.asVoid(fetchAndStore(request, policy))
713
+ };
714
+ }
715
+ return {
716
+ response: yield* fetchAndStore(request, policy),
717
+ revalidate: void 0
718
+ };
719
+ });
720
+ };
721
+ // src/HttpCacheInvalidation.ts
722
+ var HttpCacheInvalidation_exports = /* @__PURE__ */ __exportAll({
723
+ FINE_TAG_ROW_BUDGET: () => 43,
724
+ collectionChanges: () => collectionChanges,
725
+ defaultFineTagFor: () => defaultFineTagFor,
726
+ defaultTagFor: () => defaultTagFor,
727
+ invalidateOnChanges: () => invalidateOnChanges,
728
+ tagsFor: () => tagsFor,
729
+ tagsForObserved: () => tagsForObserved
730
+ });
731
+ /** The default coarse tag convention: `collection:{name}` (`HttpCache.tagFor`, shared with the edge, #206). */
732
+ const defaultTagFor = HttpCache.tagFor;
733
+ /** The default fine, per-row tag convention: `collection:{name}:{id}` (`HttpCache.fineTagFor`). */
734
+ const defaultFineTagFor = HttpCache.fineTagFor;
735
+ const EMPTY_TOUCHED = {
736
+ collections: [],
737
+ rows: []
738
+ };
739
+ /** What an engine event touched, or nothing for other events. */
740
+ const touchedBy = (event) => {
741
+ switch (event._tag) {
742
+ case "PullCompleted": return {
743
+ collections: event.collections,
744
+ rows: event.rows
745
+ };
746
+ case "ViewRebuilt": return {
747
+ collections: event.collections ?? [],
748
+ rows: []
749
+ };
750
+ default: return EMPTY_TOUCHED;
751
+ }
752
+ };
753
+ /**
754
+ * Derive the change feed from an engine's event stream: the distinct
755
+ * collections and rows each `PullCompleted` (always present) and scoped
756
+ * `ViewRebuilt` (when present) touched, dropping events that touched
757
+ * nothing. Feed the result to {@link invalidateOnChanges}.
758
+ */
759
+ const collectionChanges = (events) => events.pipe(Stream.map(touchedBy), Stream.filter((touched) => Arr.isReadonlyArrayNonEmpty(touched.collections) || Arr.isReadonlyArrayNonEmpty(touched.rows)));
760
+ /**
761
+ * Fork a scoped fibre that, per emitted change, evicts every cached response
762
+ * tagged for the touched collections (coarse) **and** the touched rows
763
+ * (fine) — `store.invalidateTags`. Invalidation cost stays proportional to
764
+ * the store's tag enumeration — this scans no more than `invalidateTags`
765
+ * already does. Runs until the scope closes; a store error is logged and
766
+ * swallowed, never fatal to the fibre, so a transient cache failure doesn't
767
+ * stop future invalidations.
768
+ */
769
+ const invalidateOnChanges = (options) => Effect.gen(function* () {
770
+ const tagFor = options.tagFor ?? defaultTagFor;
771
+ const fineTagFor = options.fineTagFor ?? defaultFineTagFor;
772
+ yield* Effect.forkScoped(Stream.runForEach(options.changes, (touched) => {
773
+ const tags = HttpCache.evictionTags(touched, {
774
+ tagFor,
775
+ fineTagFor
776
+ });
777
+ return Arr.isReadonlyArrayEmpty(tags) ? Effect.void : options.store.invalidateTags(tags).pipe(Effect.catch((error) => Effect.logWarning("HTTP cache invalidation failed", error)));
778
+ }));
779
+ });
780
+ /**
781
+ * Build the cache tags for a response that enumerates `rowIds` of
782
+ * `collection`: the coarse tag, plus one fine tag per row when the row count
783
+ * is within {@link FINE_TAG_ROW_BUDGET} — beyond it, the coarse tag alone
784
+ * (`budgetExceeded: true`), since a fine tag per row would blow the header
785
+ * budget the tag rides on. Pure and synchronous, for use building
786
+ * `HttpCachePolicy.tags`; pair with {@link tagsForObserved} to also record
787
+ * the fallback when it happens.
788
+ */
789
+ const tagsFor = (collection, rowIds, options = {}) => {
790
+ const coarse = (options.tagFor ?? defaultTagFor)(collection);
791
+ if (rowIds.length > 43) return {
792
+ tags: [coarse],
793
+ budgetExceeded: true
794
+ };
795
+ const fineTagFor = options.fineTagFor ?? defaultFineTagFor;
796
+ return {
797
+ tags: [coarse, ...rowIds.map((id) => fineTagFor(collection, id))],
798
+ budgetExceeded: false
799
+ };
800
+ };
801
+ /**
802
+ * {@link tagsFor}, plus an observable fallback: a response that exceeded the
803
+ * budget is never silently coarse — it logs a warning and increments
804
+ * `legation_cache_tag_budget_exceeded_total` (`Telemetry.cacheTagBudgetExceededTotal`),
805
+ * following the same "safely degraded, but counted" idiom as the engine's
806
+ * other bounded-hub fallbacks (`legation_hints_dropped_total`,
807
+ * `legation_change_collapses_total`).
808
+ */
809
+ const tagsForObserved = (collection, rowIds, options = {}) => Effect.gen(function* () {
810
+ const result = tagsFor(collection, rowIds, options);
811
+ if (result.budgetExceeded) {
812
+ yield* Effect.logWarning(`HTTP cache tag budget exceeded for collection "${collection}": ${rowIds.length} rows > 43; tagged coarse-only`);
813
+ yield* Metric.update(Telemetry.cacheTagBudgetExceededTotal, 1);
814
+ }
815
+ return result;
816
+ });
817
+ // src/BrowserConnectivity.ts
818
+ var BrowserConnectivity_exports = /* @__PURE__ */ __exportAll({ withBrowserConnectivity: () => withBrowserConnectivity });
819
+ /** The global `navigator` when it reports `onLine`, else undefined (SSR, or a runtime without it). */
820
+ const globalNavigator = () => P.isObjectOrArray(globalThis.navigator) && P.isBoolean(globalThis.navigator.onLine) ? globalThis.navigator : void 0;
821
+ /** Current `navigator.onLine` on the global scope, treating a missing navigator as online. */
822
+ const navigatorOnline = () => globalNavigator()?.onLine ?? true;
823
+ /**
824
+ * Register a DOM event listener on `target` for the scope's lifetime. No-op
825
+ * when `target` is undefined (e.g. server-side rendering).
826
+ */
827
+ const onEvent = (target, type, handler) => target === void 0 ? Effect.void : Effect.acquireRelease(Effect.sync(() => {
828
+ target.addEventListener(type, handler);
829
+ }), () => Effect.sync(() => target.removeEventListener(type, handler))).pipe(Effect.asVoid);
830
+ /** The pessimistic combination of two status signals. */
831
+ const worst = (a, b) => a === "disconnected" || b === "disconnected" ? "disconnected" : a === "connecting" || b === "connecting" ? "connecting" : "connected";
832
+ /**
833
+ * Wrap a `ClientTransport` so its connection status combines
834
+ * `navigator.onLine` with the transport's own signal, and a return-to-visible
835
+ * surfaces a poke. Where there is no `window` (a service worker), the
836
+ * navigator signal is probed every `pollInterval` instead of listened for.
837
+ * Scoped: forked fibres and DOM listeners are torn down when the scope closes.
838
+ */
839
+ const withBrowserConnectivity = (transport, options = {}) => Effect.gen(function* () {
840
+ const win = globalThis.window;
841
+ const doc = globalThis.document;
842
+ const initialOnline = navigatorOnline();
843
+ const status = yield* SubscriptionRef.make(initialOnline ? "connected" : "disconnected");
844
+ const statusEvents = yield* Queue.unbounded();
845
+ const focusPokes = yield* Queue.unbounded();
846
+ yield* Effect.forkScoped(Stream.fromQueue(statusEvents).pipe(Stream.runForEach((next) => SubscriptionRef.set(status, next))));
847
+ if (win !== void 0) {
848
+ yield* onEvent(win, "online", () => Queue.offerUnsafe(statusEvents, "connected"));
849
+ yield* onEvent(win, "offline", () => Queue.offerUnsafe(statusEvents, "disconnected"));
850
+ } else if (globalNavigator() !== void 0) {
851
+ let lastOnline = initialOnline;
852
+ yield* Effect.forkScoped(Effect.repeat(Effect.sync(() => {
853
+ const online = navigatorOnline();
854
+ if (online !== lastOnline) {
855
+ lastOnline = online;
856
+ Queue.offerUnsafe(statusEvents, online ? "connected" : "disconnected");
857
+ }
858
+ }), Schedule.spaced(options.pollInterval ?? "5 seconds")));
859
+ }
860
+ yield* onEvent(doc, "visibilitychange", () => {
861
+ if (doc?.visibilityState === "visible") Queue.offerUnsafe(focusPokes, { reason: "visibilitychange" });
862
+ });
863
+ return {
864
+ push: transport.push,
865
+ pull: transport.pull,
866
+ pokes: Stream.merge(transport.pokes, Stream.fromQueue(focusPokes)),
867
+ status: Effect.zipWith(SubscriptionRef.get(status), transport.status, worst),
868
+ statusChanges: Stream.zipLatest(SubscriptionRef.changes(status), transport.statusChanges).pipe(Stream.mapAccum(() => void 0, (previousNavigator, [navigatorStatus, transportStatus]) => {
869
+ return [navigatorStatus, [previousNavigator === "disconnected" && navigatorStatus === "connected" ? "connected" : worst(navigatorStatus, transportStatus)]];
870
+ }), Stream.changesWith((a, b) => a === b))
871
+ };
872
+ });
873
+ // src/BackgroundSync.ts
874
+ var BackgroundSync_exports = /* @__PURE__ */ __exportAll({
875
+ HostNotReady: () => HostNotReady,
876
+ defaultReadyTimeout: () => defaultReadyTimeout,
877
+ defaultTag: () => defaultTag,
878
+ flush: () => flush,
879
+ identityUnchecked: () => identityUnchecked,
880
+ listen: () => listen$1,
881
+ register: () => register,
882
+ registerOnJournal: () => registerOnJournal
883
+ });
884
+ /**
885
+ * The tag the library registers and answers. Short on purpose: Chromium
886
+ * refuses over-long tags.
887
+ */
888
+ const defaultTag = "legation-flush";
889
+ /** How long a `sync` event waits for a host to attach before giving up. */
890
+ const defaultReadyTimeout = "30 seconds";
891
+ /** Whether a probed `sync` member is a `SyncManager`: an object with a `register` method. */
892
+ const isSyncManager = (sync) => P.isObjectOrArray(sync) && "register" in sync && P.isFunction(sync.register);
893
+ /** The registration's `SyncManager`, if the browser gives it one. */
894
+ const syncManagerOf = (registration) => {
895
+ const sync = registration?.sync;
896
+ return isSyncManager(sync) ? sync : void 0;
897
+ };
898
+ /** The page's service-worker registration, or `undefined` where there is none. */
899
+ const pageRegistration = Effect.suspend(() => {
900
+ const container = globalThis.navigator?.serviceWorker;
901
+ return container === void 0 ? Effect.undefined : Effect.tryPromise(() => container.getRegistration()).pipe(Effect.orElseSucceed(() => void 0));
902
+ });
903
+ /**
904
+ * Ask the browser to wake the worker with the library's `sync` event once
905
+ * connectivity is back. Call it from the page after a journaled mutation, or
906
+ * let `ServiceWorkerHost` do it from the worker (see {@link ListenOptions}).
907
+ *
908
+ * Never fails and never logs above debug level: absence is the common case
909
+ * (Background Sync is Chromium-only), and the journal still flushes on the
910
+ * next connectivity signal. Registering a tag that is already pending is a
911
+ * no-op for the browser, so calling it once per mutation is fine.
912
+ */
913
+ const register = (options = {}) => Effect.gen(function* () {
914
+ const tag = options.tag ?? "legation-flush";
915
+ const registration = yield* options.registration ?? pageRegistration;
916
+ const sync = syncManagerOf(registration);
917
+ if (sync === void 0) {
918
+ yield* Effect.logDebug("Background Sync unavailable; the journal flushes on the next connectivity signal");
919
+ return "Unsupported";
920
+ }
921
+ return yield* Effect.tryPromise(() => sync.register(tag)).pipe(Effect.as("Registered"), Effect.catch((cause) => Effect.logDebug("Background Sync registration refused", cause).pipe(Effect.as("Refused"))));
922
+ });
923
+ /** Whether the worker's own view says the network is down. */
924
+ const navigatorOffline = Effect.sync(() => globalThis.navigator?.onLine === false);
925
+ /**
926
+ * Register the tag for the scope's lifetime whenever `engine`'s journal
927
+ * cannot drain now — the host's side of {@link register}, since in the
928
+ * service-worker topology a page's mutation is journaled in the worker:
929
+ *
930
+ * - a mutation is journaled while {@link RegisterOnJournalOptions.offline}
931
+ * holds; and
932
+ * - a push fails terminally (`ClientEngine.syncFailures`, after the retry
933
+ * policy is exhausted) — reachable network, unreachable backend.
934
+ *
935
+ * Never on an online mutation: the sync loop drains those, debounced and
936
+ * compacted, and an online registration would fire `sync` at once and push
937
+ * each write on its own (#246). Chromium only accepts a registration from a
938
+ * worker while a window of the origin is open; a refusal is a debug line.
939
+ */
940
+ const registerOnJournal = (engine, options = {}) => Effect.gen(function* () {
941
+ const offline = options.offline ?? navigatorOffline;
942
+ const registerOn = (stream) => Effect.forkScoped(Stream.runForEach(stream, () => register(options)), { startImmediately: true });
943
+ yield* registerOn(engine.engineEvents.pipe(Stream.filter((event) => event._tag === "MutationJournaled"), Stream.filterEffect(() => offline)));
944
+ yield* registerOn(engine.syncFailures.pipe(Stream.filter((failure) => failure.operation === "push")));
945
+ });
946
+ /**
947
+ * The explicit opt-out from the flush's identity check, for an app with **no
948
+ * signed-in identity at all** (every session is the same public principal).
949
+ * The flush then relies on the store's boundary alone: quarantined entries
950
+ * are still unreachable from push, but a store still stamped for a user who
951
+ * signed out in a tab that closed before `Client.reset` ran **would be
952
+ * pushed**. Never pass this in an app that has sign-in.
953
+ */
954
+ const identityUnchecked = Symbol.for("@thomasfosterau/legation-browser/identityUnchecked");
955
+ /**
956
+ * Flush `engine`'s journal through the ordinary push path, behind the
957
+ * identity check. A journal larger than half the engine's unpulled bound goes
958
+ * in chunks with a pull before each chunk after the first, so a flush may
959
+ * pull (#429); a smaller journal is pushed without pulling. Fails — so the
960
+ * `sync` event rejects and the browser reschedules — on a transport or store
961
+ * failure, with the journal intact. A pull between chunks that fails is a
962
+ * transport failure of the same kind, so the flush never reports `Flushed`
963
+ * over a journal it stopped short of.
964
+ */
965
+ const flush = (engine, options) => Effect.gen(function* () {
966
+ if (options.identity !== identityUnchecked) {
967
+ const current = yield* options.identity;
968
+ const stamped = yield* engine.identity;
969
+ if (current === void 0 || stamped !== current) {
970
+ yield* Effect.logDebug("Background Sync flush skipped: the replica is not stamped for the current identity");
971
+ return "IdentityChanged";
972
+ }
973
+ }
974
+ return yield* engine.push.pipe(Effect.as("Flushed"), Effect.catchTag("VersionNotSupportedError", () => Effect.succeed("UpdateNeeded")));
975
+ }).pipe(Effect.withSpan("legation.backgroundSync.flush"));
976
+ /** No host attached the listener within its `readyTimeout`. */
977
+ var HostNotReady = class extends Schema.TaggedError()("HostNotReady", {
978
+ tag: Schema.String,
979
+ message: Schema.String
980
+ }) {};
981
+ /**
982
+ * Install the `sync` listener on the worker's scope — **synchronously, at the
983
+ * top level of the worker script** (see the module docs for why). Deliberately
984
+ * a plain function rather than an Effect for that reason.
985
+ *
986
+ * ```ts
987
+ * // service-worker.ts, top level
988
+ * const backgroundSync = BackgroundSync.listen({ identity: currentFingerprint });
989
+ * // ... later, once the engine's layers are built:
990
+ * yield* ServiceWorkerHost.start({ backgroundSync, registry, nodes, urls });
991
+ * ```
992
+ */
993
+ const listen$1 = (options) => {
994
+ const tag = options.tag ?? "legation-flush";
995
+ const scope = options.scope ?? globalThis;
996
+ const readyTimeout = options.readyTimeout ?? "30 seconds";
997
+ const attached = Effect.runSync(SubscriptionRef.make(Option.none()));
998
+ const whenAttached = SubscriptionRef.changes(attached).pipe(Stream.filter(Option.isSome), Stream.map((some) => some.value), Stream.runHead, Effect.flatMap((head) => Option.isSome(head) ? Effect.succeed(head.value) : Effect.never), Effect.timeoutOrElse({
999
+ duration: readyTimeout,
1000
+ orElse: () => Effect.fail(new HostNotReady({
1001
+ tag,
1002
+ message: `No engine host attached within ${Duration.format(Duration.fromInputUnsafe(readyTimeout))}; the browser will reschedule the sync`
1003
+ }))
1004
+ }));
1005
+ const handle = (event) => Effect.runPromise(whenAttached.pipe(Effect.tapError((error) => Effect.logDebug(event.lastChance === true ? `${error.message} (the browser's last chance; the journal flushes on the next connectivity signal)` : error.message)), Effect.flatMap(({ flush: run, services }) => run.pipe(Effect.tapError((error) => error._tag === "TransportError" ? Effect.logDebug(event.lastChance === true ? "Background Sync flush failed on its last chance; the journal flushes on the next connectivity signal" : "Background Sync flush failed; the browser will reschedule it", error) : Effect.logWarning("Background Sync flush failed on the store", error)), Effect.provideContext(services)))));
1006
+ const listener = (event) => {
1007
+ if (event.tag !== tag) return;
1008
+ event.waitUntil(handle(event));
1009
+ };
1010
+ scope.addEventListener?.("sync", listener);
1011
+ const registration = Effect.sync(() => scope.registration);
1012
+ const serve = (engine) => Effect.gen(function* () {
1013
+ const entry = {
1014
+ flush: flush(engine, { identity: options.identity }),
1015
+ services: yield* Effect.context()
1016
+ };
1017
+ yield* Effect.acquireRelease(SubscriptionRef.set(attached, Option.some(entry)), () => SubscriptionRef.update(attached, (current) => Option.isSome(current) && current.value === entry ? Option.none() : current));
1018
+ if (options.registerOnJournal !== false) yield* registerOnJournal(engine, {
1019
+ tag,
1020
+ registration,
1021
+ ...options.offline === void 0 ? {} : { offline: options.offline }
1022
+ });
1023
+ });
1024
+ return {
1025
+ tag,
1026
+ serve
1027
+ };
1028
+ };
1029
+ // src/PushPoke.ts
1030
+ var PushPoke_exports = /* @__PURE__ */ __exportAll({ listen: () => listen });
1031
+ /** Whether a push is the library's poke; a push whose data cannot be read as text is not. */
1032
+ const isLibraryPoke = (event) => {
1033
+ const data = event.data;
1034
+ if (!P.hasProperty(data, "text") || !P.isFunction(data.text)) return false;
1035
+ const text = data.text;
1036
+ return Result.match(Result.try(() => String(text.call(data))), {
1037
+ onSuccess: PushPoke.isPoke,
1038
+ onFailure: () => false
1039
+ });
1040
+ };
1041
+ /**
1042
+ * Install the `push` listener on the worker's scope, synchronously at the top
1043
+ * level of the worker script.
1044
+ *
1045
+ * ```ts
1046
+ * // service-worker.ts, top level
1047
+ * const backgroundSync = BackgroundSync.listen({ identity: currentFingerprint });
1048
+ * const pushPoke = PushPoke.listen({ identity: currentFingerprint });
1049
+ * // ... once the engine's layers are built:
1050
+ * yield* ServiceWorkerHost.start({ backgroundSync, pushPoke, registry, nodes });
1051
+ * ```
1052
+ */
1053
+ const listen = (options) => {
1054
+ const scope = options.scope ?? globalThis;
1055
+ const tag = options.tag ?? "legation-flush";
1056
+ const readyTimeout = options.readyTimeout ?? "30 seconds";
1057
+ const timeoutMillis = Duration.toMillis(Duration.fromInputUnsafe(readyTimeout));
1058
+ let attached = Option.none();
1059
+ let waiting = [];
1060
+ const sameIdentity = (engine) => options.identity === identityUnchecked ? Effect.succeed(true) : Effect.all([options.identity, engine.identity.pipe(Effect.orElseSucceed(() => void 0))], { concurrency: 1 }).pipe(Effect.map(([current, stamped]) => current !== void 0 && stamped === current));
1061
+ const act = (engine) => Effect.gen(function* () {
1062
+ if (!(yield* sameIdentity(engine))) {
1063
+ yield* Effect.logDebug("Push-poke skipped: the replica is not stamped for the current identity");
1064
+ return "IdentityChanged";
1065
+ }
1066
+ if (options.action === "pull") return yield* engine.pull.pipe(Effect.as("Pulled"), Effect.catch((error) => Effect.logDebug("Push-poke pull failed; the next connectivity signal pulls", error).pipe(Effect.as("PullFailed"))));
1067
+ return yield* register({
1068
+ tag,
1069
+ registration: Effect.sync(() => scope.registration)
1070
+ });
1071
+ });
1072
+ const handle = () => Option.match(attached, {
1073
+ onSome: (host) => host.run(act(host.engine)),
1074
+ onNone: () => new Promise((resolve) => {
1075
+ const proceed = (host) => {
1076
+ clearTimeout(timer);
1077
+ resolve(host.run(act(host.engine)));
1078
+ };
1079
+ const timer = setTimeout(() => {
1080
+ waiting = waiting.filter((other) => other !== proceed);
1081
+ resolve("HostNotReady");
1082
+ }, timeoutMillis);
1083
+ waiting = [...waiting, proceed];
1084
+ })
1085
+ });
1086
+ const listener = (event) => {
1087
+ if (!isLibraryPoke(event)) return;
1088
+ event.waitUntil(handle());
1089
+ };
1090
+ scope.addEventListener?.("push", listener);
1091
+ const serve = (engine) => Effect.gen(function* () {
1092
+ const host = {
1093
+ engine,
1094
+ run: Effect.runPromiseWith(yield* Effect.context())
1095
+ };
1096
+ yield* Effect.acquireRelease(Effect.sync(() => {
1097
+ attached = Option.some(host);
1098
+ const ready = waiting;
1099
+ waiting = [];
1100
+ ready.forEach((proceed) => proceed(host));
1101
+ }), () => Effect.sync(() => {
1102
+ if (Option.isSome(attached) && attached.value === host) attached = Option.none();
1103
+ }));
1104
+ });
1105
+ return { serve };
1106
+ };
1107
+ export { BackgroundSync_exports as BackgroundSync, BroadcastMirrorChannel_exports as BroadcastMirrorChannel, BrowserConnectivity_exports as BrowserConnectivity, CacheApiHttpCache_exports as CacheApiHttpCache, ElectedHost_exports as ElectedHost, EngineHost, HttpCacheInvalidation_exports as HttpCacheInvalidation, IndexedDbClientStore_exports as IndexedDbClientStore, Mirror, MirrorChannel, MirrorProtocol, PushPoke_exports as PushPoke, ServiceWorkerHost_exports as ServiceWorkerHost };
1108
+
1109
+ //# sourceMappingURL=index.js.map