@happyvertical/smrt-web 0.38.8 → 0.38.10

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
@@ -223,7 +223,9 @@ external trigger, `teardown` unregisters); it never touches the engine —
223
223
  `createSmrtWebClient`: construct once, pass to every collection; NOT
224
224
  auto-derived per collection — one EventSource / one poll loop feeds all).
225
225
  Config: `{ eventsUrl, changesUrl, fetchFn?, eventSourceFactory?,
226
- pollIntervalMs?=5000, withCredentials?=true }`. Public surface:
226
+ pollIntervalMs?=5000, withCredentials?=true, manifestHash?, updateState? }`.
227
+ `manifestHash` + `updateState` enable live contract detection from the
228
+ server's connection-open `manifest` SSE frame (#1859). Public surface:
227
229
  `{ transport: 'sse'|'polling'|'idle', registerTable(table, invalidate) →
228
230
  unregister, invalidateAll(), close() }`.
229
231
  - `liveInvalidation({ subscriber, tableName })` — the thin per-collection
@@ -238,15 +240,17 @@ half in `packages/core/src/generators/`):
238
240
  - Push — the `_events` SSE route (`events-route.ts`): **NAMED** events
239
241
  `event: change` / `event: resync`, `data` is `{table, operation, rowId,
240
242
  tenantId}`, and the cursor `seq` is carried **only** in the SSE `id:` field
241
- (the browser mirrors it to `MessageEvent.lastEventId`). `resync` uses that
242
- `id:` as the fresh horizon for any later polling downgrade. Heartbeats are
243
- `: heartbeat` comment lines EventSource ignores natively.
243
+ (the browser mirrors it to `MessageEvent.lastEventId`). `event: manifest`
244
+ carries `{ manifestHash }` at connection open so a reconnect can latch the
245
+ contract update signal. `resync` uses its `id:` as the fresh horizon for any
246
+ later polling downgrade. Heartbeats are `: heartbeat` comment lines
247
+ EventSource ignores natively.
244
248
  - Pull — the `_changes` route (`changes-route.ts`): `GET {changesUrl}?since=
245
249
  &tables=` → `{changes, cursor, resyncRequired?, resyncCursor?}`, the full
246
250
  fallback.
247
251
 
248
252
  **The NAMED-event gotcha.** The frames are named, so the subscriber wires
249
- `es.addEventListener('change', …)` / `('resync', …)` — **`onmessage` never
253
+ `es.addEventListener('change', …)` / `('resync', …)` / `('manifest', …)` — **`onmessage` never
250
254
  fires** for a named event and would silently receive nothing. A `change` frame's
251
255
  `data` is JSON-parsed **defensively**: malformed input is logged + dropped, never
252
256
  thrown back into the EventSource message loop (a throw there breaks later
@@ -348,7 +352,8 @@ A tiny pub/sub (`update-state.ts`) with TWO INDEPENDENT signals; `updateAvailabl
348
352
  `manifestHash` (passed in by the consumer, imported from
349
353
  `@happyvertical/smrt-virt-web`) against a persisted "last-seen manifestHash" in
350
354
  durable storage; if they differ → fire `contract` + store the new value. First
351
- run (no baseline) records without firing.
355
+ run (no baseline) records without firing. The live `_events` manifest frame
356
+ can also push the same sticky signal via `notifyContractUpdated()` (#1859).
352
357
 
353
358
  The last-seen hash lives in `update-state/meta-store.ts` (a tiny IDB key/value
354
359
  store) under the durable namespace, registered as a durable resource so
@@ -356,12 +361,11 @@ store) under the durable namespace, registered as a durable resource so
356
361
  AC). Degrades gracefully if IndexedDB is absent (bundle-only) or no running hash
357
362
  is supplied.
358
363
 
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.
364
+ > **Trade-off:** live contract detection is reconnect-based. The server hash is
365
+ > advertised when the `_events` stream opens, so a deploy surfaces when the tab
366
+ > reconnects (instant when the deploy drops old SSE connections, otherwise on
367
+ > the next natural reconnect). Reconnect-independent fan-out remains a later
368
+ > enhancement.
365
369
 
366
370
  The reactive Svelte binding (`useUpdateAvailable`) ships in
367
371
  `@happyvertical/smrt-svelte/web` — it wires SvelteKit's `updated` store into the
