@happyvertical/smrt-web 0.38.6 → 0.38.8

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
@@ -282,6 +282,91 @@ replayed signal STILL fires, which is exactly what makes a reconnect miss no
282
282
  invalidation. `lastSeq` is a resume cursor for the poll fallback, not a dedup
283
283
  filter.
284
284
 
285
+ ## Version awareness & persistence (#1764)
286
+
287
+ The read-side twin of the outbox plus a first-class "an update is available"
288
+ signal. Two locked architecture decisions (maintainer): the manifest-hash source
289
+ is a **build-time inject** (core's web-module generator emits a `manifestHash`
290
+ constant — NOT a runtime endpoint), and the ETag salt is **folded into this PR**
291
+ (the same hash threads into `computeTableVersionEtag`, closing #1765's documented
292
+ shape-change staleness gap). See `packages/core/AGENTS.md` for the core halves.
293
+
294
+ ### Persistence capability — `persistCollection(config)` (opt-in)
295
+
296
+ The read-cache rehydrate the outbox left to #1764. Add it to a collection's
297
+ `capabilities`; a collection **without** it is byte-for-byte non-persistent — the
298
+ seam's no-op guarantee, and the "opt-in per model" AC. **Sensitive models simply
299
+ omit the capability and never touch disk.**
300
+
301
+ - `warmStart(ctx)` reads the persisted snapshot for
302
+ `(durableStoreNamespace(namespace), collection)` and returns its rows to seed
303
+ the cache — the stale render paints instantly; the engine then revalidates in
304
+ the background via its normal SWR (the preload path AWAITS the async warmStart,
305
+ so the first `list()` is suppressed). **Manifest-hash change drops caches
306
+ automatically:** the namespace INCLUDES `manifestHash`, so a contract-changing
307
+ deploy lands on a DIFFERENT IndexedDB database → the old snapshot is never found
308
+ → an empty warmStart → a fresh fetch with NO stale-schema hydration. No explicit
309
+ invalidation.
310
+ - Write-back: `onAttach` subscribes to the collection's changes (via the seam's
311
+ engine-free `ctx.subscribe`/`ctx.snapshot` — added for this slice, plain-DTO
312
+ payloads only, no engine type) and persists the current rows DEBOUNCED (250ms
313
+ trailing default; the scheduler is timer-based even at 0ms so a pending write is
314
+ cancelable). `teardown` sets `detached` and CLEARS the pending timer FIRST so
315
+ the "rows removed" change that the engine's own `cleanup()` fires just before
316
+ teardown can't persist an empty snapshot over the good one, then drains any
317
+ in-flight write. The per-change write-back already persisted the latest rows
318
+ during the collection's life, so the durable snapshot survives for the next
319
+ load.
320
+ - Storage: `persistence/snapshot-store.ts` — raw IndexedDB, ONE blob per
321
+ `(namespace, collection)`, mirroring the outbox's hand-rolled queue (ZERO
322
+ `@tanstack/*`; the boundary check is the proof). N collections under one
323
+ namespace share ONE ref-counted store; the last detach closes the db.
324
+ - `registerDurableResource(namespace, { kind: 'persisted-collection', clear })` at
325
+ first attach, unregistered on last detach BEFORE the db closes (so a later
326
+ `wipeDurableStore` is a no-op, not a double-clear — the #1762 discipline).
327
+ - **Namespace segregation:** the namespace folds api/tenant/identity/manifest, so
328
+ switching users on one device lands on a different database — one user can never
329
+ read another's persisted rows.
330
+ - **Logout wipe clears the outbox too:** the outbox (#1762) registers its queue
331
+ under the SAME namespace, so ONE `wipeDurableStore(namespace)` clears BOTH the
332
+ persisted collections AND the outbox queue.
333
+ - **IndexedDB unavailable** (probe `open()` throws — private mode, sandboxed
334
+ iframe): `console.warn` ONCE + behave as non-persistent (warmStart returns
335
+ nothing, write-back is a no-op). Never throws — the outbox's `probeIndexedDb`
336
+ posture.
337
+
338
+ ### `updateAvailable` primitive — `createUpdateState(config)` (framework-free)
339
+
340
+ A tiny pub/sub (`update-state.ts`) with TWO INDEPENDENT signals; `updateAvailable
341
+ = bundle || contract`:
342
+
343
+ - **bundle** — the client BUNDLE changed on the server. SvelteKit detects it
344
+ natively (`updated` store); the consumer/smrt-svelte binding pushes it via
345
+ `notifyBundleUpdated()`. This module owns no polling.
346
+ - **contract** — the API CONTRACT (manifest hash) changed, which only SMRT knows.
347
+ Under BUILD-TIME INJECT: on init the primitive compares the RUNNING build's
348
+ `manifestHash` (passed in by the consumer, imported from
349
+ `@happyvertical/smrt-virt-web`) against a persisted "last-seen manifestHash" in
350
+ durable storage; if they differ → fire `contract` + store the new value. First
351
+ run (no baseline) records without firing.
352
+
353
+ The last-seen hash lives in `update-state/meta-store.ts` (a tiny IDB key/value
354
+ store) under the durable namespace, registered as a durable resource so
355
+ `wipeDurableStore` clears it too (the "wipe clears the last-seen-hash record"
356
+ AC). Degrades gracefully if IndexedDB is absent (bundle-only) or no running hash
357
+ is supplied.
358
+
359
+ > **Trade-off / documented follow-up:** contract detection under build-time
360
+ > inject fires at most once **per load** (compare-on-init). LIVE-while-open
361
+ > contract detection — learning mid-session that the server redeployed (an SSE
362
+ > deploy signal / runtime version endpoint) — is an EXPLICIT follow-up, not this
363
+ > slice; it would push a second `contract` signal into the same primitive, which
364
+ > is shaped for it.
365
+
366
+ The reactive Svelte binding (`useUpdateAvailable`) ships in
367
+ `@happyvertical/smrt-svelte/web` — it wires SvelteKit's `updated` store into the
368
+ bundle signal and surfaces both reactively for a toast/reload UX.
369
+
285
370
  ## The engine-absorption boundary (ratified conditions, #1761)
286
371
 
287
372
  1. **No engine types in the public API.** `@tanstack/*` types must never appear
package/dist/index.d.ts CHANGED
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Serialize `list` query params into the query string the generated REST list
3
+ * route parses (`handleList` in `core/src/generators/rest.ts`): `limit`,
4
+ * `offset`, `orderBy` as scalars, and `where` entries as `field=value`
5
+ * (equality) or `field[op]=value` for a `{ op, value }` condition (`in` joins an
6
+ * array with commas). Called with no params (the collection runtime's argument-
7
+ * free `list()`) it returns `''`, so the bare-URL behavior is unchanged.
8
+ */
9
+ export declare function buildListQuery(params?: Record<string, unknown>): string;
10
+
1
11
  /**
2
12
  * Build CRUD fetchers from a generated collection definition — the same URL
3
13
  * scheme and payload handling as the generated REST client
@@ -120,6 +130,17 @@ export declare function createSmrtWebClient(): SmrtWebClient;
120
130
  */
121
131
  export declare function createSmrtWebEventSubscriber(config: SmrtWebEventSubscriberConfig): SmrtWebEventSubscriber;
122
132
 
133
+ /**
134
+ * Create the framework-free `updateAvailable` primitive. Kicks off async
135
+ * contract detection immediately (compare running vs. persisted manifest hash);
136
+ * {@link UpdateState.notifyBundleUpdated} feeds the bundle signal. A change to
137
+ * EITHER signal notifies subscribers.
138
+ */
139
+ export declare function createUpdateState(config: UpdateStateConfig): UpdateState;
140
+
141
+ /** Default trailing-debounce window (ms) for the write-back. */
142
+ export declare const DEFAULT_PERSIST_DEBOUNCE_MS = 250;
143
+
123
144
  /**
124
145
  * A durable artifact registered under a namespace — the outbox queue (#1762) or
125
146
  * a persisted collection store (#1764). Each owns its storage engine and
@@ -412,6 +433,51 @@ export declare interface OutboxSnapshotItem {
412
433
  */
413
434
  export declare type OutboxSyncState = 'pending' | 'uploading' | 'synced' | 'failed';
414
435
 
436
+ /**
437
+ * Build a durable persistence capability for a collection. Add the returned
438
+ * capability to the collection's `capabilities` array; a collection without it
439
+ * is unaffected (the seam's no-op guarantee — the "opt-in per model" AC).
440
+ *
441
+ * The same `namespace` across multiple collections shares ONE snapshot database
442
+ * (one open, one durable resource, one wipe), and each collection is keyed by
443
+ * its own `collection` name within it.
444
+ */
445
+ export declare function persistCollection<TData extends object = object>(config: PersistCollectionConfig<TData>): SmrtWebCapability<TData>;
446
+
447
+ /**
448
+ * Configuration for {@link persistCollection}. Generic in the collection's row
449
+ * type `TData` so the capability matches the collection it plugs into.
450
+ */
451
+ export declare interface PersistCollectionConfig<TData extends object = object> {
452
+ /**
453
+ * The REST collection name this snapshot is keyed by — MUST be the SAME
454
+ * `definition.name` passed to {@link createSmrtCollection} (it is the store
455
+ * key under the namespace). Provided explicitly (not read off the ctx) so the
456
+ * config is self-describing and testable.
457
+ */
458
+ collection: string;
459
+ /**
460
+ * The durable-store identity this snapshot is namespaced under — folds api
461
+ * base / tenant / identity / manifest hash, so a logout, tenant switch, or a
462
+ * contract-changing deploy lands on a different IndexedDB database (never
463
+ * cross-identity reuse, and a shape change drops old snapshots). Shared with
464
+ * the outbox (#1762) via the same {@link durableStoreNamespace}, so one
465
+ * {@link wipeDurableStore} clears both.
466
+ *
467
+ * The canonical `manifestHash` source is the build-time inject — the
468
+ * `manifestHash` constant the generated `@happyvertical/smrt-virt-web` module
469
+ * exports (#1764). Thread it here at the call site.
470
+ */
471
+ namespace: DurableStoreKey;
472
+ /**
473
+ * Trailing-debounce window (ms) for the write-back snapshot. A burst of
474
+ * mutations within the window collapses to ONE IndexedDB write of the final
475
+ * row set. Default {@link DEFAULT_PERSIST_DEBOUNCE_MS}. Set 0 to write on
476
+ * every change (tests).
477
+ */
478
+ debounceMs?: number;
479
+ }
480
+
415
481
  /**
416
482
  * Register a durable artifact under a namespace so {@link wipeDurableStore} can
417
483
  * later clear it. Returns an unregister function that removes just this
@@ -420,6 +486,29 @@ export declare type OutboxSyncState = 'pending' | 'uploading' | 'synced' | 'fail
420
486
  */
421
487
  export declare function registerDurableResource(namespace: string, resource: DurableResource): () => void;
422
488
 
489
+ /**
490
+ * Register every collection's generated tool descriptors with WebMCP.
491
+ *
492
+ * @returns a disposer that deregisters all tools this call registered. On a
493
+ * browser without WebMCP the call is a no-op and the disposer is inert.
494
+ */
495
+ export declare function registerWebMcpTools(definitions: SmrtWebCollectionDefinition[], options?: RegisterWebMcpToolsOptions): () => void;
496
+
497
+ export declare interface RegisterWebMcpToolsOptions {
498
+ /** REST base path for the fetchers (default `/api/v1`). */
499
+ basePath?: string;
500
+ /** Injectable fetch (tests / SSR-safe wrappers). */
501
+ fetchFn?: typeof fetch;
502
+ /**
503
+ * Override how a definition's CRUD fetchers are built. Defaults to
504
+ * {@link createDefinitionFetchers}; the primary seam for testing `execute`
505
+ * without a live server.
506
+ */
507
+ resolveFetchers?: (definition: SmrtWebCollectionDefinition) => SmrtCrudFetchers;
508
+ /** Predicate to include/exclude individual tools (e.g. reads-only surfaces). */
509
+ filter?: (definition: SmrtWebCollectionDefinition, descriptor: NonNullable<SmrtWebCollectionDefinition['toolDescriptors']>[number]) => boolean;
510
+ }
511
+
423
512
  /**
424
513
  * Run the `wrapMutation` hook across `capabilities` in array order for one
425
514
  * mutation, short-circuiting on the FIRST capability that returns `{ handled:
@@ -535,6 +624,34 @@ export declare interface SmrtWebCapabilityContext<TData extends object = object>
535
624
  * message) without reaching into the engine.
536
625
  */
537
626
  invalidate(): void;
627
+ /**
628
+ * A snapshot of the collection's current rows as plain public DTOs — the same
629
+ * projection as {@link SmrtWebCollection.toArray}, so the engine's virtual
630
+ * props never cross the boundary. The persistence slice (#1764) reads this in
631
+ * its write-back to serialize the current cache to disk. Populated by the real
632
+ * factory; a hand-built test context may omit it, so callers guard for its
633
+ * presence.
634
+ *
635
+ * SAFE ONLY FROM `onAttach` ONWARD. The factory's implementation closes over
636
+ * the engine collection, which is constructed AFTER `contributeCacheKey` and
637
+ * `warmStart` run — calling this from those earlier hooks would throw
638
+ * (temporal-dead-zone). Read rows from `onAttach`, `onSettled`, or an external
639
+ * trigger, never during construction.
640
+ */
641
+ snapshot?(): ReadonlyArray<SmrtWebRow<TData>>;
642
+ /**
643
+ * Subscribe to the collection's change notifications — the same stream as
644
+ * {@link SmrtWebCollection.subscribeChanges} (plain-DTO payloads). The
645
+ * persistence slice (#1764) wires its debounced write-back here in `onAttach`
646
+ * and detaches in `teardown`. Engine-free by construction: the payload is
647
+ * `unknown` (the capability only needs the change SIGNAL, then re-reads via
648
+ * {@link snapshot}), so no engine type crosses the seam. Populated by the real
649
+ * factory; a hand-built test context may omit it.
650
+ *
651
+ * SAFE ONLY FROM `onAttach` ONWARD (same reason as {@link snapshot}): it closes
652
+ * over the engine collection built after the pre-construction hooks.
653
+ */
654
+ subscribe?(callback: (changes: unknown) => void): SmrtWebSubscription;
538
655
  }
539
656
 
540
657
  /**
@@ -578,12 +695,6 @@ export declare interface SmrtWebCollection<TData extends object> {
578
695
  insert(row: SmrtWebRow<TData>): SmrtWebTransaction;
579
696
  }
580
697
 
581
- /**
582
- * One generated collection definition: everything needed to construct a client
583
- * collection over the generated REST surface. The `_row` property is a phantom
584
- * type carrier threaded through codegen — it never exists at runtime, it only
585
- * lets factories infer the row type from a definition.
586
- */
587
698
  export declare interface SmrtWebCollectionDefinition<TData extends object = object> {
588
699
  /** REST collection name (e.g. `products`). */
589
700
  name: string;
@@ -595,6 +706,12 @@ export declare interface SmrtWebCollectionDefinition<TData extends object = obje
595
706
  idField: string;
596
707
  /** CRUD + custom actions exposed by the api decorator config. */
597
708
  actions: string[];
709
+ /**
710
+ * WebMCP/MCP tool descriptors for the exposed actions (#1812). Optional so
711
+ * hand-built definitions (older codegen, tests) still satisfy the type; a
712
+ * missing value means "no WebMCP tools to register".
713
+ */
714
+ toolDescriptors?: WebToolDescriptor[];
598
715
  /** Persisted field metadata keyed by field name. */
599
716
  fields: Record<string, SmrtWebFieldDefinition>;
600
717
  /**
@@ -821,6 +938,90 @@ export declare function unwrapItemResult(result: unknown, context: string): Reco
821
938
  */
822
939
  export declare function unwrapListResult(result: unknown, collectionName: string): Array<Record<string, unknown>>;
823
940
 
941
+ /** The two independent update signals plus their derived union. */
942
+ export declare interface UpdateAvailableState {
943
+ /** The client bundle changed on the server (SvelteKit `updated`). */
944
+ readonly bundle: boolean;
945
+ /** The API contract (manifest hash) changed across this load. */
946
+ readonly contract: boolean;
947
+ /** `bundle || contract` — either means an update is available. */
948
+ readonly updateAvailable: boolean;
949
+ }
950
+
951
+ /** The framework-free update-available primitive. */
952
+ export declare interface UpdateState {
953
+ /** The current signal state (a fresh immutable snapshot). */
954
+ get(): UpdateAvailableState;
955
+ /**
956
+ * Subscribe to state changes; the callback fires immediately with the current
957
+ * state and again on every transition. Returns an unsubscribe function.
958
+ */
959
+ subscribe(callback: (state: UpdateAvailableState) => void): () => void;
960
+ /**
961
+ * Push the BUNDLE signal — call from the consumer/smrt-svelte binding when
962
+ * SvelteKit's `updated` store flips true. Idempotent: once set it stays set
963
+ * (a bundle update does not un-happen).
964
+ */
965
+ notifyBundleUpdated(): void;
966
+ /**
967
+ * Resolves once the async contract detection has settled (compared the running
968
+ * hash against the persisted last-seen value and fired the signal if needed).
969
+ * Primarily for tests/SSR; UI code just subscribes.
970
+ */
971
+ readonly ready: Promise<void>;
972
+ /** Detach the durable resource (does NOT wipe — that's wipeDurableStore). */
973
+ dispose(): void;
974
+ }
975
+
976
+ /** Configuration for {@link createUpdateState}. */
977
+ export declare interface UpdateStateConfig {
978
+ /**
979
+ * The RUNNING build's web-collection shape digest — the `manifestHash`
980
+ * constant the generated `@happyvertical/smrt-virt-web` module exports
981
+ * (#1764). Compared against the persisted last-seen value to detect a contract
982
+ * change across loads. Omit to disable contract detection (bundle-only).
983
+ */
984
+ manifestHash?: string;
985
+ /**
986
+ * The durable-store identity the last-seen manifest hash is namespaced under —
987
+ * folds api base / tenant / identity (and, yes, the manifest hash itself, but
988
+ * see below). Shared with persistence/outbox so one {@link wipeDurableStore}
989
+ * clears the last-seen record too.
990
+ *
991
+ * NOTE the namespace's OWN `manifestHash` segment should be a STABLE value
992
+ * (e.g. the app's api-version or a constant), NOT the running build hash —
993
+ * otherwise a contract change would move the namespace and the compare would
994
+ * never find the prior value (always "first run"). Contract change is detected
995
+ * by the STORED VALUE under a stable namespace, not by the namespace moving.
996
+ */
997
+ namespace: DurableStoreKey;
998
+ }
999
+
1000
+ /**
1001
+ * One generated collection definition: everything needed to construct a client
1002
+ * collection over the generated REST surface. The `_row` property is a phantom
1003
+ * type carrier threaded through codegen — it never exists at runtime, it only
1004
+ * lets factories infer the row type from a definition.
1005
+ */
1006
+ /**
1007
+ * One WebMCP / MCP tool descriptor for a collection action (#1812). Emitted by
1008
+ * the core web-collections codegen as PLAIN DATA (this package has no smrt
1009
+ * dependency), shaped to match Chrome's `document.modelContext.registerTool`
1010
+ * input — see https://developer.chrome.com/docs/ai/webmcp. Consumed by
1011
+ * {@link registerWebMcpTools} in `./webmcp`.
1012
+ */
1013
+ export declare interface WebToolDescriptor {
1014
+ /** The action this tool performs (`list` | `get` | … | a custom method name). */
1015
+ action: string;
1016
+ /** Tool id, `${className.toLowerCase()}_${action}` (e.g. `product_list`). */
1017
+ name: string;
1018
+ description: string;
1019
+ /** JSON Schema for the tool's arguments. */
1020
+ inputSchema: Record<string, unknown>;
1021
+ /** True for non-mutating reads → WebMCP `annotations.readOnlyHint`. */
1022
+ readOnly: boolean;
1023
+ }
1024
+
824
1025
  /**
825
1026
  * Clear every durable artifact registered under `namespace`, then drop the
826
1027
  * namespace. This is the single teardown point a logout / tenant-switch calls: