mikser-io-sdk-api 2.4.0 → 3.0.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/README.md CHANGED
@@ -103,7 +103,105 @@ Operations outside the endpoint's allowlist return `403`; missing or wrong token
103
103
 
104
104
  ## Entities
105
105
 
106
- `mikser.entities(endpointName, { token })` returns a per-endpoint client. The endpoint name matches a key in your `api.endpoints` config on the server.
106
+ `mikser.entities(endpointName, options)` returns a per-endpoint client. The endpoint name matches a key in your `api.endpoints` config on the server. Supported options:
107
+
108
+ | Option | Default | What it does |
109
+ |---|---|---|
110
+ | `token` | `null` | Bearer token for endpoints declared with a token. Sent on every request. |
111
+ | `data` | `{}` | Pairs the client with the mikser-io `data` plugin's static-file outputs. See below. |
112
+
113
+ ### `data` — pair with the `data` plugin for fast first paint
114
+
115
+ If you have a known, predictable query that runs on every page load — a route table for an SPA, a navigation menu, a per-document body — paying an API round-trip for it on first paint is wasted work. The mikser-io `data` plugin lets you publish that data as static files at build/finalize time, and the SDK consumes them on the client side. The option names on both sides are the same — `catalog` and `entities` — so it reads as one config split across the network.
116
+
117
+ Mikser's `data` plugin has two relevant blocks:
118
+
119
+ ```js
120
+ // mikser.config.js
121
+ data: {
122
+ catalog: {
123
+ // out/data/sitemap.json — one combined file for the whole catalog,
124
+ // projected to just the fields your router/nav needs.
125
+ sitemap: {
126
+ query: e => e.type === 'document' && e.meta?.published && e.meta?.component,
127
+ pick: ['id', 'destination', 'meta.component', 'meta.route', 'meta.title'],
128
+ },
129
+ },
130
+ entities: {
131
+ // out/data/<entity.name>.page.json — one file per published document,
132
+ // with full content. Consumed by useDocument(id) for first-paint
133
+ // single-doc reads.
134
+ page: {
135
+ query: e => e.type === 'document' && e.meta?.published,
136
+ pick: ['id', 'meta', 'content'],
137
+ },
138
+ },
139
+ },
140
+
141
+ api: {
142
+ endpoints: {
143
+ public: {
144
+ query: e => e.type === 'document' && e.meta?.published,
145
+ operations: ['list', 'subscribe'],
146
+ cache: true,
147
+ },
148
+ },
149
+ },
150
+ ```
151
+
152
+ On the client, name the same blocks. The names you pass match the keys you used on the server side:
153
+
154
+ ```js
155
+ const documents = createClient({ baseUrl: 'https://cms.example.com' })
156
+ .entities('public', {
157
+ data: {
158
+ catalog: 'sitemap', // /data/sitemap.json
159
+ entities: 'page', // /data/<entry.name>.page.json
160
+ },
161
+ })
162
+ ```
163
+
164
+ What gets used when:
165
+
166
+ - **`live({id})` / `useDocument(id)`** — if `data.entities` is set, the SDK fetches the matching per-entity file instead of calling the API. Needs `data.catalog` to be loaded too, so the entry's `name` is known. The SSE subscribe still opens for live updates.
167
+ - **`live(filter, onChange, options)` with no filter** / **`listAll()`** — if `data.catalog` is set, the SDK consults `/data/<catalog>.json` for first paint, then opens SSE.
168
+ - **`list()`**, **`urlFor()`**, **`query()`**, **`update()`**, **`delete()`**, **`render()`**, **`subscribe()`** — unchanged, always hit the API.
169
+ - **Any file fetch failure** — falls back to the live API for that call. No separate flag.
170
+
171
+ The data plugin emits each entry as `{ refId, name, date, data: {...picked} }`. The SDK strips that wrapper automatically — `onChange` and `listAll` always see plain payloads regardless of source.
172
+
173
+ This is **not** a cache. The data plugin's files are only consulted for the initial fill; ongoing changes come over SSE on the actual API endpoint. For runtime fail-safety on the live API, that's what the api plugin's `cache: true` is for — see [mikser-io's caching docs](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/caching.md).
174
+
175
+ **Edge case: first-paint flash for fast-changing snapshots.** First paint renders whatever the data plugin last wrote — which may be N seconds old — and the SSE stream then arrives and reconciles any changes since. For a route table that's invisible; routes don't move every second. For snapshots of fast-changing data (recent activity, in-stock badges, live counters) the user may briefly see stale content before SSE catches up. If that flash is visible UX, either design for it (subtle "syncing" indicator until the first SSE event arrives, or render a loading state when the snapshot age exceeds your tolerance) or skip `data.catalog` / `data.entities` for that particular client — paying the API roundtrip is the right tradeoff when the response can't be allowed to be stale.
176
+
177
+ **Snapshot-bypass warning.** Snapshots only apply when the `live()` / `listAll()` call is trivial — no filter, no sort, no skip. Add any of those and the SDK silently falls back to the live API. Since that's the kind of thing a developer can change without noticing, the SDK emits a one-time `console.warn` per `(endpoint, call kind, what-was-set)` shape:
178
+
179
+ ```
180
+ [mikser-sdk] data.catalog is set on "public" but this live() call uses filter+sort
181
+ — snapshot bypassed, falling back to live list().
182
+ Snapshots only apply when the call is trivial (no filter/sort/skip).
183
+ Either remove the filter+sort from this call, or accept the API roundtrip if
184
+ filtering is intentional.
185
+ Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.
186
+ ```
187
+
188
+ Same suppression channels as the wide-list warning above.
189
+
190
+ ### Dev-mode warning: accidentally wide queries
191
+
192
+ The common failure mode for a CMS-backed app is "I just wanted a nav menu but pulled every full document over the wire." To catch it at write time, `list()` and `live()` emit a one-time dev-mode `console.warn` when a response has more than 50 items and the query has no `fields:` projection:
193
+
194
+ ```
195
+ [mikser-sdk] list() returned 247 items (~4.2 MB) from "public" with no `fields` projection.
196
+ Add { fields: [...] } to narrow it, or — if this query runs on every page load —
197
+ move it to a `data.catalog.<name>` snapshot on the mikser side and load via:
198
+ entities('public', { data: { catalog: '<name>' } })
199
+ Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.
200
+ ```
201
+
202
+ The warning is deduped per `(endpoint, filter, sort)` shape, so an SSE-driven list that updates 30 times only fires once. It's silenced when `process.env.NODE_ENV === 'production'`, when `MIKSER_QUIET` is set, or per-call via `{ quiet: true }` on `list(query, opts)` / `live(filter, onChange, opts)`.
203
+
204
+ The mikser-io server emits a matching warning in its logs for the same shape — useful when the wide query came from curl, another SDK, or an environment where `console` isn't visible.
107
205
 
