@happyvertical/smrt-web 0.42.6 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2292 @@
1
+ import { createCollection } from "@tanstack/db";
2
+ import { QueryClient } from "@tanstack/query-core";
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/internal.ts
18
+ var persistedMutationResults = /* @__PURE__ */ new WeakMap();
19
+ var mutationTargetHydrators = /* @__PURE__ */ new WeakMap();
20
+ //#endregion
21
+ //#region src/data-query.ts
22
+ var FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
23
+ "__proto__",
24
+ "constructor",
25
+ "prototype"
26
+ ]);
27
+ var MAX_SMRT_WEB_DATA_QUERY_RESULT_BYTES = 1e7;
28
+ var MAX_SMRT_WEB_DATA_QUERY_ROWS = 1e3;
29
+ var MAX_SMRT_WEB_DATA_QUERY_FACETS = 20;
30
+ var MAX_SMRT_WEB_DATA_QUERY_FACET_VALUES = 1e3;
31
+ var MAX_SMRT_WEB_DATA_QUERY_WARNINGS = 100;
32
+ var MAX_SMRT_WEB_DATA_QUERY_PAGE_LIMIT = 1e3;
33
+ var MAX_SMRT_WEB_DATA_QUERY_OFFSET = 1e6;
34
+ var MAX_SMRT_WEB_DATA_QUERY_CONTAINER_ITEMS = 1e3;
35
+ var MAX_SMRT_WEB_DATA_QUERY_STRING_LENGTH = 65536;
36
+ function consumeBytes(budget, text, label) {
37
+ const bytes = new TextEncoder().encode(text).byteLength;
38
+ if (bytes > budget.remaining) throw new TypeError(`${label} exceeds the maximum byte limit`);
39
+ budget.remaining -= bytes;
40
+ }
41
+ function plainObject(value, label) {
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${label} must be a plain object`);
43
+ const prototype = Object.getPrototypeOf(value);
44
+ if (prototype !== Object.prototype && prototype !== null) throw new TypeError(`${label} must be a plain object`);
45
+ for (const key of Object.keys(value)) if (FORBIDDEN_KEYS.has(key)) throw new TypeError(`${label} contains a forbidden key`);
46
+ return value;
47
+ }
48
+ function exactKeys(value, allowed, label) {
49
+ const keys = new Set(allowed);
50
+ for (const key of Object.keys(value)) if (!keys.has(key)) throw new TypeError(`${label} contains ${key}`);
51
+ }
52
+ function stringValue(value, label, maxLength = 2048) {
53
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) throw new TypeError(`${label} must be a bounded non-empty string`);
54
+ return value;
55
+ }
56
+ function nonNegativeInteger(value, label) {
57
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${label} must be a non-negative safe integer`);
58
+ return value;
59
+ }
60
+ function scalar(value, label, budget) {
61
+ if (typeof value === "string") {
62
+ if (value.length > 65536) throw new TypeError(`${label} exceeds the string limit`);
63
+ consumeBytes(budget, JSON.stringify(value), label);
64
+ return value;
65
+ }
66
+ if (value === null || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
67
+ consumeBytes(budget, JSON.stringify(value), label);
68
+ return value;
69
+ }
70
+ throw new TypeError(`${label} must be a JSON scalar`);
71
+ }
72
+ function jsonValue(value, label, budget, depth = 0) {
73
+ if (depth > 16) throw new TypeError(`${label} exceeds JSON depth`);
74
+ if (typeof value === "string") {
75
+ if (value.length > 65536) throw new TypeError(`${label} exceeds the string limit`);
76
+ consumeBytes(budget, JSON.stringify(value), label);
77
+ return value;
78
+ }
79
+ if (value === null || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
80
+ consumeBytes(budget, JSON.stringify(value), label);
81
+ return value;
82
+ }
83
+ if (Array.isArray(value)) {
84
+ if (value.length > 1e3) throw new TypeError(`${label} exceeds the container-item limit`);
85
+ consumeBytes(budget, "[", label);
86
+ const result2 = [];
87
+ for (const [index, item] of value.entries()) {
88
+ if (index > 0) consumeBytes(budget, ",", label);
89
+ result2.push(jsonValue(item, `${label}[${index}]`, budget, depth + 1));
90
+ }
91
+ consumeBytes(budget, "]", label);
92
+ return result2;
93
+ }
94
+ const object = plainObject(value, label);
95
+ if (Object.keys(object).length > 1e3) throw new TypeError(`${label} exceeds the container-item limit`);
96
+ const result = /* @__PURE__ */ Object.create(null);
97
+ consumeBytes(budget, "{", label);
98
+ for (const [index, key] of Object.keys(object).sort().entries()) {
99
+ if (key.length > 65536) throw new TypeError(`${label}.${key} exceeds the string limit`);
100
+ if (index > 0) consumeBytes(budget, ",", label);
101
+ consumeBytes(budget, JSON.stringify(key), `${label}.${key}`);
102
+ consumeBytes(budget, ":", label);
103
+ result[key] = jsonValue(object[key], `${label}.${key}`, budget, depth + 1);
104
+ }
105
+ consumeBytes(budget, "}", label);
106
+ return result;
107
+ }
108
+ function normalizeTotal(value) {
109
+ const total = plainObject(value, "Data query total");
110
+ const kind = stringValue(total.kind, "Data query total kind");
111
+ if (kind === "unavailable") {
112
+ exactKeys(total, ["kind", "reason"], "Data query unavailable total");
113
+ return {
114
+ kind,
115
+ ...total.reason === void 0 ? {} : { reason: stringValue(total.reason, "Data query total reason") }
116
+ };
117
+ }
118
+ if (kind !== "exact" && kind !== "estimated") throw new TypeError("Data query total kind is invalid");
119
+ exactKeys(total, [
120
+ "kind",
121
+ "value",
122
+ "asOf"
123
+ ], "Data query total");
124
+ return {
125
+ kind,
126
+ value: nonNegativeInteger(total.value, "Data query total value"),
127
+ ...total.asOf === void 0 ? {} : { asOf: stringValue(total.asOf, "Data query total asOf", 128) }
128
+ };
129
+ }
130
+ function normalizeFacets(value, budget) {
131
+ if (value === void 0) return void 0;
132
+ if (!Array.isArray(value)) throw new TypeError("Data query facets must be an array");
133
+ if (value.length > 20) throw new TypeError("Data query facets exceed the maximum");
134
+ const fields = /* @__PURE__ */ new Set();
135
+ return value.map((candidate, index) => {
136
+ const facet = plainObject(candidate, `Data query facet ${index}`);
137
+ exactKeys(facet, [
138
+ "field",
139
+ "values",
140
+ "truncated"
141
+ ], "Data query facet");
142
+ const field = stringValue(facet.field, "Data query facet field");
143
+ if (fields.has(field)) throw new TypeError("Data query facet fields must be unique");
144
+ fields.add(field);
145
+ if (!Array.isArray(facet.values)) throw new TypeError("Data query facet values must be an array");
146
+ if (facet.values.length > 1e3) throw new TypeError("Data query facet values exceed the maximum");
147
+ if (typeof facet.truncated !== "boolean") throw new TypeError("Data query facet truncated must be boolean");
148
+ return {
149
+ field,
150
+ values: facet.values.map((value2, valueIndex) => {
151
+ const entry = plainObject(value2, `Data query facet ${index} value ${valueIndex}`);
152
+ exactKeys(entry, ["value", "count"], "Data query facet value");
153
+ return {
154
+ value: scalar(entry.value, "Data query facet value", budget),
155
+ count: nonNegativeInteger(entry.count, "Data query facet count")
156
+ };
157
+ }),
158
+ truncated: facet.truncated
159
+ };
160
+ });
161
+ }
162
+ function normalizeSmrtWebDataQueryResult(value) {
163
+ const result = plainObject(value, "Data query result");
164
+ exactKeys(result, [
165
+ "version",
166
+ "requestId",
167
+ "queryFingerprint",
168
+ "identityField",
169
+ "rows",
170
+ "page",
171
+ "total",
172
+ "facets",
173
+ "freshness",
174
+ "warnings",
175
+ "truncated"
176
+ ], "Data query result");
177
+ if (result.version !== 1) throw new TypeError("Unsupported data query version");
178
+ const identityField = stringValue(result.identityField, "Data query identity field");
179
+ if (!Array.isArray(result.rows)) throw new TypeError("Data query rows must be an array");
180
+ if (result.rows.length > 1e3) throw new TypeError("Data query rows exceed the maximum");
181
+ const budget = { remaining: MAX_SMRT_WEB_DATA_QUERY_RESULT_BYTES };
182
+ const rows = result.rows.map((row, index) => {
183
+ const object = plainObject(jsonValue(row, `Data query row ${index}`, budget), `Data query row ${index}`);
184
+ const identity = object[identityField];
185
+ if (typeof identity !== "string" && typeof identity !== "number" || identity === "") throw new TypeError("Data query row is missing its stable identity");
186
+ return object;
187
+ });
188
+ let page;
189
+ if (result.page !== void 0) {
190
+ const candidate = plainObject(result.page, "Data query page");
191
+ exactKeys(candidate, [
192
+ "kind",
193
+ "limit",
194
+ "offset",
195
+ "nextCursor",
196
+ "hasMore"
197
+ ], "Data query page");
198
+ const kind = stringValue(candidate.kind, "Data query page kind");
199
+ if (kind !== "offset" && kind !== "cursor") throw new TypeError("Data query page kind is invalid");
200
+ if (typeof candidate.hasMore !== "boolean") throw new TypeError("Data query page hasMore must be boolean");
201
+ const limit = nonNegativeInteger(candidate.limit, "Data query page limit");
202
+ if (limit === 0) throw new TypeError("Data query page limit must be positive");
203
+ if (limit > 1e3) throw new TypeError("Data query page limit exceeds the maximum");
204
+ if (kind === "offset") {
205
+ if (candidate.nextCursor !== void 0 || candidate.offset === void 0) throw new TypeError("Offset data query page must carry only an offset");
206
+ const offset = nonNegativeInteger(candidate.offset, "Data query offset");
207
+ if (offset > 1e6) throw new TypeError("Data query offset exceeds the maximum");
208
+ page = {
209
+ kind,
210
+ limit,
211
+ offset,
212
+ hasMore: candidate.hasMore
213
+ };
214
+ } else {
215
+ if (candidate.offset !== void 0) throw new TypeError("Cursor data query page cannot carry an offset");
216
+ const nextCursor = candidate.nextCursor === void 0 ? void 0 : stringValue(candidate.nextCursor, "Data query next cursor");
217
+ if (candidate.hasMore !== Boolean(nextCursor)) throw new TypeError("Cursor data query page hasMore must match nextCursor");
218
+ page = {
219
+ kind,
220
+ limit,
221
+ hasMore: candidate.hasMore,
222
+ ...nextCursor === void 0 ? {} : { nextCursor }
223
+ };
224
+ }
225
+ }
226
+ if (page && rows.length > page.limit) throw new TypeError("Data query rows exceed the declared page limit");
227
+ const freshness = plainObject(result.freshness, "Data query freshness");
228
+ exactKeys(freshness, ["state", "asOf"], "Data query freshness");
229
+ const state = stringValue(freshness.state, "Data query freshness state");
230
+ if (state !== "fresh" && state !== "stale" && state !== "unknown") throw new TypeError("Data query freshness state is invalid");
231
+ if (!Array.isArray(result.warnings) || result.warnings.length > 100 || result.warnings.some((item) => typeof item !== "string")) throw new TypeError("Data query warnings must be strings");
232
+ if (typeof result.truncated !== "boolean") throw new TypeError("Data query truncated must be boolean");
233
+ const facets = normalizeFacets(result.facets, budget);
234
+ const warnings = result.warnings.map((warning) => stringValue(warning, "Data query warning", 512));
235
+ const normalized = {
236
+ version: 1,
237
+ requestId: stringValue(result.requestId, "Data query request id"),
238
+ queryFingerprint: stringValue(result.queryFingerprint, "Data query fingerprint"),
239
+ identityField,
240
+ rows,
241
+ ...page === void 0 ? {} : { page },
242
+ total: normalizeTotal(result.total),
243
+ ...facets === void 0 ? {} : { facets },
244
+ freshness: {
245
+ state,
246
+ ...freshness.asOf === void 0 ? {} : { asOf: stringValue(freshness.asOf, "Data query freshness asOf", 128) }
247
+ },
248
+ warnings: [...new Set(warnings)].sort(),
249
+ truncated: result.truncated
250
+ };
251
+ if (new TextEncoder().encode(JSON.stringify(normalized)).byteLength > 1e7) throw new TypeError("Data query result exceeds the maximum byte limit");
252
+ return normalized;
253
+ }
254
+ async function executeSmrtWebDataQuery(transport, request, options) {
255
+ const result = normalizeSmrtWebDataQueryResult(await transport.query(request, options));
256
+ if (result.requestId !== request.requestId) throw new TypeError("Data query result request id does not match its request");
257
+ return result;
258
+ }
259
+ //#endregion
260
+ //#region src/durable-store.ts
261
+ function durableStoreNamespace(key) {
262
+ const optional = (value) => value === void 0 ? "" : `_${encodeURIComponent(value)}`;
263
+ return `smrt-web:${encodeURIComponent(key.apiBase)}:${optional(key.tenantId)}:${optional(key.identityId)}:${encodeURIComponent(key.manifestHash)}`;
264
+ }
265
+ var registry = /* @__PURE__ */ new Map();
266
+ function registerDurableResource(namespace, resource) {
267
+ let resources = registry.get(namespace);
268
+ if (!resources) {
269
+ resources = /* @__PURE__ */ new Set();
270
+ registry.set(namespace, resources);
271
+ }
272
+ resources.add(resource);
273
+ return () => {
274
+ const current = registry.get(namespace);
275
+ if (!current) return;
276
+ current.delete(resource);
277
+ if (current.size === 0) registry.delete(namespace);
278
+ };
279
+ }
280
+ async function wipeDurableStore(namespace) {
281
+ const resources = registry.get(namespace);
282
+ if (!resources || resources.size === 0) {
283
+ registry.delete(namespace);
284
+ return;
285
+ }
286
+ const snapshot = [...resources];
287
+ registry.delete(namespace);
288
+ await Promise.allSettled(snapshot.map((resource) => resource.clear()));
289
+ }
290
+ //#endregion
291
+ //#region src/offline/durable-queue.ts
292
+ var OUTBOX_STORE = "outbox";
293
+ var OUTBOX_STATE_INDEX = "state";
294
+ function promisifyRequest$2(request) {
295
+ return new Promise((resolve, reject) => {
296
+ request.onsuccess = () => resolve(request.result);
297
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB request failed"));
298
+ });
299
+ }
300
+ function awaitTransaction$2(tx) {
301
+ return new Promise((resolve, reject) => {
302
+ tx.oncomplete = () => resolve();
303
+ tx.onerror = () => reject(tx.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB transaction failed"));
304
+ tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB transaction aborted"));
305
+ });
306
+ }
307
+ async function probeIndexedDb$1() {
308
+ const idb = globalThis.indexedDB;
309
+ if (!idb) return false;
310
+ const probeName = "__smrt_web_outbox_probe__";
311
+ try {
312
+ (await new Promise((resolve, reject) => {
313
+ const request = idb.open(probeName, 1);
314
+ request.onsuccess = () => resolve(request.result);
315
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("probe failed"));
316
+ request.onblocked = () => reject(/* @__PURE__ */ new Error("probe blocked"));
317
+ })).close();
318
+ try {
319
+ idb.deleteDatabase(probeName);
320
+ } catch {}
321
+ return true;
322
+ } catch {
323
+ return false;
324
+ }
325
+ }
326
+ var DurableOutboxQueue = class {
327
+ db;
328
+ /** The IndexedDB database name (== the durable-store namespace). */
329
+ dbName;
330
+ constructor(db, dbName) {
331
+ this.db = db;
332
+ this.dbName = dbName;
333
+ }
334
+ /**
335
+ * Append a mutation to the tail of the queue in state `pending`, due
336
+ * immediately (`nextAttemptAt = 0`, `attempts = 0`). Resolves with the
337
+ * assigned `seq` once the write is durably committed.
338
+ */
339
+ async enqueue(input) {
340
+ const now = Date.now();
341
+ const row = {
342
+ itemId: input.itemId,
343
+ object: input.object,
344
+ op: input.op,
345
+ id: input.id,
346
+ payload: input.payload,
347
+ baseUpdatedAt: input.baseUpdatedAt,
348
+ state: "pending",
349
+ attempts: 0,
350
+ nextAttemptAt: 0,
351
+ enqueuedAt: now
352
+ };
353
+ const tx = this.db.transaction(OUTBOX_STORE, "readwrite");
354
+ const seq = await promisifyRequest$2(tx.objectStore(OUTBOX_STORE).add(row));
355
+ await awaitTransaction$2(tx);
356
+ return seq;
357
+ }
358
+ /**
359
+ * Persist a state transition (and any of attempts/backoff/error) for the row
360
+ * at `seq`, reading-then-writing inside ONE transaction so a concurrent drain
361
+ * in the same tab can't lose the update. A no-op if the row is already gone
362
+ * (removed by a prior terminal transition).
363
+ */
364
+ async markState(seq, patch) {
365
+ const tx = this.db.transaction(OUTBOX_STORE, "readwrite");
366
+ const store = tx.objectStore(OUTBOX_STORE);
367
+ const existing = await promisifyRequest$2(store.get(seq));
368
+ if (!existing) {
369
+ await awaitTransaction$2(tx);
370
+ return;
371
+ }
372
+ const next = {
373
+ ...existing,
374
+ ...patch,
375
+ seq
376
+ };
377
+ await promisifyRequest$2(store.put(next));
378
+ await awaitTransaction$2(tx);
379
+ }
380
+ /**
381
+ * All rows that are due to (re)send at `now`: state `pending` AND
382
+ * `nextAttemptAt <= now`, in ascending `seq` (FIFO). Uses the `state` index to
383
+ * avoid scanning terminal tombstones. Terminal rows (`synced`/`failed`) are
384
+ * excluded — they await removal, not replay.
385
+ */
386
+ async listPending(now) {
387
+ const tx = this.db.transaction(OUTBOX_STORE, "readonly");
388
+ const rows = await promisifyRequest$2(tx.objectStore(OUTBOX_STORE).index(OUTBOX_STATE_INDEX).getAll(IDBKeyRange.only("pending")));
389
+ await awaitTransaction$2(tx);
390
+ return rows.filter((row) => row.nextAttemptAt <= now).sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
391
+ }
392
+ /** Remove the row at `seq` (a terminal transition drops it). */
393
+ async remove(seq) {
394
+ const tx = this.db.transaction(OUTBOX_STORE, "readwrite");
395
+ await promisifyRequest$2(tx.objectStore(OUTBOX_STORE).delete(seq));
396
+ await awaitTransaction$2(tx);
397
+ }
398
+ /** Every row currently in the queue (any state), ascending `seq`. */
399
+ async all() {
400
+ const tx = this.db.transaction(OUTBOX_STORE, "readonly");
401
+ const rows = await promisifyRequest$2(tx.objectStore(OUTBOX_STORE).getAll());
402
+ await awaitTransaction$2(tx);
403
+ return rows.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
404
+ }
405
+ /**
406
+ * Drop every row — the durable-store `clear()` for `wipeDurableStore`. Empties
407
+ * the store but keeps the database (and its `seq` autoincrement counter) so a
408
+ * subsequent enqueue still gets fresh monotonic ids.
409
+ */
410
+ async clear() {
411
+ const tx = this.db.transaction(OUTBOX_STORE, "readwrite");
412
+ await promisifyRequest$2(tx.objectStore(OUTBOX_STORE).clear());
413
+ await awaitTransaction$2(tx);
414
+ }
415
+ /** Close the underlying database handle (called on engine dispose). */
416
+ close() {
417
+ this.db.close();
418
+ }
419
+ };
420
+ function openDurableOutboxQueue(dbName) {
421
+ const idb = globalThis.indexedDB;
422
+ if (!idb) return Promise.reject(/* @__PURE__ */ new Error("[smrt-web] IndexedDB is unavailable in this environment"));
423
+ return new Promise((resolve, reject) => {
424
+ const request = idb.open(dbName, 1);
425
+ request.onupgradeneeded = () => {
426
+ const db = request.result;
427
+ if (!db.objectStoreNames.contains("outbox")) db.createObjectStore(OUTBOX_STORE, {
428
+ keyPath: "seq",
429
+ autoIncrement: true
430
+ }).createIndex(OUTBOX_STATE_INDEX, "state", { unique: false });
431
+ };
432
+ request.onsuccess = () => resolve(new DurableOutboxQueue(request.result, dbName));
433
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error(`[smrt-web] failed to open outbox database "${dbName}"`));
434
+ request.onblocked = () => reject(/* @__PURE__ */ new Error(`[smrt-web] opening outbox database "${dbName}" was blocked`));
435
+ });
436
+ }
437
+ //#endregion
438
+ //#region src/offline/leader.ts
439
+ function getLockManager() {
440
+ const locks = globalThis.navigator?.locks;
441
+ if (locks && typeof locks.request === "function") return locks;
442
+ }
443
+ var warnedNoLocks = false;
444
+ function acquireLeadership(lockName, onAcquired, onReleased) {
445
+ const locks = getLockManager();
446
+ if (!locks) {
447
+ if (!warnedNoLocks) {
448
+ warnedNoLocks = true;
449
+ 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.");
450
+ }
451
+ let released2 = false;
452
+ const release2 = () => {
453
+ if (released2) return;
454
+ released2 = true;
455
+ onReleased();
456
+ };
457
+ queueMicrotask(() => {
458
+ if (!released2) onAcquired();
459
+ });
460
+ return release2;
461
+ }
462
+ const controller = new AbortController();
463
+ let releaseHeldLock;
464
+ let released = false;
465
+ let acquired = false;
466
+ const release = () => {
467
+ if (released) return;
468
+ released = true;
469
+ if (acquired && releaseHeldLock) releaseHeldLock();
470
+ else controller.abort();
471
+ onReleased();
472
+ };
473
+ locks.request(lockName, {
474
+ signal: controller.signal,
475
+ mode: "exclusive"
476
+ }, () => {
477
+ acquired = true;
478
+ if (released) return Promise.resolve();
479
+ onAcquired();
480
+ return new Promise((resolve) => {
481
+ releaseHeldLock = resolve;
482
+ });
483
+ }).catch((error) => {
484
+ if (error?.name !== "AbortError") console.warn("[smrt-web] outbox leader lock request failed", error);
485
+ if (!released) {
486
+ released = true;
487
+ onReleased();
488
+ }
489
+ });
490
+ return release;
491
+ }
492
+ //#endregion
493
+ //#region src/offline/types.ts
494
+ var MAX_SYNC_APPLY_BATCH_SIZE = 1e3;
495
+ var SYNC_APPLY_ROUTE_SEGMENTS = ["sync", "apply"];
496
+ var DEFAULT_BACKOFF = {
497
+ initialDelayMs: 1e3,
498
+ multiplier: 2,
499
+ maxDelayMs: 6e4
500
+ };
501
+ function computeBackoffDelay(attempts, backoff, random = Math.random) {
502
+ const exponent = Math.max(0, attempts - 1);
503
+ const raw = backoff.initialDelayMs * backoff.multiplier ** exponent;
504
+ const capped = Math.min(backoff.maxDelayMs, raw);
505
+ const jitter = .5 + random() * .5;
506
+ return Math.round(capped * jitter);
507
+ }
508
+ //#endregion
509
+ //#region src/offline/engine.ts
510
+ function envelopeKindToOp(kind) {
511
+ return kind === "insert" ? "create" : kind;
512
+ }
513
+ function newItemId() {
514
+ const cryptoRef = globalThis.crypto;
515
+ if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
516
+ return `item-${Date.now()}-${Math.random().toString(36).slice(2)}`;
517
+ }
518
+ function isDefinitelyOffline() {
519
+ return globalThis.navigator?.onLine === false;
520
+ }
521
+ var OutboxEngine = class {
522
+ config;
523
+ /**
524
+ * Per-collection callback sets, keyed by the collection route segment
525
+ * (`object`). Keyed by `object` — NOT by the queue row's `itemId` — precisely
526
+ * so replayed rows that were REHYDRATED from IndexedDB after a reload (whose
527
+ * itemIds this session never enqueued) still route their state/conflict events
528
+ * to the reloaded collection's callbacks. A `Set` per object so N collections
529
+ * sharing one engine+object each get every event (the common case is one
530
+ * collection per object, but the shared-engine model does not forbid more).
531
+ */
532
+ listenersByObject = /* @__PURE__ */ new Map();
533
+ /** Ref count: number of collections currently attached to this engine. */
534
+ refCount = 0;
535
+ /** The durable queue, once opened. undefined while opening / if IDB absent. */
536
+ queue;
537
+ /** Resolves once the async open settles (success or degraded). */
538
+ ready;
539
+ /** True when IndexedDB was unavailable and the engine is a durable no-op. */
540
+ degraded = false;
541
+ /** Leadership handle; set once we've requested the leader lock. */
542
+ leadership;
543
+ /** True while this tab holds leadership. */
544
+ isLeader = false;
545
+ /** Unregister fn from the durable-store registry. */
546
+ unregisterResource;
547
+ /** True once dispose() ran — guards late async continuations. */
548
+ disposed = false;
549
+ /**
550
+ * Paused by an auth_required/forbidden result: the loop stops draining until
551
+ * a later enqueue (the app re-authenticated and is writing again) or an
552
+ * explicit retry wakes it. Items stay queued.
553
+ */
554
+ paused = false;
555
+ /** True while a drain pass is running, to coalesce concurrent triggers. */
556
+ draining = false;
557
+ /** A drain requested while one was in flight — run one more pass after. */
558
+ drainQueued = false;
559
+ /** Timer for the next backoff-scheduled drain, if any. */
560
+ backoffTimer;
561
+ /** The `online` event listener, so we can remove it on dispose. */
562
+ onlineListener;
563
+ constructor(config) {
564
+ this.config = config;
565
+ this.ready = this.open();
566
+ this.wireOnlineListener();
567
+ this.requestLeadership();
568
+ this.ready.then(() => {
569
+ if (!this.disposed) this.drain();
570
+ });
571
+ }
572
+ /** Open the durable queue (or mark degraded if IndexedDB is unusable). */
573
+ async open() {
574
+ if (!await probeIndexedDb$1()) {
575
+ this.degraded = true;
576
+ console.warn("[smrt-web] IndexedDB unavailable — the offline outbox is disabled; offline writes will not be durable.");
577
+ return;
578
+ }
579
+ try {
580
+ this.queue = await openDurableOutboxQueue(this.config.namespace);
581
+ this.unregisterResource = this.config.registerResource(async () => {
582
+ await this.queue?.clear();
583
+ });
584
+ if (this.disposed) {
585
+ this.queue.close();
586
+ this.queue = void 0;
587
+ this.unregisterResource?.();
588
+ this.unregisterResource = void 0;
589
+ return;
590
+ }
591
+ } catch (error) {
592
+ this.degraded = true;
593
+ console.warn("[smrt-web] failed to open the offline outbox", error);
594
+ }
595
+ }
596
+ /** Wake the drain loop immediately when connectivity returns. */
597
+ wireOnlineListener() {
598
+ const target = globalThis;
599
+ if (typeof target.addEventListener !== "function") return;
600
+ const listener = () => {
601
+ this.drain();
602
+ };
603
+ target.addEventListener("online", listener);
604
+ this.onlineListener = listener;
605
+ }
606
+ /** Request cross-tab leadership; drain whenever we hold it. */
607
+ requestLeadership() {
608
+ const lockName = `smrt-web-outbox-leader:${this.config.namespace}`;
609
+ this.leadership = acquireLeadership(lockName, () => {
610
+ this.isLeader = true;
611
+ this.drain();
612
+ }, () => {
613
+ this.isLeader = false;
614
+ });
615
+ }
616
+ /**
617
+ * Attach a collection: register its per-object callbacks and bump the ref
618
+ * count. Returns the exact callback record registered so the caller can pass
619
+ * it back to {@link unregisterCollection} for precise removal (two collections
620
+ * on the same object must each detach only their own callbacks). Registering
621
+ * by `object` is what lets rehydrated rows (reloaded from IDB) reach this
622
+ * collection's callbacks even though this session never enqueued them.
623
+ */
624
+ registerCollection(binding) {
625
+ this.refCount += 1;
626
+ const record = {
627
+ onSyncStateChange: binding.onSyncStateChange,
628
+ onConflict: binding.onConflict
629
+ };
630
+ let set = this.listenersByObject.get(binding.object);
631
+ if (!set) {
632
+ set = /* @__PURE__ */ new Set();
633
+ this.listenersByObject.set(binding.object, set);
634
+ }
635
+ set.add(record);
636
+ return record;
637
+ }
638
+ /**
639
+ * Detach a collection: remove its callback record and decrement the ref count;
640
+ * when it reaches zero, dispose the engine (release the lock, unregister from
641
+ * the durable-store registry, close IndexedDB). The durable ROWS are NOT
642
+ * cleared — they must survive to replay after a reload; only the in-memory
643
+ * engine is torn down. Returns true if it disposed.
644
+ */
645
+ async unregisterCollection(object, record) {
646
+ const set = this.listenersByObject.get(object);
647
+ if (set) {
648
+ set.delete(record);
649
+ if (set.size === 0) this.listenersByObject.delete(object);
650
+ }
651
+ this.refCount = Math.max(0, this.refCount - 1);
652
+ if (this.refCount > 0) return false;
653
+ await this.dispose();
654
+ return true;
655
+ }
656
+ /** Current ref count (test/introspection aid). */
657
+ get referenceCount() {
658
+ return this.refCount;
659
+ }
660
+ /**
661
+ * Enqueue an optimistic write into the durable queue and fire the initial
662
+ * `pending` state, then kick a drain. Resolves once the row is durably
663
+ * committed (so the caller's `wrapMutation` only reports handled after
664
+ * persistence). Replay events for this row (and for rows this session did not
665
+ * enqueue — reloaded from disk) route to the registered per-`object`
666
+ * callbacks, so a reload does not lose observability.
667
+ *
668
+ * In degraded (no-IndexedDB) mode the write is NOT durable, so this returns
669
+ * `undefined` and the capability falls through to the real fetcher instead
670
+ * of acknowledging an optimistic-only write.
671
+ */
672
+ async enqueue(request) {
673
+ await this.ready;
674
+ if (!this.queue || this.degraded) return void 0;
675
+ const itemId = newItemId();
676
+ const op = envelopeKindToOp(request.kind);
677
+ const payload = op === "delete" ? void 0 : request.data;
678
+ await this.queue.enqueue({
679
+ itemId,
680
+ object: request.object,
681
+ op,
682
+ id: request.rowId,
683
+ payload,
684
+ baseUpdatedAt: request.baseUpdatedAt
685
+ });
686
+ this.emit({
687
+ itemId,
688
+ rowId: request.rowId,
689
+ object: request.object,
690
+ state: "pending",
691
+ attempts: 0
692
+ });
693
+ this.paused = false;
694
+ this.drain();
695
+ return itemId;
696
+ }
697
+ /**
698
+ * Force a retry of a specific queued item now: clears its backoff gate and
699
+ * wakes the loop. The bridge `OutboxHandle.retry(itemId)` calls this so an app
700
+ * "retry" button can flush a backed-off or auth-paused item. A no-op for an
701
+ * item that is not (or no longer) queued.
702
+ */
703
+ async retry(itemId) {
704
+ await this.ready;
705
+ if (!this.queue) return;
706
+ const row = (await this.queue.all()).find((r) => r.itemId === itemId && r.state === "pending");
707
+ if (!row || row.seq === void 0) return;
708
+ await this.queue.markState(row.seq, { nextAttemptAt: 0 });
709
+ this.paused = false;
710
+ this.drain();
711
+ }
712
+ /**
713
+ * A read-only snapshot of the durable queue — the basis of
714
+ * `OutboxHandle.snapshot()`. Because the READ cache is NOT rehydrated after a
715
+ * reload in this slice (that's #1764's warmStart), the snapshot + the raw IDB
716
+ * store are how a test/app proves durability, not `collection.toArray()`.
717
+ */
718
+ async snapshot() {
719
+ await this.ready;
720
+ if (!this.queue) return [];
721
+ return (await this.queue.all()).map((row) => ({
722
+ itemId: row.itemId,
723
+ object: row.object,
724
+ op: row.op,
725
+ rowId: row.id,
726
+ state: row.state,
727
+ attempts: row.attempts,
728
+ nextAttemptAt: row.nextAttemptAt,
729
+ lastError: row.lastError
730
+ }));
731
+ }
732
+ /**
733
+ * Deliver a state event to every callback registered for the event's
734
+ * collection `object` (best-effort). Routing by `object` — not `itemId` —
735
+ * means a row REHYDRATED from IndexedDB after a reload still reaches the
736
+ * reloaded collection's callback even though this session never enqueued it.
737
+ */
738
+ emit(event) {
739
+ const set = this.listenersByObject.get(event.object);
740
+ if (!set) return;
741
+ for (const listener of set) try {
742
+ listener.onSyncStateChange?.(event);
743
+ } catch (error) {
744
+ console.warn("[smrt-web] onSyncStateChange callback threw", error);
745
+ }
746
+ }
747
+ /** Deliver a conflict to every callback registered for its collection. */
748
+ emitConflict(conflict) {
749
+ const set = this.listenersByObject.get(conflict.object);
750
+ if (!set) return;
751
+ for (const listener of set) try {
752
+ listener.onConflict?.(conflict);
753
+ } catch (error) {
754
+ console.warn("[smrt-web] onConflict callback threw", error);
755
+ }
756
+ }
757
+ /**
758
+ * The replay loop. Gated on: (a) holding leadership, (b) not paused by an
759
+ * auth failure, (c) `navigator.onLine !== false`, (d) IndexedDB usable. Drains
760
+ * all rows due now (`nextAttemptAt <= now`), oldest-first, chunked into
761
+ * batches of ≤1000 per POST, one POST at a time to preserve FIFO across
762
+ * chunks. Concurrency-coalesced: a drain requested while one runs sets a flag
763
+ * to run exactly one more pass, so overlapping triggers never interleave.
764
+ */
765
+ async drain() {
766
+ if (this.draining) {
767
+ this.drainQueued = true;
768
+ return;
769
+ }
770
+ this.draining = true;
771
+ try {
772
+ for (;;) {
773
+ this.drainQueued = false;
774
+ await this.drainOnce();
775
+ if (!this.drainQueued) break;
776
+ }
777
+ } finally {
778
+ this.draining = false;
779
+ }
780
+ }
781
+ /** One drain pass: send every currently-due batch, then schedule backoff. */
782
+ async drainOnce() {
783
+ if (this.disposed) return;
784
+ if (!this.isLeader) return;
785
+ if (this.paused) return;
786
+ if (this.degraded || !this.queue) return;
787
+ if (isDefinitelyOffline()) return;
788
+ const pending = (await this.queue.all()).filter((row) => row.state === "pending");
789
+ if (pending.length === 0) return;
790
+ const now = Date.now();
791
+ const firstBlocked = pending.findIndex((row) => row.nextAttemptAt > now);
792
+ const due = firstBlocked === -1 ? pending : pending.slice(0, firstBlocked);
793
+ if (due.length === 0) {
794
+ await this.scheduleNextBackoff();
795
+ return;
796
+ }
797
+ for (let i = 0; i < due.length; i += MAX_SYNC_APPLY_BATCH_SIZE) {
798
+ if (this.disposed || this.paused || !this.isLeader) break;
799
+ const chunk = due.slice(i, i + MAX_SYNC_APPLY_BATCH_SIZE);
800
+ if (!await this.sendBatch(chunk)) break;
801
+ }
802
+ await this.scheduleNextBackoff();
803
+ }
804
+ /**
805
+ * Send one chunk through `POST {basePath}/sync/apply` and map results back
806
+ * onto durable transitions. On a network reject / non-200 / lost/mismatched
807
+ * response the WHOLE chunk stays `pending` (blind replay is safe) — every row
808
+ * goes back to `pending` with an incremented attempt + backoff so the loop
809
+ * doesn't hot-spin. Returns false when a retryable row remains pending, which
810
+ * stops this drain pass so newer FIFO chunks do not overtake it.
811
+ */
812
+ async sendBatch(chunk) {
813
+ for (const row of chunk) this.emit({
814
+ itemId: row.itemId,
815
+ rowId: row.id,
816
+ object: row.object,
817
+ state: "uploading",
818
+ attempts: row.attempts
819
+ });
820
+ const items = chunk.map((row) => ({
821
+ itemId: row.itemId,
822
+ object: row.object,
823
+ op: row.op,
824
+ id: row.id,
825
+ payload: row.payload,
826
+ baseUpdatedAt: row.baseUpdatedAt
827
+ }));
828
+ let results;
829
+ try {
830
+ results = await this.postBatch(items);
831
+ } catch {
832
+ await this.requeueBatch(chunk, "network error during sync");
833
+ return false;
834
+ }
835
+ if (!results) {
836
+ await this.requeueBatch(chunk, "unexpected sync response shape");
837
+ return false;
838
+ }
839
+ let drained = true;
840
+ for (let i = 0; i < chunk.length; i += 1) {
841
+ const row = chunk[i];
842
+ const result = results[i];
843
+ if (!result) {
844
+ await this.requeueRow(row, "missing result for item");
845
+ drained = false;
846
+ continue;
847
+ }
848
+ const applied = await this.applyResult(row, result);
849
+ drained = drained && applied;
850
+ }
851
+ return drained;
852
+ }
853
+ /**
854
+ * POST a batch to `{basePath}/sync/apply`. Throws on a non-2xx or a network
855
+ * error (the caller treats a throw as "response lost → keep pending"). Returns
856
+ * the positional `results` array, or `undefined` on a malformed 200 body.
857
+ */
858
+ async postBatch(items) {
859
+ const url = `${this.config.syncApplyBasePath}/${SYNC_APPLY_ROUTE_SEGMENTS.join("/")}`;
860
+ const response = await this.config.fetchFn(url, {
861
+ method: "POST",
862
+ headers: { "Content-Type": "application/json" },
863
+ body: JSON.stringify({ items })
864
+ });
865
+ if (!response.ok) throw new Error(`[smrt-web] sync/apply returned HTTP ${response.status}`);
866
+ const body = await response.json().catch(() => null);
867
+ if (!body || !Array.isArray(body.results)) return void 0;
868
+ return body.results;
869
+ }
870
+ /**
871
+ * Map one positional apply result onto a durable transition + observable
872
+ * state, per the contract's consumer notes. See the class doc's mapping table.
873
+ */
874
+ async applyResult(row, result) {
875
+ if (row.seq === void 0) return true;
876
+ if (result.status === "applied") {
877
+ await this.finishSynced(row);
878
+ return true;
879
+ }
880
+ if (result.status === "conflict") {
881
+ const reason2 = result.reason === "create_conflict" ? "create_conflict" : "stale_write";
882
+ this.emitConflict({
883
+ itemId: row.itemId,
884
+ object: row.object,
885
+ rowId: row.id,
886
+ reason: reason2,
887
+ serverUpdatedAt: result.updatedAt
888
+ });
889
+ await this.finishSynced(row);
890
+ return true;
891
+ }
892
+ const reason = result.reason;
893
+ if (reason === "auth_required" || reason === "forbidden") {
894
+ this.paused = true;
895
+ await this.queue?.markState(row.seq, {
896
+ state: "pending",
897
+ lastError: `sync ${reason}`
898
+ });
899
+ this.emit({
900
+ itemId: row.itemId,
901
+ rowId: row.id,
902
+ object: row.object,
903
+ state: "pending",
904
+ attempts: row.attempts,
905
+ error: `sync ${reason}`
906
+ });
907
+ return false;
908
+ }
909
+ if (reason === "write_failed") {
910
+ await this.requeueRow(row, "sync write_failed");
911
+ return false;
912
+ }
913
+ await this.queue?.remove(row.seq);
914
+ this.emit({
915
+ itemId: row.itemId,
916
+ rowId: row.id,
917
+ object: row.object,
918
+ state: "failed",
919
+ attempts: row.attempts,
920
+ error: reason ? `sync ${reason}` : "sync rejected"
921
+ });
922
+ return true;
923
+ }
924
+ /** Remove a successfully-applied (or conflict-resolved) row → `synced`. */
925
+ async finishSynced(row) {
926
+ if (row.seq !== void 0) await this.queue?.remove(row.seq);
927
+ this.emit({
928
+ itemId: row.itemId,
929
+ rowId: row.id,
930
+ object: row.object,
931
+ state: "synced",
932
+ attempts: row.attempts
933
+ });
934
+ }
935
+ /** Re-queue every row of a failed batch (network path) with backoff. */
936
+ async requeueBatch(chunk, error) {
937
+ for (const row of chunk) await this.requeueRow(row, error);
938
+ }
939
+ /** Re-queue one row: attempts++, backoff gate, `pending` event. */
940
+ async requeueRow(row, error) {
941
+ if (row.seq === void 0) return;
942
+ const attempts = row.attempts + 1;
943
+ const delay = computeBackoffDelay(attempts, this.config.backoff, this.config.random);
944
+ const nextAttemptAt = Date.now() + delay;
945
+ await this.queue?.markState(row.seq, {
946
+ state: "pending",
947
+ attempts,
948
+ nextAttemptAt,
949
+ lastError: error
950
+ });
951
+ this.emit({
952
+ itemId: row.itemId,
953
+ rowId: row.id,
954
+ object: row.object,
955
+ state: "pending",
956
+ attempts,
957
+ error
958
+ });
959
+ }
960
+ /**
961
+ * Schedule the next drain for the soonest backed-off row's `nextAttemptAt`.
962
+ * Only one timer is ever pending; a sooner schedule replaces a later one.
963
+ */
964
+ async scheduleNextBackoff() {
965
+ if (this.disposed || this.paused || !this.queue) return;
966
+ const firstPending = (await this.queue.all()).find((r) => r.state === "pending");
967
+ if (!firstPending) return;
968
+ const now = Date.now();
969
+ const delay = Math.max(0, firstPending.nextAttemptAt - now);
970
+ if (this.backoffTimer) clearTimeout(this.backoffTimer);
971
+ const timers = globalThis;
972
+ if (typeof timers.setTimeout !== "function") return;
973
+ this.backoffTimer = timers.setTimeout(() => {
974
+ this.backoffTimer = void 0;
975
+ this.drain();
976
+ }, delay);
977
+ this.backoffTimer.unref?.();
978
+ }
979
+ /**
980
+ * Tear down the in-memory engine: release leadership, remove the online
981
+ * listener, clear timers, unregister from the durable-store registry, and
982
+ * close IndexedDB. Does NOT clear the durable rows — they must survive to
983
+ * replay on the next load.
984
+ */
985
+ async dispose() {
986
+ if (this.disposed) return;
987
+ this.disposed = true;
988
+ if (this.backoffTimer) {
989
+ clearTimeout(this.backoffTimer);
990
+ this.backoffTimer = void 0;
991
+ }
992
+ const target = globalThis;
993
+ if (this.onlineListener && typeof target.removeEventListener === "function") {
994
+ target.removeEventListener("online", this.onlineListener);
995
+ this.onlineListener = void 0;
996
+ }
997
+ this.leadership?.();
998
+ this.leadership = void 0;
999
+ this.unregisterResource?.();
1000
+ this.unregisterResource = void 0;
1001
+ await this.ready.catch(() => void 0);
1002
+ this.queue?.close();
1003
+ this.queue = void 0;
1004
+ this.listenersByObject.clear();
1005
+ }
1006
+ };
1007
+ var engines = /* @__PURE__ */ new Map();
1008
+ function getOrCreateOutboxEngine(config) {
1009
+ let engine = engines.get(config.namespace);
1010
+ if (!engine) {
1011
+ engine = new OutboxEngine(config);
1012
+ engines.set(config.namespace, engine);
1013
+ }
1014
+ return engine;
1015
+ }
1016
+ function acquireOutboxEngine(config, binding) {
1017
+ const engine = getOrCreateOutboxEngine(config);
1018
+ return {
1019
+ engine,
1020
+ record: engine.registerCollection(binding)
1021
+ };
1022
+ }
1023
+ async function releaseOutboxEngine(namespace, engine, object, record) {
1024
+ if (await engine.unregisterCollection(object, record) && engines.get(namespace) === engine) engines.delete(namespace);
1025
+ }
1026
+ //#endregion
1027
+ //#region src/offline.ts
1028
+ function resolveBackoff(backoff) {
1029
+ return {
1030
+ initialDelayMs: backoff?.initialDelayMs ?? DEFAULT_BACKOFF.initialDelayMs,
1031
+ multiplier: backoff?.multiplier ?? DEFAULT_BACKOFF.multiplier,
1032
+ maxDelayMs: backoff?.maxDelayMs ?? DEFAULT_BACKOFF.maxDelayMs
1033
+ };
1034
+ }
1035
+ var handlesByNamespace = /* @__PURE__ */ new Map();
1036
+ function getPayloadUpdatedAt(data) {
1037
+ const value = data.updatedAt ?? data.updated_at;
1038
+ if (typeof value === "string") return value;
1039
+ if (value instanceof Date) return value.toISOString();
1040
+ }
1041
+ function offlineOutbox(config) {
1042
+ const namespace = durableStoreNamespace(config.namespace);
1043
+ const syncApplyBasePath = config.syncApplyBasePath ?? "/api/v1";
1044
+ const fetchFn = config.fetchFn ?? ((...args) => globalThis.fetch(...args));
1045
+ const backoff = resolveBackoff(config.backoff);
1046
+ const object = config.object.name;
1047
+ let engine;
1048
+ let record;
1049
+ return {
1050
+ name: "offline-outbox",
1051
+ onAttach() {
1052
+ const acquired = acquireOutboxEngine({
1053
+ namespace,
1054
+ syncApplyBasePath,
1055
+ fetchFn,
1056
+ backoff,
1057
+ random: config.random,
1058
+ registerResource: (clear) => registerDurableResource(namespace, {
1059
+ kind: "outbox",
1060
+ clear
1061
+ })
1062
+ }, {
1063
+ object,
1064
+ onSyncStateChange: config.onSyncStateChange,
1065
+ onConflict: config.onConflict
1066
+ });
1067
+ engine = acquired.engine;
1068
+ record = acquired.record;
1069
+ handlesByNamespace.set(namespace, engine);
1070
+ },
1071
+ async wrapMutation(envelope) {
1072
+ if (!engine) return { handled: false };
1073
+ if (!await engine.enqueue({
1074
+ kind: envelope.kind,
1075
+ object,
1076
+ rowId: envelope.key,
1077
+ data: envelope.data,
1078
+ baseUpdatedAt: envelope.baseUpdatedAt ?? getPayloadUpdatedAt(envelope.data)
1079
+ })) return { handled: false };
1080
+ return {
1081
+ handled: true,
1082
+ result: envelope.data
1083
+ };
1084
+ },
1085
+ async teardown() {
1086
+ if (!engine || !record) return;
1087
+ const current = engine;
1088
+ const currentRecord = record;
1089
+ engine = void 0;
1090
+ record = void 0;
1091
+ const before = current.referenceCount;
1092
+ await releaseOutboxEngine(namespace, current, object, currentRecord);
1093
+ if (before <= 1 && handlesByNamespace.get(namespace) === current) handlesByNamespace.delete(namespace);
1094
+ }
1095
+ };
1096
+ }
1097
+ function getOutboxHandle(namespace) {
1098
+ const engine = handlesByNamespace.get(namespace);
1099
+ if (!engine) return void 0;
1100
+ return {
1101
+ snapshot: () => engine.snapshot(),
1102
+ retry: (itemId) => engine.retry(itemId)
1103
+ };
1104
+ }
1105
+ //#endregion
1106
+ //#region src/persistence/snapshot-store.ts
1107
+ var SNAPSHOT_STORE = "snapshots";
1108
+ var SNAPSHOT_DB_SUFFIX = "::snapshots";
1109
+ function promisifyRequest$1(request) {
1110
+ return new Promise((resolve, reject) => {
1111
+ request.onsuccess = () => resolve(request.result);
1112
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB request failed"));
1113
+ });
1114
+ }
1115
+ function awaitTransaction$1(tx) {
1116
+ return new Promise((resolve, reject) => {
1117
+ tx.oncomplete = () => resolve();
1118
+ tx.onerror = () => reject(tx.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB transaction failed"));
1119
+ tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB transaction aborted"));
1120
+ });
1121
+ }
1122
+ async function probeIndexedDb() {
1123
+ const idb = globalThis.indexedDB;
1124
+ if (!idb) return false;
1125
+ const probeName = "__smrt_web_snapshot_probe__";
1126
+ try {
1127
+ (await new Promise((resolve, reject) => {
1128
+ const request = idb.open(probeName, 1);
1129
+ request.onsuccess = () => resolve(request.result);
1130
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("probe failed"));
1131
+ request.onblocked = () => reject(/* @__PURE__ */ new Error("probe blocked"));
1132
+ })).close();
1133
+ try {
1134
+ idb.deleteDatabase(probeName);
1135
+ } catch {}
1136
+ return true;
1137
+ } catch {
1138
+ return false;
1139
+ }
1140
+ }
1141
+ var SnapshotStore = class {
1142
+ db;
1143
+ /** The IndexedDB database name (== the durable-store namespace). */
1144
+ dbName;
1145
+ constructor(db, dbName) {
1146
+ this.db = db;
1147
+ this.dbName = dbName;
1148
+ }
1149
+ /**
1150
+ * Read the persisted rows for `collection`, or `undefined` if none were ever
1151
+ * saved (or the record is malformed). `undefined` is the warm-start "nothing
1152
+ * on disk" signal — the engine then fetches fresh.
1153
+ */
1154
+ async load(collection) {
1155
+ const tx = this.db.transaction(SNAPSHOT_STORE, "readonly");
1156
+ const record = await promisifyRequest$1(tx.objectStore(SNAPSHOT_STORE).get(collection));
1157
+ await awaitTransaction$1(tx);
1158
+ if (!record || !Array.isArray(record.rows)) return void 0;
1159
+ return record.rows;
1160
+ }
1161
+ /**
1162
+ * Write (replacing) the snapshot for `collection`. Resolves once the write is
1163
+ * durably committed. A single blob per collection — the whole current row set,
1164
+ * not a delta — so a restore is one read with no reconciliation.
1165
+ */
1166
+ async save(collection, rows) {
1167
+ const tx = this.db.transaction(SNAPSHOT_STORE, "readwrite");
1168
+ const store = tx.objectStore(SNAPSHOT_STORE);
1169
+ const record = {
1170
+ collection,
1171
+ rows
1172
+ };
1173
+ await promisifyRequest$1(store.put(record));
1174
+ await awaitTransaction$1(tx);
1175
+ }
1176
+ /**
1177
+ * Drop the snapshot for a single `collection` (its capability's own teardown
1178
+ * does NOT clear — the persisted rows must survive for the next load; this is
1179
+ * only for an explicit targeted purge). Kept for completeness / tests.
1180
+ */
1181
+ async remove(collection) {
1182
+ const tx = this.db.transaction(SNAPSHOT_STORE, "readwrite");
1183
+ await promisifyRequest$1(tx.objectStore(SNAPSHOT_STORE).delete(collection));
1184
+ await awaitTransaction$1(tx);
1185
+ }
1186
+ /**
1187
+ * Drop EVERY snapshot — the durable-store `clear()` for `wipeDurableStore`.
1188
+ * Empties the store but keeps the database so a subsequent save still works.
1189
+ */
1190
+ async clear() {
1191
+ const tx = this.db.transaction(SNAPSHOT_STORE, "readwrite");
1192
+ await promisifyRequest$1(tx.objectStore(SNAPSHOT_STORE).clear());
1193
+ await awaitTransaction$1(tx);
1194
+ }
1195
+ /** Close the underlying database handle (called on the last detach). */
1196
+ close() {
1197
+ this.db.close();
1198
+ }
1199
+ };
1200
+ function openSnapshotStore(namespace) {
1201
+ const idb = globalThis.indexedDB;
1202
+ if (!idb) return Promise.reject(/* @__PURE__ */ new Error("[smrt-web] IndexedDB is unavailable in this environment"));
1203
+ const dbName = `${namespace}${SNAPSHOT_DB_SUFFIX}`;
1204
+ return new Promise((resolve, reject) => {
1205
+ const request = idb.open(dbName, 1);
1206
+ request.onupgradeneeded = () => {
1207
+ const db = request.result;
1208
+ if (!db.objectStoreNames.contains("snapshots")) db.createObjectStore(SNAPSHOT_STORE, { keyPath: "collection" });
1209
+ };
1210
+ request.onsuccess = () => resolve(new SnapshotStore(request.result, dbName));
1211
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error(`[smrt-web] failed to open snapshot database "${dbName}"`));
1212
+ request.onblocked = () => reject(/* @__PURE__ */ new Error(`[smrt-web] opening snapshot database "${dbName}" was blocked`));
1213
+ });
1214
+ }
1215
+ //#endregion
1216
+ //#region src/persistence.ts
1217
+ var DEFAULT_PERSIST_DEBOUNCE_MS = 250;
1218
+ var enginesByNamespace = /* @__PURE__ */ new Map();
1219
+ var warnedNoIndexedDb = false;
1220
+ function warnNoIndexedDbOnce() {
1221
+ if (warnedNoIndexedDb) return;
1222
+ warnedNoIndexedDb = true;
1223
+ console.warn("[smrt-web] IndexedDB is unavailable; persistence is disabled (collections behave as non-persistent).");
1224
+ }
1225
+ function acquireSnapshotEngine(namespace) {
1226
+ const existing = enginesByNamespace.get(namespace);
1227
+ if (existing) {
1228
+ existing.refCount += 1;
1229
+ return existing;
1230
+ }
1231
+ const engine = {
1232
+ store: void 0,
1233
+ refCount: 1,
1234
+ unregister: void 0,
1235
+ ready: Promise.resolve(void 0)
1236
+ };
1237
+ engine.ready = (async () => {
1238
+ if (!await probeIndexedDb()) {
1239
+ warnNoIndexedDbOnce();
1240
+ return;
1241
+ }
1242
+ try {
1243
+ const store = await openSnapshotStore(namespace);
1244
+ engine.store = store;
1245
+ engine.unregister = registerDurableResource(namespace, {
1246
+ kind: "persisted-collection",
1247
+ clear: () => store.clear()
1248
+ });
1249
+ return store;
1250
+ } catch {
1251
+ warnNoIndexedDbOnce();
1252
+ return;
1253
+ }
1254
+ })();
1255
+ enginesByNamespace.set(namespace, engine);
1256
+ return engine;
1257
+ }
1258
+ async function releaseSnapshotEngine(namespace) {
1259
+ const engine = enginesByNamespace.get(namespace);
1260
+ if (!engine) return;
1261
+ engine.refCount -= 1;
1262
+ if (engine.refCount > 0) return;
1263
+ enginesByNamespace.delete(namespace);
1264
+ await engine.ready;
1265
+ engine.unregister?.();
1266
+ engine.unregister = void 0;
1267
+ engine.store?.close();
1268
+ engine.store = void 0;
1269
+ }
1270
+ function persistCollection(config) {
1271
+ const namespace = durableStoreNamespace(config.namespace);
1272
+ const collectionName = config.collection;
1273
+ const debounceMs = config.debounceMs ?? 250;
1274
+ let engine;
1275
+ let subscription;
1276
+ let debounceTimer;
1277
+ let readSnapshot;
1278
+ let detached = false;
1279
+ let flushing;
1280
+ let dirty = false;
1281
+ const doFlush = async () => {
1282
+ while (dirty && !detached) {
1283
+ dirty = false;
1284
+ if (!engine || !readSnapshot) return;
1285
+ const store = await engine.ready;
1286
+ if (detached || !store) return;
1287
+ const rows = readSnapshot().map((row) => ({ ...row }));
1288
+ try {
1289
+ await store.save(collectionName, rows);
1290
+ } catch {}
1291
+ }
1292
+ };
1293
+ const runFlush = () => {
1294
+ dirty = true;
1295
+ if (flushing) return;
1296
+ flushing = doFlush().finally(() => {
1297
+ flushing = void 0;
1298
+ if (dirty && !detached) runFlush();
1299
+ });
1300
+ };
1301
+ const scheduleFlush = () => {
1302
+ if (detached) return;
1303
+ if (debounceTimer) clearTimeout(debounceTimer);
1304
+ debounceTimer = setTimeout(() => {
1305
+ debounceTimer = void 0;
1306
+ runFlush();
1307
+ }, Math.max(0, debounceMs));
1308
+ debounceTimer.unref?.();
1309
+ };
1310
+ return {
1311
+ name: "persistence",
1312
+ async warmStart(ctx) {
1313
+ const acquired = acquireSnapshotEngine(namespace);
1314
+ engine = acquired;
1315
+ readSnapshot = ctx.snapshot ? () => ctx.snapshot?.() ?? [] : void 0;
1316
+ const store = await acquired.ready;
1317
+ if (!store) return void 0;
1318
+ const rows = await store.load(collectionName);
1319
+ if (!rows || rows.length === 0) return void 0;
1320
+ return rows;
1321
+ },
1322
+ onAttach(ctx) {
1323
+ if (!engine) engine = acquireSnapshotEngine(namespace);
1324
+ if (!readSnapshot && ctx.snapshot) readSnapshot = () => ctx.snapshot?.() ?? [];
1325
+ if (!ctx.snapshot || !ctx.subscribe || !readSnapshot) return;
1326
+ subscription = ctx.subscribe(() => scheduleFlush());
1327
+ scheduleFlush();
1328
+ },
1329
+ async teardown() {
1330
+ detached = true;
1331
+ if (debounceTimer) {
1332
+ clearTimeout(debounceTimer);
1333
+ debounceTimer = void 0;
1334
+ }
1335
+ subscription?.unsubscribe();
1336
+ subscription = void 0;
1337
+ readSnapshot = void 0;
1338
+ if (flushing) await flushing;
1339
+ const current = engine;
1340
+ engine = void 0;
1341
+ if (current) await releaseSnapshotEngine(namespace);
1342
+ }
1343
+ };
1344
+ }
1345
+ //#endregion
1346
+ //#region src/sse-client.ts
1347
+ var EVENT_SOURCE_CLOSED = 2;
1348
+ function defaultEventSourceFactory(url, init) {
1349
+ const EventSourceCtor = globalThis.EventSource;
1350
+ if (typeof EventSourceCtor !== "function") return void 0;
1351
+ return new EventSourceCtor(url, init);
1352
+ }
1353
+ function createSmrtWebEventSubscriber(config) {
1354
+ const { eventsUrl, changesUrl, fetchFn = (...args) => globalThis.fetch(...args), eventSourceFactory = defaultEventSourceFactory, pollIntervalMs = 5e3, withCredentials = true, manifestHash, updateState } = config;
1355
+ const tableInvalidators = /* @__PURE__ */ new Map();
1356
+ let lastSeq = null;
1357
+ let transport = "idle";
1358
+ let eventSource = null;
1359
+ let pollTimer = null;
1360
+ let closed = false;
1361
+ const registeredTables = () => [...tableInvalidators.keys()].sort((a, b) => a.localeCompare(b));
1362
+ const buildChangesUrl = (since, tables) => {
1363
+ const url = new URL(changesUrl, "http://smrt.local/");
1364
+ url.searchParams.set("since", String(since));
1365
+ url.searchParams.set("tables", tables.join(","));
1366
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(changesUrl)) return url.href;
1367
+ const pathQueryHash = `${url.pathname}${url.search}${url.hash}`;
1368
+ if (changesUrl.startsWith("//")) return `//${url.host}${pathQueryHash}`;
1369
+ if (changesUrl.startsWith("/")) return pathQueryHash;
1370
+ return pathQueryHash.startsWith("/") ? pathQueryHash.slice(1) : pathQueryHash;
1371
+ };
1372
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
1373
+ const warn = (message, error) => {
1374
+ console.warn(`[smrt-web] live subscriber: ${message}`, error);
1375
+ };
1376
+ const fireAll = (invalidators) => {
1377
+ if (!invalidators || invalidators.size === 0) return;
1378
+ for (const invalidate of [...invalidators]) try {
1379
+ invalidate();
1380
+ } catch (error) {
1381
+ warn("an invalidator threw; ignoring", error);
1382
+ }
1383
+ };
1384
+ const invalidateTable = (table) => {
1385
+ fireAll(tableInvalidators.get(table));
1386
+ };
1387
+ const invalidateAll = () => {
1388
+ for (const invalidators of tableInvalidators.values()) fireAll(invalidators);
1389
+ };
1390
+ const advanceLastSeqFromEventId = (lastEventId) => {
1391
+ const seq = Number(lastEventId);
1392
+ if (Number.isFinite(seq)) lastSeq = seq;
1393
+ };
1394
+ const onChange = (ev) => {
1395
+ if (closed) return;
1396
+ let table;
1397
+ try {
1398
+ const parsed = JSON.parse(ev.data);
1399
+ if (typeof parsed.table === "string") table = parsed.table;
1400
+ } catch (error) {
1401
+ warn("dropping malformed change frame", error);
1402
+ return;
1403
+ }
1404
+ if (table === void 0) {
1405
+ warn("dropping change frame with no table", ev.data);
1406
+ return;
1407
+ }
1408
+ advanceLastSeqFromEventId(ev.lastEventId);
1409
+ invalidateTable(table);
1410
+ };
1411
+ const onResync = (ev) => {
1412
+ if (closed) return;
1413
+ advanceLastSeqFromEventId(ev.lastEventId);
1414
+ invalidateAll();
1415
+ };
1416
+ const onManifest = (ev) => {
1417
+ if (closed || manifestHash === void 0 || !updateState) return;
1418
+ try {
1419
+ const serverHash = JSON.parse(ev.data).manifestHash;
1420
+ if (typeof serverHash !== "string" || serverHash.length === 0) {
1421
+ warn("dropping manifest frame with no manifestHash", ev.data);
1422
+ return;
1423
+ }
1424
+ if (serverHash !== manifestHash) updateState.notifyContractUpdated();
1425
+ } catch (error) {
1426
+ warn("dropping malformed manifest frame", error);
1427
+ }
1428
+ };
1429
+ const poll = async () => {
1430
+ if (closed) return;
1431
+ const tables = registeredTables();
1432
+ if (tables.length === 0) return;
1433
+ try {
1434
+ const since = lastSeq ?? 0;
1435
+ const url = buildChangesUrl(since, tables);
1436
+ const response = await fetchFn(url, { credentials: "include" });
1437
+ if (closed) return;
1438
+ const page = await response.json();
1439
+ if (closed) return;
1440
+ if (page.resyncRequired) {
1441
+ invalidateAll();
1442
+ if (isFiniteNumber(page.resyncCursor)) lastSeq = page.resyncCursor;
1443
+ else if (isFiniteNumber(page.cursor) && page.cursor > since) lastSeq = page.cursor;
1444
+ else lastSeq = null;
1445
+ return;
1446
+ }
1447
+ for (const change of page.changes ?? []) if (typeof change.table === "string") invalidateTable(change.table);
1448
+ if (typeof page.cursor === "number") lastSeq = page.cursor;
1449
+ } catch (error) {
1450
+ warn("poll failed; will retry on the next interval", error);
1451
+ }
1452
+ };
1453
+ const startPolling = () => {
1454
+ if (closed || transport === "polling") return;
1455
+ transport = "polling";
1456
+ pollTimer = setInterval(() => {
1457
+ poll();
1458
+ }, pollIntervalMs);
1459
+ pollTimer.unref?.();
1460
+ };
1461
+ const connectSse = (source) => {
1462
+ transport = "sse";
1463
+ eventSource = source;
1464
+ source.addEventListener("change", onChange);
1465
+ source.addEventListener("resync", onResync);
1466
+ source.addEventListener("manifest", onManifest);
1467
+ source.onerror = () => {
1468
+ if (closed) return;
1469
+ if (source.readyState === EVENT_SOURCE_CLOSED) {
1470
+ try {
1471
+ source.close();
1472
+ } catch {}
1473
+ eventSource = null;
1474
+ startPolling();
1475
+ }
1476
+ };
1477
+ };
1478
+ const initialSource = eventSourceFactory(eventsUrl, { withCredentials });
1479
+ if (initialSource) connectSse(initialSource);
1480
+ else startPolling();
1481
+ return {
1482
+ get transport() {
1483
+ return transport;
1484
+ },
1485
+ registerTable(table, invalidate) {
1486
+ let set = tableInvalidators.get(table);
1487
+ if (!set) {
1488
+ set = /* @__PURE__ */ new Set();
1489
+ tableInvalidators.set(table, set);
1490
+ }
1491
+ set.add(invalidate);
1492
+ return () => {
1493
+ const current = tableInvalidators.get(table);
1494
+ if (!current) return;
1495
+ current.delete(invalidate);
1496
+ if (current.size === 0) tableInvalidators.delete(table);
1497
+ };
1498
+ },
1499
+ invalidateAll,
1500
+ close() {
1501
+ if (closed) return;
1502
+ closed = true;
1503
+ if (eventSource) {
1504
+ try {
1505
+ eventSource.close();
1506
+ } catch {}
1507
+ eventSource = null;
1508
+ }
1509
+ if (pollTimer) {
1510
+ clearInterval(pollTimer);
1511
+ pollTimer = null;
1512
+ }
1513
+ tableInvalidators.clear();
1514
+ transport = "idle";
1515
+ }
1516
+ };
1517
+ }
1518
+ function liveInvalidation(config) {
1519
+ const { subscriber, tableName } = config;
1520
+ let unregister;
1521
+ return {
1522
+ name: "live-invalidation",
1523
+ onAttach(ctx) {
1524
+ unregister = subscriber.registerTable(tableName, () => ctx.invalidate());
1525
+ },
1526
+ teardown() {
1527
+ unregister?.();
1528
+ unregister = void 0;
1529
+ }
1530
+ };
1531
+ }
1532
+ //#endregion
1533
+ //#region src/update-state/meta-store.ts
1534
+ var META_STORE = "meta";
1535
+ var META_DB_SUFFIX = "::meta";
1536
+ var LAST_SEEN_MANIFEST_HASH_KEY = "lastSeenManifestHash";
1537
+ function promisifyRequest(request) {
1538
+ return new Promise((resolve, reject) => {
1539
+ request.onsuccess = () => resolve(request.result);
1540
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB request failed"));
1541
+ });
1542
+ }
1543
+ function awaitTransaction(tx) {
1544
+ return new Promise((resolve, reject) => {
1545
+ tx.oncomplete = () => resolve();
1546
+ tx.onerror = () => reject(tx.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB transaction failed"));
1547
+ tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("[smrt-web] IndexedDB transaction aborted"));
1548
+ });
1549
+ }
1550
+ var VersionMetaStore = class {
1551
+ db;
1552
+ /** The IndexedDB database name (== the durable-store namespace). */
1553
+ dbName;
1554
+ constructor(db, dbName) {
1555
+ this.db = db;
1556
+ this.dbName = dbName;
1557
+ }
1558
+ /** Read the value for `key`, or `undefined` if unset / malformed. */
1559
+ async get(key) {
1560
+ const tx = this.db.transaction(META_STORE, "readonly");
1561
+ const record = await promisifyRequest(tx.objectStore(META_STORE).get(key));
1562
+ await awaitTransaction(tx);
1563
+ return record && typeof record.value === "string" ? record.value : void 0;
1564
+ }
1565
+ /** Write (replacing) the value for `key`; resolves once durably committed. */
1566
+ async set(key, value) {
1567
+ const tx = this.db.transaction(META_STORE, "readwrite");
1568
+ const record = {
1569
+ key,
1570
+ value
1571
+ };
1572
+ await promisifyRequest(tx.objectStore(META_STORE).put(record));
1573
+ await awaitTransaction(tx);
1574
+ }
1575
+ /**
1576
+ * Drop EVERY meta record — the durable-store `clear()` for
1577
+ * {@link wipeDurableStore}, so a logout also clears the last-seen manifest
1578
+ * hash (the AC "wipe clears the last-seen-hash record").
1579
+ */
1580
+ async clear() {
1581
+ const tx = this.db.transaction(META_STORE, "readwrite");
1582
+ await promisifyRequest(tx.objectStore(META_STORE).clear());
1583
+ await awaitTransaction(tx);
1584
+ }
1585
+ /** Close the underlying database handle. */
1586
+ close() {
1587
+ this.db.close();
1588
+ }
1589
+ };
1590
+ function openVersionMetaStore(namespace) {
1591
+ const idb = globalThis.indexedDB;
1592
+ if (!idb) return Promise.reject(/* @__PURE__ */ new Error("[smrt-web] IndexedDB is unavailable in this environment"));
1593
+ const dbName = `${namespace}${META_DB_SUFFIX}`;
1594
+ return new Promise((resolve, reject) => {
1595
+ const request = idb.open(dbName, 1);
1596
+ request.onupgradeneeded = () => {
1597
+ const db = request.result;
1598
+ if (!db.objectStoreNames.contains("meta")) db.createObjectStore(META_STORE, { keyPath: "key" });
1599
+ };
1600
+ request.onsuccess = () => resolve(new VersionMetaStore(request.result, dbName));
1601
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error(`[smrt-web] failed to open meta database "${dbName}"`));
1602
+ request.onblocked = () => reject(/* @__PURE__ */ new Error(`[smrt-web] opening meta database "${dbName}" was blocked`));
1603
+ });
1604
+ }
1605
+ //#endregion
1606
+ //#region src/update-state.ts
1607
+ function createUpdateState(config) {
1608
+ const namespace = durableStoreNamespace(config.namespace);
1609
+ let bundle = false;
1610
+ let contract = false;
1611
+ const subscribers = /* @__PURE__ */ new Set();
1612
+ let metaStore;
1613
+ let unregister;
1614
+ let disposed = false;
1615
+ const snapshot = () => ({
1616
+ bundle,
1617
+ contract,
1618
+ updateAvailable: bundle || contract
1619
+ });
1620
+ const notify = () => {
1621
+ const state = snapshot();
1622
+ for (const callback of [...subscribers]) try {
1623
+ callback(state);
1624
+ } catch (error) {
1625
+ console.warn("[smrt-web] updateAvailable subscriber threw", error);
1626
+ }
1627
+ };
1628
+ const setBundle = () => {
1629
+ if (bundle) return;
1630
+ bundle = true;
1631
+ notify();
1632
+ };
1633
+ const setContract = () => {
1634
+ if (contract) return;
1635
+ contract = true;
1636
+ notify();
1637
+ };
1638
+ return {
1639
+ get: snapshot,
1640
+ subscribe(callback) {
1641
+ subscribers.add(callback);
1642
+ try {
1643
+ callback(snapshot());
1644
+ } catch (error) {
1645
+ console.warn("[smrt-web] updateAvailable subscriber threw", error);
1646
+ }
1647
+ return () => {
1648
+ subscribers.delete(callback);
1649
+ };
1650
+ },
1651
+ notifyBundleUpdated: setBundle,
1652
+ notifyContractUpdated: setContract,
1653
+ ready: (async () => {
1654
+ const runningHash = config.manifestHash;
1655
+ if (runningHash === void 0) return;
1656
+ let store;
1657
+ try {
1658
+ store = await openVersionMetaStore(namespace);
1659
+ } catch {
1660
+ return;
1661
+ }
1662
+ if (disposed) {
1663
+ store.close();
1664
+ return;
1665
+ }
1666
+ metaStore = store;
1667
+ unregister = registerDurableResource(namespace, {
1668
+ kind: "persisted-collection",
1669
+ clear: () => store.clear()
1670
+ });
1671
+ let lastSeen;
1672
+ try {
1673
+ lastSeen = await store.get(LAST_SEEN_MANIFEST_HASH_KEY);
1674
+ } catch {
1675
+ lastSeen = void 0;
1676
+ }
1677
+ if (disposed) return;
1678
+ if (lastSeen !== void 0 && lastSeen !== runningHash) setContract();
1679
+ if (lastSeen !== runningHash) try {
1680
+ await store.set(LAST_SEEN_MANIFEST_HASH_KEY, runningHash);
1681
+ } catch {}
1682
+ })(),
1683
+ dispose() {
1684
+ disposed = true;
1685
+ unregister?.();
1686
+ unregister = void 0;
1687
+ metaStore?.close();
1688
+ metaStore = void 0;
1689
+ subscribers.clear();
1690
+ }
1691
+ };
1692
+ }
1693
+ //#endregion
1694
+ //#region src/webmcp.ts
1695
+ function getModelContext() {
1696
+ const mc = globalThis.document?.modelContext;
1697
+ if (mc && typeof mc.registerTool === "function") return mc;
1698
+ }
1699
+ function registerWebMcpTools(definitions, options = {}) {
1700
+ const ctx = getModelContext();
1701
+ if (!ctx) return () => {};
1702
+ const basePath = options.basePath ?? "/api/v1";
1703
+ const controller = new AbortController();
1704
+ const collections = /* @__PURE__ */ new Map();
1705
+ for (const definition of definitions) {
1706
+ const descriptors = definition.toolDescriptors;
1707
+ if (!descriptors || descriptors.length === 0) continue;
1708
+ const fetchers = options.resolveFetchers ? options.resolveFetchers(definition) : createDefinitionFetchers(definition, basePath, options.fetchFn);
1709
+ const collection = createSmrtCollection(definition, {
1710
+ fetchers,
1711
+ basePath,
1712
+ fetchFn: options.fetchFn,
1713
+ ...options.client ? { client: options.client } : {},
1714
+ ...options.scope ? { scope: options.scope } : {}
1715
+ });
1716
+ collections.set(definition, collection);
1717
+ for (const descriptor of descriptors) {
1718
+ if (options.filter && !options.filter(definition, descriptor)) continue;
1719
+ ctx.registerTool({
1720
+ name: descriptor.name,
1721
+ description: descriptor.description,
1722
+ inputSchema: descriptor.inputSchema,
1723
+ annotations: { readOnlyHint: descriptor.readOnly },
1724
+ execute: (args) => dispatch(fetchers, collection, definition, descriptor.action, descriptor.route, args ?? {})
1725
+ }, { signal: controller.signal });
1726
+ }
1727
+ }
1728
+ return () => {
1729
+ controller.abort();
1730
+ for (const collection of collections.values()) collection.cleanup().catch(() => void 0);
1731
+ };
1732
+ }
1733
+ function requireId(args, action) {
1734
+ const id = args.id;
1735
+ if (typeof id !== "string" || id.length === 0) throw new Error(`WebMCP ${action} requires a string 'id' argument`);
1736
+ return id;
1737
+ }
1738
+ function requireIdentifier(args) {
1739
+ const value = args.id ?? args.slug;
1740
+ if (typeof value !== "string" || value.length === 0) throw new Error("WebMCP get requires a string 'id' or 'slug' argument");
1741
+ return value;
1742
+ }
1743
+ function listParams(args) {
1744
+ const params = {};
1745
+ if (args.limit !== void 0) params.limit = args.limit;
1746
+ if (args.offset !== void 0) params.offset = args.offset;
1747
+ if (args.orderBy !== void 0) params.orderBy = args.orderBy;
1748
+ if (args.where !== void 0) params.where = args.where;
1749
+ return params;
1750
+ }
1751
+ async function dispatch(fetchers, collection, definition, action, route, args) {
1752
+ switch (action) {
1753
+ case "list": {
1754
+ const rows = unwrapListResult(await fetchers.list(listParams(args)), definition.name);
1755
+ return JSON.stringify(rows);
1756
+ }
1757
+ case "get": {
1758
+ if (!fetchers.get) throw new Error(`${definition.name} has no get action`);
1759
+ const row = unwrapItemResult(await fetchers.get(requireIdentifier(args)), `get(${definition.name})`);
1760
+ return JSON.stringify(row);
1761
+ }
1762
+ case "create": {
1763
+ const localId = newLocalId();
1764
+ const row = unwrapItemResult(await settleTransaction(collection.insert({
1765
+ ...args,
1766
+ id: localId
1767
+ }), collection, localId, args), `create(${definition.name})`);
1768
+ return JSON.stringify(row);
1769
+ }
1770
+ case "update": {
1771
+ if (!fetchers.update) throw new Error(`${definition.name} has no update action`);
1772
+ const { id: _id, ...body } = args;
1773
+ await hydrateMutationTarget(fetchers, collection, definition, requireId(args, "update"));
1774
+ const row = unwrapItemResult(await settleTransaction(collection.update(requireId(args, "update"), body), collection, requireId(args, "update"), body), `update(${definition.name})`);
1775
+ return JSON.stringify(row);
1776
+ }
1777
+ case "delete": {
1778
+ if (!fetchers.delete) throw new Error(`${definition.name} has no delete action`);
1779
+ const id = requireId(args, "delete");
1780
+ await hydrateMutationTarget(fetchers, collection, definition, id);
1781
+ await settleTransaction(collection.delete(id), collection, id, {});
1782
+ return JSON.stringify({
1783
+ success: true,
1784
+ id
1785
+ });
1786
+ }
1787
+ default:
1788
+ if (!fetchers.custom) throw new Error(`${definition.name} has no custom action fetcher`);
1789
+ return JSON.stringify(await collection.action(action, args, route));
1790
+ }
1791
+ }
1792
+ async function settleTransaction(transaction, collection, key, fallback) {
1793
+ await transaction.isPersisted.promise;
1794
+ const persisted = persistedMutationResults.get(collection)?.get(key);
1795
+ if (persisted !== void 0) return persisted;
1796
+ return collection.get(key) ?? {
1797
+ ...fallback,
1798
+ id: key
1799
+ };
1800
+ }
1801
+ async function hydrateMutationTarget(fetchers, collection, definition, id) {
1802
+ if (!fetchers.get) throw new Error(`${definition.name} has no get action; cannot hydrate mutation target '${id}'`);
1803
+ const row = unwrapItemResult(await fetchers.get(id), `get(${definition.name})`);
1804
+ const hydrate = mutationTargetHydrators.get(collection);
1805
+ if (!hydrate) throw new Error(`${definition.name} cannot hydrate mutation target '${id}'`);
1806
+ await hydrate(row);
1807
+ }
1808
+ //#endregion
1809
+ //#region src/index.ts
1810
+ var CUSTOM_OPTIONS_QUERY_MARKER = "__smrt_options";
1811
+ var SmrtWebRequestError = class extends Error {
1812
+ payload;
1813
+ status;
1814
+ code;
1815
+ constructor(message, payload, status, code) {
1816
+ super(message);
1817
+ this.name = "SmrtWebRequestError";
1818
+ this.payload = payload;
1819
+ if (status !== void 0) this.status = status;
1820
+ if (code !== void 0) this.code = code;
1821
+ }
1822
+ };
1823
+ function getWebErrorDetail(payload) {
1824
+ if (!payload || typeof payload !== "object") return {};
1825
+ const error = payload.error;
1826
+ if (typeof error === "string") return { message: error };
1827
+ if (!error || typeof error !== "object" || Array.isArray(error)) return {};
1828
+ const failure = error;
1829
+ return {
1830
+ message: typeof failure.message === "string" ? failure.message : void 0,
1831
+ code: typeof failure.code === "string" ? failure.code : void 0
1832
+ };
1833
+ }
1834
+ function createHttpRequestError(collectionName, status, payload) {
1835
+ if (status >= 500) return new SmrtWebRequestError(`[smrt-web] ${collectionName} request failed: server error`, void 0, status);
1836
+ const detail = getWebErrorDetail(payload);
1837
+ return new SmrtWebRequestError(`[smrt-web] ${collectionName} request failed: ${detail.message ?? `HTTP ${status}`}`, payload, status, detail.code);
1838
+ }
1839
+ function unwrapListResult(result, collectionName) {
1840
+ if (Array.isArray(result)) return result;
1841
+ if (result && typeof result === "object") {
1842
+ const record = result;
1843
+ if (typeof record.error === "string") throw new SmrtWebRequestError(`[smrt-web] list(${collectionName}) failed: ${record.error}`, result);
1844
+ if (Array.isArray(record.data)) return record.data;
1845
+ }
1846
+ throw new SmrtWebRequestError(`[smrt-web] list(${collectionName}) returned an unexpected payload shape`, result);
1847
+ }
1848
+ function unwrapItemResult(result, context) {
1849
+ if (result && typeof result === "object" && !Array.isArray(result)) {
1850
+ const record = result;
1851
+ if (typeof record.error === "string") throw new SmrtWebRequestError(`[smrt-web] ${context} failed: ${record.error}`, result);
1852
+ if (record.data && typeof record.data === "object" && !Array.isArray(record.data)) return record.data;
1853
+ return record;
1854
+ }
1855
+ throw new SmrtWebRequestError(`[smrt-web] ${context} returned an unexpected payload shape`, result);
1856
+ }
1857
+ var SMRT_TO_REST_OPERATOR = {
1858
+ ">": "gt",
1859
+ ">=": "gte",
1860
+ "<": "lt",
1861
+ "<=": "lte",
1862
+ "!=": "ne",
1863
+ in: "in",
1864
+ like: "like"
1865
+ };
1866
+ function buildListQuery(params) {
1867
+ if (!params) return "";
1868
+ const search = new URLSearchParams();
1869
+ const { limit, offset, orderBy, where } = params;
1870
+ if (limit !== void 0) search.set("limit", String(limit));
1871
+ if (offset !== void 0) search.set("offset", String(offset));
1872
+ if (orderBy !== void 0) search.set("orderBy", Array.isArray(orderBy) ? orderBy.join(", ") : String(orderBy));
1873
+ if (where && typeof where === "object") {
1874
+ for (const [field, condition] of Object.entries(where)) if (condition && typeof condition === "object" && !Array.isArray(condition) && "op" in condition && "value" in condition) {
1875
+ const { op, value } = condition;
1876
+ const restOp = SMRT_TO_REST_OPERATOR[op];
1877
+ const token = Array.isArray(value) ? value.join(",") : String(value);
1878
+ search.set(restOp ? `${field}[${restOp}]` : field, token);
1879
+ } else if (condition !== void 0 && condition !== null) search.set(field, String(condition));
1880
+ }
1881
+ const qs = search.toString();
1882
+ return qs ? `?${qs}` : "";
1883
+ }
1884
+ function createDefinitionFetchers(definition, basePath = "/api/v1", fetchFn = (...args) => globalThis.fetch(...args)) {
1885
+ const collectionUrl = `${basePath}${definition.endpoint}`;
1886
+ const headers = { "Content-Type": "application/json" };
1887
+ const parse = async (response) => {
1888
+ if (!response.ok) {
1889
+ if (response.status >= 500) throw createHttpRequestError(definition.name, response.status);
1890
+ const payload = await response.json().catch(() => null);
1891
+ throw createHttpRequestError(definition.name, response.status, payload);
1892
+ }
1893
+ return response.json().catch(() => null);
1894
+ };
1895
+ return {
1896
+ list: async (params) => parse(await fetchFn(`${collectionUrl}${buildListQuery(params)}`, { headers })),
1897
+ get: async (id) => parse(await fetchFn(`${collectionUrl}/${id}`, { headers })),
1898
+ create: async (data) => parse(await fetchFn(collectionUrl, {
1899
+ method: "POST",
1900
+ headers,
1901
+ body: JSON.stringify(data)
1902
+ })),
1903
+ update: async (id, data) => parse(await fetchFn(`${collectionUrl}/${id}`, {
1904
+ method: "PUT",
1905
+ headers,
1906
+ body: JSON.stringify(data)
1907
+ })),
1908
+ delete: async (id) => {
1909
+ const response = await fetchFn(`${collectionUrl}/${id}`, {
1910
+ method: "DELETE",
1911
+ headers
1912
+ });
1913
+ if (!response.ok) {
1914
+ if (response.status >= 500) throw createHttpRequestError(definition.name, response.status);
1915
+ const payload = await response.json().catch(() => null);
1916
+ throw createHttpRequestError(definition.name, response.status, payload);
1917
+ }
1918
+ return true;
1919
+ },
1920
+ custom: async (action, args, route) => {
1921
+ const customRoute = route ?? {
1922
+ method: "POST",
1923
+ scope: typeof args.id === "string" ? "item" : "collection",
1924
+ path: [action]
1925
+ };
1926
+ const optionsBag = customRoute.optionsBag === true;
1927
+ const optionsValue = optionsBag ? args.options : void 0;
1928
+ const transportArgs = optionsBag && optionsValue !== null && typeof optionsValue === "object" ? optionsValue : args;
1929
+ const pathArgs = new Set(customRoute.path.filter((segment) => /^\[[^\]]+\]$/.test(segment)).map((segment) => segment.slice(1, -1)));
1930
+ const parameterAliases = customRoute.parameterAliases ?? {};
1931
+ const inputNameFor = (parameterName) => Object.entries(parameterAliases).find(([, originalName]) => originalName === parameterName)?.[0] ?? parameterName;
1932
+ const valueForPath = (parameterName) => {
1933
+ return args[inputNameFor(parameterName)] ?? args[parameterName];
1934
+ };
1935
+ const id = customRoute.scope === "item" ? args.id : void 0;
1936
+ if (customRoute.scope === "item" && typeof id !== "string") throw new Error(`${definition.name} custom action '${action}' requires an id`);
1937
+ for (const name of pathArgs) if (valueForPath(name) === void 0 || valueForPath(name) === null) throw new Error(`${definition.name} custom action '${action}' requires '${name}'`);
1938
+ const consumedInputs = new Set([...pathArgs].map((name) => inputNameFor(name)));
1939
+ const aliasedInputs = new Set(Object.keys(parameterAliases));
1940
+ const body = optionsBag ? optionsValue : Object.fromEntries(Object.entries(transportArgs).filter(([key]) => key !== "id" && !pathArgs.has(key) && !consumedInputs.has(key) && !aliasedInputs.has(key)));
1941
+ if (!optionsBag) {
1942
+ for (const [inputName, originalName] of Object.entries(parameterAliases)) if (!consumedInputs.has(inputName) && transportArgs[inputName] !== void 0 && originalName !== "id") body[originalName] = transportArgs[inputName];
1943
+ }
1944
+ const idAlias = Object.entries(parameterAliases).find(([, originalName]) => originalName === "id")?.[0];
1945
+ if (!optionsBag && idAlias && !consumedInputs.has(idAlias) && transportArgs[idAlias] !== void 0) body.id = transportArgs[idAlias];
1946
+ const requestPath = `${[
1947
+ collectionUrl,
1948
+ ...customRoute.scope === "item" ? [encodeURIComponent(String(id))] : [],
1949
+ ...customRoute.path.map((segment) => /^\[[^\]]+\]$/.test(segment) ? encodeURIComponent(String(valueForPath(segment.slice(1, -1)) ?? "")) : segment)
1950
+ ].join("/")}`;
1951
+ const init = {
1952
+ method: customRoute.method,
1953
+ headers
1954
+ };
1955
+ if (customRoute.method !== "GET") init.body = JSON.stringify(body);
1956
+ else {
1957
+ const queryParams = new URLSearchParams();
1958
+ if (optionsBag) if (optionsValue === void 0) queryParams.set(CUSTOM_OPTIONS_QUERY_MARKER, "undefined");
1959
+ else if (optionsValue === null) queryParams.set(CUSTOM_OPTIONS_QUERY_MARKER, "null");
1960
+ else {
1961
+ const entries = Object.entries(typeof optionsValue === "object" && !Array.isArray(optionsValue) ? optionsValue : {}).filter(([, value]) => value !== void 0 && value !== null);
1962
+ for (const [key, value] of entries) queryParams.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
1963
+ if (entries.length === 0) queryParams.set(CUSTOM_OPTIONS_QUERY_MARKER, "object");
1964
+ }
1965
+ else {
1966
+ const queryBody = body !== null && typeof body === "object" && !Array.isArray(body) ? body : {};
1967
+ for (const [key, value] of Object.entries(queryBody)) {
1968
+ if (value === void 0 || value === null) continue;
1969
+ queryParams.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
1970
+ }
1971
+ }
1972
+ const query = queryParams.toString() ? `?${queryParams.toString()}` : "";
1973
+ return parse(await fetchFn(`${requestPath}${query}`, { ...init }));
1974
+ }
1975
+ return parse(await fetchFn(requestPath, init));
1976
+ }
1977
+ };
1978
+ }
1979
+ function newLocalId() {
1980
+ const cryptoRef = globalThis.crypto;
1981
+ if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
1982
+ return `local-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1983
+ }
1984
+ function createSmrtWebClient() {
1985
+ return {
1986
+ __smrtWebClient: "SmrtWebClient",
1987
+ queryClient: new QueryClient()
1988
+ };
1989
+ }
1990
+ function resolveQueryClient(client) {
1991
+ if (!client) return new QueryClient();
1992
+ const engine = client;
1993
+ if (engine.__smrtWebClient !== "SmrtWebClient" || !engine.queryClient) throw new SmrtWebRequestError("[smrt-web] options.client must be a handle from createSmrtWebClient()");
1994
+ return engine.queryClient;
1995
+ }
1996
+ function toPlainRow(row) {
1997
+ const plain = {};
1998
+ for (const [key, value] of Object.entries(row)) if (key.charCodeAt(0) !== 36) plain[key] = value;
1999
+ return plain;
2000
+ }
2001
+ function projectChanges(changes) {
2002
+ if (!Array.isArray(changes)) return changes;
2003
+ return changes.map((change) => {
2004
+ if (!change || typeof change !== "object") return change;
2005
+ const record = change;
2006
+ const projected = { ...record };
2007
+ if (record.value && typeof record.value === "object") projected.value = toPlainRow(record.value);
2008
+ if (record.previousValue && typeof record.previousValue === "object") projected.previousValue = toPlainRow(record.previousValue);
2009
+ return projected;
2010
+ });
2011
+ }
2012
+ function getBaseUpdatedAt(row) {
2013
+ if (!row || typeof row !== "object") return void 0;
2014
+ const record = row;
2015
+ const value = record.updatedAt ?? record.updated_at;
2016
+ if (typeof value === "string") return value;
2017
+ if (value instanceof Date) return value.toISOString();
2018
+ }
2019
+ var engineCollections = /* @__PURE__ */ new WeakMap();
2020
+ function getEngineCollection(handle) {
2021
+ const engine = engineCollections.get(handle);
2022
+ if (engine === void 0) throw new SmrtWebRequestError("[smrt-web] getEngineCollection: not a smrt-web collection handle");
2023
+ return engine;
2024
+ }
2025
+ function warnCapability(capability, hook, error) {
2026
+ console.warn(`[smrt-web] capability "${capability.name}" ${hook} threw; ignoring`, error);
2027
+ }
2028
+ function createSmrtCollection(definition, options) {
2029
+ const { staleTimeMs = 3e4, retry = false, scope, initialData } = options;
2030
+ const capabilities = options.capabilities ?? [];
2031
+ const fetchers = options.fetchers ?? createDefinitionFetchers(definition, options.basePath, options.fetchFn);
2032
+ const queryClient = resolveQueryClient(options.client);
2033
+ const idField = definition.idField || "id";
2034
+ const mutationResults = /* @__PURE__ */ new Map();
2035
+ let cacheId = scope ? `smrt:${scope}:${definition.name}` : `smrt:${definition.name}`;
2036
+ let queryKey = scope ? [
2037
+ "smrt",
2038
+ scope,
2039
+ definition.name
2040
+ ] : ["smrt", definition.name];
2041
+ const invalidationTargets = /* @__PURE__ */ new Set([definition.name]);
2042
+ for (const relationship of definition.relationships ?? []) invalidationTargets.add(relationship.relatedCollection);
2043
+ const invalidateRelated = () => {
2044
+ queryClient.invalidateQueries({ predicate: (query) => {
2045
+ const key = query.queryKey;
2046
+ if (!Array.isArray(key) || key.length === 0) return false;
2047
+ const collectionSegment = key[key.length - 1];
2048
+ return typeof collectionSegment === "string" && invalidationTargets.has(collectionSegment);
2049
+ } });
2050
+ };
2051
+ const ctx = {
2052
+ definition,
2053
+ fetchers,
2054
+ get cacheKey() {
2055
+ return queryKey;
2056
+ },
2057
+ get cacheId() {
2058
+ return cacheId;
2059
+ },
2060
+ invalidate: () => invalidateRelated(),
2061
+ snapshot: () => collection.toArray.map((row) => toPlainRow(row)),
2062
+ subscribe: (callback) => {
2063
+ const subscription = collection.subscribeChanges((changes) => callback(projectChanges(changes)));
2064
+ return { unsubscribe: () => subscription.unsubscribe() };
2065
+ }
2066
+ };
2067
+ for (const capability of capabilities) {
2068
+ let extra;
2069
+ try {
2070
+ extra = capability.contributeCacheKey?.(ctx);
2071
+ } catch (error) {
2072
+ warnCapability(capability, "contributeCacheKey", error);
2073
+ }
2074
+ if (extra && extra.length > 0) {
2075
+ const name = queryKey[queryKey.length - 1];
2076
+ queryKey = [
2077
+ ...queryKey.slice(0, -1),
2078
+ ...extra,
2079
+ name
2080
+ ];
2081
+ cacheId = `${cacheId}:${extra.join(":")}`;
2082
+ }
2083
+ }
2084
+ const seedCache = (rows) => {
2085
+ queryClient.setQueryData(queryKey, (existing) => existing ?? rows);
2086
+ };
2087
+ let disposed = false;
2088
+ const warmRowsFrom = async (capability, warm) => {
2089
+ try {
2090
+ return await warm;
2091
+ } catch (error) {
2092
+ warnCapability(capability, "warmStart", error);
2093
+ return;
2094
+ }
2095
+ };
2096
+ let warmStartPending;
2097
+ if (initialData !== void 0) seedCache(initialData);
2098
+ else for (let i = 0; i < capabilities.length; i += 1) {
2099
+ const capability = capabilities[i];
2100
+ let warm;
2101
+ try {
2102
+ warm = capability.warmStart?.(ctx);
2103
+ } catch (error) {
2104
+ warnCapability(capability, "warmStart", error);
2105
+ continue;
2106
+ }
2107
+ if (warm === void 0) continue;
2108
+ if (warm instanceof Promise) {
2109
+ const firstPromise = warm;
2110
+ warmStartPending = (async () => {
2111
+ let rows = await warmRowsFrom(capability, firstPromise);
2112
+ for (let j = i + 1; rows === void 0 && j < capabilities.length; j += 1) {
2113
+ const later = capabilities[j];
2114
+ let laterWarm;
2115
+ try {
2116
+ laterWarm = later.warmStart?.(ctx);
2117
+ } catch (error) {
2118
+ warnCapability(later, "warmStart", error);
2119
+ continue;
2120
+ }
2121
+ if (laterWarm === void 0) continue;
2122
+ rows = await warmRowsFrom(later, laterWarm);
2123
+ }
2124
+ if (rows !== void 0 && !disposed) seedCache(rows);
2125
+ })();
2126
+ break;
2127
+ }
2128
+ seedCache(warm);
2129
+ break;
2130
+ }
2131
+ const notifySettled = (envelope, outcome) => {
2132
+ for (const capability of capabilities) try {
2133
+ capability.onSettled?.(envelope, outcome, ctx);
2134
+ } catch (error) {
2135
+ warnCapability(capability, "onSettled", error);
2136
+ }
2137
+ };
2138
+ const persistMutation = async (envelope, runFetcher) => {
2139
+ try {
2140
+ const wrapped = await runWrapMutation(capabilities, envelope, ctx);
2141
+ const result = wrapped.handled ? wrapped.result : await runFetcher();
2142
+ notifySettled(envelope, {
2143
+ ok: true,
2144
+ result
2145
+ });
2146
+ return {
2147
+ handled: wrapped.handled,
2148
+ result
2149
+ };
2150
+ } catch (error) {
2151
+ notifySettled(envelope, {
2152
+ ok: false,
2153
+ error
2154
+ });
2155
+ throw error;
2156
+ }
2157
+ };
2158
+ const collection = createCollection(queryCollectionOptions({
2159
+ id: cacheId,
2160
+ queryKey,
2161
+ queryClient,
2162
+ staleTime: staleTimeMs,
2163
+ retry,
2164
+ queryFn: async () => unwrapListResult(await fetchers.list(), definition.name),
2165
+ getKey: (row) => String(row[idField]),
2166
+ onInsert: async ({ transaction }) => {
2167
+ let anyHandled = false;
2168
+ for (const mutation of transaction.mutations) {
2169
+ const modified = mutation.modified;
2170
+ const envelope = {
2171
+ kind: "insert",
2172
+ key: String(modified[idField]),
2173
+ data: modified
2174
+ };
2175
+ const outcome = await persistMutation(envelope, async () => {
2176
+ const { [idField]: _localId, ...data } = modified;
2177
+ return unwrapItemResult(await fetchers.create(data), `create(${definition.name})`);
2178
+ });
2179
+ mutationResults.set(envelope.key, outcome.result);
2180
+ anyHandled = anyHandled || outcome.handled;
2181
+ }
2182
+ if (anyHandled) return { refetch: false };
2183
+ invalidateRelated();
2184
+ },
2185
+ onUpdate: fetchers.update ? async ({ transaction }) => {
2186
+ let anyHandled = false;
2187
+ for (const mutation of transaction.mutations) {
2188
+ const key = String(mutation.key);
2189
+ const changes = mutation.changes;
2190
+ const envelope = {
2191
+ kind: "update",
2192
+ key,
2193
+ data: changes,
2194
+ baseUpdatedAt: getBaseUpdatedAt(mutation.original)
2195
+ };
2196
+ const outcome = await persistMutation(envelope, async () => unwrapItemResult(await fetchers.update(key, changes), `update(${definition.name})`));
2197
+ mutationResults.set(envelope.key, outcome.result);
2198
+ anyHandled = anyHandled || outcome.handled;
2199
+ }
2200
+ if (anyHandled) return { refetch: false };
2201
+ invalidateRelated();
2202
+ } : void 0,
2203
+ onDelete: fetchers.delete ? async ({ transaction }) => {
2204
+ let anyHandled = false;
2205
+ for (const mutation of transaction.mutations) {
2206
+ const key = String(mutation.key);
2207
+ const envelope = {
2208
+ kind: "delete",
2209
+ key,
2210
+ data: {},
2211
+ baseUpdatedAt: getBaseUpdatedAt(mutation.original)
2212
+ };
2213
+ const outcome = await persistMutation(envelope, async () => fetchers.delete(key));
2214
+ mutationResults.set(envelope.key, outcome.result);
2215
+ anyHandled = anyHandled || outcome.handled;
2216
+ }
2217
+ if (anyHandled) return { refetch: false };
2218
+ invalidateRelated();
2219
+ } : void 0
2220
+ }));
2221
+ const handle = {
2222
+ get toArray() {
2223
+ return collection.toArray.map((row) => toPlainRow(row));
2224
+ },
2225
+ get size() {
2226
+ return collection.size;
2227
+ },
2228
+ has(key) {
2229
+ return collection.has(key);
2230
+ },
2231
+ get(key) {
2232
+ const row = collection.get(key);
2233
+ return row === void 0 ? void 0 : toPlainRow(row);
2234
+ },
2235
+ preload() {
2236
+ if (!warmStartPending) return collection.preload();
2237
+ return warmStartPending.then(() => {
2238
+ if (disposed) return;
2239
+ return collection.preload();
2240
+ });
2241
+ },
2242
+ async cleanup() {
2243
+ disposed = true;
2244
+ await collection.cleanup();
2245
+ for (const capability of capabilities) try {
2246
+ await capability.teardown?.(ctx);
2247
+ } catch (error) {
2248
+ warnCapability(capability, "teardown", error);
2249
+ }
2250
+ },
2251
+ subscribeChanges(callback) {
2252
+ const subscription = collection.subscribeChanges((changes) => callback(projectChanges(changes)));
2253
+ return { unsubscribe: () => subscription.unsubscribe() };
2254
+ },
2255
+ insert(row) {
2256
+ return collection.insert(row);
2257
+ },
2258
+ update(key, changes) {
2259
+ if (!fetchers.update) throw new Error(`${definition.name} has no update action`);
2260
+ const { id: _id, ...safeChanges } = changes;
2261
+ return collection.update(key, (draft) => {
2262
+ Object.assign(draft, safeChanges);
2263
+ });
2264
+ },
2265
+ delete(key) {
2266
+ if (!fetchers.delete) throw new Error(`${definition.name} has no delete action`);
2267
+ return collection.delete(key);
2268
+ },
2269
+ async action(action, args, route) {
2270
+ if (!fetchers.custom) throw new Error(`${definition.name} has no custom action fetcher`);
2271
+ const result = route === void 0 ? await fetchers.custom(action, args) : await fetchers.custom(action, args, route);
2272
+ invalidateRelated();
2273
+ return result;
2274
+ }
2275
+ };
2276
+ persistedMutationResults.set(handle, mutationResults);
2277
+ mutationTargetHydrators.set(handle, async (row) => {
2278
+ await collection.preload();
2279
+ collection.utils.writeUpsert(row);
2280
+ });
2281
+ engineCollections.set(handle, collection);
2282
+ for (const capability of capabilities) try {
2283
+ capability.onAttach?.(ctx);
2284
+ } catch (error) {
2285
+ warnCapability(capability, "onAttach", error);
2286
+ }
2287
+ return handle;
2288
+ }
2289
+ //#endregion
2290
+ export { executeSmrtWebDataQuery as A, MAX_SMRT_WEB_DATA_QUERY_FACET_VALUES as C, MAX_SMRT_WEB_DATA_QUERY_ROWS as D, MAX_SMRT_WEB_DATA_QUERY_RESULT_BYTES as E, runWrapMutation as M, MAX_SMRT_WEB_DATA_QUERY_STRING_LENGTH as O, MAX_SMRT_WEB_DATA_QUERY_FACETS as S, MAX_SMRT_WEB_DATA_QUERY_PAGE_LIMIT as T, offlineOutbox as _, createSmrtWebClient as a, wipeDurableStore as b, unwrapItemResult as c, createUpdateState as d, createSmrtWebEventSubscriber as f, getOutboxHandle as g, persistCollection as h, createSmrtCollection as i, normalizeSmrtWebDataQueryResult as j, MAX_SMRT_WEB_DATA_QUERY_WARNINGS as k, unwrapListResult as l, DEFAULT_PERSIST_DEBOUNCE_MS as m, buildListQuery as n, getEngineCollection as o, liveInvalidation as p, createDefinitionFetchers as r, newLocalId as s, SmrtWebRequestError as t, registerWebMcpTools as u, durableStoreNamespace as v, MAX_SMRT_WEB_DATA_QUERY_OFFSET as w, MAX_SMRT_WEB_DATA_QUERY_CONTAINER_ITEMS as x, registerDurableResource as y };
2291
+
2292
+ //# sourceMappingURL=src-CDdW9uYx.js.map