mikser-io-sdk-api 2.4.0 → 2.5.1

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,94 @@ 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
+ | `initialUrl` | `null` | Static-snapshot URL — see below. |
112
+ | `fallbackToList` | `true` | If `initialUrl` is set and the fetch fails (404 in dev, wrong shape, network error), fall back to a live `list()`. Set `false` for production deploys where missing the snapshot should be a hard error. |
113
+
114
+ ### `initialUrl` — pair with the `data` plugin for fast first paint
115
+
116
+ If you have a known, predictable query that runs on every page load — a route table for an SPA, a navigation menu, a category list — paying an API round-trip for it on first paint is wasted work. The right shape is to publish that data as a static file at build/finalize time and have the SDK pick it up.
117
+
118
+ Mikser's `data` plugin does the publishing side. A `catalog.<name>` entry writes one JSON file per name under `out/data/`; `<name>` is whatever you want to call it — name it after the role the snapshot plays in your app (`sitemap`, `menu`, `tags`, `categories`, etc.). Example for a router-driving snapshot:
119
+
120
+ ```js
121
+ // mikser.config.js
122
+ plugins: ['documents', 'front-matter', 'data', 'api', /* ... */],
123
+
124
+ data: {
125
+ catalog: {
126
+ // out/data/sitemap.json — one entry per published, component-having
127
+ // document, projected to just the routing fields. `sitemap` is the
128
+ // catalog-entry name; the SDK below uses the matching URL path.
129
+ sitemap: {
130
+ query: e => e.type === 'document' && e.meta?.published && e.meta?.component,
131
+ pick: ['id', 'destination', 'meta.component', 'meta.route', 'meta.title'],
132
+ },
133
+ },
134
+ },
135
+
136
+ api: {
137
+ endpoints: {
138
+ public: {
139
+ query: e => e.type === 'document' && e.meta?.published,
140
+ operations: ['list', 'subscribe'],
141
+ cache: true,
142
+ },
143
+ },
144
+ },
145
+ ```
146
+
147
+ On the client, point `entities()` at the matching file. The URL path mirrors the catalog name: `data.catalog.sitemap` → `/data/sitemap.json`, `data.catalog.menu` → `/data/menu.json`, and so on.
148
+
149
+ ```js
150
+ const documents = createClient({ baseUrl: 'https://cms.example.com' })
151
+ .entities('public', { initialUrl: '/data/sitemap.json' })
152
+ ```
153
+
154
+ What `initialUrl` changes:
155
+
156
+ - **`live(filter, onChange, options)`** fires `onChange` with the snapshot immediately (no API round-trip), then opens the SSE subscribe stream as usual. So `useMikserRoutes` / `useMikserPages` get a populated route table before the network even settles.
157
+ - **`listAll()`** consults the snapshot first and only falls back to paginated `list()` calls if the snapshot is missing or `fallbackToList: true` and the fetch failed.
158
+ - **`list()`**, `urlFor()`, `query()`, `update()`, `delete()`, `render()`, `subscribe()` are unchanged — they always go to the API.
159
+
160
+ The data plugin emits each entry as `{ refId, name, date, data: {...picked} }`. The SDK strips that wrapper automatically and hands `onChange` / `listAll` a plain array of the `data` payloads, so a static snapshot looks identical to a live response.
161
+
162
+ This is **not** a cache. The snapshot is only consulted for the initial fill; ongoing changes come over SSE on the actual API endpoint. For runtime fail-safety on per-id reads, 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).
163
+
164
+ **Edge case: first-paint flash for fast-changing snapshots.** First paint renders the snapshot — which may be N seconds old, depending on when mikser last wrote it — 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 (e.g. show a subtle "syncing" indicator until the first SSE event arrives, or render a loading state when the snapshot age exceeds your tolerance) or skip `initialUrl` for that particular query — paying the API roundtrip is the right tradeoff when the response can't be allowed to be stale.
165
+
166
+ **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:
167
+
168
+ ```
169
+ [mikser-sdk] initialUrl is set on "public" but this live() call uses filter+sort
170
+ — snapshot bypassed, falling back to live list().
171
+ Snapshots only apply when the call is trivial (no filter/sort/skip).
172
+ Either remove the filter+sort from this call, or accept the API roundtrip if
173
+ filtering is intentional.
174
+ Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.
175
+ ```
176
+
177
+ Same suppression channels as the wide-list warning above.
178
+
179
+ ### Dev-mode warning: accidentally wide queries
180
+
181
+ 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:
182
+
183
+ ```
184
+ [mikser-sdk] list() returned 247 items (~4.2 MB) from "public" with no `fields` projection.
185
+ Add { fields: [...] } to narrow it, or — if this query runs on every page load —
186
+ move it to a `data.catalog.<name>` snapshot on the mikser side and load via:
187
+ entities('public', { initialUrl: '/data/<name>.json' })
188
+ Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.
189
+ ```
190
+
191
+ 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)`.
192
+
193
+ 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
194
 
108
195
  ### `list(query)` — body-based
109
196
 
package/index.d.ts CHANGED
@@ -16,6 +16,32 @@ 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
+ * URL (relative to baseUrl or absolute) of a pre-built static JSON
21
+ * snapshot of the entity set. When set, `live()` and `listAll()`
22
+ * read from this URL on first call instead of hitting the live API.
23
+ *
24
+ * Largely obsolete with the api plugin's per-query disk cache
25
+ * (mikser-io ^6.25.0) — the cache writes to the same URL the live
26
+ * API serves, and a reverse proxy can fail over to it transparently.
27
+ * Use `initialUrl` only when:
28
+ * - the snapshot URL is a different host (CDN, edge cache,
29
+ * pre-rendered static asset on disk)
30
+ * - you want to skip the live API roundtrip on boot even when
31
+ * mikser is up (microoptimization)
32
+ *
33
+ * Otherwise, just configure your reverse proxy to fail over to the
34
+ * cache on backend errors and the live URL Just Works.
35
+ */
36
+ initialUrl?: string
37
+ /**
38
+ * When the snapshot fetch fails (404 in dev, network error,
39
+ * unrecognised shape), default behaviour is to silently fall
40
+ * back to a fresh `list()` call. Set false for production
41
+ * environments where you require the snapshot to be present.
42
+ * @default true
43
+ */
44
+ fallbackToList?: boolean
19
45
  }
20
46
 
21
47
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-sdk-api",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
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,197 @@ 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}', { initialUrl: '/data/<name>.json' })\n` +
61
+ ` Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.`,
62
+ )
63
+ }
64
+
65
+ // Snapshot-bypass warning — fires when `initialUrl` is configured but
66
+ // the call is non-trivial (has filter / sort / skip), so the snapshot
67
+ // can't be used and the SDK falls back to the live API. The bypass is
68
+ // correct behavior, but it's silent: developers often set initialUrl
69
+ // once and then add a sort to one of their useDocuments calls without
70
+ // noticing the snapshot is no longer involved. Deduped per
71
+ // (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] initialUrl 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
+ // initialUrl: optional URL (relative to baseUrl or absolute) for a
99
+ // pre-built static JSON snapshot — typically produced by the
100
+ // `data` plugin's catalog.<name> output. When set, live() fires
101
+ // onChange with the snapshot immediately, then opens the SSE
102
+ // subscribe stream as usual. listAll() also consults the
103
+ // snapshot before falling back to paginated list calls.
104
+ //
105
+ // The fetched JSON is unwrapped automatically — the data
106
+ // plugin emits `[{ refId, name, date, data }, ...]`; we
107
+ // return the `.data` payloads. Plain arrays and { items }
108
+ // envelopes are passed through unchanged.
109
+ //
110
+ // Pre-built snapshots are the fast first-paint path:
111
+ // CDN-cached, no API roundtrip, no SSE latency tax on boot.
112
+ // Pair the snapshot's data-plugin filter with your live()
113
+ // filter so the initial state matches what SSE will send.
114
+ initialUrl,
115
+ // When initialUrl fetch fails (404 in dev, network error,
116
+ // wrong shape), default behavior is to log and fall back to
117
+ // a fresh list() call — keeps dev mode trivial. Set false
118
+ // for environments where you require the snapshot.
119
+ fallbackToList = true,
120
+ } = opts
12
121
  const endpointBase = `${basePath}/${name}`
