@happyvertical/smrt-web 0.43.3 → 0.43.5

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 CHANGED
@@ -38,7 +38,7 @@ module you are editing. This file keeps what holds in every module.
38
38
  | `index.ts` hooks + `durable-store.ts` | the six capability hook points, hook error isolation, the no-op guarantee, and the shared durable-store namespacing/wipe registry | [agents/capability-seam.md](agents/capability-seam.md) |
39
39
  | `offline/` | durable offline writes — config, sync-apply-only replay, idempotency, the shared namespace-keyed engine, and Web Locks leader election | [agents/offline-outbox.md](agents/offline-outbox.md) |
40
40
  | `sse-client.ts` | the client half of live cache invalidation — the app-wide subscriber, the wire contract it consumes, and SSE-vs-polling behaviour | [agents/live-invalidation.md](agents/live-invalidation.md) |
41
- | `webmcp.ts` | framework-agnostic WebMCP registrar; registers generated collection tools and routes mutations through shared smrt-web cache state | — |
41
+ | `webmcp.ts` | framework-agnostic WebMCP registrar; validates an optionally bounded/namespaced, effect-filtered prospective set atomically, keeps legacy list-backed tools on collection state, and executes canonical tool-only definitions directly through REST fetchers | — |
42
42
  | `persistence/` + `update-state.ts` | the read-cache rehydrate capability and the framework-free `updateAvailable` primitive (bundle + contract signals) | [agents/version-persistence.md](agents/version-persistence.md) |
