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