@uniweb/core 0.19.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,6 +17,7 @@
17
17
  "./route-match": "./src/route-match.js",
18
18
  "./section-id": "./src/section-id.js",
19
19
  "./services": "./src/services.js",
20
+ "./sort": "./src/sort.js",
20
21
  "./tracker": "./src/tracker.js"
21
22
  },
22
23
  "files": [
package/src/block.js CHANGED
@@ -223,6 +223,12 @@ export default class Block {
223
223
  // Components check this to show loading UI (spinners, skeletons)
224
224
  this.dataLoading = false
225
225
 
226
+ // Data failure state — set by BlockRenderer when a runtime fetch FAILED:
227
+ // `{ <binding key>: <message> }`, or null. A failed key is absent from
228
+ // `content.data` (never `[]`, which is a delivered value), so this is the
229
+ // only way a component can tell "no records" from "the request failed".
230
+ this.dataError = null
231
+
226
232
  // Whether engine-level background is active (set by BlockRenderer/prerender)
227
233
  // Components check this to skip their own opaque background
228
234
  this.hasBackground = false
@@ -231,8 +237,12 @@ export default class Block {
231
237
  this.refId = blockData.refId || null
232
238
 
233
239
  // Dynamic route context (params from URL matching)
234
- // Set when accessing a dynamic page like /blog/:slug -> /blog/my-post
235
- this.dynamicContext = blockData.dynamicContext || null
240
+ // Set when accessing a dynamic page like /blog/:slug -> /blog/my-post.
241
+ // The PAGE's context is the fallback: the SPA stamps it on every section
242
+ // as it creates the page, the static build stamps it on the page only, and a
243
+ // section reading `block.dynamicContext` must see the same thing on both
244
+ // lanes — it did not until 2026-09-04 (empty on every prerendered page).
245
+ this.dynamicContext = blockData.dynamicContext || this.page?.dynamicContext || null
236
246
 
237
247
  // State management (dynamic, can change at runtime)
238
248
  this.startState = null
package/src/datastore.js CHANGED
@@ -19,17 +19,26 @@
19
19
  * slug read from `page.state`) must declare their own `cacheKey(request)`
20
20
  * on the fetcher so reactive changes miss the cache and re-fetch.
21
21
  *
22
- * Fields that contribute to the key:
23
- * - path, url — what resource is being fetched
24
- * - schema — which entity type the response will be stored under
25
- * - transform — any per-fetch response unwrap; different transforms
26
- * of the same endpoint produce different cached data
27
- * - method (POST) — POST requests may share a URL with GET; don't collide
28
- * - body (POST) — two POSTs to the same URL with different bodies are
29
- * different queries; must cache distinctly
22
+ * TWO IDENTITIES, decided by whether the request carries an ADDRESS.
30
23
  *
31
- * Post-processing fields like `limit`, `sort`, `filter` are applied after
32
- * fetch and must not split the cache.
24
+ * An ADDRESSED request `path` or `url` is identified by where it
25
+ * goes: the address, the binding key, the unwrap, and for a POST its method and
26
+ * body. Post-processing fields (`limit`, `sort`, `where`) are applied after the
27
+ * fetch over one shared copy and must not split the cache; `query` and `depth`
28
+ * are not hashed either, because the address already carries them (a query's
29
+ * list and its per-record file are different addresses) and because a kit hook
30
+ * asking for `{ path, as }` must hit the entry the page's declaration filled —
31
+ * that shared cache is a documented property of `useFetched`.
32
+ *
33
+ * An ADDRESS-LESS request — a QUESTION sent to a records door — is identified by
34
+ * the question: `query`, `schema`, `scope`, `where`, `sort`, `limit`, `depth`.
35
+ * ⛔ The reason: with no per-query address, two pages
36
+ * binding one `as` to two queries would otherwise share an entry, and a list
37
+ * (brief) and a record (full) of one query would collide — the one defect on
38
+ * this path that delivers WRONG data rather than none.
39
+ *
40
+ * `locale` is hashed on both when present: two locales' answers must not share
41
+ * an entry, and a door config always carries the locale it was asked in.
33
42
  *
34
43
  * @param {Object} request - Normalized request (or fetch config)
35
44
  * @returns {string} A stable JSON string usable as a cache-Map key
@@ -38,22 +47,68 @@ export function deriveCacheKey(request) {
38
47
  // ⭐ `as` is the binding key — the name it has had since 2026-09-02, when the
39
48
  // compatibility alias for the older `schema` spelling was removed alongside
40
49
  // frontend's and hosting's.
41
- const { path, url, endpoint, transform } = request || {}
50
+ const { path, url, transform, locale } = request || {}
42
51
  const as = request?.as
43
52
  const method = request?.method && request.method.toUpperCase() !== 'GET'
44
53
  ? request.method.toUpperCase()
45
54
  : undefined
46
55
  const body = method === 'POST' ? request?.body : undefined
47
- // ⚠️ The field NAME is part of the hash, so renaming it moves every key ONCE.
48
- // In-memory stores repopulate; a consumer with a persistent cache takes one
49
- // cold pass. Chosen over hashing under the old name, which would have hidden
50
- // the rename inside the one function whose job is to be canonical.
51
- return JSON.stringify({ path, url, endpoint, as, transform, method, body })
56
+ if (path || url) {
57
+ // ⚠️ The field NAME is part of the hash, so renaming it moves every key ONCE.
58
+ // In-memory stores repopulate; a consumer with a persistent cache takes one
59
+ // cold pass. Chosen over hashing under the old name, which would have hidden
60
+ // the rename inside the one function whose job is to be canonical.
61
+ return JSON.stringify({ path, url, as, transform, method, body, locale })
62
+ }
63
+ const { query, schema, scope, where, sort, limit, depth } = request || {}
64
+ return JSON.stringify({ query, schema, scope, where, sort, limit, depth, as, transform, locale })
65
+ }
66
+
67
+ /**
68
+ * A record's identity — the backend-minted `$uuid`, present on every record a
69
+ * live lane serves and on a file-lane record whose source was synced once. A
70
+ * record with none is held inline in its result and never indexed.
71
+ *
72
+ * @param {*} record
73
+ * @returns {string|null}
74
+ */
75
+ export function recordIdentity(record) {
76
+ const id = record && typeof record === 'object' ? record.$uuid : null
77
+ return typeof id === 'string' && id.length > 0 ? id : null
52
78
  }
53
79
 
80
+ /** Which of two depths holds MORE of a record. */
81
+ const DEPTH_RANK = { brief: 1, full: 2 }
82
+
83
+ function indexableDepth(entry) {
84
+ const depth = entry?.meta?.depth
85
+ return depth === 'brief' || depth === 'full' ? depth : null
86
+ }
87
+
88
+ /**
89
+ * ⭐ THE RECORD INDEX — records are held ONCE, by identity, with the depth they
90
+ * were fetched at; a query's result holds their ids.
91
+ *
92
+ * [Diego, 2026-09-04]: "they need to be able to hydrate records on their own…
93
+ * track if they already have the briefs, and don't confuse that with knowing the
94
+ * full records." Before this, a list and a detail fetch of one query landed in
95
+ * separate slots only because their ADDRESSES differed, and a detail page whose
96
+ * record was already held in full still fetched it again, or — with the record's
97
+ * list already cached — delivered the brief as if it were the record.
98
+ *
99
+ * Three rules:
100
+ * R1 an entry whose `meta.depth` is `brief` or `full` and whose records all
101
+ * carry `$uuid` is filed by id; `get()` materializes it from the index, so
102
+ * every list holding a record sees the record's latest depth;
103
+ * R2 depth is MONOTONIC — a brief never overwrites a record held in full;
104
+ * R3 an upgrade MERGES the full record over the brief rather than replacing
105
+ * it, so nothing depends on the full being a superset of the brief.
106
+ * An entry with no depth, or with a record lacking identity, is held inline
107
+ * exactly as before — the file lane with no synced records changes nothing.
108
+ */
54
109
  export default class DataStore {
55
110
  constructor() {
56
- // key → { data, meta? }
111
+ // key → { data, meta? } or, when indexed, { ids, single?, meta, _at, _data }
57
112
  this._cache = new Map()
58
113
  // key → { promise, signals: Set<AbortSignal> }
59
114
  this._inflight = new Map()
@@ -61,6 +116,10 @@ export default class DataStore {
61
116
  this._listeners = new Set()
62
117
  // Key-scoped listeners: key → Set<Function>
63
118
  this._keyedListeners = new Map()
119
+ // $uuid → { depth, record } — the record index
120
+ this._records = new Map()
121
+ // bumps on every index write, so a materialized list knows it is stale
122
+ this._recordsVersion = 0
64
123
 
65
124
  Object.seal(this)
66
125
  }
@@ -120,18 +179,31 @@ export default class DataStore {
120
179
  * @returns {{ data: any, meta?: Object } | null}
121
180
  */
122
181
  get(key) {
123
- return this._cache.has(key) ? this._cache.get(key) : null
182
+ const entry = this._cache.has(key) ? this._cache.get(key) : null
183
+ if (!entry || !entry.ids) return entry
184
+ // Indexed: materialize from the record index, once per index version.
185
+ if (entry._at === this._recordsVersion && entry._data) return entry._data
186
+ const records = entry.ids.map((id) => this._records.get(id)?.record).filter(Boolean)
187
+ const data = entry.single ? (records[0] ?? null) : records
188
+ const out = entry.meta !== undefined ? { data, meta: entry.meta } : { data }
189
+ entry._at = this._recordsVersion
190
+ entry._data = out
191
+ return out
124
192
  }
125
193
 
126
194
  /**
127
195
  * Cache store. Fires listeners: first the global ones (all-writes), then
128
196
  * any subscribers registered for this specific key.
129
197
  *
198
+ * An entry carrying `meta.depth` whose records all carry `$uuid` is filed in
199
+ * the record index and stored as ids — see the class note. Anything else is
200
+ * stored as given.
201
+ *
130
202
  * @param {string} key
131
203
  * @param {{ data: any, meta?: Object }} entry
132
204
  */
133
205
  set(key, entry) {
134
- this._cache.set(key, entry)
206
+ this._cache.set(key, this._index(entry))
135
207
  for (const fn of this._listeners) fn()
136
208
  const keyed = this._keyedListeners.get(key)
137
209
  if (keyed) {
@@ -139,6 +211,59 @@ export default class DataStore {
139
211
  }
140
212
  }
141
213
 
214
+ /**
215
+ * The record held under an identity, with the depth it was fetched at — the
216
+ * question a detail page asks before fetching: "do I hold this in full?"
217
+ *
218
+ * @param {string} id - a `$uuid`
219
+ * @returns {{ depth: 'brief'|'full', record: Object } | null}
220
+ */
221
+ getRecord(id) {
222
+ return (id && this._records.get(id)) || null
223
+ }
224
+
225
+ /**
226
+ * File one record at a depth, honouring R2 and R3. Returns the record now held.
227
+ *
228
+ * @param {Object} record
229
+ * @param {'brief'|'full'} depth
230
+ * @returns {Object}
231
+ */
232
+ upsertRecord(record, depth) {
233
+ const id = recordIdentity(record)
234
+ if (!id || !DEPTH_RANK[depth]) return record
235
+ const held = this._records.get(id)
236
+ if (!held) {
237
+ this._records.set(id, { depth, record })
238
+ this._recordsVersion += 1
239
+ return record
240
+ }
241
+ if (DEPTH_RANK[depth] < DEPTH_RANK[held.depth]) return held.record // R2
242
+ const next = DEPTH_RANK[depth] > DEPTH_RANK[held.depth]
243
+ ? { ...held.record, ...record } // R3 — merge on upgrade
244
+ : record // same depth: the fresher copy
245
+ this._records.set(id, { depth, record: next })
246
+ this._recordsVersion += 1
247
+ return next
248
+ }
249
+
250
+ _index(entry) {
251
+ const depth = indexableDepth(entry)
252
+ if (!depth || !entry || entry.ids) return entry
253
+ const { data } = entry
254
+ if (Array.isArray(data)) {
255
+ if (data.length === 0 || !data.every((r) => recordIdentity(r))) return entry
256
+ const ids = data.map((r) => recordIdentity(r))
257
+ for (const r of data) this.upsertRecord(r, depth)
258
+ return { ids, meta: entry.meta, _at: -1, _data: null }
259
+ }
260
+ if (recordIdentity(data)) {
261
+ this.upsertRecord(data, depth)
262
+ return { ids: [recordIdentity(data)], single: true, meta: entry.meta, _at: -1, _data: null }
263
+ }
264
+ return entry
265
+ }
266
+
142
267
  /**
143
268
  * Drop one entry, and any in-flight record for the same key.
144
269
  *
@@ -180,5 +305,7 @@ export default class DataStore {
180
305
  clear() {
181
306
  this._cache.clear()
182
307
  this._inflight.clear()
308
+ this._records.clear()
309
+ this._recordsVersion += 1
183
310
  }
184
311
  }
package/src/detail-url.js CHANGED
@@ -22,6 +22,7 @@
22
22
  * a matched route pattern says nothing about the record behind it.
23
23
  */
24
24
 
25
+ import { recordHandle } from './route-match.js'
25
26
  import { substitutePlaceholders } from './substitute-placeholders.js'
26
27
 
27
28
  /**
@@ -42,8 +43,23 @@ import { substitutePlaceholders } from './substitute-placeholders.js'
42
43
  * present in the context, so an unrelated `{name}` still passes through
43
44
  * literally, as it always has.
44
45
  */
45
- function paramContext(paramName, paramValue) {
46
- return { [paramName]: paramValue, param: paramValue }
46
+ function paramContext(paramName, paramValue, record) {
47
+ const context = { [paramName]: paramValue, param: paramValue }
48
+ // ⭐ `{slug}` is the RECORD'S slug, whatever the route calls its param. The
49
+ // file lane keys a query's per-record files by `item.slug` (`writeQueryFiles`)
50
+ // and injects `/data/<name>/{slug}.json` — so on a site routing `[id]`, the
51
+ // route's context carried `id` and `param` and `{slug}` stayed literal: the
52
+ // detail URL was `/data/articles/{slug}.json`, a guaranteed 404, on every
53
+ // template page with `deferred:` fields (measured 2026-09-04). When the caller
54
+ // holds the record — the entity store does (it matched it), and so does
55
+ // `useEntityDetail` — its slug fills the name the FILE was written under. A
56
+ // caller with no record in hand leaves `{slug}` literal rather than guessing
57
+ // the capture is one: a visibly unresolved address beats a plausible wrong one.
58
+ const handle = recordHandle(record)
59
+ if (paramName !== 'slug' && handle != null && handle !== '') {
60
+ context.slug = handle
61
+ }
62
+ return context
47
63
  }
48
64
 
49
65
  /**
@@ -67,26 +83,70 @@ function paramContext(paramName, paramValue) {
67
83
  * @param {Object} queryConfig - A resolved fetch config for the query
68
84
  * (post-`resolveFetchConfigs`, so `detail` may have been auto-injected for a
69
85
  * `deferred:` query — see `./fetch-config.js`).
70
- * @param {{ paramName: string, paramValue: string }} dynamicContext
86
+ * @param {{ paramName: string, paramValue: string, record?: Object|null }} dynamicContext -
87
+ * the route's param and its value; `record`, when the caller already holds the
88
+ * matched record, lets `{slug}` resolve to the record's own slug (see
89
+ * `paramContext`).
71
90
  * @returns {Object|null} A fetch config carrying `url` or `path`, or null.
72
91
  */
92
+ /**
93
+ * The key a route param narrows a QUESTION by — the entry's own handle, which
94
+ * a records door guarantees unique among siblings (the records door's contract
95
+ * §1b: `$name`, "addressed AND filtered"). ⚠️ One constant, because the spelling
96
+ * moved four times in one day (`path_segment` → `$slug` → `$name` → `meta::name`
97
+ * → `$name`); the door is dark until a host stamps it, so this is the one place
98
+ * to change if it moves again. Not read by the file lane or the address door,
99
+ * which narrow by the route's own param (`item[paramName]`).
100
+ */
101
+ export const ROUTE_HANDLE_KEY = '$name'
102
+
73
103
  export function buildDetailConfig(queryConfig, dynamicContext) {
74
104
  const { detail } = queryConfig
75
105
  if (!detail) return null
76
- const { paramName, paramValue } = dynamicContext
106
+ const { paramName, paramValue, record = null } = dynamicContext
77
107
  if (!paramName || paramValue === undefined) return null
78
108
 
79
- // Three address kinds now, and the detail request must come back as the SAME
80
- // kind: an `endpoint` carries remote semantics the fetcher decides on, so
81
- // returning a detail as `path` would silently drop operator pushdown and the
82
- // site's static headers for exactly the request that is one record.
83
- const baseUrl = queryConfig.endpoint || queryConfig.url || queryConfig.path
109
+ // A QUESTION DOOR: the record is the list's own question, narrowed to one
110
+ // entry by its handle and asked in full — the list page and the detail page
111
+ // are the same query, differing only by whether the parameter is bound
112
+ // (the records door's contract, §1a). `sort` and `limit` are the
113
+ // list's and drop; `scope` and the authored `where` stay, so a scoped query
114
+ // cannot be escaped through the URL.
115
+ if (queryConfig.door) {
116
+ const { sort, limit, detail: _detail, ...rest } = queryConfig
117
+ return {
118
+ ...rest,
119
+ where: { ...(queryConfig.where && typeof queryConfig.where === 'object' ? queryConfig.where : {}), [ROUTE_HANDLE_KEY]: String(paramValue) },
120
+ depth: 'full',
121
+ dynamicContext: { paramName, paramValue },
122
+ }
123
+ }
124
+
125
+ // Two address kinds, and the detail request comes back as the SAME kind as
126
+ // the list: a `url` the author wrote stays a `url`, a compiled `path` stays a
127
+ // `path`. (A third kind, the host's address door, was retired 2026-09-04.)
128
+ const baseUrl = queryConfig.url || queryConfig.path
84
129
  if (!baseUrl) return null
85
- const addressKey = queryConfig.endpoint
86
- ? 'endpoint'
87
- : queryConfig.url
88
- ? 'url'
89
- : 'path'
130
+ const addressKey = queryConfig.url ? 'url' : 'path'
131
+
132
+ // What every detail config carries beside its address:
133
+ // `as` — the binding key, so the record lands where the list did;
134
+ // `query` — the query it is one record of (identity on a door);
135
+ // `depth: 'full'` — what it asks for, and what the record index files it as;
136
+ // `dynamicContext` — the route param, which the default fetcher already keys
137
+ // a SINGLE-RECORD response on (`envelope.item`, body
138
+ // placeholders). ⛔ The entity store never passed it, so
139
+ // a live lane's bare record response was unwrapped with
140
+ // the LIST key and read as `[]` (found 2026-09-04).
141
+ // `locale` — carried from the list, so the two share a locale.
142
+ const common = {
143
+ as: queryConfig.as,
144
+ transform: queryConfig.transform,
145
+ depth: 'full',
146
+ dynamicContext: { paramName, paramValue },
147
+ }
148
+ if (typeof queryConfig.query === 'string') common.query = queryConfig.query
149
+ if (queryConfig.locale !== undefined) common.locale = queryConfig.locale
90
150
 
91
151
  // Object form: `detail: { body, envelope }`. Reuses the query's URL +
92
152
  // method + headers + auth. The body is placeholder-substituted against
@@ -94,14 +154,13 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
94
154
  if (detail && typeof detail === 'object') {
95
155
  const out = {
96
156
  [addressKey]: baseUrl,
97
- as: queryConfig.as,
98
- transform: queryConfig.transform,
157
+ ...common,
99
158
  }
100
159
  if (queryConfig.method) out.method = queryConfig.method
101
160
  if (detail.body !== undefined) {
102
- out.body = substitutePlaceholders(detail.body, paramContext(paramName, paramValue), { encode: false })
161
+ out.body = substitutePlaceholders(detail.body, paramContext(paramName, paramValue, record), { encode: false })
103
162
  } else if (queryConfig.body !== undefined) {
104
- out.body = substitutePlaceholders(queryConfig.body, paramContext(paramName, paramValue), { encode: false })
163
+ out.body = substitutePlaceholders(queryConfig.body, paramContext(paramName, paramValue, record), { encode: false })
105
164
  }
106
165
  if (detail.envelope) out.envelope = detail.envelope
107
166
  return out
@@ -140,12 +199,11 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
140
199
  // Custom pattern like '/articles/{slug}' — substitute placeholders
141
200
  // from the dynamic-route context. Only placeholders matching the
142
201
  // active paramName resolve; others pass through as literal `{name}`.
143
- detailUrl = substitutePlaceholders(detail, paramContext(paramName, paramValue))
202
+ detailUrl = substitutePlaceholders(detail, paramContext(paramName, paramValue, record))
144
203
  }
145
204
 
146
205
  return {
147
206
  [addressKey]: detailUrl,
148
- as: queryConfig.as,
149
- transform: queryConfig.transform,
207
+ ...common,
150
208
  }
151
209
  }