13
122
  const queryUrl = joinUrl(baseUrl, `${endpointBase}/entities/query`)
14
123
  const listUrl = joinUrl(baseUrl, `${endpointBase}/entities`)
15
124
  const subscribeUrl = joinUrl(baseUrl, `${endpointBase}/entities/subscribe`)
16
125
  const renderUrl = joinUrl(baseUrl, `${endpointBase}/render`)
17
126
 
127
+ const resolvedInitialUrl = initialUrl
128
+ ? (/^https?:\/\//.test(initialUrl) ? initialUrl : joinUrl(baseUrl, initialUrl))
129
+ : null
130
+
131
+ // Cached snapshot promise — concurrent live() / listAll() calls
132
+ // share one fetch. Cleared on error so a flaky connection can
133
+ // recover on the next call.
134
+ let snapshotPromise = null
135
+ async function loadSnapshot() {
136
+ if (!resolvedInitialUrl) return null
137
+ if (snapshotPromise) return snapshotPromise
138
+ snapshotPromise = (async () => {
139
+ try {
140
+ const res = await doFetch(resolvedInitialUrl, {
141
+ method: 'GET',
142
+ headers: { accept: 'application/json', ...defaultHeaders },
143
+ })
144
+ if (!res.ok) {
145
+ if (!fallbackToList) {
146
+ throw new MikserError(res.status, res.statusText, null, resolvedInitialUrl)
147
+ }
148
+ return null
149
+ }
150
+ const payload = await res.json()
151
+ return unwrapSnapshot(payload)
152
+ } catch (err) {
153
+ // Clear the cached promise so the next call can retry.
154
+ snapshotPromise = null
155
+ if (!fallbackToList) throw err
156
+ return null
157
+ }
158
+ })()
159
+ return snapshotPromise
160
+ }
161
+
18
162
  /**
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 }.
163
+ * Run a list query. Returns the standard envelope:
164
+ * { items, page, limit, total, totalPages, hasNext, hasPrev }.
165
+ *
166
+ * Transport selection: tries GET first because GET responses
167
+ * are what the api plugin's per-query disk cache writes (and
168
+ * what a reverse proxy can serve as failover when mikser is
169
+ * down). Falls back to POST when:
170
+ * - the encoded URL exceeds GET_MAX_URL (browsers and most
171
+ * proxies start refusing past ~2KB; we use a conservative
172
+ * limit of 1800)
173
+ * - GET is explicitly disabled via opts.method = 'POST'
174
+ *
175
+ * GET can express anything sift accepts via the `.$op` URL-param
176
+ * suffix scheme (see urlFor). Use POST when you want to be
177
+ * explicit about not engaging the cache path, e.g. for queries
178
+ * with secrets in the body that shouldn't sit in proxy logs.
22
179
  */
23
- async function list(query = {}) {
180
+ async function list(query = {}, opts = {}) {
181
+ const forcePost = opts.method === 'POST'
182
+
183
+ if (!forcePost) {
184
+ const url = urlFor(query)
185
+ if (url.length <= GET_MAX_URL) {
186
+ const res = await doFetch(url, {
187
+ method: 'GET',
188
+ headers: {
189
+ accept: 'application/json',
190
+ ...defaultHeaders,
191
+ ...bearer(token),
192
+ },
193
+ })
194
+ const envelope = await jsonOrThrow(res, url)
195
+ maybeWarnWide({ endpoint: name, query, envelopeOrItems: envelope, quiet: opts.quiet })
196
+ return envelope
197
+ }
198
+ }
199
+
200
+ // POST fallback — long URLs and forced POST land here.
24
201
  const res = await doFetch(queryUrl, {
25
202
  method: 'POST',
26
203
  headers: {
@@ -30,7 +207,9 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
30
207
  },
31
208
  body: JSON.stringify(query),
32
209
  })
33
- return jsonOrThrow(res, queryUrl)
210
+ const envelope = await jsonOrThrow(res, queryUrl)
211
+ maybeWarnWide({ endpoint: name, query, envelopeOrItems: envelope, quiet: opts.quiet })
212
+ return envelope
34
213
  }
35
214
 
36
215
  /**
@@ -78,6 +257,27 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
78
257
  * is wasteful. Use pages() directly and stream-process there.
79
258
  */
80
259
  async function listAll(query = {}) {
260
+ // Snapshot fast path: if no filter/sort/skip is requested
261
+ // beyond what the snapshot was built with, return the
262
+ // pre-built array directly. The caller's filter/sort would
263
+ // require re-querying the live endpoint anyway, so any
264
+ // non-trivial query falls through to the paginated fetch.
265
+ const trivial = !query.filter && !query.sort && !query.skip
266
+ if (trivial && resolvedInitialUrl) {
267
+ const snapshot = await loadSnapshot()
268
+ if (snapshot) {
269
+ return query.fields
270
+ ? snapshot.map(item => pickFields(item, query.fields))
271
+ : snapshot
272
+ }
273
+ // Snapshot unavailable — fall through to paginated fetch.
274
+ }
275
+ if (!trivial && resolvedInitialUrl) {
276
+ maybeWarnSnapshotBypass({
277
+ endpoint: name, kind: 'listAll',
278
+ filter: query.filter, sort: query.sort, skip: query.skip,
279
+ })
280
+ }
81
281
  const items = []
82
282
  for await (const env of pages({ limit: 1000, ...query })) {
83
283
  items.push(...env.items)
@@ -170,6 +370,7 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
170
370
  function live(filter, onChange, options = {}) {
171
371
  const {
172
372
  sort, fields, limit, skip,
373
+ quiet,
173
374
  signal: externalSignal,
174
375
  onError = (err) => console.error('mikser-io-sdk-api live error:', err),
175
376
  } = options
@@ -185,10 +386,40 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
185
386
 
186
387
  const loop = (async () => {
187
388
  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)
389
+ // Snapshot fast path. Use it only when the caller's
390
+ // query is trivial enough that the pre-built array
391
+ // reflects what list() would return — anything more
392
+ // specific (filter, sort, skip) goes through list()
393
+ // so the caller's intent is honored.
394
+ let usedSnapshot = false
395
+ const trivial = !filter && !sort && !skip
396
+ if (trivial && resolvedInitialUrl) {
397
+ const snapshot = await loadSnapshot()
398
+ if (snapshot) {
399
+ if (disposed || ac.signal.aborted) return
400
+ items = fields ? snapshot.map(i => pickFields(i, fields)) : snapshot
401
+ onChange(items)
402
+ usedSnapshot = true
403
+ }
404
+ }
405
+ if (!trivial && resolvedInitialUrl) {
406
+ maybeWarnSnapshotBypass({
407
+ endpoint: name, kind: 'live',
408
+ filter, sort, skip, quiet,
409
+ })
410
+ }
411
+ if (!usedSnapshot) {
412
+ // Pass { quiet } so list()'s wide-warning honors
413
+ // the live() caller's quiet opt. live() also
414
+ // does its own snapshot-path warning below for
415
+ // the case where the initial fill came from
416
+ // /data/<name>.json — those callers already
417
+ // opted into the narrow shape, so no warning.
418
+ const env = await list({ filter, sort, fields, limit, skip }, { quiet })
419
+ if (disposed || ac.signal.aborted) return
420
+ items = env.items
421
+ onChange(items)
422
+ }
192
423
 
193
424
  for await (const event of watch({ filter }, { signal: ac.signal })) {
194
425
  if (disposed) return
@@ -288,3 +519,56 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
288
519
  return { list, listAll, urlFor, pages, watch, live, update, delete: remove, render }
289
520
  }
290
521
  }
522
+
523
+ // Unwrap a snapshot payload into a plain array of entity-like objects.
524
+ // Recognises three shapes:
525
+ // - data-plugin catalog output: [{ refId, name, date, data }, ...]
526
+ // - plain array of entities: [{ id, meta, ... }, ...]
527
+ // - list() envelope: { items: [...], page, ... }
528
+ // Returns null on unrecognised shapes so the caller can decide to fall
529
+ // back to a fresh list() call (when fallbackToList is true).
530
+ function unwrapSnapshot(payload) {
531
+ if (Array.isArray(payload)) {
532
+ if (payload.length === 0) return []
533
+ // Heuristic: data-plugin entries have `refId` + `data`; treat any
534
+ // object with `data` as a wrapped entry and unwrap. Anything else
535
+ // is a plain array.
536
+ if (payload[0] && typeof payload[0] === 'object' && 'data' in payload[0]) {
537
+ return payload.map(entry => entry.data).filter(Boolean)
538
+ }
539
+ return payload
540
+ }
541
+ if (payload && typeof payload === 'object' && Array.isArray(payload.items)) {
542
+ return payload.items
543
+ }
544
+ return null
545
+ }
546
+
547
+ // Project an entity object to only the fields requested. Dotted paths
548
+ // supported ("meta.title" → object with `meta.title` set, other meta
549
+ // keys dropped). Used by the snapshot fast paths in list/live so a
550
+ // caller asking for narrow fields still gets narrow data from the
551
+ // snapshot — saves a re-fetch and keeps memory/bundles smaller.
552
+ function pickFields(entity, fields) {
553
+ const out = {}
554
+ for (const field of fields) {
555
+ const parts = field.split('.')
556
+ let src = entity, dst = out
557
+ for (let i = 0; i < parts.length - 1; i++) {
558
+ const part = parts[i]
559
+ if (src == null || typeof src !== 'object') { src = undefined; break }
560
+ src = src[part]
561
+ if (dst[part] == null || typeof dst[part] !== 'object') dst[part] = {}
562
+ dst = dst[part]
563
+ }
564
+ if (src !== undefined) {
565
+ const last = parts[parts.length - 1]
566
+ if (src != null && typeof src === 'object' && last in src) {
567
+ dst[last] = src[last]
568
+ } else if (parts.length === 1 && entity[last] !== undefined) {
569
+ out[last] = entity[last]
570
+ }
571
+ }
572
+ }
573
+ return out
574
+ }