@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/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
- const cacheId = scope ? `smrt:${scope}:${definition.name}` : `smrt:${definition.name}`;
110
- const queryKey = scope ? [
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 { [idField]: _localId, ...data } = mutation.modified;
137
- unwrapItemResult(await fetchers.create(data), `create(${definition.name})`);
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
- unwrapItemResult(await fetchers.update(key, changes), `update(${definition.name})`);
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
- for (const mutation of transaction.mutations) await fetchers.delete(String(mutation.key));
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
- return collection.cleanup();
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