package/dist/index.d.ts CHANGED
@@ -133,8 +133,9 @@ export declare function createSmrtWebEventSubscriber(config: SmrtWebEventSubscri
133
133
  /**
134
134
  * Create the framework-free `updateAvailable` primitive. Kicks off async
135
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.
136
+ * {@link UpdateState.notifyBundleUpdated} feeds the bundle signal, and
137
+ * {@link UpdateState.notifyContractUpdated} feeds the live contract signal. A
138
+ * change to EITHER signal notifies subscribers.
138
139
  */
139
140
  export declare function createUpdateState(config: UpdateStateConfig): UpdateState;
140
141
 
@@ -807,6 +808,17 @@ export declare interface SmrtWebEventSubscriberConfig {
807
808
  pollIntervalMs?: number;
808
809
  /** `withCredentials` for the EventSource (cookie auth). Default true. */
809
810
  withCredentials?: boolean;
811
+ /**
812
+ * The RUNNING build's web-collection shape digest. When paired with
813
+ * `updateState`, a differing server hash from the `_events` manifest frame
814
+ * latches the contract update signal (#1859).
815
+ */
816
+ manifestHash?: string;
817
+ /**
818
+ * Optional update-state primitive from `createUpdateState()`. Only its
819
+ * contract signal is used here; bundle polling remains the caller's job.
820
+ */
821
+ updateState?: Pick<UpdateState, 'notifyContractUpdated'>;
810
822
  }
811
823
 
812
824
  /**
@@ -963,6 +975,12 @@ export declare interface UpdateState {
963
975
  * (a bundle update does not un-happen).
964
976
  */
965
977
  notifyBundleUpdated(): void;
978
+ /**
979
+ * Push the API CONTRACT signal — call when a live transport observes a server
980
+ * `manifestHash` that differs from this running build (#1859). Idempotent:
981
+ * once set it stays set (a contract mismatch does not un-happen).
982
+ */
983
+ notifyContractUpdated(): void;
966
984
  /**
967
985
  * Resolves once the async contract detection has settled (compared the running
968
986
  * hash against the persisted last-seen value and fired the signal if needed).
package/dist/index.js CHANGED
@@ -1108,7 +1108,7 @@ function defaultEventSourceFactory(url, init) {
1108
1108
  return new EventSourceCtor(url, init);
1109
1109
  }
1110
1110
  function createSmrtWebEventSubscriber(config) {
1111
- const { eventsUrl, changesUrl, fetchFn = (...args) => globalThis.fetch(...args), eventSourceFactory = defaultEventSourceFactory, pollIntervalMs = 5e3, withCredentials = true } = config;
1111
+ const { eventsUrl, changesUrl, fetchFn = (...args) => globalThis.fetch(...args), eventSourceFactory = defaultEventSourceFactory, pollIntervalMs = 5e3, withCredentials = true, manifestHash, updateState } = config;
1112
1112
  const tableInvalidators = /* @__PURE__ */ new Map();
1113
1113
  let lastSeq = null;
1114
1114
  let transport = "idle";
@@ -1170,6 +1170,19 @@ function createSmrtWebEventSubscriber(config) {
1170
1170
  advanceLastSeqFromEventId(ev.lastEventId);
1171
1171
  invalidateAll();
1172
1172
  };
1173
+ const onManifest = (ev) => {
1174
+ if (closed || manifestHash === void 0 || !updateState) return;
1175
+ try {
1176
+ const serverHash = JSON.parse(ev.data).manifestHash;
1177
+ if (typeof serverHash !== "string" || serverHash.length === 0) {
1178
+ warn("dropping manifest frame with no manifestHash", ev.data);
1179
+ return;
1180
+ }
1181
+ if (serverHash !== manifestHash) updateState.notifyContractUpdated();
1182
+ } catch (error) {
1183
+ warn("dropping malformed manifest frame", error);
1184
+ }
1185
+ };
1173
1186
  const poll = async () => {
1174
1187
  if (closed) return;
1175
1188
  const tables = registeredTables();
@@ -1207,6 +1220,7 @@ function createSmrtWebEventSubscriber(config) {
1207
1220
  eventSource = source;
1208
1221
  source.addEventListener("change", onChange);
1209
1222
  source.addEventListener("resync", onResync);
1223
+ source.addEventListener("manifest", onManifest);
1210
1224
  source.onerror = () => {
1211
1225
  if (closed) return;
1212
1226
  if (source.readyState === EVENT_SOURCE_CLOSED) {
@@ -1392,6 +1406,7 @@ function createUpdateState(config) {
1392
1406
  };
1393
1407
  },
1394
1408
  notifyBundleUpdated: setBundle,
1409
+ notifyContractUpdated: setContract,
1395
1410
  ready: (async () => {
1396
1411
  const runningHash = config.manifestHash;
1397
1412
  if (runningHash === void 0) return;