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 +88 -1
- package/index.d.ts +26 -0
- package/package.json +1 -1
- package/src/entities.js +294 -10
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,
|
|
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
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,
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}
|