108
206
  ### `list(query)` — body-based
109
207
 
package/index.d.ts CHANGED
@@ -16,6 +16,37 @@ export interface ClientOptions {
16
16
  export interface EntityOptions {
17
17
  /** Bearer token sent on every request to this endpoint. */
18
18
  token?: string
19
+ /**
20
+ * Mirrors the mikser-io `data` plugin's config block. The SDK
21
+ * consumes the static JSON files the data plugin writes under
22
+ * `out/data/` so first-paint reads come from disk (CDN-cacheable)
23
+ * instead of hitting the live API.
24
+ *
25
+ * The names are the same as on the server:
26
+ * - `data.catalog` pairs with `data.catalog.<name>` on mikser
27
+ * - `data.entities` pairs with `data.entities.<name>` on mikser
28
+ *
29
+ * On a fetch failure the SDK silently falls back to the live API
30
+ * for that call — no separate flag.
31
+ */
32
+ data?: DataOptions
33
+ }
34
+
35
+ export interface DataOptions {
36
+ /**
37
+ * Name of a `data.catalog.<name>` block on the server. The SDK
38
+ * loads `/data/<name>.json` (one combined file) on first paint;
39
+ * `live()` and `listAll()` consult it before hitting the API.
40
+ */
41
+ catalog?: string
42
+ /**
43
+ * Name of a `data.entities.<name>` block on the server. When
44
+ * `live({id})` (the shape `useDocument` issues) fires, the SDK
45
+ * loads `/data/<entry.name>.<name>.json` (one file per entity)
46
+ * instead of calling the API. Requires `data.catalog` so the
47
+ * `entry.name` mapping is available.
48
+ */
49
+ entities?: string
19
50
  }
20
51
 
21
52
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-sdk-api",
3
- "version": "2.4.0",
3
+ "version": "3.0.0",
4
4
  "description": "Client SDK for mikser-io's api plugin — query the document catalog from the browser or Node",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/src/entities.js CHANGED
@@ -7,20 +7,234 @@ import { bearer, jsonOrThrow } from './http.js'
7
7
  import { joinUrl, sortToParam, filterToParams } from './url.js'
8
8
  import { parseSseEvent } from './sse.js'
9
9
 
10
+ // Conservative URL-length ceiling for the GET form of list(). Real
11
+ // browsers and proxies vary (Chrome ~32k, IIS ~16k, nginx default
12
+ // 8k, some CDNs 4k), but the safest interop floor for list-with-
13
+ // many-filter-params is well under 2k. Queries past this fall back
14
+ // to POST automatically.
15
+ const GET_MAX_URL = 1800
16
+
17
+ // Wide-list warning — surfaces in dev mode when a list/live call
18
+ // returns more than this without a `fields` projection. Catches the
19
+ // common "I just wanted a nav menu but pulled every full document"
20
+ // failure mode at the first place it manifests (developer's DevTools
21
+ // console). Server-side has a matching warning that fires for all
22
+ // clients regardless of SDK use.
23
+ const WIDE_RESPONSE_ITEMS = 50
24
+ const _warnedShapes = new Set()
25
+
26
+ function isProductionEnv() {
27
+ try {
28
+ return typeof process !== 'undefined'
29
+ && process.env?.NODE_ENV === 'production'
30
+ } catch { return false }
31
+ }
32
+ function isQuiet() {
33
+ try {
34
+ return typeof process !== 'undefined' && process.env?.MIKSER_QUIET
35
+ } catch { return false }
36
+ }
37
+
38
+ function maybeWarnWide({ endpoint, query, envelopeOrItems, quiet }) {
39
+ if (quiet || isProductionEnv() || isQuiet()) return
40
+ const items = Array.isArray(envelopeOrItems)
41
+ ? envelopeOrItems
42
+ : envelopeOrItems?.items
43
+ if (!Array.isArray(items) || items.length <= WIDE_RESPONSE_ITEMS) return
44
+ const hasFields = Array.isArray(query?.fields) && query.fields.length > 0
45
+ if (hasFields) return
46
+ const shape = `${endpoint}|${JSON.stringify(query?.filter ?? null)}|${JSON.stringify(query?.sort ?? null)}`
47
+ if (_warnedShapes.has(shape)) return
48
+ _warnedShapes.add(shape)
49
+ let sizeNote = ''
50
+ try {
51
+ const bytes = JSON.stringify(items).length
52
+ sizeNote = bytes >= 1024 * 1024
53
+ ? ` (~${(bytes / 1024 / 1024).toFixed(1)} MB)`
54
+ : ` (~${Math.round(bytes / 1024)} KB)`
55
+ } catch { /* size note is best-effort */ }
56
+ console.warn(
57
+ `[mikser-sdk] list() returned ${items.length} items${sizeNote} from "${endpoint}" with no \`fields\` projection.\n` +
58
+ ` Add { fields: [...] } to narrow it, or — if this query runs on every page load —\n` +
59
+ ` move it to a \`data.catalog.<name>\` snapshot on the mikser side and load via:\n` +
60
+ ` entities('${endpoint}', { data: { catalog: '<name>' } })\n` +
61
+ ` Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.`,
62
+ )
63
+ }
64
+
65
+ // Snapshot-bypass warning — fires when `data.catalog` is configured
66
+ // but the call is non-trivial (has filter / sort / skip), so the
67
+ // snapshot can't be used and the SDK falls back to the live API. The
68
+ // bypass is correct behavior, but it's silent: developers often set
69
+ // data.catalog once and then add a sort to one of their useDocuments
70
+ // calls without noticing the snapshot is no longer involved. Deduped
71
+ // per (endpoint, kind, what-was-set) so a page with 3 filtered calls
72
+ // produces 3 warnings, not 30.
73
+ const _bypassedShapes = new Set()
74
+ function maybeWarnSnapshotBypass({ endpoint, kind, filter, sort, skip, quiet }) {
75
+ if (quiet || isProductionEnv() || isQuiet()) return
76
+ const reasons = []
77
+ if (filter) reasons.push('filter')
78
+ if (sort) reasons.push('sort')
79
+ if (skip != null) reasons.push('skip')
80
+ if (reasons.length === 0) return
81
+ const reasonLabel = reasons.join('+')
82
+ const shape = `${endpoint}|${kind}|${reasonLabel}`
83
+ if (_bypassedShapes.has(shape)) return
84
+ _bypassedShapes.add(shape)
85
+ const fallback = kind === 'live' ? 'live list()' : 'paginated fetch'
86
+ console.warn(
87
+ `[mikser-sdk] data.catalog is set on "${endpoint}" but this ${kind}() call uses ${reasonLabel} — snapshot bypassed, falling back to ${fallback}.\n` +
88
+ ` Snapshots only apply when the call is trivial (no filter/sort/skip).\n` +
89
+ ` Either remove the ${reasonLabel} from this call, or accept the API roundtrip if filtering is intentional.\n` +
90
+ ` Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.`,
91
+ )
92
+ }
93
+
10
94
  export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, headers: defaultHeaders }) {
11
- return function entities(name, { token } = {}) {
95
+ return function entities(name, opts = {}) {
96
+ const {
97
+ token,
98
+ // `data` mirrors the mikser-io `data` plugin's config block:
99
+ //
100
+ // data: {
101
+ // catalog: 'sitemap', // pairs with data.catalog.sitemap
102
+ // entities: 'page', // pairs with data.entities.page
103
+ // }
104
+ //
105
+ // On the server the data plugin writes:
106
+ // - catalog.<name> → out/data/<name>.json (one combined file)
107
+ // - entities.<name> → out/data/<entity.name>.<name>.json (one file per entity)
108
+ //
109
+ // On the client:
110
+ // - `data.catalog` makes live() / listAll() consult
111
+ // /data/<this>.json on first paint, falling back to a
112
+ // fresh list() if the file is missing.
113
+ // - `data.entities` makes live({id}) consult the per-entity
114
+ // file /data/<entry.name>.<this>.json, falling back to a
115
+ // fresh list({filter:{id}}) call. Requires `data.catalog`
116
+ // to be loaded so the entity's `name` is known (the
117
+ // mapping comes from the catalog wrapper, not the id).
118
+ //
119
+ // Default URL prefix is /data/ to match the data plugin's
120
+ // default `dataFolder`. If a project customizes `data.dataFolder`
121
+ // server-side, add a matching prefix here — but for now this
122
+ // is hardcoded.
123
+ data: dataConfig = {},
124
+ } = opts
125
+ const { catalog: catalogName, entities: entitiesName } = dataConfig
12
126
  const endpointBase = `${basePath}/${name}`
13
127
  const queryUrl = joinUrl(baseUrl, `${endpointBase}/entities/query`)
14
128
  const listUrl = joinUrl(baseUrl, `${endpointBase}/entities`)
15
129
  const subscribeUrl = joinUrl(baseUrl, `${endpointBase}/entities/subscribe`)
16
130
  const renderUrl = joinUrl(baseUrl, `${endpointBase}/render`)
17
131
 
132
+ // Default URL prefix matches the data plugin's default folder.
133
+ const DATA_PREFIX = '/data'
134
+
135
+ const catalogUrl = catalogName
136
+ ? joinUrl(baseUrl, `${DATA_PREFIX}/${catalogName}.json`)
137
+ : null
138
+
139
+ // id → entity.name index, populated when the catalog snapshot
140
+ // loads. Lets `live({id})` derive the per-entity file URL
141
+ // without re-fetching the catalog or guessing.
142
+ const nameById = new Map()
143
+
144
+ // Cached snapshot promise — concurrent live() / listAll() calls
145
+ // share one fetch. Cleared on error so a flaky connection can
146
+ // recover on the next call.
147
+ let snapshotPromise = null
148
+ async function loadSnapshot() {
149
+ if (!catalogUrl) return null
150
+ if (snapshotPromise) return snapshotPromise
151
+ snapshotPromise = (async () => {
152
+ try {
153
+ const res = await doFetch(catalogUrl, {
154
+ method: 'GET',
155
+ headers: { accept: 'application/json', ...defaultHeaders },
156
+ })
157
+ if (!res.ok) return null
158
+ const payload = await res.json()
159
+ return unwrapSnapshot(payload, nameById)
160
+ } catch (err) {
161
+ // Clear the cached promise so the next call can retry.
162
+ snapshotPromise = null
163
+ return null
164
+ }
165
+ })()
166
+ return snapshotPromise
167
+ }
168
+
169
+ // Per-entity file fetch — for live({id}) when data.entities is
170
+ // configured. Always returns either the entity or null (null
171
+ // means "fall back to the API"). Never throws.
172
+ async function loadEntityFile(id) {
173
+ if (!entitiesName) return null
174
+ const entityName = nameById.get(id)
175
+ if (!entityName) {
176
+ // The catalog isn't loaded yet, or this id wasn't in it.
177
+ // Trigger a catalog load lazily; the next call will hit
178
+ // the populated map.
179
+ await loadSnapshot()
180
+ if (!nameById.has(id)) return null
181
+ }
182
+ const url = joinUrl(baseUrl, `${DATA_PREFIX}/${nameById.get(id)}.${entitiesName}.json`)
183
+ try {
184
+ const res = await doFetch(url, {
185
+ method: 'GET',
186
+ headers: { accept: 'application/json', ...defaultHeaders },
187
+ })
188
+ if (!res.ok) return null
189
+ const payload = await res.json()
190
+ // Per-entity files are single-object wrappers:
191
+ // { refId, name, date, data: {...} }
192
+ // unwrapEntityFile pulls out `data`.
193
+ return unwrapEntityFile(payload)
194
+ } catch {
195
+ return null
196
+ }
197
+ }
198
+
18
199
  /**
19
- * Body-based query. Send everything sift accepts —
20
- * $and / $or / $regex, projections, sorts. Returns the standard
21
- * envelope: { items, page, limit, total, totalPages, hasNext, hasPrev }.
200
+ * Run a list query. Returns the standard envelope:
201
+ * { items, page, limit, total, totalPages, hasNext, hasPrev }.
202
+ *
203
+ * Transport selection: tries GET first because GET responses
204
+ * are what the api plugin's per-query disk cache writes (and
205
+ * what a reverse proxy can serve as failover when mikser is
206
+ * down). Falls back to POST when:
207
+ * - the encoded URL exceeds GET_MAX_URL (browsers and most
208
+ * proxies start refusing past ~2KB; we use a conservative
209
+ * limit of 1800)
210
+ * - GET is explicitly disabled via opts.method = 'POST'
211
+ *
212
+ * GET can express anything sift accepts via the `.$op` URL-param
213
+ * suffix scheme (see urlFor). Use POST when you want to be
214
+ * explicit about not engaging the cache path, e.g. for queries
215
+ * with secrets in the body that shouldn't sit in proxy logs.
22
216
  */
23
- async function list(query = {}) {
217
+ async function list(query = {}, opts = {}) {
218
+ const forcePost = opts.method === 'POST'
219
+
220
+ if (!forcePost) {
221
+ const url = urlFor(query)
222
+ if (url.length <= GET_MAX_URL) {
223
+ const res = await doFetch(url, {
224
+ method: 'GET',
225
+ headers: {
226
+ accept: 'application/json',
227
+ ...defaultHeaders,
228
+ ...bearer(token),
229
+ },
230
+ })
231
+ const envelope = await jsonOrThrow(res, url)
232
+ maybeWarnWide({ endpoint: name, query, envelopeOrItems: envelope, quiet: opts.quiet })
233
+ return envelope
234
+ }
235
+ }
236
+
237
+ // POST fallback — long URLs and forced POST land here.
24
238
  const res = await doFetch(queryUrl, {
25
239
  method: 'POST',
26
240
  headers: {
@@ -30,7 +244,9 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
30
244
  },
31
245
  body: JSON.stringify(query),
32
246
  })
33
- return jsonOrThrow(res, queryUrl)
247
+ const envelope = await jsonOrThrow(res, queryUrl)
248
+ maybeWarnWide({ endpoint: name, query, envelopeOrItems: envelope, quiet: opts.quiet })
249
+ return envelope
34
250
  }
35
251
 
36
252
  /**
@@ -78,6 +294,27 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
78
294
  * is wasteful. Use pages() directly and stream-process there.
79
295
  */
80
296
  async function listAll(query = {}) {
297
+ // Snapshot fast path: if no filter/sort/skip is requested
298
+ // beyond what the snapshot was built with, return the
299
+ // pre-built array directly. The caller's filter/sort would
300
+ // require re-querying the live endpoint anyway, so any
301
+ // non-trivial query falls through to the paginated fetch.
302
+ const trivial = !query.filter && !query.sort && !query.skip
303
+ if (trivial && catalogUrl) {
304
+ const snapshot = await loadSnapshot()
305
+ if (snapshot) {
306
+ return query.fields
307
+ ? snapshot.map(item => pickFields(item, query.fields))
308
+ : snapshot
309
+ }
310
+ // Snapshot unavailable — fall through to paginated fetch.
311
+ }
312
+ if (!trivial && catalogUrl) {
313
+ maybeWarnSnapshotBypass({
314
+ endpoint: name, kind: 'listAll',
315
+ filter: query.filter, sort: query.sort, skip: query.skip,
316
+ })
317
+ }
81
318
  const items = []
82
319
  for await (const env of pages({ limit: 1000, ...query })) {
83
320
  items.push(...env.items)
@@ -170,6 +407,7 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
170
407
  function live(filter, onChange, options = {}) {
171
408
  const {
172
409
  sort, fields, limit, skip,
410
+ quiet,
173
411
  signal: externalSignal,
174
412
  onError = (err) => console.error('mikser-io-sdk-api live error:', err),
175
413
  } = options
@@ -185,10 +423,65 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
185
423
 
186
424
  const loop = (async () => {
187
425
  try {
188
- const env = await list({ filter, sort, fields, limit, skip })
189
- if (disposed || ac.signal.aborted) return
190
- items = env.items
191
- onChange(items)
426
+ let usedFastPath = false
427
+ const trivial = !filter && !sort && !skip
428
+
429
+ // Per-entity file fast path. When the call is a
430
+ // single-id lookup (the shape useDocument issues)
431
+ // and `data.entities` is configured, fetch the
432
+ // pre-built file the data plugin wrote instead of
433
+ // calling the API. Falls back to list() if the
434
+ // file isn't there.
435
+ const isSingleIdLookup = (
436
+ entitiesName &&
437
+ filter && typeof filter === 'object' &&
438
+ Object.keys(filter).length === 1 &&
439
+ 'id' in filter && filter.id != null
440
+ )
441
+ if (isSingleIdLookup) {
442
+ const entity = await loadEntityFile(filter.id)
443
+ if (entity) {
444
+ if (disposed || ac.signal.aborted) return
445
+ items = fields ? [pickFields(entity, fields)] : [entity]
446
+ onChange(items)
447
+ usedFastPath = true
448
+ }
449
+ }
450
+
451
+ // Catalog snapshot fast path. Used only when the
452
+ // call is trivial enough that the pre-built array
453
+ // reflects what list() would return — anything
454
+ // more specific (filter, sort, skip) goes through
455
+ // list() so the caller's intent is honored.
456
+ if (!usedFastPath && trivial && catalogUrl) {
457
+ const snapshot = await loadSnapshot()
458
+ if (snapshot) {
459
+ if (disposed || ac.signal.aborted) return
460
+ items = fields ? snapshot.map(i => pickFields(i, fields)) : snapshot
461
+ onChange(items)
462
+ usedFastPath = true
463
+ }
464
+ }
465
+
466
+ // Bypass warning for cases where the user opted into
467
+ // a snapshot but their call doesn't fit either fast
468
+ // path. The single-id lookup is its own valid shape,
469
+ // so don't warn for it.
470
+ if (!usedFastPath && !isSingleIdLookup && !trivial && catalogUrl) {
471
+ maybeWarnSnapshotBypass({
472
+ endpoint: name, kind: 'live',
473
+ filter, sort, skip, quiet,
474
+ })
475
+ }
476
+
477
+ if (!usedFastPath) {
478
+ // Pass { quiet } so list()'s wide-warning honors
479
+ // the live() caller's quiet opt.
480
+ const env = await list({ filter, sort, fields, limit, skip }, { quiet })
481
+ if (disposed || ac.signal.aborted) return
482
+ items = env.items
483
+ onChange(items)
484
+ }
192
485
 
193
486
  for await (const event of watch({ filter }, { signal: ac.signal })) {
194
487
  if (disposed) return
@@ -288,3 +581,79 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
288
581
  return { list, listAll, urlFor, pages, watch, live, update, delete: remove, render }
289
582
  }
290
583
  }
584
+
585
+ // Unwrap a snapshot payload into a plain array of entity-like objects.
586
+ // Recognises three shapes:
587
+ // - data-plugin catalog output: [{ refId, name, date, data }, ...]
588
+ // - plain array of entities: [{ id, meta, ... }, ...]
589
+ // - list() envelope: { items: [...], page, ... }
590
+ // Returns null on unrecognised shapes so the caller can decide to fall
591
+ // back to a fresh list() call.
592
+ function unwrapSnapshot(payload, nameById) {
593
+ if (Array.isArray(payload)) {
594
+ if (payload.length === 0) return []
595
+ // Heuristic: data-plugin entries have `refId` + `data`; treat any
596
+ // object with `data` as a wrapped entry and unwrap. Anything else
597
+ // is a plain array.
598
+ if (payload[0] && typeof payload[0] === 'object' && 'data' in payload[0]) {
599
+ return payload
600
+ .map(entry => {
601
+ if (!entry || !entry.data) return null
602
+ // Side-table: stash entity.name keyed by entity.id so
603
+ // live({id}) can compute per-entity file URLs later.
604
+ if (nameById && entry.data.id != null && entry.name != null) {
605
+ nameById.set(entry.data.id, entry.name)
606
+ }
607
+ return entry.data
608
+ })
609
+ .filter(Boolean)
610
+ }
611
+ return payload
612
+ }
613
+ if (payload && typeof payload === 'object' && Array.isArray(payload.items)) {
614
+ return payload.items
615
+ }
616
+ return null
617
+ }
618
+
619
+ // Per-entity files are single-object wrappers — `{ refId, name, date,
620
+ // data: {...} }` — written by `data.entities.<name>` on the server.
621
+ // Returns the unwrapped data, or null if the shape isn't recognised.
622
+ function unwrapEntityFile(payload) {
623
+ if (!payload || typeof payload !== 'object') return null
624
+ if ('data' in payload && payload.data && typeof payload.data === 'object') {
625
+ return payload.data
626
+ }
627
+ // Plain entity (someone hand-wrote a JSON file, no wrapper) — accept it.
628
+ if ('id' in payload) return payload
629
+ return null
630
+ }
631
+
632
+ // Project an entity object to only the fields requested. Dotted paths
633
+ // supported ("meta.title" → object with `meta.title` set, other meta
634
+ // keys dropped). Used by the snapshot fast paths in list/live so a
635
+ // caller asking for narrow fields still gets narrow data from the
636
+ // snapshot — saves a re-fetch and keeps memory/bundles smaller.
637
+ function pickFields(entity, fields) {
638
+ const out = {}
639
+ for (const field of fields) {
640
+ const parts = field.split('.')
641
+ let src = entity, dst = out
642
+ for (let i = 0; i < parts.length - 1; i++) {
643
+ const part = parts[i]
644
+ if (src == null || typeof src !== 'object') { src = undefined; break }
645
+ src = src[part]
646
+ if (dst[part] == null || typeof dst[part] !== 'object') dst[part] = {}
647
+ dst = dst[part]
648
+ }
649
+ if (src !== undefined) {
650
+ const last = parts[parts.length - 1]
651
+ if (src != null && typeof src === 'object' && last in src) {
652
+ dst[last] = src[last]
653
+ } else if (parts.length === 1 && entity[last] !== undefined) {
654
+ out[last] = entity[last]
655
+ }
656
+ }
657
+ }
658
+ return out
659
+ }