@uniweb/core 0.16.0 → 0.18.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.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,6 +9,7 @@
9
9
  "./collection-address": "./src/query-address.js",
10
10
  "./query-address": "./src/query-address.js",
11
11
  "./data-paths": "./src/data-paths.js",
12
+ "./datastore": "./src/datastore.js",
12
13
  "./detail-url": "./src/detail-url.js",
13
14
  "./fetch-config": "./src/fetch-config.js",
14
15
  "./icon-corpus": "./src/icon-corpus.js",
@@ -42,8 +43,8 @@
42
43
  "vitest": "^4.1.7"
43
44
  },
44
45
  "dependencies": {
45
- "@uniweb/theming": "^0.1.15",
46
- "@uniweb/semantic-parser": "^1.4.0"
46
+ "@uniweb/semantic-parser": "^1.4.0",
47
+ "@uniweb/theming": "^0.1.15"
47
48
  },
48
49
  "scripts": {
49
50
  "test": "vitest run"
package/src/datastore.js CHANGED
@@ -35,12 +35,21 @@
35
35
  * @returns {string} A stable JSON string usable as a cache-Map key
36
36
  */
37
37
  export function deriveCacheKey(request) {
38
- const { path, url, endpoint, schema, transform } = request || {}
38
+ // `as` is the binding key; `schema` is the name it had until 2026-09-02 and
39
+ // still arrives on every payload published before then. Normalised HERE so one
40
+ // logical request hashes to one key whichever spelling reached us — a consumer
41
+ // deriving a key must not get two entries for the same fetch.
42
+ const { path, url, endpoint, transform } = request || {}
43
+ const as = request?.as ?? request?.schema
39
44
  const method = request?.method && request.method.toUpperCase() !== 'GET'
40
45
  ? request.method.toUpperCase()
41
46
  : undefined
42
47
  const body = method === 'POST' ? request?.body : undefined
43
- return JSON.stringify({ path, url, endpoint, schema, transform, method, body })
48
+ // ⚠️ The field NAME is part of the hash, so renaming it moves every key ONCE.
49
+ // In-memory stores repopulate; a consumer with a persistent cache takes one
50
+ // cold pass. Chosen over hashing under the old name, which would have hidden
51
+ // the rename inside the one function whose job is to be canonical.
52
+ return JSON.stringify({ path, url, endpoint, as, transform, method, body })
44
53
  }
45
54
 
46
55
  export default class DataStore {
@@ -15,6 +15,13 @@
15
15
  */
16
16
 
17
17
  import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
18
+
19
+ /**
20
+ * A fetch config's binding key — the `content.data.<key>` a component reads.
21
+ * `as` is the name; `schema` is what it was called until 2026-09-02 and still
22
+ * arrives on any payload published before then. See `fetch-config.js`.
23
+ */
24
+ const bindingKeyOf = (cfg) => cfg?.as ?? cfg?.schema
18
25
  import { buildDetailConfig } from './detail-url.js'
19
26
 
20
27
  /**
@@ -201,7 +208,7 @@ export default class EntityStore {
201
208
  // collecting all cascade matches.
202
209
  if (requested === null && block.fetch) {
203
210
  const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
204
- const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
211
+ const schemas = blockFetchList.filter(bindingKeyOf).map(bindingKeyOf)
205
212
  if (schemas.length > 0) requested = schemas
206
213
  }
207
214
 
@@ -299,7 +306,7 @@ export default class EntityStore {
299
306
  let requested = this._getRequestedSchemas(meta)
300
307
  if (requested === null && block.fetch) {
301
308
  const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
302
- const schemas = blockFetchList.filter((cfg) => cfg.schema).map((cfg) => cfg.schema)
309
+ const schemas = blockFetchList.filter(bindingKeyOf).map(bindingKeyOf)
303
310
  if (schemas.length > 0) requested = schemas
304
311
  }
305
312
  if (requested === null) return { data: null }
@@ -109,19 +109,37 @@ function applyDeferredDetail(cfg, queries, records) {
109
109
  // projection is not obliged to carry — so on such a host that rule can never
110
110
  // fire, and this is the only way a detail page reaches a whole record.
111
111
  if (cfg.endpoint) {
112
- const recordPattern = resolveRecordAddressPattern(cfg.query ?? cfg.schema, records)
112
+ // `cfg.query`, not `cfg.query ?? cfg.schema`. The `??` was unreachable:
113
+ // `endpoint` is set in exactly one place (`resolveQuerySource`), which returns
114
+ // early unless `cfg.query` is a non-empty string — so reaching here proves it.
115
+ // It read as a tolerance for two producer shapes and was really a vestige of
116
+ // the build lane not emitting `query`, which it now does.
117
+ const recordPattern = resolveRecordAddressPattern(cfg.query, records)
113
118
  if (recordPattern) return { ...cfg, detail: recordPattern }
114
119
  }
115
120
 
116
- const schema = cfg.schema
117
- if (!schema || !queries) return cfg
118
- const collConfig = queries[schema]
121
+ // **`config.queries` is keyed by QUERY NAME, so look it up by the query.**
122
+ // This read `cfg.schema` the BINDING KEY, which merely defaults to the query
123
+ // name. `fetch: { query: 'articles', schema: 'posts' }` is a supported, allow-
124
+ // listed, unwarned form (`RECOGNIZED_FETCH_KEYS.query`), and under it the lookup
125
+ // missed and a detail page silently rendered the brief without its body.
126
+ // Measured 2026-09-01, control passing: `{query:'articles'}` resolved
127
+ // `/data/articles/{slug}.json`; `{query:'articles',schema:'posts'}` resolved
128
+ // nothing, from the same file.
129
+ //
130
+ // ⚖️ The `|| cfg.schema` is NOT the vestige deleted above. A source-shape fetch
131
+ // (`{ path: … }`) has no query at all, and its schema — inferred from the path —
132
+ // is the only key there is. Two shapes, two answers; the deleted one had one
133
+ // shape and pretended otherwise.
134
+ const queryName = cfg.query || bindingKey(cfg)
135
+ if (!queryName || !queries) return cfg
136
+ const collConfig = queries[queryName]
119
137
  if (!collConfig || typeof collConfig !== 'object') return cfg
120
138
  const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
121
139
  if (!deferred || deferred.length === 0) return cfg
122
140
  const pattern = typeof collConfig.detailUrl === 'string'
123
141
  ? collConfig.detailUrl
124
- : recordDataUrl(schema, '{slug}')
142
+ : recordDataUrl(queryName, '{slug}')
125
143
  return { ...cfg, detail: pattern }
126
144
  }
127
145
 
@@ -164,6 +182,28 @@ function resolveQuerySource(cfg, records) {
164
182
  return { ...cfg, path: queryDataUrl(cfg.query) }
165
183
  }
166
184
 
185
+ /**
186
+ * The binding key of a fetch config — the `content.data.<key>` a component reads.
187
+ *
188
+ * ⭐ **`as` is the name; `schema` is what it was called until 2026-09-02.** The old
189
+ * spelling still arrives on every payload published before then and on any seed
190
+ * built against an older release, so this is not a deprecation window — it is a
191
+ * permanent reader of stored data. *(Renaming it was not cosmetic: `schema`
192
+ * already means the MODEL REF one record over, on a `queries` declaration, and
193
+ * one word for two things is what let a binding-key override silently break
194
+ * detail resolution.)*
195
+ *
196
+ * ⛔ Do not "simplify" this to `cfg.as`. The `??` here is earned — it spans
197
+ * stored payloads we cannot rewrite — unlike the one deleted from
198
+ * `applyDeferredDetail`, which spanned two producers we control.
199
+ *
200
+ * @param {Object} cfg
201
+ * @returns {string|undefined}
202
+ */
203
+ function bindingKey(cfg) {
204
+ return cfg?.as ?? cfg?.schema
205
+ }
206
+
167
207
  /**
168
208
  * Resolve the applicable fetch configs from an ordered list of sources.
169
209
  *
@@ -206,14 +246,15 @@ export function resolveFetchConfigs(sources, options = {}) {
206
246
  if (!source) continue
207
247
  const configList = Array.isArray(source) ? source : [source]
208
248
  for (const cfg of configList) {
209
- if (!cfg?.schema) continue
210
- if (configs.has(cfg.schema)) continue
211
- if (!collectAll && !schemas.includes(cfg.schema)) continue
249
+ const key = bindingKey(cfg)
250
+ if (!key) continue
251
+ if (configs.has(key)) continue
252
+ if (!collectAll && !schemas.includes(key)) continue
212
253
  // Address first: localization and deferred-detail both key on `path`,
213
254
  // which a query ref does not have until this runs.
214
255
  const sourced = resolveQuerySource(cfg, records)
215
256
  const localized = localizeConfig(sourced, locale, defaultLocale)
216
- configs.set(cfg.schema, applyDeferredDetail(localized, queries, records))
257
+ configs.set(key, applyDeferredDetail(localized, queries, records))
217
258
  }
218
259
  }
219
260