@happyvertical/smrt-web 0.38.2 → 0.38.3
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/AGENTS.md +254 -0
- package/dist/index.d.ts +551 -35
- package/dist/index.js +1202 -11
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,6 +1,1038 @@
|
|
|
1
1
|
import { createCollection } from "@tanstack/db";
|
|
2
2
|
import { QueryClient } from "@tanstack/query-core";
|
|
3
3
|
import { queryCollectionOptions } from "@tanstack/query-db-collection";
|
|
4
|
+
//#region src/capability.ts
|
|
5
|
+
async function runWrapMutation(capabilities, envelope, ctx) {
|
|
6
|
+
for (const capability of capabilities) {
|
|
7
|
+
if (!capability.wrapMutation) continue;
|
|
8
|
+
const outcome = await capability.wrapMutation(envelope, ctx);
|
|
9
|
+
if (outcome?.handled) return {
|
|
10
|
+
handled: true,
|
|
11
|
+
result: outcome.result
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return { handled: false };
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/durable-store.ts
|
|
18
|
+
function durableStoreNamespace(key) {
|
|
19
|
+
const optional = (value) => value === void 0 ? "" : `_${encodeURIComponent(value)}`;
|
|
20
|
+
return `smrt-web:${encodeURIComponent(key.apiBase)}:${optional(key.tenantId)}:${optional(key.identityId)}:${encodeURIComponent(key.manifestHash)}`;
|
|
21
|
+
}
|
|
22
|
+
var registry = /* @__PURE__ */ new Map();
|
|
23
|
+
function registerDurableResource(namespace, resource) {
|
|
24
|
+
let resources = registry.get(namespace);
|
|
25
|
+
if (!resources) {
|
|
26
|
+
resources = /* @__PURE__ */ new Set();
|
|
27
|
+
registry.set(namespace, resources);
|
|
28
|
+
}
|
|
29
|
+
resources.add(resource);
|
|
30
|
+
return () => {
|
|
31
|
+
const current = registry.get(namespace);
|
|
32
|
+
if (!current) return;
|
|
33
|
+
current.delete(resource);
|
|
34
|
+
if (current.size === 0) registry.delete(namespace);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function wipeDurableStore(namespace) {
|
|
38
|
+
const resources = registry.get(namespace);
|
|
39
|
+
if (!resources || resources.size === 0) {
|
|
40
|
+
registry.delete(namespace);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const snapshot = [...resources];
|
|
44
|
+
registry.delete(namespace);
|
|
45
|
+
await Promise.allSettled(snapshot.map((resource) => resource.clear()));
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/offline/durable-queue.ts
|
|
49
|
+
var OUTBOX_STORE = "outbox";
|
|
50
|
+
var OUTBOX_STATE_INDEX = "state";
|
|
51
|
+
function promisifyRequest(request) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
request.onsuccess = () => resolve(request.result);
|
|
54
|
+
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB request failed"));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function awaitTransaction(tx) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
tx.oncomplete = () => resolve();
|
|
60
|
+
tx.onerror = () => reject(tx.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB transaction failed"));
|
|
61
|
+
tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB transaction aborted"));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function probeIndexedDb() {
|
|
65
|
+
const idb = globalThis.indexedDB;
|
|
66
|
+
if (!idb) return false;
|
|
67
|
+
const probeName = "__smrt_web_outbox_probe__";
|
|
68
|
+
try {
|
|
69
|
+
(await new Promise((resolve, reject) => {
|
|
70
|
+
const request = idb.open(probeName, 1);
|
|
71
|
+
request.onsuccess = () => resolve(request.result);
|
|
72
|
+
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("probe failed"));
|
|
73
|
+
request.onblocked = () => reject(/* @__PURE__ */ new Error("probe blocked"));
|
|
74
|
+
})).close();
|
|
75
|
+
try {
|
|
76
|
+
idb.deleteDatabase(probeName);
|
|
77
|
+
} catch {}
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
var DurableOutboxQueue = class {
|
|
84
|
+
db;
|
|
85
|
+
/** The IndexedDB database name (== the durable-store namespace). */
|
|
86
|
+
dbName;
|
|
87
|
+
constructor(db, dbName) {
|
|
88
|
+
this.db = db;
|
|
89
|
+
this.dbName = dbName;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Append a mutation to the tail of the queue in state `pending`, due
|
|
93
|
+
* immediately (`nextAttemptAt = 0`, `attempts = 0`). Resolves with the
|
|
94
|
+
* assigned `seq` once the write is durably committed.
|
|
95
|
+
*/
|
|
96
|
+
async enqueue(input) {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const row = {
|
|
99
|
+
itemId: input.itemId,
|
|
100
|
+
object: input.object,
|
|
101
|
+
op: input.op,
|
|
102
|
+
id: input.id,
|
|
103
|
+
payload: input.payload,
|
|
104
|
+
baseUpdatedAt: input.baseUpdatedAt,
|
|
105
|
+
state: "pending",
|
|
106
|
+
attempts: 0,
|
|
107
|
+
nextAttemptAt: 0,
|
|
108
|
+
enqueuedAt: now
|
|
109
|
+
};
|
|
110
|
+
const tx = this.db.transaction(OUTBOX_STORE, "readwrite");
|
|
111
|
+
const seq = await promisifyRequest(tx.objectStore(OUTBOX_STORE).add(row));
|
|
112
|
+
await awaitTransaction(tx);
|
|
113
|
+
return seq;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Persist a state transition (and any of attempts/backoff/error) for the row
|
|
117
|
+
* at `seq`, reading-then-writing inside ONE transaction so a concurrent drain
|
|
118
|
+
* in the same tab can't lose the update. A no-op if the row is already gone
|
|
119
|
+
* (removed by a prior terminal transition).
|
|
120
|
+
*/
|
|
121
|
+
async markState(seq, patch) {
|
|
122
|
+
const tx = this.db.transaction(OUTBOX_STORE, "readwrite");
|
|
123
|
+
const store = tx.objectStore(OUTBOX_STORE);
|
|
124
|
+
const existing = await promisifyRequest(store.get(seq));
|
|
125
|
+
if (!existing) {
|
|
126
|
+
await awaitTransaction(tx);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const next = {
|
|
130
|
+
...existing,
|
|
131
|
+
...patch,
|
|
132
|
+
seq
|
|
133
|
+
};
|
|
134
|
+
await promisifyRequest(store.put(next));
|
|
135
|
+
await awaitTransaction(tx);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* All rows that are due to (re)send at `now`: state `pending` AND
|
|
139
|
+
* `nextAttemptAt <= now`, in ascending `seq` (FIFO). Uses the `state` index to
|
|
140
|
+
* avoid scanning terminal tombstones. Terminal rows (`synced`/`failed`) are
|
|
141
|
+
* excluded — they await removal, not replay.
|
|
142
|
+
*/
|
|
143
|
+
async listPending(now) {
|
|
144
|
+
const tx = this.db.transaction(OUTBOX_STORE, "readonly");
|
|
145
|
+
const rows = await promisifyRequest(tx.objectStore(OUTBOX_STORE).index(OUTBOX_STATE_INDEX).getAll(IDBKeyRange.only("pending")));
|
|
146
|
+
await awaitTransaction(tx);
|
|
147
|
+
return rows.filter((row) => row.nextAttemptAt <= now).sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
148
|
+
}
|
|
149
|
+
/** Remove the row at `seq` (a terminal transition drops it). */
|
|
150
|
+
async remove(seq) {
|
|
151
|
+
const tx = this.db.transaction(OUTBOX_STORE, "readwrite");
|
|
152
|
+
await promisifyRequest(tx.objectStore(OUTBOX_STORE).delete(seq));
|
|
153
|
+
await awaitTransaction(tx);
|
|
154
|
+
}
|
|
155
|
+
/** Every row currently in the queue (any state), ascending `seq`. */
|
|
156
|
+
async all() {
|
|
157
|
+
const tx = this.db.transaction(OUTBOX_STORE, "readonly");
|
|
158
|
+
const rows = await promisifyRequest(tx.objectStore(OUTBOX_STORE).getAll());
|
|
159
|
+
await awaitTransaction(tx);
|
|
160
|
+
return rows.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Drop every row — the durable-store `clear()` for `wipeDurableStore`. Empties
|
|
164
|
+
* the store but keeps the database (and its `seq` autoincrement counter) so a
|
|
165
|
+
* subsequent enqueue still gets fresh monotonic ids.
|
|
166
|
+
*/
|
|
167
|
+
async clear() {
|
|
168
|
+
const tx = this.db.transaction(OUTBOX_STORE, "readwrite");
|
|
169
|
+
await promisifyRequest(tx.objectStore(OUTBOX_STORE).clear());
|
|
170
|
+
await awaitTransaction(tx);
|
|
171
|
+
}
|
|
172
|
+
/** Close the underlying database handle (called on engine dispose). */
|
|
173
|
+
close() {
|
|
174
|
+
this.db.close();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
function openDurableOutboxQueue(dbName) {
|
|
178
|
+
const idb = globalThis.indexedDB;
|
|
179
|
+
if (!idb) return Promise.reject(/* @__PURE__ */ new Error("[smrt-web] IndexedDB is unavailable in this environment"));
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const request = idb.open(dbName, 1);
|
|
182
|
+
request.onupgradeneeded = () => {
|
|
183
|
+
const db = request.result;
|
|
184
|
+
if (!db.objectStoreNames.contains("outbox")) db.createObjectStore(OUTBOX_STORE, {
|
|
185
|
+
keyPath: "seq",
|
|
186
|
+
autoIncrement: true
|
|
187
|
+
}).createIndex(OUTBOX_STATE_INDEX, "state", { unique: false });
|
|
188
|
+
};
|
|
189
|
+
request.onsuccess = () => resolve(new DurableOutboxQueue(request.result, dbName));
|
|
190
|
+
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error(`[smrt-web] failed to open outbox database "${dbName}"`));
|
|
191
|
+
request.onblocked = () => reject(/* @__PURE__ */ new Error(`[smrt-web] opening outbox database "${dbName}" was blocked`));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/offline/leader.ts
|
|
196
|
+
function getLockManager() {
|
|
197
|
+
const locks = globalThis.navigator?.locks;
|
|
198
|
+
if (locks && typeof locks.request === "function") return locks;
|
|
199
|
+
}
|
|
200
|
+
var warnedNoLocks = false;
|
|
201
|
+
function acquireLeadership(lockName, onAcquired, onReleased) {
|
|
202
|
+
const locks = getLockManager();
|
|
203
|
+
if (!locks) {
|
|
204
|
+
if (!warnedNoLocks) {
|
|
205
|
+
warnedNoLocks = true;
|
|
206
|
+
console.warn("[smrt-web] Web Locks API unavailable — the offline outbox falls back to single-tab leadership; the multi-tab exactly-one-replayer guarantee does not hold across tabs.");
|
|
207
|
+
}
|
|
208
|
+
let released2 = false;
|
|
209
|
+
const release2 = () => {
|
|
210
|
+
if (released2) return;
|
|
211
|
+
released2 = true;
|
|
212
|
+
onReleased();
|
|
213
|
+
};
|
|
214
|
+
queueMicrotask(() => {
|
|
215
|
+
if (!released2) onAcquired();
|
|
216
|
+
});
|
|
217
|
+
return release2;
|
|
218
|
+
}
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
let releaseHeldLock;
|
|
221
|
+
let released = false;
|
|
222
|
+
let acquired = false;
|
|
223
|
+
const release = () => {
|
|
224
|
+
if (released) return;
|
|
225
|
+
released = true;
|
|
226
|
+
if (acquired && releaseHeldLock) releaseHeldLock();
|
|
227
|
+
else controller.abort();
|
|
228
|
+
onReleased();
|
|
229
|
+
};
|
|
230
|
+
locks.request(lockName, {
|
|
231
|
+
signal: controller.signal,
|
|
232
|
+
mode: "exclusive"
|
|
233
|
+
}, () => {
|
|
234
|
+
acquired = true;
|
|
235
|
+
if (released) return Promise.resolve();
|
|
236
|
+
onAcquired();
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
releaseHeldLock = resolve;
|
|
239
|
+
});
|
|
240
|
+
}).catch((error) => {
|
|
241
|
+
if (error?.name !== "AbortError") console.warn("[smrt-web] outbox leader lock request failed", error);
|
|
242
|
+
if (!released) {
|
|
243
|
+
released = true;
|
|
244
|
+
onReleased();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
return release;
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/offline/types.ts
|
|
251
|
+
var MAX_SYNC_APPLY_BATCH_SIZE = 1e3;
|
|
252
|
+
var SYNC_APPLY_ROUTE_SEGMENTS = ["sync", "apply"];
|
|
253
|
+
var DEFAULT_BACKOFF = {
|
|
254
|
+
initialDelayMs: 1e3,
|
|
255
|
+
multiplier: 2,
|
|
256
|
+
maxDelayMs: 6e4
|
|
257
|
+
};
|
|
258
|
+
function computeBackoffDelay(attempts, backoff, random = Math.random) {
|
|
259
|
+
const exponent = Math.max(0, attempts - 1);
|
|
260
|
+
const raw = backoff.initialDelayMs * backoff.multiplier ** exponent;
|
|
261
|
+
const capped = Math.min(backoff.maxDelayMs, raw);
|
|
262
|
+
const jitter = .5 + random() * .5;
|
|
263
|
+
return Math.round(capped * jitter);
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/offline/engine.ts
|
|
267
|
+
function envelopeKindToOp(kind) {
|
|
268
|
+
return kind === "insert" ? "create" : kind;
|
|
269
|
+
}
|
|
270
|
+
function newItemId() {
|
|
271
|
+
const cryptoRef = globalThis.crypto;
|
|
272
|
+
if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
|
|
273
|
+
return `item-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
274
|
+
}
|
|
275
|
+
function isDefinitelyOffline() {
|
|
276
|
+
return globalThis.navigator?.onLine === false;
|
|
277
|
+
}
|
|
278
|
+
var OutboxEngine = class {
|
|
279
|
+
config;
|
|
280
|
+
/**
|
|
281
|
+
* Per-collection callback sets, keyed by the collection route segment
|
|
282
|
+
* (`object`). Keyed by `object` — NOT by the queue row's `itemId` — precisely
|
|
283
|
+
* so replayed rows that were REHYDRATED from IndexedDB after a reload (whose
|
|
284
|
+
* itemIds this session never enqueued) still route their state/conflict events
|
|
285
|
+
* to the reloaded collection's callbacks. A `Set` per object so N collections
|
|
286
|
+
* sharing one engine+object each get every event (the common case is one
|
|
287
|
+
* collection per object, but the shared-engine model does not forbid more).
|
|
288
|
+
*/
|
|
289
|
+
listenersByObject = /* @__PURE__ */ new Map();
|
|
290
|
+
/** Ref count: number of collections currently attached to this engine. */
|
|
291
|
+
refCount = 0;
|
|
292
|
+
/** The durable queue, once opened. undefined while opening / if IDB absent. */
|
|
293
|
+
queue;
|
|
294
|
+
/** Resolves once the async open settles (success or degraded). */
|
|
295
|
+
ready;
|
|
296
|
+
/** True when IndexedDB was unavailable and the engine is a durable no-op. */
|
|
297
|
+
degraded = false;
|
|
298
|
+
/** Leadership handle; set once we've requested the leader lock. */
|
|
299
|
+
leadership;
|
|
300
|
+
/** True while this tab holds leadership. */
|
|
301
|
+
isLeader = false;
|
|
302
|
+
/** Unregister fn from the durable-store registry. */
|
|
303
|
+
unregisterResource;
|
|
304
|
+
/** True once dispose() ran — guards late async continuations. */
|
|
305
|
+
disposed = false;
|
|
306
|
+
/**
|
|
307
|
+
* Paused by an auth_required/forbidden result: the loop stops draining until
|
|
308
|
+
* a later enqueue (the app re-authenticated and is writing again) or an
|
|
309
|
+
* explicit retry wakes it. Items stay queued.
|
|
310
|
+
*/
|
|
311
|
+
paused = false;
|
|
312
|
+
/** True while a drain pass is running, to coalesce concurrent triggers. */
|
|
313
|
+
draining = false;
|
|
314
|
+
/** A drain requested while one was in flight — run one more pass after. */
|
|
315
|
+
drainQueued = false;
|
|
316
|
+
/** Timer for the next backoff-scheduled drain, if any. */
|
|
317
|
+
backoffTimer;
|
|
318
|
+
/** The `online` event listener, so we can remove it on dispose. */
|
|
319
|
+
onlineListener;
|
|
320
|
+
constructor(config) {
|
|
321
|
+
this.config = config;
|
|
322
|
+
this.ready = this.open();
|
|
323
|
+
this.wireOnlineListener();
|
|
324
|
+
this.requestLeadership();
|
|
325
|
+
this.ready.then(() => {
|
|
326
|
+
if (!this.disposed) this.drain();
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/** Open the durable queue (or mark degraded if IndexedDB is unusable). */
|
|
330
|
+
async open() {
|
|
331
|
+
if (!await probeIndexedDb()) {
|
|
332
|
+
this.degraded = true;
|
|
333
|
+
console.warn("[smrt-web] IndexedDB unavailable — the offline outbox is disabled; offline writes will not be durable.");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
this.queue = await openDurableOutboxQueue(this.config.namespace);
|
|
338
|
+
this.unregisterResource = this.config.registerResource(async () => {
|
|
339
|
+
await this.queue?.clear();
|
|
340
|
+
});
|
|
341
|
+
if (this.disposed) {
|
|
342
|
+
this.queue.close();
|
|
343
|
+
this.queue = void 0;
|
|
344
|
+
this.unregisterResource?.();
|
|
345
|
+
this.unregisterResource = void 0;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
} catch (error) {
|
|
349
|
+
this.degraded = true;
|
|
350
|
+
console.warn("[smrt-web] failed to open the offline outbox", error);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Wake the drain loop immediately when connectivity returns. */
|
|
354
|
+
wireOnlineListener() {
|
|
355
|
+
const target = globalThis;
|
|
356
|
+
if (typeof target.addEventListener !== "function") return;
|
|
357
|
+
const listener = () => {
|
|
358
|
+
this.drain();
|
|
359
|
+
};
|
|
360
|
+
target.addEventListener("online", listener);
|
|
361
|
+
this.onlineListener = listener;
|
|
362
|
+
}
|
|
363
|
+
/** Request cross-tab leadership; drain whenever we hold it. */
|
|
364
|
+
requestLeadership() {
|
|
365
|
+
const lockName = `smrt-web-outbox-leader:${this.config.namespace}`;
|
|
366
|
+
this.leadership = acquireLeadership(lockName, () => {
|
|
367
|
+
this.isLeader = true;
|
|
368
|
+
this.drain();
|
|
369
|
+
}, () => {
|
|
370
|
+
this.isLeader = false;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Attach a collection: register its per-object callbacks and bump the ref
|
|
375
|
+
* count. Returns the exact callback record registered so the caller can pass
|
|
376
|
+
* it back to {@link unregisterCollection} for precise removal (two collections
|
|
377
|
+
* on the same object must each detach only their own callbacks). Registering
|
|
378
|
+
* by `object` is what lets rehydrated rows (reloaded from IDB) reach this
|
|
379
|
+
* collection's callbacks even though this session never enqueued them.
|
|
380
|
+
*/
|
|
381
|
+
registerCollection(binding) {
|
|
382
|
+
this.refCount += 1;
|
|
383
|
+
const record = {
|
|
384
|
+
onSyncStateChange: binding.onSyncStateChange,
|
|
385
|
+
onConflict: binding.onConflict
|
|
386
|
+
};
|
|
387
|
+
let set = this.listenersByObject.get(binding.object);
|
|
388
|
+
if (!set) {
|
|
389
|
+
set = /* @__PURE__ */ new Set();
|
|
390
|
+
this.listenersByObject.set(binding.object, set);
|
|
391
|
+
}
|
|
392
|
+
set.add(record);
|
|
393
|
+
return record;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Detach a collection: remove its callback record and decrement the ref count;
|
|
397
|
+
* when it reaches zero, dispose the engine (release the lock, unregister from
|
|
398
|
+
* the durable-store registry, close IndexedDB). The durable ROWS are NOT
|
|
399
|
+
* cleared — they must survive to replay after a reload; only the in-memory
|
|
400
|
+
* engine is torn down. Returns true if it disposed.
|
|
401
|
+
*/
|
|
402
|
+
async unregisterCollection(object, record) {
|
|
403
|
+
const set = this.listenersByObject.get(object);
|
|
404
|
+
if (set) {
|
|
405
|
+
set.delete(record);
|
|
406
|
+
if (set.size === 0) this.listenersByObject.delete(object);
|
|
407
|
+
}
|
|
408
|
+
this.refCount = Math.max(0, this.refCount - 1);
|
|
409
|
+
if (this.refCount > 0) return false;
|
|
410
|
+
await this.dispose();
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
/** Current ref count (test/introspection aid). */
|
|
414
|
+
get referenceCount() {
|
|
415
|
+
return this.refCount;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Enqueue an optimistic write into the durable queue and fire the initial
|
|
419
|
+
* `pending` state, then kick a drain. Resolves once the row is durably
|
|
420
|
+
* committed (so the caller's `wrapMutation` only reports handled after
|
|
421
|
+
* persistence). Replay events for this row (and for rows this session did not
|
|
422
|
+
* enqueue — reloaded from disk) route to the registered per-`object`
|
|
423
|
+
* callbacks, so a reload does not lose observability.
|
|
424
|
+
*
|
|
425
|
+
* In degraded (no-IndexedDB) mode the write is NOT durable, so this returns
|
|
426
|
+
* `undefined` and the capability falls through to the real fetcher instead
|
|
427
|
+
* of acknowledging an optimistic-only write.
|
|
428
|
+
*/
|
|
429
|
+
async enqueue(request) {
|
|
430
|
+
await this.ready;
|
|
431
|
+
if (!this.queue || this.degraded) return void 0;
|
|
432
|
+
const itemId = newItemId();
|
|
433
|
+
const op = envelopeKindToOp(request.kind);
|
|
434
|
+
const payload = op === "delete" ? void 0 : request.data;
|
|
435
|
+
await this.queue.enqueue({
|
|
436
|
+
itemId,
|
|
437
|
+
object: request.object,
|
|
438
|
+
op,
|
|
439
|
+
id: request.rowId,
|
|
440
|
+
payload,
|
|
441
|
+
baseUpdatedAt: request.baseUpdatedAt
|
|
442
|
+
});
|
|
443
|
+
this.emit({
|
|
444
|
+
itemId,
|
|
445
|
+
rowId: request.rowId,
|
|
446
|
+
object: request.object,
|
|
447
|
+
state: "pending",
|
|
448
|
+
attempts: 0
|
|
449
|
+
});
|
|
450
|
+
this.paused = false;
|
|
451
|
+
this.drain();
|
|
452
|
+
return itemId;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Force a retry of a specific queued item now: clears its backoff gate and
|
|
456
|
+
* wakes the loop. The bridge `OutboxHandle.retry(itemId)` calls this so an app
|
|
457
|
+
* "retry" button can flush a backed-off or auth-paused item. A no-op for an
|
|
458
|
+
* item that is not (or no longer) queued.
|
|
459
|
+
*/
|
|
460
|
+
async retry(itemId) {
|
|
461
|
+
await this.ready;
|
|
462
|
+
if (!this.queue) return;
|
|
463
|
+
const row = (await this.queue.all()).find((r) => r.itemId === itemId && r.state === "pending");
|
|
464
|
+
if (!row || row.seq === void 0) return;
|
|
465
|
+
await this.queue.markState(row.seq, { nextAttemptAt: 0 });
|
|
466
|
+
this.paused = false;
|
|
467
|
+
this.drain();
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* A read-only snapshot of the durable queue — the basis of
|
|
471
|
+
* `OutboxHandle.snapshot()`. Because the READ cache is NOT rehydrated after a
|
|
472
|
+
* reload in this slice (that's #1764's warmStart), the snapshot + the raw IDB
|
|
473
|
+
* store are how a test/app proves durability, not `collection.toArray()`.
|
|
474
|
+
*/
|
|
475
|
+
async snapshot() {
|
|
476
|
+
await this.ready;
|
|
477
|
+
if (!this.queue) return [];
|
|
478
|
+
return (await this.queue.all()).map((row) => ({
|
|
479
|
+
itemId: row.itemId,
|
|
480
|
+
object: row.object,
|
|
481
|
+
op: row.op,
|
|
482
|
+
rowId: row.id,
|
|
483
|
+
state: row.state,
|
|
484
|
+
attempts: row.attempts,
|
|
485
|
+
nextAttemptAt: row.nextAttemptAt,
|
|
486
|
+
lastError: row.lastError
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Deliver a state event to every callback registered for the event's
|
|
491
|
+
* collection `object` (best-effort). Routing by `object` — not `itemId` —
|
|
492
|
+
* means a row REHYDRATED from IndexedDB after a reload still reaches the
|
|
493
|
+
* reloaded collection's callback even though this session never enqueued it.
|
|
494
|
+
*/
|
|
495
|
+
emit(event) {
|
|
496
|
+
const set = this.listenersByObject.get(event.object);
|
|
497
|
+
if (!set) return;
|
|
498
|
+
for (const listener of set) try {
|
|
499
|
+
listener.onSyncStateChange?.(event);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
console.warn("[smrt-web] onSyncStateChange callback threw", error);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Deliver a conflict to every callback registered for its collection. */
|
|
505
|
+
emitConflict(conflict) {
|
|
506
|
+
const set = this.listenersByObject.get(conflict.object);
|
|
507
|
+
if (!set) return;
|
|
508
|
+
for (const listener of set) try {
|
|
509
|
+
listener.onConflict?.(conflict);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
console.warn("[smrt-web] onConflict callback threw", error);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The replay loop. Gated on: (a) holding leadership, (b) not paused by an
|
|
516
|
+
* auth failure, (c) `navigator.onLine !== false`, (d) IndexedDB usable. Drains
|
|
517
|
+
* all rows due now (`nextAttemptAt <= now`), oldest-first, chunked into
|
|
518
|
+
* batches of ≤1000 per POST, one POST at a time to preserve FIFO across
|
|
519
|
+
* chunks. Concurrency-coalesced: a drain requested while one runs sets a flag
|
|
520
|
+
* to run exactly one more pass, so overlapping triggers never interleave.
|
|
521
|
+
*/
|
|
522
|
+
async drain() {
|
|
523
|
+
if (this.draining) {
|
|
524
|
+
this.drainQueued = true;
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
this.draining = true;
|
|
528
|
+
try {
|
|
529
|
+
for (;;) {
|
|
530
|
+
this.drainQueued = false;
|
|
531
|
+
await this.drainOnce();
|
|
532
|
+
if (!this.drainQueued) break;
|
|
533
|
+
}
|
|
534
|
+
} finally {
|
|
535
|
+
this.draining = false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/** One drain pass: send every currently-due batch, then schedule backoff. */
|
|
539
|
+
async drainOnce() {
|
|
540
|
+
if (this.disposed) return;
|
|
541
|
+
if (!this.isLeader) return;
|
|
542
|
+
if (this.paused) return;
|
|
543
|
+
if (this.degraded || !this.queue) return;
|
|
544
|
+
if (isDefinitelyOffline()) return;
|
|
545
|
+
const pending = (await this.queue.all()).filter((row) => row.state === "pending");
|
|
546
|
+
if (pending.length === 0) return;
|
|
547
|
+
const now = Date.now();
|
|
548
|
+
const firstBlocked = pending.findIndex((row) => row.nextAttemptAt > now);
|
|
549
|
+
const due = firstBlocked === -1 ? pending : pending.slice(0, firstBlocked);
|
|
550
|
+
if (due.length === 0) {
|
|
551
|
+
await this.scheduleNextBackoff();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
for (let i = 0; i < due.length; i += MAX_SYNC_APPLY_BATCH_SIZE) {
|
|
555
|
+
if (this.disposed || this.paused || !this.isLeader) break;
|
|
556
|
+
const chunk = due.slice(i, i + MAX_SYNC_APPLY_BATCH_SIZE);
|
|
557
|
+
if (!await this.sendBatch(chunk)) break;
|
|
558
|
+
}
|
|
559
|
+
await this.scheduleNextBackoff();
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Send one chunk through `POST {basePath}/sync/apply` and map results back
|
|
563
|
+
* onto durable transitions. On a network reject / non-200 / lost/mismatched
|
|
564
|
+
* response the WHOLE chunk stays `pending` (blind replay is safe) — every row
|
|
565
|
+
* goes back to `pending` with an incremented attempt + backoff so the loop
|
|
566
|
+
* doesn't hot-spin. Returns false when a retryable row remains pending, which
|
|
567
|
+
* stops this drain pass so newer FIFO chunks do not overtake it.
|
|
568
|
+
*/
|
|
569
|
+
async sendBatch(chunk) {
|
|
570
|
+
for (const row of chunk) this.emit({
|
|
571
|
+
itemId: row.itemId,
|
|
572
|
+
rowId: row.id,
|
|
573
|
+
object: row.object,
|
|
574
|
+
state: "uploading",
|
|
575
|
+
attempts: row.attempts
|
|
576
|
+
});
|
|
577
|
+
const items = chunk.map((row) => ({
|
|
578
|
+
itemId: row.itemId,
|
|
579
|
+
object: row.object,
|
|
580
|
+
op: row.op,
|
|
581
|
+
id: row.id,
|
|
582
|
+
payload: row.payload,
|
|
583
|
+
baseUpdatedAt: row.baseUpdatedAt
|
|
584
|
+
}));
|
|
585
|
+
let results;
|
|
586
|
+
try {
|
|
587
|
+
results = await this.postBatch(items);
|
|
588
|
+
} catch {
|
|
589
|
+
await this.requeueBatch(chunk, "network error during sync");
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
if (!results) {
|
|
593
|
+
await this.requeueBatch(chunk, "unexpected sync response shape");
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
let drained = true;
|
|
597
|
+
for (let i = 0; i < chunk.length; i += 1) {
|
|
598
|
+
const row = chunk[i];
|
|
599
|
+
const result = results[i];
|
|
600
|
+
if (!result) {
|
|
601
|
+
await this.requeueRow(row, "missing result for item");
|
|
602
|
+
drained = false;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const applied = await this.applyResult(row, result);
|
|
606
|
+
drained = drained && applied;
|
|
607
|
+
}
|
|
608
|
+
return drained;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* POST a batch to `{basePath}/sync/apply`. Throws on a non-2xx or a network
|
|
612
|
+
* error (the caller treats a throw as "response lost → keep pending"). Returns
|
|
613
|
+
* the positional `results` array, or `undefined` on a malformed 200 body.
|
|
614
|
+
*/
|
|
615
|
+
async postBatch(items) {
|
|
616
|
+
const url = `${this.config.syncApplyBasePath}/${SYNC_APPLY_ROUTE_SEGMENTS.join("/")}`;
|
|
617
|
+
const response = await this.config.fetchFn(url, {
|
|
618
|
+
method: "POST",
|
|
619
|
+
headers: { "Content-Type": "application/json" },
|
|
620
|
+
body: JSON.stringify({ items })
|
|
621
|
+
});
|
|
622
|
+
if (!response.ok) throw new Error(`[smrt-web] sync/apply returned HTTP ${response.status}`);
|
|
623
|
+
const body = await response.json().catch(() => null);
|
|
624
|
+
if (!body || !Array.isArray(body.results)) return void 0;
|
|
625
|
+
return body.results;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Map one positional apply result onto a durable transition + observable
|
|
629
|
+
* state, per the contract's consumer notes. See the class doc's mapping table.
|
|
630
|
+
*/
|
|
631
|
+
async applyResult(row, result) {
|
|
632
|
+
if (row.seq === void 0) return true;
|
|
633
|
+
if (result.status === "applied") {
|
|
634
|
+
await this.finishSynced(row);
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
if (result.status === "conflict") {
|
|
638
|
+
const reason2 = result.reason === "create_conflict" ? "create_conflict" : "stale_write";
|
|
639
|
+
this.emitConflict({
|
|
640
|
+
itemId: row.itemId,
|
|
641
|
+
object: row.object,
|
|
642
|
+
rowId: row.id,
|
|
643
|
+
reason: reason2,
|
|
644
|
+
serverUpdatedAt: result.updatedAt
|
|
645
|
+
});
|
|
646
|
+
await this.finishSynced(row);
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
const reason = result.reason;
|
|
650
|
+
if (reason === "auth_required" || reason === "forbidden") {
|
|
651
|
+
this.paused = true;
|
|
652
|
+
await this.queue?.markState(row.seq, {
|
|
653
|
+
state: "pending",
|
|
654
|
+
lastError: `sync ${reason}`
|
|
655
|
+
});
|
|
656
|
+
this.emit({
|
|
657
|
+
itemId: row.itemId,
|
|
658
|
+
rowId: row.id,
|
|
659
|
+
object: row.object,
|
|
660
|
+
state: "pending",
|
|
661
|
+
attempts: row.attempts,
|
|
662
|
+
error: `sync ${reason}`
|
|
663
|
+
});
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
if (reason === "write_failed") {
|
|
667
|
+
await this.requeueRow(row, "sync write_failed");
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
await this.queue?.remove(row.seq);
|
|
671
|
+
this.emit({
|
|
672
|
+
itemId: row.itemId,
|
|
673
|
+
rowId: row.id,
|
|
674
|
+
object: row.object,
|
|
675
|
+
state: "failed",
|
|
676
|
+
attempts: row.attempts,
|
|
677
|
+
error: reason ? `sync ${reason}` : "sync rejected"
|
|
678
|
+
});
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
/** Remove a successfully-applied (or conflict-resolved) row → `synced`. */
|
|
682
|
+
async finishSynced(row) {
|
|
683
|
+
if (row.seq !== void 0) await this.queue?.remove(row.seq);
|
|
684
|
+
this.emit({
|
|
685
|
+
itemId: row.itemId,
|
|
686
|
+
rowId: row.id,
|
|
687
|
+
object: row.object,
|
|
688
|
+
state: "synced",
|
|
689
|
+
attempts: row.attempts
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
/** Re-queue every row of a failed batch (network path) with backoff. */
|
|
693
|
+
async requeueBatch(chunk, error) {
|
|
694
|
+
for (const row of chunk) await this.requeueRow(row, error);
|
|
695
|
+
}
|
|
696
|
+
/** Re-queue one row: attempts++, backoff gate, `pending` event. */
|
|
697
|
+
async requeueRow(row, error) {
|
|
698
|
+
if (row.seq === void 0) return;
|
|
699
|
+
const attempts = row.attempts + 1;
|
|
700
|
+
const delay = computeBackoffDelay(attempts, this.config.backoff, this.config.random);
|
|
701
|
+
const nextAttemptAt = Date.now() + delay;
|
|
702
|
+
await this.queue?.markState(row.seq, {
|
|
703
|
+
state: "pending",
|
|
704
|
+
attempts,
|
|
705
|
+
nextAttemptAt,
|
|
706
|
+
lastError: error
|
|
707
|
+
});
|
|
708
|
+
this.emit({
|
|
709
|
+
itemId: row.itemId,
|
|
710
|
+
rowId: row.id,
|
|
711
|
+
object: row.object,
|
|
712
|
+
state: "pending",
|
|
713
|
+
attempts,
|
|
714
|
+
error
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Schedule the next drain for the soonest backed-off row's `nextAttemptAt`.
|
|
719
|
+
* Only one timer is ever pending; a sooner schedule replaces a later one.
|
|
720
|
+
*/
|
|
721
|
+
async scheduleNextBackoff() {
|
|
722
|
+
if (this.disposed || this.paused || !this.queue) return;
|
|
723
|
+
const firstPending = (await this.queue.all()).find((r) => r.state === "pending");
|
|
724
|
+
if (!firstPending) return;
|
|
725
|
+
const now = Date.now();
|
|
726
|
+
const delay = Math.max(0, firstPending.nextAttemptAt - now);
|
|
727
|
+
if (this.backoffTimer) clearTimeout(this.backoffTimer);
|
|
728
|
+
const timers = globalThis;
|
|
729
|
+
if (typeof timers.setTimeout !== "function") return;
|
|
730
|
+
this.backoffTimer = timers.setTimeout(() => {
|
|
731
|
+
this.backoffTimer = void 0;
|
|
732
|
+
this.drain();
|
|
733
|
+
}, delay);
|
|
734
|
+
this.backoffTimer.unref?.();
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Tear down the in-memory engine: release leadership, remove the online
|
|
738
|
+
* listener, clear timers, unregister from the durable-store registry, and
|
|
739
|
+
* close IndexedDB. Does NOT clear the durable rows — they must survive to
|
|
740
|
+
* replay on the next load.
|
|
741
|
+
*/
|
|
742
|
+
async dispose() {
|
|
743
|
+
if (this.disposed) return;
|
|
744
|
+
this.disposed = true;
|
|
745
|
+
if (this.backoffTimer) {
|
|
746
|
+
clearTimeout(this.backoffTimer);
|
|
747
|
+
this.backoffTimer = void 0;
|
|
748
|
+
}
|
|
749
|
+
const target = globalThis;
|
|
750
|
+
if (this.onlineListener && typeof target.removeEventListener === "function") {
|
|
751
|
+
target.removeEventListener("online", this.onlineListener);
|
|
752
|
+
this.onlineListener = void 0;
|
|
753
|
+
}
|
|
754
|
+
this.leadership?.();
|
|
755
|
+
this.leadership = void 0;
|
|
756
|
+
this.unregisterResource?.();
|
|
757
|
+
this.unregisterResource = void 0;
|
|
758
|
+
await this.ready.catch(() => void 0);
|
|
759
|
+
this.queue?.close();
|
|
760
|
+
this.queue = void 0;
|
|
761
|
+
this.listenersByObject.clear();
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
var engines = /* @__PURE__ */ new Map();
|
|
765
|
+
function getOrCreateOutboxEngine(config) {
|
|
766
|
+
let engine = engines.get(config.namespace);
|
|
767
|
+
if (!engine) {
|
|
768
|
+
engine = new OutboxEngine(config);
|
|
769
|
+
engines.set(config.namespace, engine);
|
|
770
|
+
}
|
|
771
|
+
return engine;
|
|
772
|
+
}
|
|
773
|
+
function acquireOutboxEngine(config, binding) {
|
|
774
|
+
const engine = getOrCreateOutboxEngine(config);
|
|
775
|
+
return {
|
|
776
|
+
engine,
|
|
777
|
+
record: engine.registerCollection(binding)
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
async function releaseOutboxEngine(namespace, engine, object, record) {
|
|
781
|
+
if (await engine.unregisterCollection(object, record) && engines.get(namespace) === engine) engines.delete(namespace);
|
|
782
|
+
}
|
|
783
|
+
//#endregion
|
|
784
|
+
//#region src/offline.ts
|
|
785
|
+
function resolveBackoff(backoff) {
|
|
786
|
+
return {
|
|
787
|
+
initialDelayMs: backoff?.initialDelayMs ?? DEFAULT_BACKOFF.initialDelayMs,
|
|
788
|
+
multiplier: backoff?.multiplier ?? DEFAULT_BACKOFF.multiplier,
|
|
789
|
+
maxDelayMs: backoff?.maxDelayMs ?? DEFAULT_BACKOFF.maxDelayMs
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
var handlesByNamespace = /* @__PURE__ */ new Map();
|
|
793
|
+
function getPayloadUpdatedAt(data) {
|
|
794
|
+
const value = data.updatedAt ?? data.updated_at;
|
|
795
|
+
if (typeof value === "string") return value;
|
|
796
|
+
if (value instanceof Date) return value.toISOString();
|
|
797
|
+
}
|
|
798
|
+
function offlineOutbox(config) {
|
|
799
|
+
const namespace = durableStoreNamespace(config.namespace);
|
|
800
|
+
const syncApplyBasePath = config.syncApplyBasePath ?? "/api/v1";
|
|
801
|
+
const fetchFn = config.fetchFn ?? ((...args) => globalThis.fetch(...args));
|
|
802
|
+
const backoff = resolveBackoff(config.backoff);
|
|
803
|
+
const object = config.object.name;
|
|
804
|
+
let engine;
|
|
805
|
+
let record;
|
|
806
|
+
return {
|
|
807
|
+
name: "offline-outbox",
|
|
808
|
+
onAttach() {
|
|
809
|
+
const acquired = acquireOutboxEngine({
|
|
810
|
+
namespace,
|
|
811
|
+
syncApplyBasePath,
|
|
812
|
+
fetchFn,
|
|
813
|
+
backoff,
|
|
814
|
+
random: config.random,
|
|
815
|
+
registerResource: (clear) => registerDurableResource(namespace, {
|
|
816
|
+
kind: "outbox",
|
|
817
|
+
clear
|
|
818
|
+
})
|
|
819
|
+
}, {
|
|
820
|
+
object,
|
|
821
|
+
onSyncStateChange: config.onSyncStateChange,
|
|
822
|
+
onConflict: config.onConflict
|
|
823
|
+
});
|
|
824
|
+
engine = acquired.engine;
|
|
825
|
+
record = acquired.record;
|
|
826
|
+
handlesByNamespace.set(namespace, engine);
|
|
827
|
+
},
|
|
828
|
+
async wrapMutation(envelope) {
|
|
829
|
+
if (!engine) return { handled: false };
|
|
830
|
+
if (!await engine.enqueue({
|
|
831
|
+
kind: envelope.kind,
|
|
832
|
+
object,
|
|
833
|
+
rowId: envelope.key,
|
|
834
|
+
data: envelope.data,
|
|
835
|
+
baseUpdatedAt: envelope.baseUpdatedAt ?? getPayloadUpdatedAt(envelope.data)
|
|
836
|
+
})) return { handled: false };
|
|
837
|
+
return {
|
|
838
|
+
handled: true,
|
|
839
|
+
result: envelope.data
|
|
840
|
+
};
|
|
841
|
+
},
|
|
842
|
+
async teardown() {
|
|
843
|
+
if (!engine || !record) return;
|
|
844
|
+
const current = engine;
|
|
845
|
+
const currentRecord = record;
|
|
846
|
+
engine = void 0;
|
|
847
|
+
record = void 0;
|
|
848
|
+
const before = current.referenceCount;
|
|
849
|
+
await releaseOutboxEngine(namespace, current, object, currentRecord);
|
|
850
|
+
if (before <= 1 && handlesByNamespace.get(namespace) === current) handlesByNamespace.delete(namespace);
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
function getOutboxHandle(namespace) {
|
|
855
|
+
const engine = handlesByNamespace.get(namespace);
|
|
856
|
+
if (!engine) return void 0;
|
|
857
|
+
return {
|
|
858
|
+
snapshot: () => engine.snapshot(),
|
|
859
|
+
retry: (itemId) => engine.retry(itemId)
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/sse-client.ts
|
|
864
|
+
var EVENT_SOURCE_CLOSED = 2;
|
|
865
|
+
function defaultEventSourceFactory(url, init) {
|
|
866
|
+
const EventSourceCtor = globalThis.EventSource;
|
|
867
|
+
if (typeof EventSourceCtor !== "function") return void 0;
|
|
868
|
+
return new EventSourceCtor(url, init);
|
|
869
|
+
}
|
|
870
|
+
function createSmrtWebEventSubscriber(config) {
|
|
871
|
+
const { eventsUrl, changesUrl, fetchFn = (...args) => globalThis.fetch(...args), eventSourceFactory = defaultEventSourceFactory, pollIntervalMs = 5e3, withCredentials = true } = config;
|
|
872
|
+
const tableInvalidators = /* @__PURE__ */ new Map();
|
|
873
|
+
let lastSeq = null;
|
|
874
|
+
let transport = "idle";
|
|
875
|
+
let eventSource = null;
|
|
876
|
+
let pollTimer = null;
|
|
877
|
+
let closed = false;
|
|
878
|
+
const registeredTables = () => [...tableInvalidators.keys()].sort((a, b) => a.localeCompare(b));
|
|
879
|
+
const buildChangesUrl = (since, tables) => {
|
|
880
|
+
const url = new URL(changesUrl, "http://smrt.local/");
|
|
881
|
+
url.searchParams.set("since", String(since));
|
|
882
|
+
url.searchParams.set("tables", tables.join(","));
|
|
883
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(changesUrl)) return url.href;
|
|
884
|
+
const pathQueryHash = `${url.pathname}${url.search}${url.hash}`;
|
|
885
|
+
if (changesUrl.startsWith("//")) return `//${url.host}${pathQueryHash}`;
|
|
886
|
+
if (changesUrl.startsWith("/")) return pathQueryHash;
|
|
887
|
+
return pathQueryHash.startsWith("/") ? pathQueryHash.slice(1) : pathQueryHash;
|
|
888
|
+
};
|
|
889
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
890
|
+
const warn = (message, error) => {
|
|
891
|
+
console.warn(`[smrt-web] live subscriber: ${message}`, error);
|
|
892
|
+
};
|
|
893
|
+
const fireAll = (invalidators) => {
|
|
894
|
+
if (!invalidators || invalidators.size === 0) return;
|
|
895
|
+
for (const invalidate of [...invalidators]) try {
|
|
896
|
+
invalidate();
|
|
897
|
+
} catch (error) {
|
|
898
|
+
warn("an invalidator threw; ignoring", error);
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
const invalidateTable = (table) => {
|
|
902
|
+
fireAll(tableInvalidators.get(table));
|
|
903
|
+
};
|
|
904
|
+
const invalidateAll = () => {
|
|
905
|
+
for (const invalidators of tableInvalidators.values()) fireAll(invalidators);
|
|
906
|
+
};
|
|
907
|
+
const advanceLastSeqFromEventId = (lastEventId) => {
|
|
908
|
+
const seq = Number(lastEventId);
|
|
909
|
+
if (Number.isFinite(seq)) lastSeq = seq;
|
|
910
|
+
};
|
|
911
|
+
const onChange = (ev) => {
|
|
912
|
+
if (closed) return;
|
|
913
|
+
let table;
|
|
914
|
+
try {
|
|
915
|
+
const parsed = JSON.parse(ev.data);
|
|
916
|
+
if (typeof parsed.table === "string") table = parsed.table;
|
|
917
|
+
} catch (error) {
|
|
918
|
+
warn("dropping malformed change frame", error);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (table === void 0) {
|
|
922
|
+
warn("dropping change frame with no table", ev.data);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
advanceLastSeqFromEventId(ev.lastEventId);
|
|
926
|
+
invalidateTable(table);
|
|
927
|
+
};
|
|
928
|
+
const onResync = (ev) => {
|
|
929
|
+
if (closed) return;
|
|
930
|
+
advanceLastSeqFromEventId(ev.lastEventId);
|
|
931
|
+
invalidateAll();
|
|
932
|
+
};
|
|
933
|
+
const poll = async () => {
|
|
934
|
+
if (closed) return;
|
|
935
|
+
const tables = registeredTables();
|
|
936
|
+
if (tables.length === 0) return;
|
|
937
|
+
try {
|
|
938
|
+
const since = lastSeq ?? 0;
|
|
939
|
+
const url = buildChangesUrl(since, tables);
|
|
940
|
+
const response = await fetchFn(url, { credentials: "include" });
|
|
941
|
+
if (closed) return;
|
|
942
|
+
const page = await response.json();
|
|
943
|
+
if (closed) return;
|
|
944
|
+
if (page.resyncRequired) {
|
|
945
|
+
invalidateAll();
|
|
946
|
+
if (isFiniteNumber(page.resyncCursor)) lastSeq = page.resyncCursor;
|
|
947
|
+
else if (isFiniteNumber(page.cursor) && page.cursor > since) lastSeq = page.cursor;
|
|
948
|
+
else lastSeq = null;
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
for (const change of page.changes ?? []) if (typeof change.table === "string") invalidateTable(change.table);
|
|
952
|
+
if (typeof page.cursor === "number") lastSeq = page.cursor;
|
|
953
|
+
} catch (error) {
|
|
954
|
+
warn("poll failed; will retry on the next interval", error);
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
const startPolling = () => {
|
|
958
|
+
if (closed || transport === "polling") return;
|
|
959
|
+
transport = "polling";
|
|
960
|
+
pollTimer = setInterval(() => {
|
|
961
|
+
poll();
|
|
962
|
+
}, pollIntervalMs);
|
|
963
|
+
pollTimer.unref?.();
|
|
964
|
+
};
|
|
965
|
+
const connectSse = (source) => {
|
|
966
|
+
transport = "sse";
|
|
967
|
+
eventSource = source;
|
|
968
|
+
source.addEventListener("change", onChange);
|
|
969
|
+
source.addEventListener("resync", onResync);
|
|
970
|
+
source.onerror = () => {
|
|
971
|
+
if (closed) return;
|
|
972
|
+
if (source.readyState === EVENT_SOURCE_CLOSED) {
|
|
973
|
+
try {
|
|
974
|
+
source.close();
|
|
975
|
+
} catch {}
|
|
976
|
+
eventSource = null;
|
|
977
|
+
startPolling();
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
};
|
|
981
|
+
const initialSource = eventSourceFactory(eventsUrl, { withCredentials });
|
|
982
|
+
if (initialSource) connectSse(initialSource);
|
|
983
|
+
else startPolling();
|
|
984
|
+
return {
|
|
985
|
+
get transport() {
|
|
986
|
+
return transport;
|
|
987
|
+
},
|
|
988
|
+
registerTable(table, invalidate) {
|
|
989
|
+
let set = tableInvalidators.get(table);
|
|
990
|
+
if (!set) {
|
|
991
|
+
set = /* @__PURE__ */ new Set();
|
|
992
|
+
tableInvalidators.set(table, set);
|
|
993
|
+
}
|
|
994
|
+
set.add(invalidate);
|
|
995
|
+
return () => {
|
|
996
|
+
const current = tableInvalidators.get(table);
|
|
997
|
+
if (!current) return;
|
|
998
|
+
current.delete(invalidate);
|
|
999
|
+
if (current.size === 0) tableInvalidators.delete(table);
|
|
1000
|
+
};
|
|
1001
|
+
},
|
|
1002
|
+
invalidateAll,
|
|
1003
|
+
close() {
|
|
1004
|
+
if (closed) return;
|
|
1005
|
+
closed = true;
|
|
1006
|
+
if (eventSource) {
|
|
1007
|
+
try {
|
|
1008
|
+
eventSource.close();
|
|
1009
|
+
} catch {}
|
|
1010
|
+
eventSource = null;
|
|
1011
|
+
}
|
|
1012
|
+
if (pollTimer) {
|
|
1013
|
+
clearInterval(pollTimer);
|
|
1014
|
+
pollTimer = null;
|
|
1015
|
+
}
|
|
1016
|
+
tableInvalidators.clear();
|
|
1017
|
+
transport = "idle";
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
function liveInvalidation(config) {
|
|
1022
|
+
const { subscriber, tableName } = config;
|
|
1023
|
+
let unregister;
|
|
1024
|
+
return {
|
|
1025
|
+
name: "live-invalidation",
|
|
1026
|
+
onAttach(ctx) {
|
|
1027
|
+
unregister = subscriber.registerTable(tableName, () => ctx.invalidate());
|
|
1028
|
+
},
|
|
1029
|
+
teardown() {
|
|
1030
|
+
unregister?.();
|
|
1031
|
+
unregister = void 0;
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
//#endregion
|
|
4
1036
|
//#region src/index.ts
|
|
5
1037
|
var SmrtWebRequestError = class extends Error {
|
|
6
1038
|
payload;
|
|
@@ -95,24 +1127,34 @@ function projectChanges(changes) {
|
|
|
95
1127
|
return projected;
|
|
96
1128
|
});
|
|
97
1129
|
}
|
|
1130
|
+
function getBaseUpdatedAt(row) {
|
|
1131
|
+
if (!row || typeof row !== "object") return void 0;
|
|
1132
|
+
const record = row;
|
|
1133
|
+
const value = record.updatedAt ?? record.updated_at;
|
|
1134
|
+
if (typeof value === "string") return value;
|
|
1135
|
+
if (value instanceof Date) return value.toISOString();
|
|
1136
|
+
}
|
|
98
1137
|
var engineCollections = /* @__PURE__ */ new WeakMap();
|
|
99
1138
|
function getEngineCollection(handle) {
|
|
100
1139
|
const engine = engineCollections.get(handle);
|
|
101
1140
|
if (engine === void 0) throw new SmrtWebRequestError("[smrt-web] getEngineCollection: not a smrt-web collection handle");
|
|
102
1141
|
return engine;
|
|
103
1142
|
}
|
|
1143
|
+
function warnCapability(capability, hook, error) {
|
|
1144
|
+
console.warn(`[smrt-web] capability "${capability.name}" ${hook} threw; ignoring`, error);
|
|
1145
|
+
}
|
|
104
1146
|
function createSmrtCollection(definition, options) {
|
|
105
1147
|
const { staleTimeMs = 3e4, retry = false, scope, initialData } = options;
|
|
1148
|
+
const capabilities = options.capabilities ?? [];
|
|
106
1149
|
const fetchers = options.fetchers ?? createDefinitionFetchers(definition, options.basePath, options.fetchFn);
|
|
107
1150
|
const queryClient = resolveQueryClient(options.client);
|
|
108
1151
|
const idField = definition.idField || "id";
|
|
109
|
-
|
|
110
|
-
|
|
1152
|
+
let cacheId = scope ? `smrt:${scope}:${definition.name}` : `smrt:${definition.name}`;
|
|
1153
|
+
let queryKey = scope ? [
|
|
111
1154
|
"smrt",
|
|
112
1155
|
scope,
|
|
113
1156
|
definition.name
|
|
114
1157
|
] : ["smrt", definition.name];
|
|
115
|
-
if (initialData !== void 0) queryClient.setQueryData(queryKey, (existing) => existing ?? initialData);
|
|
116
1158
|
const invalidationTargets = /* @__PURE__ */ new Set([definition.name]);
|
|
117
1159
|
for (const relationship of definition.relationships ?? []) invalidationTargets.add(relationship.relatedCollection);
|
|
118
1160
|
const invalidateRelated = () => {
|
|
@@ -123,6 +1165,108 @@ function createSmrtCollection(definition, options) {
|
|
|
123
1165
|
return typeof collectionSegment === "string" && invalidationTargets.has(collectionSegment);
|
|
124
1166
|
} });
|
|
125
1167
|
};
|
|
1168
|
+
const ctx = {
|
|
1169
|
+
definition,
|
|
1170
|
+
fetchers,
|
|
1171
|
+
get cacheKey() {
|
|
1172
|
+
return queryKey;
|
|
1173
|
+
},
|
|
1174
|
+
get cacheId() {
|
|
1175
|
+
return cacheId;
|
|
1176
|
+
},
|
|
1177
|
+
invalidate: () => invalidateRelated()
|
|
1178
|
+
};
|
|
1179
|
+
for (const capability of capabilities) {
|
|
1180
|
+
let extra;
|
|
1181
|
+
try {
|
|
1182
|
+
extra = capability.contributeCacheKey?.(ctx);
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
warnCapability(capability, "contributeCacheKey", error);
|
|
1185
|
+
}
|
|
1186
|
+
if (extra && extra.length > 0) {
|
|
1187
|
+
const name = queryKey[queryKey.length - 1];
|
|
1188
|
+
queryKey = [
|
|
1189
|
+
...queryKey.slice(0, -1),
|
|
1190
|
+
...extra,
|
|
1191
|
+
name
|
|
1192
|
+
];
|
|
1193
|
+
cacheId = `${cacheId}:${extra.join(":")}`;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const seedCache = (rows) => {
|
|
1197
|
+
queryClient.setQueryData(queryKey, (existing) => existing ?? rows);
|
|
1198
|
+
};
|
|
1199
|
+
let disposed = false;
|
|
1200
|
+
const warmRowsFrom = async (capability, warm) => {
|
|
1201
|
+
try {
|
|
1202
|
+
return await warm;
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
warnCapability(capability, "warmStart", error);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
let warmStartPending;
|
|
1209
|
+
if (initialData !== void 0) seedCache(initialData);
|
|
1210
|
+
else for (let i = 0; i < capabilities.length; i += 1) {
|
|
1211
|
+
const capability = capabilities[i];
|
|
1212
|
+
let warm;
|
|
1213
|
+
try {
|
|
1214
|
+
warm = capability.warmStart?.(ctx);
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
warnCapability(capability, "warmStart", error);
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
if (warm === void 0) continue;
|
|
1220
|
+
if (warm instanceof Promise) {
|
|
1221
|
+
const firstPromise = warm;
|
|
1222
|
+
warmStartPending = (async () => {
|
|
1223
|
+
let rows = await warmRowsFrom(capability, firstPromise);
|
|
1224
|
+
for (let j = i + 1; rows === void 0 && j < capabilities.length; j += 1) {
|
|
1225
|
+
const later = capabilities[j];
|
|
1226
|
+
let laterWarm;
|
|
1227
|
+
try {
|
|
1228
|
+
laterWarm = later.warmStart?.(ctx);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
warnCapability(later, "warmStart", error);
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
if (laterWarm === void 0) continue;
|
|
1234
|
+
rows = await warmRowsFrom(later, laterWarm);
|
|
1235
|
+
}
|
|
1236
|
+
if (rows !== void 0 && !disposed) seedCache(rows);
|
|
1237
|
+
})();
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
seedCache(warm);
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
const notifySettled = (envelope, outcome) => {
|
|
1244
|
+
for (const capability of capabilities) try {
|
|
1245
|
+
capability.onSettled?.(envelope, outcome, ctx);
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
warnCapability(capability, "onSettled", error);
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
const persistMutation = async (envelope, runFetcher) => {
|
|
1251
|
+
try {
|
|
1252
|
+
const wrapped = await runWrapMutation(capabilities, envelope, ctx);
|
|
1253
|
+
const result = wrapped.handled ? wrapped.result : await runFetcher();
|
|
1254
|
+
notifySettled(envelope, {
|
|
1255
|
+
ok: true,
|
|
1256
|
+
result
|
|
1257
|
+
});
|
|
1258
|
+
return {
|
|
1259
|
+
handled: wrapped.handled,
|
|
1260
|
+
result
|
|
1261
|
+
};
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
notifySettled(envelope, {
|
|
1264
|
+
ok: false,
|
|
1265
|
+
error
|
|
1266
|
+
});
|
|
1267
|
+
throw error;
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
126
1270
|
const collection = createCollection(queryCollectionOptions({
|
|
127
1271
|
id: cacheId,
|
|
128
1272
|
queryKey,
|
|
@@ -132,22 +1276,54 @@ function createSmrtCollection(definition, options) {
|
|
|
132
1276
|
queryFn: async () => unwrapListResult(await fetchers.list(), definition.name),
|
|
133
1277
|
getKey: (row) => String(row[idField]),
|
|
134
1278
|
onInsert: async ({ transaction }) => {
|
|
1279
|
+
let anyHandled = false;
|
|
135
1280
|
for (const mutation of transaction.mutations) {
|
|
136
|
-
const
|
|
137
|
-
|
|
1281
|
+
const modified = mutation.modified;
|
|
1282
|
+
const envelope = {
|
|
1283
|
+
kind: "insert",
|
|
1284
|
+
key: String(modified[idField]),
|
|
1285
|
+
data: modified
|
|
1286
|
+
};
|
|
1287
|
+
const outcome = await persistMutation(envelope, async () => {
|
|
1288
|
+
const { [idField]: _localId, ...data } = modified;
|
|
1289
|
+
return unwrapItemResult(await fetchers.create(data), `create(${definition.name})`);
|
|
1290
|
+
});
|
|
1291
|
+
anyHandled = anyHandled || outcome.handled;
|
|
138
1292
|
}
|
|
1293
|
+
if (anyHandled) return { refetch: false };
|
|
139
1294
|
invalidateRelated();
|
|
140
1295
|
},
|
|
141
1296
|
onUpdate: fetchers.update ? async ({ transaction }) => {
|
|
1297
|
+
let anyHandled = false;
|
|
142
1298
|
for (const mutation of transaction.mutations) {
|
|
143
1299
|
const key = String(mutation.key);
|
|
144
1300
|
const changes = mutation.changes;
|
|
145
|
-
|
|
1301
|
+
const envelope = {
|
|
1302
|
+
kind: "update",
|
|
1303
|
+
key,
|
|
1304
|
+
data: changes,
|
|
1305
|
+
baseUpdatedAt: getBaseUpdatedAt(mutation.original)
|
|
1306
|
+
};
|
|
1307
|
+
const outcome = await persistMutation(envelope, async () => unwrapItemResult(await fetchers.update(key, changes), `update(${definition.name})`));
|
|
1308
|
+
anyHandled = anyHandled || outcome.handled;
|
|
146
1309
|
}
|
|
1310
|
+
if (anyHandled) return { refetch: false };
|
|
147
1311
|
invalidateRelated();
|
|
148
1312
|
} : void 0,
|
|
149
1313
|
onDelete: fetchers.delete ? async ({ transaction }) => {
|
|
150
|
-
|
|
1314
|
+
let anyHandled = false;
|
|
1315
|
+
for (const mutation of transaction.mutations) {
|
|
1316
|
+
const key = String(mutation.key);
|
|
1317
|
+
const envelope = {
|
|
1318
|
+
kind: "delete",
|
|
1319
|
+
key,
|
|
1320
|
+
data: {},
|
|
1321
|
+
baseUpdatedAt: getBaseUpdatedAt(mutation.original)
|
|
1322
|
+
};
|
|
1323
|
+
const outcome = await persistMutation(envelope, async () => fetchers.delete(key));
|
|
1324
|
+
anyHandled = anyHandled || outcome.handled;
|
|
1325
|
+
}
|
|
1326
|
+
if (anyHandled) return { refetch: false };
|
|
151
1327
|
invalidateRelated();
|
|
152
1328
|
} : void 0
|
|
153
1329
|
}));
|
|
@@ -166,10 +1342,20 @@ function createSmrtCollection(definition, options) {
|
|
|
166
1342
|
return row === void 0 ? void 0 : toPlainRow(row);
|
|
167
1343
|
},
|
|
168
1344
|
preload() {
|
|
169
|
-
return collection.preload();
|
|
1345
|
+
if (!warmStartPending) return collection.preload();
|
|
1346
|
+
return warmStartPending.then(() => {
|
|
1347
|
+
if (disposed) return;
|
|
1348
|
+
return collection.preload();
|
|
1349
|
+
});
|
|
170
1350
|
},
|
|
171
|
-
cleanup() {
|
|
172
|
-
|
|
1351
|
+
async cleanup() {
|
|
1352
|
+
disposed = true;
|
|
1353
|
+
await collection.cleanup();
|
|
1354
|
+
for (const capability of capabilities) try {
|
|
1355
|
+
await capability.teardown?.(ctx);
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
warnCapability(capability, "teardown", error);
|
|
1358
|
+
}
|
|
173
1359
|
},
|
|
174
1360
|
subscribeChanges(callback) {
|
|
175
1361
|
const subscription = collection.subscribeChanges((changes) => callback(projectChanges(changes)));
|
|
@@ -180,9 +1366,14 @@ function createSmrtCollection(definition, options) {
|
|
|
180
1366
|
}
|
|
181
1367
|
};
|
|
182
1368
|
engineCollections.set(handle, collection);
|
|
1369
|
+
for (const capability of capabilities) try {
|
|
1370
|
+
capability.onAttach?.(ctx);
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
warnCapability(capability, "onAttach", error);
|
|
1373
|
+
}
|
|
183
1374
|
return handle;
|
|
184
1375
|
}
|
|
185
1376
|
//#endregion
|
|
186
|
-
export { SmrtWebRequestError, createDefinitionFetchers, createSmrtCollection, createSmrtWebClient, getEngineCollection, newLocalId, unwrapItemResult, unwrapListResult };
|
|
1377
|
+
export { SmrtWebRequestError, createDefinitionFetchers, createSmrtCollection, createSmrtWebClient, createSmrtWebEventSubscriber, durableStoreNamespace, getEngineCollection, getOutboxHandle, liveInvalidation, newLocalId, offlineOutbox, registerDurableResource, runWrapMutation, unwrapItemResult, unwrapListResult, wipeDurableStore };
|
|
187
1378
|
|
|
188
1379
|
//# sourceMappingURL=index.js.map
|