@thomasfosterau/legation-browser 0.0.0 → 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/CHANGELOG.md +842 -0
- package/LICENSE +21 -0
- package/README.md +322 -1
- package/dist/index.d.ts +656 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1109 -0
- package/dist/index.js.map +1 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/package.json +49 -3
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
|