43
43
  | `data-query.ts` | dependency-free browser mirror and defensive response normalizer for the canonical bounded data-query envelope (#2444) | — |
44
44
  | `remote-query.ts` | query-shaped remote pages over a `SmrtWebCollection`, with keyed stale cache, execution modes, cancellation/latest-query-wins, and optional query-scoped live subscriptions (#2445) | — |
@@ -61,6 +61,34 @@ module you are editing. This file keeps what holds in every module.
61
61
 
62
62
  ## Conventions
63
63
 
64
+ - **WebMCP definition mirror** — `WebMcpToolDefinition` textually mirrors the
65
+ generated `@happyvertical/smrt-virt-web` / physical `@smrt/web` declaration.
66
+ It is transport-complete and does not imply that a list-materialized client
67
+ collection exists. Keep the mirror dependency-free. Register canonical tools
68
+ or legacy collection definitions for overlapping collections; when composing
69
+ the two forms, keep their names and collection/action identities disjoint.
70
+ Duplicates fail atomically before registration.
71
+ With no exposure policy, only `read` effects are selected; broader effects
72
+ require explicit opt-in, and undeclared custom actions are destructive.
73
+ Direct mutations invalidate their
74
+ own and relationship-derived collection names through the public
75
+ `invalidateSmrtWebCollections()` seam when the host supplies its shared
76
+ `SmrtWebClient`. Legacy `filter` callbacks receive complete collection
77
+ metadata; canonical definitions use `filterTool`. Supplying either filter for
78
+ definitions of the other kind fails closed rather than ignoring the predicate
79
+ or fabricating incomplete metadata for a policy decision. Filters and fetcher
80
+ resolvers receive isolated value snapshots, so integrations must key external
81
+ state by stable values such as collection/action rather than definition object
82
+ identity.
83
+ Canonical writes validate that shared client handle before registration, and
84
+ string or structured `{ error }` REST envelopes fail before cache
85
+ invalidation. The private `__smrt_options` GET sentinel is reserved only for
86
+ no-path single-options-bag actions; positional actions preserve a legitimate
87
+ parameter with that name.
88
+ This capability policy is not authorization:
89
+ the authenticated REST surface remains the auth, tenant, field-write, and
90
+ sensitive-data boundary.
91
+
64
92
  - **No inter-smrt dependencies** — depends only on TanStack packages
65
93
  (dependency-DAG guardrails). Definitions and fetchers arrive as arguments.
66
94
  - **Data-query mirror** — `data-query.ts` mirrors the portable
@@ -79,3 +107,17 @@ different request id — before they reach a UI surface.
79
107
 
80
108
  `packages/products` consumes the runtime as its reference store across npm,
81
109
  federation, and standalone modes (see the smrt-web track, PRD #1755).
110
+
111
+ ## WebMCP integration fixture
112
+
113
+ `src/webmcp-e2e.integration.test.ts` is the production-shaped composition
114
+ fixture for generated WebMCP tools. It uses `smrtVitestPlugin()`, a real
115
+ in-memory SQLite database, and generated REST handlers. Only the browser
116
+ `document.modelContext` and external AI boundary are doubled. Keep this fixture
117
+ focused on the WebMCP contract: auth failures, effect/exposure policy,
118
+ tool-only fetches, custom actions, relationship cache invalidation, and
119
+ registration disposal. Its descriptors come from the OXC manifest adapter so
120
+ the test follows the same generation path as the virtual WebMCP module; the
121
+ Vitest config excludes test files from the package manifest to avoid exposing
122
+ fixture-only classes to package discovery. The integration model is documented in
123
+ `docs/content/webmcp-integration.md`.
package/README.md CHANGED
@@ -133,6 +133,83 @@ core.
133
133
  | Version awareness | `createUpdateState` |
134
134
  | WebMCP | `registerWebMcpTools` |
135
135
 
136
+ ## WebMCP capability exposure
137
+
138
+ `registerWebMcpTools()` is secure by default: omitting an exposure policy
139
+ registers only `read` tools. CRUD effects are fixed (`list`/`get` are `read`,
140
+ `create`/`update` are `write`, and `delete` is `destructive`). A custom action
141
+ without declared metadata is treated as destructive, non-idempotent, and open
142
+ world. Declare safer custom-action semantics in the route metadata only when
143
+ they are true:
144
+
145
+ ```ts
146
+ @smrt({
147
+ api: {
148
+ routes: {
149
+ preview: {
150
+ method: 'GET',
151
+ effect: 'read',
152
+ idempotent: true,
153
+ openWorld: false,
154
+ },
155
+ },
156
+ },
157
+ })
158
+ class Report extends SmrtObject {}
159
+ ```
160
+
161
+ Opt into broader capabilities explicitly. `namespace` prevents cross-surface
162
+ name collisions, an explicit `maxTools` bounds the selected set, and duplicate
163
+ names or stable collection/action identities reject the entire call before the
164
+ first browser registration. No implicit budget is applied to whole-manifest
165
+ read registration:
166
+
167
+ ```ts
168
+ registerWebMcpTools(definitions, {
169
+ effects: ['read', 'write', 'destructive'],
170
+ namespace: 'admin',
171
+ maxTools: 32,
172
+ filter: (collection, tool) => collection.fields.tenantId !== undefined,
173
+ filterTool: (tool) => tool.collection === 'reports',
174
+ });
175
+ ```
176
+
177
+ The returned disposer also exposes a `ready` promise. Await it when the host
178
+ must report browser-side registration rejection; any rejected tool aborts all
179
+ sibling registrations from that call before `ready` rejects.
180
+
181
+ `filter` receives legacy collection metadata; `filterTool` receives canonical
182
+ per-tool definitions. Configuring only one filter while registering definitions
183
+ for the other filter kind fails closed; canonical tools do not carry complete
184
+ collection field metadata, and legacy descriptors do not satisfy the canonical
185
+ filter contract. Policy callbacks and fetcher resolvers receive isolated value
186
+ snapshots; key host-side maps by stable values such as collection/action or tool
187
+ name, not definition object identity.
188
+
189
+ Do not concatenate complete legacy and canonical definition sets for the same
190
+ collections. Their duplicate tool names or collection/action identities reject
191
+ the registration atomically. Prefer the canonical set for complete generated
192
+ coverage, or compose only disjoint legacy and canonical subsets.
193
+
194
+ WebMCP policy controls which capabilities a page advertises; it is not an
195
+ authorization boundary. Execution still uses the page's authenticated REST
196
+ transport, whose auth, tenancy, writable-field, and sensitive-field guards must
197
+ remain enabled. All application-derived tool results are annotated as untrusted
198
+ content, including mutation responses.
199
+
200
+ Policy only narrows the actions already exposed by the generated API metadata.
201
+ Legacy descriptors outside their collection's `actions` set reject the whole
202
+ registration, and intrinsic CRUD effects cannot be relabeled by caller data.
203
+
204
+ Migration note: registrations that previously relied on every descriptor being
205
+ exposed must now pass `effects: ['read', 'write', 'destructive']`. Prefer a
206
+ narrower allowlist for each browser surface. Integrations that previously keyed
207
+ filter or resolver state by definition object identity must migrate to stable
208
+ name or collection/action keys. If both legacy and canonical definition arrays
209
+ are available, select one complete source or remove overlaps before combining
210
+ them. Set `maxTools` explicitly on surfaces that need a hard capability budget;
211
+ overflow rejects the complete registration rather than truncating it.
212
+
136
213
  ## Development
137
214
 
138
215
  ```bash
@@ -2116,38 +2116,266 @@ function getModelContext() {
2116
2116
  if (mc && typeof mc.registerTool === "function") return mc;
2117
2117
  }
2118
2118
  function registerWebMcpTools(definitions, options = {}) {
2119
+ const exposure = validateExposurePolicy(options);
2119
2120
  const ctx = getModelContext();
2120
- if (!ctx) return () => {};
2121
+ if (!ctx) return registrationDisposer(() => {}, Promise.resolve());
2121
2122
  const basePath = options.basePath ?? "/api/v1";
2123
+ const client = options.client;
2124
+ const { tools, allowedEffects } = selectProspectiveTools(definitions, options, exposure);
2125
+ if (client && tools.some((tool) => tool.kind === "canonical" && tool.effect !== "read")) validateSmrtWebClient(client);
2122
2126
  const controller = new AbortController();
2123
2127
  const collections = /* @__PURE__ */ new Map();
2124
- for (const definition of definitions) {
2125
- const descriptors = definition.toolDescriptors;
2126
- if (!descriptors || descriptors.length === 0) continue;
2127
- const fetchers = options.resolveFetchers ? options.resolveFetchers(definition) : createDefinitionFetchers(definition, basePath, options.fetchFn);
2128
- const collection = createSmrtCollection(definition, {
2129
- fetchers,
2130
- basePath,
2131
- fetchFn: options.fetchFn,
2132
- ...options.client ? { client: options.client } : {},
2133
- ...options.scope ? { scope: options.scope } : {}
2134
- });
2135
- collections.set(definition, collection);
2136
- for (const descriptor of descriptors) {
2137
- if (options.filter && !options.filter(definition, descriptor)) continue;
2138
- ctx.registerTool({
2139
- name: descriptor.name,
2140
- description: descriptor.description,
2141
- inputSchema: descriptor.inputSchema,
2142
- annotations: { readOnlyHint: descriptor.readOnly },
2143
- execute: (args) => dispatch(fetchers, collection, definition, descriptor.action, descriptor.route, args ?? {})
2144
- }, { signal: controller.signal });
2145
- }
2146
- }
2147
- return () => {
2128
+ const collectionFetchers = /* @__PURE__ */ new Map();
2129
+ let disposed = false;
2130
+ const dispose = () => {
2131
+ if (disposed) return;
2132
+ disposed = true;
2148
2133
  controller.abort();
2149
2134
  for (const collection of collections.values()) collection.cleanup().catch(() => void 0);
2150
2135
  };
2136
+ const registrations = [];
2137
+ try {
2138
+ for (const tool of tools) {
2139
+ if (tool.kind === "legacy") {
2140
+ const { definition: definition2, descriptor } = tool;
2141
+ let fetchers2 = collectionFetchers.get(definition2);
2142
+ let collection = collections.get(definition2);
2143
+ if (!fetchers2 || !collection) {
2144
+ fetchers2 = options.resolveFetchers ? options.resolveFetchers(snapshotLegacyDefinition(definition2)) : createDefinitionFetchers(definition2, basePath, options.fetchFn);
2145
+ collection = createSmrtCollection(definition2, {
2146
+ fetchers: fetchers2,
2147
+ basePath,
2148
+ fetchFn: options.fetchFn,
2149
+ ...client ? { client } : {},
2150
+ ...options.scope ? { scope: options.scope } : {}
2151
+ });
2152
+ collectionFetchers.set(definition2, fetchers2);
2153
+ collections.set(definition2, collection);
2154
+ }
2155
+ registrations.push(Promise.resolve(ctx.registerTool({
2156
+ name: tool.name,
2157
+ description: descriptor.description,
2158
+ inputSchema: descriptor.inputSchema,
2159
+ annotations: annotationsFor(tool),
2160
+ execute: guardedExecute(tool, allowedEffects, () => disposed, (args) => dispatchCollection(fetchers2, collection, definition2, descriptor.action, descriptor.route, args))
2161
+ }, { signal: controller.signal })));
2162
+ continue;
2163
+ }
2164
+ const { definition } = tool;
2165
+ const fetchers = options.resolveToolFetchers ? options.resolveToolFetchers(snapshotCanonicalDefinition(definition, {
2166
+ effect: tool.effect,
2167
+ destructive: tool.destructive,
2168
+ idempotent: tool.idempotent,
2169
+ openWorld: tool.openWorld
2170
+ })) : createDefinitionFetchers({
2171
+ name: definition.collection,
2172
+ endpoint: definition.endpoint
2173
+ }, basePath, options.fetchFn);
2174
+ registrations.push(Promise.resolve(ctx.registerTool({
2175
+ name: tool.name,
2176
+ description: definition.description,
2177
+ inputSchema: definition.inputSchema,
2178
+ annotations: annotationsFor(tool),
2179
+ execute: guardedExecute(tool, allowedEffects, () => disposed, (args) => dispatchDirect(fetchers, definition, args, client))
2180
+ }, { signal: controller.signal })));
2181
+ }
2182
+ } catch (error) {
2183
+ Promise.all(registrations).catch(() => void 0);
2184
+ dispose();
2185
+ throw error;
2186
+ }
2187
+ const ready = Promise.all(registrations).then(() => void 0).catch((error) => {
2188
+ dispose();
2189
+ throw error;
2190
+ });
2191
+ ready.catch(() => void 0);
2192
+ return registrationDisposer(dispose, ready);
2193
+ }
2194
+ function registrationDisposer(dispose, ready) {
2195
+ return Object.assign(dispose, { ready });
2196
+ }
2197
+ var VALID_EFFECTS = [
2198
+ "read",
2199
+ "write",
2200
+ "destructive"
2201
+ ];
2202
+ var NAMESPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
2203
+ function validateExposurePolicy(options) {
2204
+ const effects = options.effects ?? ["read"];
2205
+ for (const effect of effects) if (!VALID_EFFECTS.includes(effect)) throw new Error(`Invalid WebMCP effect: ${String(effect)}`);
2206
+ const maxTools = options.maxTools;
2207
+ if (maxTools !== void 0 && (!Number.isSafeInteger(maxTools) || maxTools < 0)) throw new Error("WebMCP maxTools must be a non-negative safe integer");
2208
+ const namespace = options.namespace;
2209
+ if (namespace !== void 0 && !NAMESPACE_PATTERN.test(namespace)) throw new Error("WebMCP namespace must start with an alphanumeric character and contain only letters, numbers, underscores, or hyphens");
2210
+ return {
2211
+ allowedEffects: new Set(effects),
2212
+ ...maxTools !== void 0 ? { maxTools } : {},
2213
+ ...namespace !== void 0 ? { namespace } : {}
2214
+ };
2215
+ }
2216
+ function selectProspectiveTools(definitions, options, exposure) {
2217
+ const { allowedEffects, maxTools, namespace } = exposure;
2218
+ const stableDefinitions = definitions.map((definition) => snapshotValue(definition));
2219
+ const tools = [];
2220
+ for (const definition of stableDefinitions) {
2221
+ if (isCanonicalToolDefinition(definition)) {
2222
+ const semantics = actionSemantics(definition.action, definition);
2223
+ if (!allowedEffects.has(semantics.effect)) continue;
2224
+ if (options.filter && !options.filterTool) throw new Error("[smrt-web] canonical WebMCP definitions require filterTool when filter is configured");
2225
+ const stableDefinition2 = snapshotCanonicalDefinition(definition, semantics);
2226
+ if (options.filterTool && !options.filterTool(snapshotCanonicalDefinition(stableDefinition2, semantics))) continue;
2227
+ tools.push({
2228
+ kind: "canonical",
2229
+ definition: stableDefinition2,
2230
+ descriptor: stableDefinition2,
2231
+ name: qualifiedToolName(stableDefinition2.name, namespace),
2232
+ identity: `${stableDefinition2.collection}#${stableDefinition2.action}`,
2233
+ ...semantics
2234
+ });
2235
+ continue;
2236
+ }
2237
+ const stableDefinition = snapshotLegacyDefinition(definition);
2238
+ for (const descriptor of stableDefinition.toolDescriptors ?? []) {
2239
+ if (!stableDefinition.actions.includes(descriptor.action)) throw new Error(`WebMCP tool ${descriptor.name} exposes action ${descriptor.action} outside ${stableDefinition.name}'s allowed actions`);
2240
+ const semantics = actionSemantics(descriptor.action, descriptor);
2241
+ if (!allowedEffects.has(semantics.effect)) continue;
2242
+ if (options.filterTool && !options.filter) throw new Error("[smrt-web] legacy WebMCP definitions require filter when filterTool is configured");
2243
+ const stableDescriptor = snapshotLegacyDescriptor(descriptor, semantics);
2244
+ if (options.filter && !options.filter(snapshotLegacyDefinition(stableDefinition), snapshotLegacyDescriptor(stableDescriptor, semantics))) continue;
2245
+ tools.push({
2246
+ kind: "legacy",
2247
+ definition: stableDefinition,
2248
+ descriptor: stableDescriptor,
2249
+ name: qualifiedToolName(stableDescriptor.name, namespace),
2250
+ identity: `${stableDefinition.name}#${stableDescriptor.action}`,
2251
+ ...semantics
2252
+ });
2253
+ }
2254
+ }
2255
+ validateProspectiveTools(tools, maxTools);
2256
+ return {
2257
+ tools,
2258
+ allowedEffects
2259
+ };
2260
+ }
2261
+ function qualifiedToolName(name, namespace) {
2262
+ return namespace ? `${namespace}_${name}` : name;
2263
+ }
2264
+ function actionSemantics(action, declared) {
2265
+ switch (action) {
2266
+ case "list":
2267
+ case "get": return {
2268
+ effect: "read",
2269
+ destructive: false,
2270
+ idempotent: true,
2271
+ openWorld: false
2272
+ };
2273
+ case "create": return {
2274
+ effect: "write",
2275
+ destructive: true,
2276
+ idempotent: false,
2277
+ openWorld: false
2278
+ };
2279
+ case "update": return {
2280
+ effect: "write",
2281
+ destructive: true,
2282
+ idempotent: true,
2283
+ openWorld: false
2284
+ };
2285
+ case "delete": return {
2286
+ effect: "destructive",
2287
+ destructive: true,
2288
+ idempotent: true,
2289
+ openWorld: false
2290
+ };
2291
+ default: {
2292
+ const effect = VALID_EFFECTS.includes(declared.effect) ? declared.effect : "destructive";
2293
+ return {
2294
+ effect,
2295
+ destructive: effect !== "read",
2296
+ idempotent: declared.idempotent ?? false,
2297
+ openWorld: declared.openWorld ?? true
2298
+ };
2299
+ }
2300
+ }
2301
+ }
2302
+ function snapshotRoute(route) {
2303
+ return route ? snapshotValue(route) : void 0;
2304
+ }
2305
+ function snapshotValue(value, active = /* @__PURE__ */ new WeakSet(), path = "$") {
2306
+ if (value && typeof value === "object") {
2307
+ if (active.has(value)) throw new Error(`[smrt-web] WebMCP definitions must be acyclic (cycle at ${path})`);
2308
+ active.add(value);
2309
+ }
2310
+ try {
2311
+ if (Array.isArray(value)) return value.map((entry, index) => snapshotValue(entry, active, `${path}[${index}]`));
2312
+ if (value && typeof value === "object") {
2313
+ const snapshot = {};
2314
+ for (const [key, entry] of Object.entries(value)) snapshot[key] = snapshotValue(entry, active, `${path}.${key}`);
2315
+ return snapshot;
2316
+ }
2317
+ return value;
2318
+ } finally {
2319
+ if (value && typeof value === "object") active.delete(value);
2320
+ }
2321
+ }
2322
+ function snapshotLegacyDescriptor(descriptor, semantics) {
2323
+ return snapshotValue({
2324
+ ...descriptor,
2325
+ effect: semantics.effect,
2326
+ idempotent: semantics.idempotent,
2327
+ openWorld: semantics.openWorld,
2328
+ readOnly: semantics.effect === "read",
2329
+ route: snapshotRoute(descriptor.route)
2330
+ });
2331
+ }
2332
+ function snapshotLegacyDefinition(definition) {
2333
+ const snapshot = snapshotValue({
2334
+ ...definition,
2335
+ actions: [...definition.actions]
2336
+ });
2337
+ snapshot.toolDescriptors = snapshot.toolDescriptors?.map((descriptor) => snapshotLegacyDescriptor(descriptor, actionSemantics(descriptor.action, descriptor)));
2338
+ return snapshot;
2339
+ }
2340
+ function snapshotCanonicalDefinition(definition, semantics) {
2341
+ return snapshotValue({
2342
+ ...definition,
2343
+ effect: semantics.effect,
2344
+ idempotent: semantics.idempotent,
2345
+ openWorld: semantics.openWorld,
2346
+ readOnly: semantics.effect === "read",
2347
+ route: snapshotRoute(definition.route)
2348
+ });
2349
+ }
2350
+ function validateProspectiveTools(tools, maxTools) {
2351
+ if (maxTools !== void 0 && tools.length > maxTools) throw new Error(`WebMCP tool budget exceeded: ${tools.length} tools selected, maximum is ${maxTools}`);
2352
+ const names = /* @__PURE__ */ new Set();
2353
+ const identities = /* @__PURE__ */ new Set();
2354
+ for (const tool of tools) {
2355
+ if (names.has(tool.name)) throw new Error(`Duplicate WebMCP tool name: ${tool.name}`);
2356
+ names.add(tool.name);
2357
+ if (identities.has(tool.identity)) throw new Error(`Duplicate WebMCP tool identity: ${tool.identity}`);
2358
+ identities.add(tool.identity);
2359
+ }
2360
+ }
2361
+ function annotationsFor(tool) {
2362
+ return {
2363
+ readOnlyHint: tool.effect === "read",
2364
+ destructiveHint: tool.destructive,
2365
+ idempotentHint: tool.idempotent,
2366
+ openWorldHint: tool.openWorld,
2367
+ untrustedContentHint: true
2368
+ };
2369
+ }
2370
+ function guardedExecute(tool, allowedEffects, isDisposed, execute) {
2371
+ return (args) => {
2372
+ if (isDisposed()) throw new Error(`WebMCP tool ${tool.name} is no longer registered`);
2373
+ if (!allowedEffects.has(tool.effect)) throw new Error(`WebMCP policy no longer allows ${tool.effect} tool ${tool.name}`);
2374
+ return execute(args ?? {});
2375
+ };
2376
+ }
2377
+ function isCanonicalToolDefinition(definition) {
2378
+ return "collection" in definition && "readOnly" in definition;
2151
2379
  }
