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