2152
2380
  function requireId(args, action) {
2153
2381
  const id = args.id;
@@ -2167,7 +2395,7 @@ function listParams(args) {
2167
2395
  if (args.where !== void 0) params.where = args.where;
2168
2396
  return params;
2169
2397
  }
2170
- async function dispatch(fetchers, collection, definition, action, route, args) {
2398
+ async function dispatchCollection(fetchers, collection, definition, action, route, args) {
2171
2399
  switch (action) {
2172
2400
  case "list": {
2173
2401
  const rows = unwrapListResult(await fetchers.list(listParams(args)), definition.name);
@@ -2208,6 +2436,45 @@ async function dispatch(fetchers, collection, definition, action, route, args) {
2208
2436
  return JSON.stringify(await collection.action(action, args, route));
2209
2437
  }
2210
2438
  }
2439
+ async function dispatchDirect(fetchers, definition, args, client) {
2440
+ let result;
2441
+ switch (definition.action) {
2442
+ case "list":
2443
+ if (!fetchers.list) throw new Error(`${definition.collection} has no list action`);
2444
+ result = unwrapListResult(await fetchers.list(listParams(args)), definition.collection);
2445
+ break;
2446
+ case "get":
2447
+ if (!fetchers.get) throw new Error(`${definition.collection} has no get action`);
2448
+ result = unwrapItemResult(await fetchers.get(requireIdentifier(args)), `get(${definition.collection})`);
2449
+ break;
2450
+ case "create":
2451
+ if (!fetchers.create) throw new Error(`${definition.collection} has no create action`);
2452
+ result = unwrapItemResult(await fetchers.create(args), `create(${definition.collection})`);
2453
+ break;
2454
+ case "update": {
2455
+ if (!fetchers.update) throw new Error(`${definition.collection} has no update action`);
2456
+ const id = requireId(args, "update");
2457
+ const { id: _id, ...body } = args;
2458
+ result = unwrapItemResult(await fetchers.update(id, body), `update(${definition.collection})`);
2459
+ break;
2460
+ }
2461
+ case "delete": {
2462
+ if (!fetchers.delete) throw new Error(`${definition.collection} has no delete action`);
2463
+ const id = requireId(args, "delete");
2464
+ throwIfSmrtWebError(await fetchers.delete(id), `delete(${definition.collection})`);
2465
+ result = {
2466
+ success: true,
2467
+ id
2468
+ };
2469
+ break;
2470
+ }
2471
+ default:
2472
+ if (!fetchers.custom) throw new Error(`${definition.collection} has no custom action fetcher`);
2473
+ result = throwIfSmrtWebError(await fetchers.custom(definition.action, args, definition.route), `${definition.action}(${definition.collection})`);
2474
+ }
2475
+ if (!definition.readOnly && client) invalidateSmrtWebCollections(client, [definition.collection, ...definition.relationships.map((relationship) => relationship.relatedCollection)]);
2476
+ return JSON.stringify(result);
2477
+ }
2211
2478
  async function settleTransaction(transaction, collection, key, fallback) {
2212
2479
  await transaction.isPersisted.promise;
2213
2480
  const persisted = persistedMutationResults.get(collection)?.get(key);
@@ -2256,23 +2523,34 @@ function createHttpRequestError(collectionName, status, payload) {
2256
2523
  return new SmrtWebRequestError(`[smrt-web] ${collectionName} request failed: ${detail.message ?? `HTTP ${status}`}`, payload, status, detail.code);
2257
2524
  }
2258
2525
  function unwrapListResult(result, collectionName) {
2526
+ throwIfSmrtWebError(result, `list(${collectionName})`);
2259
2527
  if (Array.isArray(result)) return result;
2260
2528
  if (result && typeof result === "object") {
2261
2529
  const record = result;
2262
- if (typeof record.error === "string") throw new SmrtWebRequestError(`[smrt-web] list(${collectionName}) failed: ${record.error}`, result);
2263
2530
  if (Array.isArray(record.data)) return record.data;
2264
2531
  }
2265
2532
  throw new SmrtWebRequestError(`[smrt-web] list(${collectionName}) returned an unexpected payload shape`, result);
2266
2533
  }
2267
2534
  function unwrapItemResult(result, context) {
2535
+ throwIfSmrtWebError(result, context);
2268
2536
  if (result && typeof result === "object" && !Array.isArray(result)) {
2269
2537
  const record = result;
2270
- if (typeof record.error === "string") throw new SmrtWebRequestError(`[smrt-web] ${context} failed: ${record.error}`, result);
2271
2538
  if (record.data && typeof record.data === "object" && !Array.isArray(record.data)) return record.data;
2272
2539
  return record;
2273
2540
  }
2274
2541
  throw new SmrtWebRequestError(`[smrt-web] ${context} returned an unexpected payload shape`, result);
2275
2542
  }
2543
+ function throwIfSmrtWebError(result, context) {
2544
+ if (!result || typeof result !== "object" || Array.isArray(result)) return result;
2545
+ const error = result.error;
2546
+ if (typeof error === "string") throw new SmrtWebRequestError(`[smrt-web] ${context} failed: ${error}`, result);
2547
+ if (!error || typeof error !== "object" || Array.isArray(error)) return result;
2548
+ const failure = error;
2549
+ if (failure.ok !== false || typeof failure.code !== "string" || typeof failure.message !== "string") return result;
2550
+ const status = typeof failure.status === "number" && Number.isInteger(failure.status) && failure.status >= 400 && failure.status <= 599 ? failure.status : void 0;
2551
+ if (status !== void 0 && status >= 500) throw new SmrtWebRequestError(`[smrt-web] ${context} failed: server error`, void 0, status);
2552
+ throw new SmrtWebRequestError(`[smrt-web] ${context} failed: ${failure.message}`, result, status, failure.code);
2553
+ }
2276
2554
  var SMRT_TO_REST_OPERATOR = {
2277
2555
  ">": "gt",
2278
2556
  ">=": "gte",
@@ -2374,12 +2652,12 @@ function createDefinitionFetchers(definition, basePath = "/api/v1", fetchFn = (.
2374
2652
  if (customRoute.method !== "GET") init.body = JSON.stringify(body);
2375
2653
  else {
2376
2654
  const queryParams = new URLSearchParams();
2377
- if (optionsBag) if (optionsValue === void 0) queryParams.set(CUSTOM_OPTIONS_QUERY_MARKER, "undefined");
2378
- else if (optionsValue === null) queryParams.set(CUSTOM_OPTIONS_QUERY_MARKER, "null");
2655
+ if (optionsBag) if (optionsValue === void 0 && pathArgs.size === 0) queryParams.set(CUSTOM_OPTIONS_QUERY_MARKER, "undefined");
2656
+ else if (optionsValue === null && pathArgs.size === 0) queryParams.set(CUSTOM_OPTIONS_QUERY_MARKER, "null");
2379
2657
  else {
2380
- const entries = Object.entries(typeof optionsValue === "object" && !Array.isArray(optionsValue) ? optionsValue : {}).filter(([, value]) => value !== void 0 && value !== null);
2658
+ const entries = Object.entries(optionsValue !== null && typeof optionsValue === "object" && !Array.isArray(optionsValue) ? optionsValue : {}).filter(([, value]) => value !== void 0 && value !== null);
2381
2659
  for (const [key, value] of entries) queryParams.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
2382
- if (entries.length === 0) queryParams.set(CUSTOM_OPTIONS_QUERY_MARKER, "object");
2660
+ if (entries.length === 0 && pathArgs.size === 0) queryParams.set(CUSTOM_OPTIONS_QUERY_MARKER, "object");
2383
2661
  }
2384
2662
  else {
2385
2663
  const queryBody = body !== null && typeof body === "object" && !Array.isArray(body) ? body : {};
@@ -2400,18 +2678,36 @@ function newLocalId() {
2400
2678
  if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
2401
2679
  return `local-${Date.now()}-${Math.random().toString(36).slice(2)}`;
2402
2680
  }
2681
+ var smrtWebClientHandles = /* @__PURE__ */ new WeakSet();
2403
2682
  function createSmrtWebClient() {
2404
- return {
2683
+ const engine = {
2405
2684
  __smrtWebClient: "SmrtWebClient",
2406
2685
  queryClient: new QueryClient()
2407
2686
  };
2687
+ smrtWebClientHandles.add(engine);
2688
+ return engine;
2408
2689
  }
2409
2690
  function resolveQueryClient(client) {
2410
2691
  if (!client) return new QueryClient();
2411
2692
  const engine = client;
2412
- if (engine.__smrtWebClient !== "SmrtWebClient" || !engine.queryClient) throw new SmrtWebRequestError("[smrt-web] options.client must be a handle from createSmrtWebClient()");
2693
+ if (!smrtWebClientHandles.has(engine) || engine.__smrtWebClient !== "SmrtWebClient" || !engine.queryClient) throw new SmrtWebRequestError("[smrt-web] options.client must be a handle from createSmrtWebClient()");
2413
2694
  return engine.queryClient;
2414
2695
  }
2696
+ function validateSmrtWebClient(client) {
2697
+ resolveQueryClient(client);
2698
+ }
2699
+ function invalidateCollectionQueries(queryClient, collectionNames) {
2700
+ if (collectionNames.size === 0) return;
2701
+ queryClient.invalidateQueries({ predicate: (query) => {
2702
+ const key = query.queryKey;
2703
+ if (!Array.isArray(key) || key.length === 0) return false;
2704
+ const collectionSegment = key[key.length - 1];
2705
+ return typeof collectionSegment === "string" && collectionNames.has(collectionSegment);
2706
+ } });
2707
+ }
2708
+ function invalidateSmrtWebCollections(client, collectionNames) {
2709
+ invalidateCollectionQueries(resolveQueryClient(client), new Set(collectionNames));
2710
+ }
2415
2711
  function toPlainRow(row) {
2416
2712
  const plain = {};
2417
2713
  for (const [key, value] of Object.entries(row)) if (key.charCodeAt(0) !== 36) plain[key] = value;
@@ -2460,12 +2756,7 @@ function createSmrtCollection(definition, options) {
2460
2756
  const invalidationTargets = /* @__PURE__ */ new Set([definition.name]);
2461
2757
  for (const relationship of definition.relationships ?? []) invalidationTargets.add(relationship.relatedCollection);
2462
2758
  const invalidateRelated = () => {
2463
- queryClient.invalidateQueries({ predicate: (query) => {
2464
- const key = query.queryKey;
2465
- if (!Array.isArray(key) || key.length === 0) return false;
2466
- const collectionSegment = key[key.length - 1];
2467
- return typeof collectionSegment === "string" && invalidationTargets.has(collectionSegment);
2468
- } });
2759
+ invalidateCollectionQueries(queryClient, invalidationTargets);
2469
2760
  };
2470
2761
  const ctx = {
2471
2762
  definition,
@@ -2629,7 +2920,7 @@ function createSmrtCollection(definition, options) {
2629
2920
  data: {},
2630
2921
  baseUpdatedAt: getBaseUpdatedAt(mutation.original)
2631
2922
  };
2632
- const outcome = await persistMutation(envelope, async () => fetchers.delete(key));
2923
+ const outcome = await persistMutation(envelope, async () => throwIfSmrtWebError(await fetchers.delete(key), `delete(${definition.name})`));
2633
2924
  mutationResults.set(envelope.key, outcome.result);
2634
2925
  anyHandled = anyHandled || outcome.handled;
2635
2926
  }
@@ -2688,6 +2979,7 @@ function createSmrtCollection(definition, options) {
2688
2979
  async action(action, args, route) {
2689
2980
  if (!fetchers.custom) throw new Error(`${definition.name} has no custom action fetcher`);
2690
2981
  const result = route === void 0 ? await fetchers.custom(action, args) : await fetchers.custom(action, args, route);
2982
+ throwIfSmrtWebError(result, `${action}(${definition.name})`);
2691
2983
  invalidateRelated();
2692
2984
  return result;
2693
2985
  }
@@ -2706,6 +2998,6 @@ function createSmrtCollection(definition, options) {
2706
2998
  return handle;
2707
2999
  }
2708
3000
  //#endregion
2709
- export { MAX_SMRT_WEB_DATA_QUERY_WARNINGS as A, MAX_SMRT_WEB_DATA_QUERY_FACETS as C, MAX_SMRT_WEB_DATA_QUERY_RESULT_BYTES as D, MAX_SMRT_WEB_DATA_QUERY_PAGE_LIMIT as E, normalizeSmrtWebDataQueryResult as M, runWrapMutation as N, MAX_SMRT_WEB_DATA_QUERY_ROWS as O, MAX_SMRT_WEB_DATA_QUERY_CONTAINER_ITEMS as S, MAX_SMRT_WEB_DATA_QUERY_OFFSET as T, getOutboxHandle as _, createSmrtWebClient as a, registerDurableResource as b, unwrapItemResult as c, createUpdateState as d, createSmrtWebEventSubscriber as f, persistCollection as g, DEFAULT_PERSIST_DEBOUNCE_MS as h, createSmrtCollection as i, executeSmrtWebDataQuery as j, MAX_SMRT_WEB_DATA_QUERY_STRING_LENGTH as k, unwrapListResult as l, createSmrtWebQuery as m, buildListQuery as n, getEngineCollection as o, liveInvalidation as p, createDefinitionFetchers as r, newLocalId as s, SmrtWebRequestError as t, registerWebMcpTools as u, offlineOutbox as v, MAX_SMRT_WEB_DATA_QUERY_FACET_VALUES as w, wipeDurableStore as x, durableStoreNamespace as y };
3001
+ export { MAX_SMRT_WEB_DATA_QUERY_RESULT_BYTES as A, registerDurableResource as C, MAX_SMRT_WEB_DATA_QUERY_FACET_VALUES as D, MAX_SMRT_WEB_DATA_QUERY_FACETS as E, normalizeSmrtWebDataQueryResult as F, runWrapMutation as I, MAX_SMRT_WEB_DATA_QUERY_STRING_LENGTH as M, MAX_SMRT_WEB_DATA_QUERY_WARNINGS as N, MAX_SMRT_WEB_DATA_QUERY_OFFSET as O, executeSmrtWebDataQuery as P, durableStoreNamespace as S, MAX_SMRT_WEB_DATA_QUERY_CONTAINER_ITEMS as T, createSmrtWebQuery as _, createSmrtWebClient as a, getOutboxHandle as b, newLocalId as c, unwrapListResult as d, validateSmrtWebClient as f, liveInvalidation as g, createSmrtWebEventSubscriber as h, createSmrtCollection as i, MAX_SMRT_WEB_DATA_QUERY_ROWS as j, MAX_SMRT_WEB_DATA_QUERY_PAGE_LIMIT as k, throwIfSmrtWebError as l, createUpdateState as m, buildListQuery as n, getEngineCollection as o, registerWebMcpTools as p, createDefinitionFetchers as r, invalidateSmrtWebCollections as s, SmrtWebRequestError as t, unwrapItemResult as u, DEFAULT_PERSIST_DEBOUNCE_MS as v, wipeDurableStore as w, offlineOutbox as x, persistCollection as y };
2710
3002
 
2711
- //# sourceMappingURL=src-D9RvoJSQ.js.map
3003
+ //# sourceMappingURL=src-n14q6RHC.js.map