@uniweb/build 0.59.0 → 0.60.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/build",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,11 +57,11 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/schemas": "^0.3.4",
60
+ "@uniweb/content-reader": "^1.2.5",
61
61
  "@uniweb/semantic-parser": "^1.4.1",
62
62
  "@uniweb/content-writer": "^0.3.4",
63
63
  "@uniweb/projections": "^0.7.3",
64
- "@uniweb/content-reader": "^1.2.5",
64
+ "@uniweb/schemas": "^0.5.0",
65
65
  "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/runtime": "^0.27.4",
78
- "@uniweb/core": "^0.29.8"
77
+ "@uniweb/core": "^0.29.8",
78
+ "@uniweb/runtime": "^0.27.4"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -287,17 +287,40 @@ async function collectSchemasFromExports(packageDir) {
287
287
  // Source 2: a bare `schemas/` directory of schema files, one schema per file
288
288
  // (named by basename). Returns `{}` when the directory is absent.
289
289
  async function collectSchemasFromDir(dir) {
290
- if (!existsSync(dir)) return {}
291
290
  const out = {}
292
- for (const file of readdirSync(dir).sort()) {
293
- const ext = extname(file)
294
- if (!SCHEMA_EXTENSIONS.includes(ext)) continue
295
- const name = basename(file, ext)
291
+ for (const { file, name } of schemaFilesIn(dir)) {
296
292
  out[`@/${name}`] = validateAndNormalizeSchema(await loadSchemaFile(join(dir, file)), `@/${name}`)
297
293
  }
298
294
  return out
299
295
  }
300
296
 
297
+ // The schema files directly in a folder, one schema per file, named by basename.
298
+ // ⭐ A leading `_` leaves a file out — a draft — as it does for a record.
299
+ function schemaFilesIn(dir) {
300
+ if (!existsSync(dir)) return []
301
+ return readdirSync(dir)
302
+ .sort()
303
+ .filter((file) => SCHEMA_EXTENSIONS.includes(extname(file)) && !file.startsWith('_'))
304
+ .map((file) => ({ file, name: basename(file, extname(file)) }))
305
+ }
306
+
307
+ /**
308
+ * The data schemas a foundation DEFINES: one `@/<name>` per file in its `schemas/`
309
+ * folder, a file named with a leading `_` left out.
310
+ *
311
+ * ⭐ THE FOLDER IS THE DECLARATION [Diego, 2026-09-24] — the same rule as for a
312
+ * record: placing the file there is what makes it one. Until then a foundation's
313
+ * data schemas were only the ones a section binding reached, so a type only the app
314
+ * uses — a lesson, a quiz, a learner's progress, which no section renders — was
315
+ * never in `schema.json` and never registered.
316
+ *
317
+ * @param {string} srcDir - the foundation's source root
318
+ * @returns {string[]}
319
+ */
320
+ export function ownSchemaRefs(srcDir) {
321
+ return schemaFilesIn(join(srcDir, 'schemas')).map(({ name }) => `@/${name}`)
322
+ }
323
+
301
324
  // Resolve a package's module entry FILE (absolute) from its package.json —
302
325
  // `exports['.']` (string or a conditional import/default/node), else `main`, else
303
326
  // `index.js`. Loads a schemas package's own exports without self-resolution.
package/src/schema.js CHANGED
@@ -17,7 +17,7 @@ import { isFontVar } from '@uniweb/theming'
17
17
  import { join, dirname, extname, basename } from 'node:path'
18
18
  import { pathToFileURL } from 'node:url'
19
19
  import { inferTitle } from './utils/infer-title.js'
20
- import { collectSchemaRefs, buildDataSchemaMap } from './resolve-data-schema.js'
20
+ import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
21
21
  import {
22
22
  composeSupports,
23
23
  deriveRecordsSupport,
@@ -960,10 +960,13 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
960
960
  // Discover section types
961
961
  const components = await discoverComponents(srcDir, sectionPaths)
962
962
 
963
- // Resolve the data schemas referenced by section bindings (carried in
964
- // schema.json for the editor/platform — see named-data-schemas.md).
963
+ // Resolve the data schemas the foundation DEFINES — every file in its `schemas/`
964
+ // folder (`ownSchemaRefs`) — and the ones its section bindings reference, carried
965
+ // in schema.json for the editor/platform (see named-data-schemas.md); `register`
966
+ // submits the `@/` ones. Bound refs come first, so a foundation whose schemas are
967
+ // all bound builds the same schema.json it always did.
965
968
  const dataSchemas = await buildDataSchemaMap(
966
- collectSchemaRefs(components, foundationConfig.data),
969
+ new Set([...collectSchemaRefs(components, foundationConfig.data), ...ownSchemaRefs(srcDir)]),
967
970
  { srcDir },
968
971
  )
969
972
 
@@ -12,6 +12,14 @@
12
12
  // canonical). It reads from the SOURCE file (not the backend's finalized document),
13
13
  // so field values round-trip untouched and no inverse decode is needed in v1.
14
14
  //
15
+ // ⛔ A PUSH NEVER RENDERS THE BACKEND'S DOCUMENT OVER THE AUTHOR'S FILE. It did, for
16
+ // a record's first push, from 2026-05-29 to 2026-09-23 ("variant A"), and that
17
+ // document is not the author's file: it carries the serve URL the push put where the
18
+ // author wrote `/images/x.png`, and only the brief section's declared fields. So a
19
+ // first push rewrote the record's images to a backend route and deleted every key the
20
+ // Model does not declare, and every field of any other section. What a backend holds
21
+ // reaches the file on a pull (`records-project.js`), which restores asset paths first.
22
+ //
15
23
  // Single-record YAML/JSON/markdown files are rendered/back-filled in place.
16
24
  // Multi-record files — array-form YAML/JSON and BibTeX (many records in one
17
25
  // file) — are grouped by file and written once, one `$uuid` per record keyed by
@@ -213,27 +221,19 @@ function briefSectionOf(declaration) {
213
221
  return entry ? { name: entry[0], ...entry[1] } : null
214
222
  }
215
223
 
216
- // Whether a Model's brief section declares a CONTENT body field (a markup `text`
217
- // field, or a `format: prosemirror` json field) — the md-body target. A markdown
218
- // source file can only be safely rendered from the document when its body has a
219
- // field home — otherwise the body would be lost (variant B then).
220
- function briefHasContentBody(declaration) {
221
- const brief = briefSectionOf(declaration)
222
- return Object.values(brief?.fields || {}).some((f) => isContentBodyField(f))
223
- }
224
-
225
224
  /**
226
- * Render a finalized entity `document` back to its source-file authoring shape
227
- * (variant A — the file becomes a projection of backend state). For a flat/brief
228
- * entity: the entity `$uuid` + the brief section's fields (localized unwrapped,
225
+ * Render an entity `document` a pull brought back to its source-file authoring shape
226
+ * — the file becomes a projection of the backend's state, which is what a pull is
227
+ * (`writeRecordFile`, `records-project.js`). A push never uses it (header ⛔). For a
228
+ * flat/brief entity: the entity `$uuid` + the brief section's fields (localized unwrapped,
229
229
  * date/scalars verbatim), with the brief record's own `$uuid` DROPPED (the backend
230
- * matches a single-section item by singularity) and `$model`/`$id`/`$meta` omitted.
230
+ * matches a single-section item by singularity) and `$schema`/`$id`/`$meta` omitted.
231
231
  * A disabled entity (`$disabled: true`) writes `draft: true`, and an enabled one writes
232
232
  * no `draft:` at all.
233
233
  * For markdown, the content body field becomes the body; for YAML/JSON it stays a field.
234
234
  *
235
235
  * @param {object} params
236
- * @param {object} params.document - finalized `{ $uuid, $model, <brief>: {…} }`
236
+ * @param {object} params.document - pulled `{ $uuid, $schema, <brief>: {…} }`
237
237
  * @param {object} params.declaration - the Model declaration (`brief` + `sections`)
238
238
  * @param {string} params.format - 'yaml' | 'json' | 'md'
239
239
  * @param {string} [params.sourceLocale]
@@ -296,40 +296,26 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
296
296
  return yaml.dump(record) // yaml / yaml
297
297
  }
298
298
 
299
- // Write text only when it differs from what's on disk (idempotent).
300
- function writeIfChanged(filePath, text) {
301
- let current = ''
302
- try {
303
- current = readFileSync(filePath, 'utf8')
304
- } catch {
305
- // new file / unreadable — treat as a change
306
- }
307
- if (text === current) return 'unchanged'
308
- writeFileSync(filePath, text)
309
- return 'updated'
310
- }
311
-
312
299
  /**
313
300
  * Back-fill the sync response into the source files. Correlation is by **`index`**
314
301
  * — `finalized[i].index` is the 0-based position of the entity in the submitted
315
302
  * sequence, which equals the producer's `index` array order (the backend does not
316
- * echo `$id`). Single-record files are rendered from the finalized `document`
317
- * (variant A) when the document + declaration are present and lossless for the
318
- * format (markdown needs a content body field to carry the body); otherwise the
319
- * entity `$uuid` is back-filled in place (variant B). Multi-record YAML/JSON files
320
- * get a per-entry `$uuid` keyed by slug, grouped so each file is written once;
321
- * BibTeX stays deferred.
303
+ * echo `$id`). A single-record file gains the entity `$uuid` and nothing else —
304
+ * never the returned `document` (see the ⛔ in the header). Multi-record YAML/JSON
305
+ * files get a per-entry `$uuid` keyed by slug, and BibTeX one per cite key, grouped
306
+ * so each file is written once.
322
307
  *
323
308
  * @param {object} params
324
309
  * @param {object[]} params.index - the emitter's per-entity index, in submit
325
- * order: `{ id, model, slug, sourceFile, format?, multiRecord?, declaration? }`.
310
+ * order: `{ id, model, slug, sourceFile, format?, multiRecord?, ownId?, draft? }`.
326
311
  * @param {object[]} params.finalized - response entries `{ index, uuid, changed?, document? }`.
327
- * @param {string} [params.sourceLocale]
312
+ * `document` is read for one thing only: whether a record sent as a draft
313
+ * came back disabled.
328
314
  * @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[], mapped: Object<string,string>, notKeptAsDrafts: string[] }}
329
315
  * `mapped` is own id → the uuid this backend minted, for the caller to record per backend.
330
316
  * `notKeptAsDrafts` names records sent as drafts whose returned document is not disabled.
331
317
  */
332
- export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
318
+ export function backfillEntityUuids({ index, finalized }) {
333
319
  const updated = []
334
320
  const unchanged = []
335
321
  const deferred = []
@@ -364,10 +350,9 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
364
350
  // ⛔ A DRAFT THE BACKEND DID NOT KEEP AS ONE. A backend is obliged to echo
365
351
  // `$disabled: true` on the document of an entity it stored disabled. One that predates
366
352
  // the key skips it without a word and stores the record enabled, so it is delivered
367
- // once the site is published. Rendering that document over the file (variant A) would
368
- // then erase `draft: true` from the author's file as well, so the file keeps its own
369
- // text and the caller is told. Only a returned document that lacks the flag counts:
370
- // with no document there is nothing to judge.
353
+ // once the site is published. The file keeps its `draft: true` — only the uuid is
354
+ // written — and the caller is told. Only a returned document that lacks the flag
355
+ // counts: with no document there is nothing to judge.
371
356
  const draftNotKept = entry.draft === true && fin.document && fin.document.$disabled !== true
372
357
  if (draftNotKept) notKeptAsDrafts.push(entry.sourceFile || entry.id)
373
358
  if (!entry.sourceFile) {
@@ -383,9 +368,9 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
383
368
 
384
369
  // ⛔ A record that already carries its OWN id is NOT rewritten. Its id is stable
385
370
  // and travels with the file; this backend's uuid belongs in the map, not in the
386
- // author's file. And it must not be: variant A below re-renders the WHOLE file from
387
- // the backend's document, which carries THIS backend's `$uuid` — so a second
388
- // backend would silently overwrite the record's identity with its own.
371
+ // author's file. And it must not be: the write below puts THIS backend's `$uuid` in
372
+ // the file, so a second backend would silently overwrite the record's identity
373
+ // with its own.
389
374
  if (entry.ownId) {
390
375
  ownIdFiles.add(entry.sourceFile)
391
376
  continue
@@ -404,31 +389,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
404
389
  continue
405
390
  }
406
391
 
407
- // Variant A: render the finalized document over the file when we have it +
408
- // the declaration, and it's lossless for the format (md needs a content body
409
- // field for its body). Otherwise variant B: back-fill the uuid in place.
410
- const canRenderA =
411
- !draftNotKept &&
412
- fin.document &&
413
- entry.declaration &&
414
- (entry.format !== 'md' || briefHasContentBody(entry.declaration))
415
- let res
416
- if (canRenderA) {
417
- try {
418
- const text = renderEntityDocument({
419
- document: fin.document,
420
- declaration: entry.declaration,
421
- format: entry.format,
422
- sourceLocale,
423
- })
424
- res = { status: writeIfChanged(entry.sourceFile, text) }
425
- } catch (err) {
426
- warnings.push(`${entry.sourceFile}: ${err.message}; fell back to uuid back-fill`)
427
- res = backfillUuid(entry.sourceFile, uuid)
428
- }
429
- } else {
430
- res = backfillUuid(entry.sourceFile, uuid)
431
- }
392
+ // The uuid, into the author's own file — never the returned document (header ⛔).
393
+ const res = backfillUuid(entry.sourceFile, uuid)
432
394
  if (res.status === 'updated') updated.push(entry.sourceFile)
433
395
  else if (res.status === 'unchanged') unchanged.push(entry.sourceFile)
434
396
  else if (res.status === 'deferred') deferred.push({ index: i, id: entry.id, file: entry.sourceFile, reason: res.message })
@@ -79,7 +79,7 @@ export function isContentBodyField(field) {
79
79
  * (item_ref) ref to its full `@org/model/<section>` path. Falls back to
80
80
  * `resolveName` (model only) when not supplied.
81
81
  * @returns {Object} the declaration (`{ name, label?, description?, source_locale?,
82
- * creatable_by?, linkable?, sections }`).
82
+ * linkable?, sections }`).
83
83
  */
84
84
  export function toDataSchemaDeclaration(normalized, { name, resolveName, resolveOptions } = {}) {
85
85
  if (!name) throw new Error('toDataSchemaDeclaration: a registry name is required')
@@ -104,10 +104,8 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
104
104
  if (normalized.label) decl.label = normalized.label
105
105
  if (normalized.description) decl.description = normalized.description
106
106
  if (normalized.sourceLocale) decl.source_locale = normalized.sourceLocale
107
- // `creatable_by` — who may instantiate this Model. Omitted when the author
108
- // declares nothing, because the registry's own default (open) is the absent
109
- // meaning; sending it explicitly would state a policy the author did not.
110
- if (normalized.creatableBy) decl.creatable_by = normalized.creatableBy
107
+ // ⛔ No `creatable_by`: retired 2026-09-23 — Models are open, and `@uniweb/schemas`
108
+ // refuses the key. (It was emitted here when authored.)
111
109
 
112
110
  // `linkable` — DERIVED AND AUTHORED, and the two compose in one direction only.
113
111
  //
@@ -10,7 +10,7 @@
10
10
  "systemKeys": [
11
11
  "$uuid",
12
12
  "$id",
13
- "$model"
13
+ "$schema"
14
14
  ],
15
15
  "sections": {
16
16
  "info": {
@@ -3,7 +3,7 @@
3
3
  // Distinct from package.js's emitEntityPackage (the register / site-content lane,
4
4
  // whose per-entity body is the legacy `items[]` shape). The sync lane's per-entity
5
5
  // body is the section-keyed `$`-document (see docs/reference/entity-content.md):
6
- // `$uuid?` then `$id` `$model`, then one key per top-level section.
6
+ // `$uuid?` then `$id` `$schema`, then one key per top-level section.
7
7
  //
8
8
  // Identity rides in the BODY — the backend reads `$id` always, `$uuid` when
9
9
  // present, and MINTS `$uuid` on first sync. The manifest is the INDEX, not a copy
@@ -25,6 +25,27 @@ import {
25
25
  computePackageSha256,
26
26
  } from './manifest.js'
27
27
 
28
+ /**
29
+ * The data schema a `$`-document names — its scoped name (`@scope/name`), or null.
30
+ *
31
+ * ⭐ `$schema` is the key, agreed with backend on 2026-09-24 to match the query wire's
32
+ * `schema` for the same value: what the two lanes exchange is a data schema, and the
33
+ * scoped name is how we address one in the registry of the backend we talk to. ⛔ Not
34
+ * an identity ACROSS backends — each has its own registry, so one name on two of them
35
+ * names two data schemas [Diego, 2026-09-24]. It was `$model` until
36
+ * then, and ⛔ nothing reads that spelling — no production backend existed to keep a
37
+ * window for [Diego, 2026-09-24]. The value comes back as it is, so a caller can still
38
+ * tell an id from a name and say which it met.
39
+ * Record: `kb/framework/build/uwx-format.md` § *The data schema an entity names*.
40
+ *
41
+ * @param {object} doc - an entity `$`-document
42
+ * @returns {string|null}
43
+ */
44
+ export function documentSchema(doc) {
45
+ const value = doc?.$schema
46
+ return typeof value === 'string' && value ? value : null
47
+ }
48
+
28
49
  // `exporter.instance`: caller-override -> env UNIWEB_INSTANCE_ID -> "unknown".
29
50
  // Provenance only; neutralized in the digest, so its value is inert.
30
51
  function defaultExporter() {
package/src/uwx/folder.js CHANGED
@@ -52,18 +52,22 @@ export const FOLDER_MODEL_NAME = '@uniweb/folder'
52
52
  export const FOLDER_ENTITY_KEY = '@folder'
53
53
 
54
54
  // Point one authored leaf at the record entity it names. The folder's `contents`
55
- // field is polymorphic (it can reference any Model), so the ref uses the
56
- // entity_ref OPEN form `{ model, entity }` — not a bare uuid (a bare uuid is only
57
- // valid when the field pins a single model). Known uuid → `entry: { model, entity
55
+ // field is polymorphic (it can reference any data schema), so the ref uses the
56
+ // entity_ref OPEN form `{ schema, entity }` — not a bare uuid (a bare uuid is only
57
+ // valid when the field pins a single schema). Known uuid → `entry: { schema, entity
58
58
  // }`; brand-new → `$ref` handle (resolved within this payload to the minted
59
59
  // entity).
60
60
  //
61
- // TODO: the sync lane is uuid-keyed, so `model` should be the resolved Model UUID;
62
- // it currently carries the Model NAME (e.g. `@std/article`). Wire the name→uuid
63
- // resolution (a registry data-schema read) as a follow-up.
61
+ // ⭐ `schema` carries the data schema's SCOPED NAME (`@std/article`), never a Model id
62
+ // — agreed with backend 2026-09-24: a Model id is the backend's internal identity and
63
+ // does not cross `/dev`, while a scoped name is how we address a data schema in the
64
+ // registry of the backend we talk to. ⛔ Neither is an identity across backends: each
65
+ // has its own registry [Diego, 2026-09-24]. (A TODO here asked
66
+ // for the id until then; the agreement settled it the other way.) The key was `model`
67
+ // until the same date. Record: `kb/framework/build/uwx-format.md` § 8.
64
68
  function refLeaf(entity) {
65
69
  const leaf = { kind: 'ref', name: entity.slug }
66
- if (entity.uuid) leaf.entry = { model: entity.model, entity: entity.uuid }
70
+ if (entity.uuid) leaf.entry = { schema: entity.model, entity: entity.uuid }
67
71
  else leaf.$ref = entity.id // the payload-local handle
68
72
  return leaf
69
73
  }
@@ -103,7 +107,7 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
103
107
  return out
104
108
  }
105
109
 
106
- // The uuid of the record a leaf references — once minted, `entry: { model, entity }`
110
+ // The uuid of the record a leaf references — once minted, `entry: { schema, entity }`
107
111
  // (a bare uuid is tolerated). A brand-new record's leaf carries `$ref` instead.
108
112
  function recordOf(item) {
109
113
  if (item?.kind !== 'ref') return null
@@ -255,7 +259,7 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
255
259
 
256
260
  const document = {
257
261
  $id: FOLDER_ENTITY_KEY,
258
- $model: FOLDER_MODEL_NAME,
262
+ $schema: FOLDER_MODEL_NAME,
259
263
  contents,
260
264
  }
261
265
  // Re-arm placement identity. Without it a second send reads as "every item is
package/src/uwx/index.js CHANGED
@@ -21,7 +21,7 @@ export {
21
21
  PACKAGE_SHA256_ASSUMPTIONS,
22
22
  } from './manifest.js'
23
23
  export { emitEntityPackage } from './package.js'
24
- export { emitEntitySyncPackage } from './entity-document.js'
24
+ export { emitEntitySyncPackage, documentSchema } from './entity-document.js'
25
25
  export {
26
26
  FOUNDATION_SCHEMA_TYPE_UUID,
27
27
  SITE_CONTENT_TYPE_UUID,
@@ -212,7 +212,12 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
212
212
  if (value === null || value === undefined) {
213
213
  delete existing[key]
214
214
  } else if (typeof value === 'object' && !Array.isArray(value) && !replace.includes(key)) {
215
- existing[key] = { ...(existing[key] || {}), ...value }
215
+ // ⛔ Merged only INTO AN OBJECT. Spread, a string becomes its characters: a
216
+ // pull wrote `events: '@/event'` back as `events: { '0': '@', '1': '/', …,
217
+ // schema: '@/event' }` (measured 2026-09-23). Anything else that is not an
218
+ // object has nothing to keep, so the incoming value replaces it.
219
+ const prior = existing[key]
220
+ existing[key] = isPlainObject(prior) ? { ...prior, ...value } : value
216
221
  } else {
217
222
  existing[key] = value
218
223
  }
@@ -220,6 +225,8 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
220
225
  return writeIfChanged(filePath, yaml.dump(existing, YAML_DUMP_OPTS))
221
226
  }
222
227
 
228
+ const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
229
+
223
230
  /**
224
231
  * Merge `config` into `site.yml` (shallow). Preserves keys not present in the
225
232
  * update (foundation, base, paths, …).
@@ -290,9 +297,45 @@ export function writeMergedYaml(filePath, projected, managedKeys) {
290
297
  * @returns {'updated'|'unchanged'}
291
298
  */
292
299
  export function writeQueriesConfig(siteRoot, queries) {
293
- return mergeYamlConfig(queriesYmlPath(siteRoot), queries)
300
+ const filePath = queriesYmlPath(siteRoot)
301
+ let authored = {}
302
+ try {
303
+ authored = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
304
+ } catch {
305
+ // missing / invalid → nothing authored to keep
306
+ }
307
+ // ⭐ A query the file already states is left as the author wrote it — in the same
308
+ // words, or in a shorthand (`team: '@/person'` is `{ schema: '@/person' }`, a bare
309
+ // `events:` is `{}`), which a pull brings back in its long form. And a pull that
310
+ // restates every query writes nothing: the file is re-dumped whole otherwise, which
311
+ // rewrites a bare `events:` as `events: null` and flow style as block style.
312
+ const changes = {}
313
+ for (const [name, decl] of Object.entries(queries)) {
314
+ if (restates(authored?.[name], decl)) continue
315
+ changes[name] = decl
316
+ }
317
+ if (Object.keys(changes).length === 0) return 'unchanged'
318
+ // ⛔ Each query WHOLE (`replace`), as this function's header always said. Merged into
319
+ // the local one — what the call did until 2026-09-23 — a key the backend's
320
+ // declaration no longer has survived every pull: the defect `writeSiteConfig`
321
+ // records for the declaration keys ("a stale `limit` survived every pull").
322
+ return mergeYamlConfig(filePath, changes, { replace: Object.keys(changes) })
323
+ }
324
+
325
+ // Whether a pulled declaration says only what the author's entry already says.
326
+ function restates(authored, decl) {
327
+ const keys = Object.keys(decl || {})
328
+ if (typeof authored === 'string') return keys.length === 1 && decl.schema === authored
329
+ if (authored === null) return keys.length === 0
330
+ return isPlainObject(authored) && canonicalJson(authored) === canonicalJson(decl)
294
331
  }
295
332
 
333
+ // JSON with every object's keys sorted — equal for equal values, whatever the order.
334
+ const canonicalJson = (value) =>
335
+ JSON.stringify(value, (_, v) =>
336
+ isPlainObject(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
337
+ )
338
+
296
339
  /**
297
340
  * Write the records folder's organization — `folder.yml` in the records directory
298
341
  * (`records/`, or wherever `site.yml::paths.records` puts it), as a LIST.
@@ -324,13 +367,13 @@ export function writeThemeFile(siteRoot, theme) {
324
367
  }
325
368
 
326
369
  /**
327
- * Render a finalized collection-record `document` to its source-file shape
328
- * (variant A, via renderEntityDocument) and write it idempotently. The record
329
- * half of the collections lane's write step.
370
+ * Render a collection-record `document` a pull brought back to its source-file shape
371
+ * (via renderEntityDocument) and write it idempotently. The record half of the
372
+ * collections lane's write step.
330
373
  *
331
374
  * @param {object} opts
332
375
  * @param {string} opts.filePath
333
- * @param {object} opts.document - finalized `{ $uuid, $model, <brief>: {…} }`
376
+ * @param {object} opts.document - pulled `{ $uuid, $schema, <brief>: {…} }`
334
377
  * @param {object} opts.declaration - the record's data-schema declaration
335
378
  * @param {'yaml'|'json'|'md'} opts.format
336
379
  * @param {string} [opts.sourceLocale]
@@ -6,12 +6,13 @@
6
6
  //
7
7
  // Identity & placement:
8
8
  // - a record's slug comes from the FOLDER document — each ref leaf is
9
- // `{ entry: { model, entity: <uuid> }, name: <slug> }`, at the top of the folder
9
+ // `{ entry: { schema, entity: <uuid> }, name: <slug> }`, at the top of the folder
10
10
  // or inside a branch (its `$children`) whose `name` names the sub-folder. The
11
11
  // folder is the authoritative organization on a read (the record document's own
12
12
  // `$id` envelope is not guaranteed to be echoed back), with the record
13
13
  // document's `$id` (its position in the directory) as a fallback when present.
14
- // - the record's directory comes from its `$model` — `records/{schema}/` is where
14
+ // - the record's directory comes from its data schema (`$schema`, read by
15
+ // `documentSchema`) — `records/{schema}/` is where
15
16
  // a record of that model lives (`site.yml::paths.records` moves it). Not from any
16
17
  // query: a query has no directory, and which query selects a record is not a
17
18
  // fact about it.
@@ -21,7 +22,8 @@
21
22
  // brief has a content body field, else YAML.
22
23
  //
23
24
  // Field rendering reuses renderEntityDocument (via writeRecordFile) — localized
24
- // unwrap, date handling, content-body→body are already inverted there.
25
+ // unwrap, date handling, content-body→body are already inverted there. Asset paths
26
+ // are restored before it runs (`restoreAssetRefs`), as the content lane does.
25
27
  //
26
28
  // Deferred: array-form & BibTeX multi-record files (a pulled record is placed as
27
29
  // its own single-record file; merging into an existing array file is a later
@@ -30,6 +32,8 @@
30
32
  // record is reported.
31
33
 
32
34
  import { readBackendState, updateBackendMap } from './sync-store.js'
35
+ import { restoreAssetRefs } from './asset-map.js'
36
+ import { documentSchema } from './entity-document.js'
33
37
  import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs'
34
38
  import { join, resolve, relative, extname, basename, sep } from 'node:path'
35
39
  import yaml from 'js-yaml'
@@ -116,7 +120,7 @@ function briefHasContentBody(declaration) {
116
120
  // folder is a self-nesting tree under `contents`, nesting via `$children` (the
117
121
  // site-content invariant — folder.js). A leaf sits in a branch whose `name` is
118
122
  // the collection; the leaf's `name` is the slug (the handle) and its `entry` is
119
- // the entity_ref open form `{ model, entity: <uuid> }`. ⛔ `path_segment` is not
123
+ // the entity_ref open form `{ schema, entity: <uuid> }`. ⛔ `path_segment` is not
120
124
  // read: the store renamed it on 2026-09-04 and a pull emits the new shape only —
121
125
  // a reader that kept the old key would index every record as
122
126
  // `{ folderPath: null, slug: undefined }` and rewrite records.yml with
@@ -131,7 +135,7 @@ function indexFolder(folderDoc) {
131
135
  if (node?.kind === 'branch') {
132
136
  walk(node.$children, node.name ?? folderPath)
133
137
  } else if (node?.kind === 'ref' && node.entry) {
134
- // `entry` is `{ model, entity: <uuid> }`; tolerate a bare uuid defensively.
138
+ // `entry` is `{ schema, entity: <uuid> }`; tolerate a bare uuid defensively.
135
139
  const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
136
140
  if (uuid) byUuid.set(uuid, { folderPath, slug: node.name })
137
141
  }
@@ -456,11 +460,11 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
456
460
  *
457
461
  * @param {object} params
458
462
  * @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
459
- * @param {object[]} params.recordDocs - record `$`-documents `{ $uuid?, $id?, $model, <brief> }`
463
+ * @param {object[]} params.recordDocs - record `$`-documents `{ $uuid?, $id?, $schema, <brief> }`
460
464
  * @param {string} params.siteRoot
461
465
  * @param {object} params.opts
462
466
  * @param {(modelName: string) => object|null|undefined} params.opts.resolveDeclaration
463
- * - resolve a Model's data-schema declaration by name (`$model`).
467
+ * - resolve a data schema's declaration by its scoped name (`documentSchema`).
464
468
  * @param {string|null} [params.opts.scope] - the scope the push qualified a record's
465
469
  * `@/x` Model with — the site's foundation's — so a `@scope/x` model is placed
466
470
  * back where the author wrote it. The caller resolves it up front, as it does
@@ -495,8 +499,16 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
495
499
  const unchanged = []
496
500
  const skipped = []
497
501
  const warnings = []
502
+ const backendState = opts.backend ? readBackendState(siteRoot, opts.backend) : {}
503
+ // ⛔ PUT THE AUTHOR'S ASSET PATHS BACK BEFORE ANYTHING IS RENDERED — the step the
504
+ // content lane takes (`site-project.js`), for the same reason. A pushed record goes
505
+ // up with the serve URL the push put where its author wrote `/images/x.png`, and it
506
+ // comes back that way; rendered as is, a pull rewrites the author's file to a
507
+ // backend route. Asset ids and fingerprints are per backend, so a projection tied
508
+ // to none restores nothing. Missing here until 2026-09-23.
509
+ restoreAssetRefs(recordDocs, backendState.assets || {})
498
510
  // This backend's record map, inverted: its uuid → the record's own id.
499
- const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
511
+ const recordMap = backendState.records || {}
500
512
  const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
501
513
  // own id → their uuid, for every record this pull wrote. Recorded at the end.
502
514
  const learned = {}
@@ -512,18 +524,19 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
512
524
  skipped.push({ uuid: document.$uuid, reason: 'no slug (not in the folder, no $id)' })
513
525
  continue
514
526
  }
515
- const declaration = document.$model ? resolveDeclaration(document.$model) : null
527
+ const schema = documentSchema(document)
528
+ const declaration = schema ? resolveDeclaration(schema) : null
516
529
  if (!declaration) {
517
- skipped.push({ uuid: document.$uuid, slug: where.slug, reason: `unresolved model ${document.$model || '(none)'}` })
530
+ skipped.push({ uuid: document.$uuid, slug: where.slug, reason: `unresolved model ${schema || '(none)'}` })
518
531
  continue
519
532
  }
520
533
 
521
- const poolDir = recordDirFor(recordsRoot, document.$model, selfScope)
534
+ const poolDir = recordDirFor(recordsRoot, schema, selfScope)
522
535
  if (!poolDir) {
523
536
  skipped.push({
524
537
  uuid: document.$uuid,
525
538
  slug: where.slug,
526
- reason: `model ${document.$model} names no pool folder (expected @/name or @org/name)`,
539
+ reason: `model ${schema} names no pool folder (expected @/name or @org/name)`,
527
540
  })
528
541
  continue
529
542
  }
@@ -550,7 +563,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
550
563
 
551
564
  // The free-form home for this record's content body (locale-independent); a
552
565
  // target-locale full-doc body is written under locales/freeform/{locale}/here.
553
- const freeformRelPath = buildFreeformRecordPath(document.$model, where.slug)
566
+ const freeformRelPath = buildFreeformRecordPath(schema, where.slug)
554
567
 
555
568
  // ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
556
569
  // backend must not overwrite the identity the record already has.
@@ -559,7 +572,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
559
572
  try {
560
573
  status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath })
561
574
  } catch (err) {
562
- warnings.push(`${where.slug}: ${err.message}`)
575
+ // ⛔ A record that could not be written was not placed, so it is a SKIP, not a
576
+ // warning: a caller that counts what it placed (the CLI's pull) must see it.
577
+ // Until 2026-09-24 it was a warning, and a pull over records with no Sections —
578
+ // what a partly applied push left — reported every one of them as taken.
579
+ skipped.push({ uuid: document.$uuid, slug: where.slug, reason: err.message })
563
580
  continue
564
581
  }
565
582
  if (status === 'unchanged') unchanged.push(filePath)
@@ -2,7 +2,7 @@
2
2
  // referenced BY NAME, on the entity-content SYNC lane.
3
3
  //
4
4
  // Each record becomes a section-keyed `$`-document (docs/reference/entity-content.md):
5
- // `$id` (the producer-local handle), `$model` (the Model by name), and
5
+ // `$id` (the producer-local handle), `$schema` (its data schema, by scoped name), and
6
6
  // each SINGLE section keyed by its name — the brief plus any sibling singles, not
7
7
  // the brief alone. The backend MINTS `$uuid` on first sync and
8
8
  // returns it in the finalized response; the verb back-fills it into the source
@@ -75,7 +75,7 @@ const SKIP_KEYS = new Set([
75
75
  'slug',
76
76
  '$id',
77
77
  '$uuid',
78
- '$model',
78
+ '$schema',
79
79
  '$owner',
80
80
  '$unit',
81
81
  '$meta',
@@ -85,7 +85,7 @@ const SKIP_KEYS = new Set([
85
85
  'draft',
86
86
  ])
87
87
 
88
- // Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$model`/… — never
88
+ // Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$schema`/… — never
89
89
  // field data; the sigil-exclusivity invariant guarantees this) at every level,
90
90
  // so a back-filled `$uuid` doesn't change the hash. Two are exceptions, because
91
91
  // they are content rather than identity:
@@ -103,7 +103,7 @@ function stripSigils(value) {
103
103
  // encoding rather than the reference made the folder's hash unreproducible.
104
104
  //
105
105
  // `refLeaf` (uwx/folder.js) emits `$ref: <the record's $id>` — the pool position
106
- // `<dirs>/<slug>` — while the record is brand-new, and `entry: { model, entity:
106
+ // `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema, entity:
107
107
  // <uuid> }` once it has been minted.
108
108
  // Both denote the same record. A push hashes the folder BEFORE submitting, then
109
109
  // back-fills the minted `$uuid` into every record's source file — so the very
@@ -205,8 +205,10 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
205
205
  * @param {string} [params.sourceLocale] - locale for localized-field wrap
206
206
  * @param {object} [params.translations] - `{ locale: { hash: tgt } }` for wrapping
207
207
  * localized scalar fields per-locale (from loadLocaleTranslations)
208
- * @returns {{ entities: object[], warnings: string[] }} each entity is
209
- * `{ id, uuid, model, file, document }` — `document` is the section-keyed body.
208
+ * @returns {{ entities: object[], warnings: string[], refusals: string[] }} each
209
+ * entity is `{ id, uuid, model, file, document }` — `document` is the section-keyed
210
+ * body. `refusals` names the records that cannot be sent as written, one line
211
+ * each; a caller that sends must not send while any is present.
210
212
  */
211
213
  export function recordsToEntities({
212
214
  label,
@@ -259,6 +261,14 @@ export function recordsToEntities({
259
261
  if (!fieldByKey.has(key)) fieldByKey.set(key, field)
260
262
  }
261
263
  }
264
+ // Every section by name, single or list — to recognize a record written BY SECTION
265
+ // (`details: { title: … }`), the shape docs/reference/entity-content.md gives a
266
+ // sections-form record. This mapper reads a record's fields from the top of its
267
+ // file, so such a record is refused below rather than sent without them.
268
+ const sectionNames = new Set(sectionEntries.map(([name]) => name))
269
+ const listSections = new Set(
270
+ sectionEntries.filter(([, s]) => s && s.multiple === true).map(([name]) => name)
271
+ )
262
272
 
263
273
  // The markdown body of a `.md` record is the value of the Model's CONTENT body
264
274
  // field — a markup `text` field (raw source string) or a `format: prosemirror`
@@ -276,6 +286,8 @@ export function recordsToEntities({
276
286
 
277
287
  const entities = []
278
288
  const warnings = []
289
+ // Records that cannot be sent as written — see the ⛔ at the end of the loop.
290
+ const refusals = []
279
291
  if (contentMatches.length > 1) {
280
292
  warnings.push(
281
293
  `${label}: ${declaration.name} has more than one content ` +
@@ -299,7 +311,7 @@ export function recordsToEntities({
299
311
  // only the fallback for a record that did not arrive that way.
300
312
  //
301
313
  // The qualification is a CONSTRAINT, not a style: the sync response is keyed per
302
- // (`$model`, `$id`), so a bare slug would collide whenever two schema folders
314
+ // (`$schema`, `$id`), so a bare slug would collide whenever two schema folders
303
315
  // resolving to one Model reuse one (see the duplicate check). ⇒ Do not describe this
304
316
  // value as "the slug" — the folder leaf's `name` is the bare segment, and
305
317
  // conflating the two has already misdirected a naming decision.
@@ -326,31 +338,74 @@ export function recordsToEntities({
326
338
  }
327
339
  if (Object.keys(data).length) sectionData[secName] = data
328
340
  }
329
- // Warn for author keys not on ANY record section. A real unknown key means the
330
- // frontmatter doesn't match the collection's data schema — that SHOULD warn
331
- // (only identity/transport keys in SKIP_KEYS are silent).
341
+ // Author keys on no record section (only identity/transport keys in SKIP_KEYS are
342
+ // exempt). A key naming a SECTION is not unknown: the record is written by
343
+ // section, refused below.
344
+ const bySection = []
345
+ const undeclared = []
332
346
  for (const key of Object.keys(record)) {
333
347
  if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
334
- warnings.push(
335
- `${label}/${slug}: field "${key}" is not on ` +
336
- `${declaration.name} — not synced`
348
+ if (sectionNames.has(key)) bySection.push(key)
349
+ else undeclared.push(key)
350
+ }
351
+
352
+ // ⛔ A RECORD THE BACKEND WOULD REFUSE — OR WOULD STORE EMPTY — IS REFUSED HERE,
353
+ // before anything is sent. Two cases are visible from here, and the second is only
354
+ // checked when the first does not already explain it:
355
+ // - a record written by section, whose sections' contents this mapper cannot
356
+ // send (it reads fields from the top of the file). Sent, it arrives with an
357
+ // empty brief: refused when the brief has a `required` field, and otherwise
358
+ // STORED EMPTY, in silence — so this stays whatever the backend does with a
359
+ // refusal;
360
+ // - a `required` field the send would lack — the brief is always sent, another
361
+ // single section only when the record fills it. The backend refuses it too,
362
+ // but names the field, not the file.
363
+ // Measured 2026-09-23: a record written by section went up with an empty brief and
364
+ // the lane was refused — and the refused lane left the folder's entries and
365
+ // entities with no data behind, so every later push of the site's records was
366
+ // refused. The backend's push has been one transaction since 2026-09-24; a refusal
367
+ // now writes nothing.
368
+ // ⛔ And what a push cannot carry is refused too, because it is lost twice over: the
369
+ // backend never receives it, and the next pull writes the file without it — an
370
+ // undeclared key (`tags:` beside a schema with no `tags`), or a markdown body where
371
+ // the schema has no field to hold it. Both were warnings until 2026-09-24
372
+ // ("not synced"), and a pull then deleted the value from the author's file.
373
+ if (undeclared.length) {
374
+ const one = undeclared.length === 1
375
+ refusals.push(
376
+ `${label}/${slug}: ${quoted(undeclared)} ${one ? 'is not a field' : 'are not fields'} of ` +
377
+ `${declaration.name} — a push cannot carry ${one ? 'it' : 'them'}, and the next pull would ` +
378
+ `drop ${one ? 'it' : 'them'} from the file. Declare ${one ? 'it' : 'them'} in the schema, ` +
379
+ `or remove ${one ? 'it' : 'them'}.`
337
380
  )
338
381
  }
339
382
  if (hasBody && !bodyTarget) {
340
- warnings.push(
341
- `${label}/${slug}: markdown body present but ` +
342
- `${declaration.name} has no content body field — body not synced`
383
+ refusals.push(
384
+ `${label}/${slug}: the file has a markdown body, and ${declaration.name} has no field for ` +
385
+ 'it (a `markdown` or `richtext` field) — a push cannot carry it. Add one to the schema, ' +
386
+ 'or move the text into a field.'
343
387
  )
344
388
  }
389
+ if (bySection.length) {
390
+ refusals.push(sectionShapeRefusal(`${label}/${slug}`, bySection, listSections, declaration.name))
391
+ } else {
392
+ const missing = missingRequired(recordSections, sectionData, briefName)
393
+ if (missing.length) {
394
+ refusals.push(
395
+ `${label}/${slug}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
396
+ `has no value for ${missing.length === 1 ? 'it' : 'them'}.`
397
+ )
398
+ }
399
+ }
345
400
 
346
- // The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`, `$disabled?`,
401
+ // The `$`-document, in canonical key order: `$uuid?`, `$id`, `$schema`, `$disabled?`,
347
402
  // then each populated section in declared order (the brief always present as the
348
403
  // card). `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
349
404
  // its side.
350
405
  const document = {}
351
406
  if (uuid) document.$uuid = uuid
352
407
  document.$id = id
353
- document.$model = declaration.name
408
+ document.$schema = declaration.name
354
409
  // ⭐ A DRAFT IS SENT AS A DISABLED ENTITY: it stays in the folder and is never
355
410
  // publicly delivered. Only `true` travels. An absent key means enabled [Diego,
356
411
  // 2026-09-21], so a record whose `draft: true` is removed is sent without the key
@@ -373,7 +428,51 @@ export function recordsToEntities({
373
428
  document,
374
429
  })
375
430
  }
376
- return { entities, warnings }
431
+ return { entities, warnings, refusals }
432
+ }
433
+
434
+ // "a", "a and b", "a, b and c" — items are already formatted.
435
+ function listOf(items) {
436
+ if (items.length <= 1) return items.join('')
437
+ return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`
438
+ }
439
+
440
+ const quoted = (keys) => listOf(keys.map((k) => `"${k}"`))
441
+
442
+ // Why a record written by section cannot be sent, and what to do about each kind of
443
+ // section it names: a single section's fields can move to the top of the file; a
444
+ // list section has no file form a push can send.
445
+ function sectionShapeRefusal(where, keys, listSections, modelName) {
446
+ const singles = keys.filter((k) => !listSections.has(k))
447
+ const lists = keys.filter((k) => listSections.has(k))
448
+ const one = keys.length === 1
449
+ let message =
450
+ `${where}: ${quoted(keys)} ${one ? 'is a section' : 'are sections'} of ${modelName}, and a ` +
451
+ `push reads a record's fields from the top of its file — what ${one ? 'it holds' : 'they hold'} ` +
452
+ 'would not be sent.'
453
+ if (singles.length) message += ` Move the fields of ${quoted(singles)} up a level.`
454
+ if (lists.length) {
455
+ message +=
456
+ ` ${quoted(lists)} ${lists.length === 1 ? 'holds' : 'hold'} a list, which a push cannot ` +
457
+ 'send from a file yet.'
458
+ }
459
+ return message
460
+ }
461
+
462
+ // The `required` fields a record's send would lack. The brief is always sent; another
463
+ // single section only when the record fills it, so an empty one is not checked. A
464
+ // field counts as present when the send carries a value for it (`null` is none).
465
+ function missingRequired(recordSections, sectionData, briefName) {
466
+ const missing = []
467
+ for (const [secName, sec] of recordSections) {
468
+ const data = sectionData[secName]
469
+ if (secName !== briefName && !data) continue
470
+ for (const [key, field] of Object.entries(sec.fields || {})) {
471
+ if (field?.required !== true || data?.[key] != null) continue
472
+ missing.push(secName === briefName ? `"${key}"` : `"${key}" (section "${secName}")`)
473
+ }
474
+ }
475
+ return missing
377
476
  }
378
477
 
379
478
  // Post-pass: override a collection record's localized CONTENT body with a per-locale
@@ -544,7 +643,7 @@ function resolveDeclaration(schema, modelName) {
544
643
  * (e.g. site-content) into one sync package. First sync sends no `$uuid` (the
545
644
  * backend mints); re-sync round-trips the back-filled `$uuid`. Throws on an
546
645
  * unresolvable EXPLICIT Model, an invalid `records/folder.yml`, or a duplicate
547
- * ($model, $id) within the submission.
646
+ * ($schema, $id) within the submission.
548
647
  *
549
648
  * @param {string} siteRoot - directory containing site.yml
550
649
  * @param {object} [opts]
@@ -558,8 +657,9 @@ function resolveDeclaration(schema, modelName) {
558
657
  * Defaults to the site's foundation's (`siteSelfScope`); a caller that has it
559
658
  * already passes it, so one emit reads it once.
560
659
  * @returns {Promise<{ entities: object[], index: object[], warnings: string[],
561
- * schemaless: Array<{name: string, model: string}>, colConfig: object,
562
- * folder: object, recordsDirExists: boolean }>}
660
+ * refusals: string[], schemaless: Array<{name: string, model: string}>,
661
+ * colConfig: object, folder: object, recordsDirExists: boolean }>}
662
+ * `refusals` — records that cannot be sent as written (`recordsToEntities`).
563
663
  * `schemaless` lists the QUERIES whose schema resolved to nothing (the
564
664
  * convention-default soft-skip) — their records are not pushed as entities, and
565
665
  * the composite deploy delivers those queries statically instead.
@@ -597,6 +697,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
597
697
  throw new Error(`uwx/records: ${recordsCfg.file} is invalid —\n ${folder.errors.join('\n ')}`)
598
698
  }
599
699
  const warnings = [...pool.errors, ...folder.warnings]
700
+ const refusals = []
600
701
 
601
702
  const colConfig = opts.queriesConfig || (await resolveQueriesConfig(siteRoot))
602
703
  const queries = siteQueries(colConfig.declarations)
@@ -606,6 +707,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
606
707
  entities: [],
607
708
  index: [],
608
709
  warnings,
710
+ refusals,
609
711
  schemaless: [],
610
712
  colConfig,
611
713
  folder: { ...folder, nodes: [] },
@@ -657,7 +759,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
657
759
  // ⭐ Resolving BEFORE `declarationFor` is what keeps this to one line of behaviour:
658
760
  // `resolveDeclaration` already matches a fully-qualified name against the
659
761
  // foundation's `@/`-keyed `dataSchemas`, so a resolved name looks up correctly and
660
- // `declaration.name` — the value that becomes `$model` — is the resolved one.
762
+ // `declaration.name` — the value that becomes `$schema` — is the resolved one.
661
763
  //
662
764
  // ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
663
765
  // (`site.js::queriesNested`): a query's `schema` must name exactly the Model
@@ -740,7 +842,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
740
842
  const producedBy = new Map()
741
843
  // Schemas (as written) whose Model resolved to nothing — soft-skipped.
742
844
  const unresolved = new Set()
743
- // The sync response is keyed per ($model, $id), so the pair must be unique
845
+ // The sync response is keyed per ($schema, $id), so the pair must be unique
744
846
  // within one submission.
745
847
  const seen = new Set()
746
848
 
@@ -821,14 +923,14 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
821
923
  const dupKey = `${e.model} ${e.id}`
822
924
  if (seen.has(dupKey)) {
823
925
  throw new Error(
824
- `uwx/records: duplicate ($model, $id) in one sync — "${e.id}" of ` +
926
+ `uwx/records: duplicate ($schema, $id) in one sync — "${e.id}" of ` +
825
927
  `${e.model} comes from more than one record. Each record in a schema ` +
826
928
  'folder needs its own slug; make the slugs unique.'
827
929
  )
828
930
  }
829
931
  seen.add(dupKey)
830
932
  // The verb back-fills the minted `$uuid` into this source file, matched
831
- // back from the finalized response by ($model, $id).
933
+ // back from the finalized response by ($schema, $id).
832
934
  const src = sourceBySlug.get(e.slug)
833
935
  index.push({
834
936
  id: e.id,
@@ -843,16 +945,13 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
843
945
  ownId: ownIds.get(e.slug) ?? null,
844
946
  format: src ? src.format : null,
845
947
  multiRecord: src ? src.multiRecord : false,
846
- // The Model declaration, so the back-fill can render the finalized
847
- // document → authoring shape (variant A): unwrap localized fields, route
848
- // the content body field to the md body, drop the brief record `$uuid`.
849
- declaration,
850
948
  // Sent disabled, so the back-fill can check that the backend kept it that way.
851
949
  draft: e.document.$disabled === true,
852
950
  })
853
951
  }
854
952
  entities.push(...mappedOut.entities)
855
953
  warnings.push(...mappedOut.warnings)
954
+ refusals.push(...mappedOut.refusals)
856
955
  }
857
956
 
858
957
  // Queries whose schema resolved to nothing — not pushed as entities. The composite
@@ -903,6 +1002,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
903
1002
  entities,
904
1003
  index,
905
1004
  warnings,
1005
+ refusals,
906
1006
  schemaless,
907
1007
  colConfig,
908
1008
  folder: { ...folder, nodes },
@@ -968,11 +1068,11 @@ export function filterChanged(entities, index, { priorHashes = {}, sendAll = fal
968
1068
  * @param {object} [opts] - buildRecordEntities opts, plus `priorHashes`,
969
1069
  * `sendAll`, `exporter`, `exportedAt`.
970
1070
  * @returns {Promise<{ buffer: Buffer|null, models: string[], entityCount: number,
971
- * warnings: string[], index: object[], hashes: Object<string,string>,
972
- * skipped: number }>}
1071
+ * warnings: string[], refusals: string[], index: object[],
1072
+ * hashes: Object<string,string>, skipped: number }>}
973
1073
  */
974
1074
  export async function emitRecordSyncPackage(siteRoot, opts = {}) {
975
- const { entities, index, warnings, recordsDirExists } = await buildRecordEntities(siteRoot, opts)
1075
+ const { entities, index, warnings, refusals, recordsDirExists } = await buildRecordEntities(siteRoot, opts)
976
1076
  if (entities.length === 0) {
977
1077
  throw new Error(
978
1078
  'uwx/records: no records to export — ' +
@@ -989,7 +1089,7 @@ export async function emitRecordSyncPackage(siteRoot, opts = {}) {
989
1089
 
990
1090
  const sentModels = [...new Set(sendEntities.map((e) => e.model))]
991
1091
  if (sendEntities.length === 0) {
992
- return { buffer: null, models: sentModels, entityCount: 0, warnings, index: [], hashes, skipped }
1092
+ return { buffer: null, models: sentModels, entityCount: 0, warnings, refusals, index: [], hashes, skipped }
993
1093
  }
994
1094
 
995
1095
  const buffer = emitEntitySyncPackage({
@@ -1000,5 +1100,5 @@ export async function emitRecordSyncPackage(siteRoot, opts = {}) {
1000
1100
  exportedAt: opts.exportedAt,
1001
1101
  })
1002
1102
 
1003
- return { buffer, models: sentModels, entityCount: sendEntities.length, warnings, index: sendIndex, hashes, skipped }
1103
+ return { buffer, models: sentModels, entityCount: sendEntities.length, warnings, refusals, index: sendIndex, hashes, skipped }
1004
1104
  }
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // ⭐ ONE RULE FOR EVERY PATH THAT SHIPS A REF FROM ONE PUBLISH, and its inverse:
11
11
  //
12
- // - a record's `$model` `records.js::buildRecordEntities`
12
+ // - a record's `$schema` `records.js::buildRecordEntities`
13
13
  // - a query's `schema` `site.js::queriesNested` (the `queries` Section)
14
14
  // - the author's spelling, on pull `records-project.js` (placement and declarations)
15
15
  //
package/src/uwx/site.js CHANGED
@@ -850,7 +850,7 @@ export function isSiteRelativeExtensionUrl(decl) {
850
850
  * @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
851
851
  * response or a pull. Absent on a first sync, where minting is correct.
852
852
  * @param {string} [org] the publish org. A foundation-relative `schema` (`@/x`)
853
- * is qualified with it (`./self-scope.js`), exactly as the records' `$model`
853
+ * is qualified with it (`./self-scope.js`), exactly as the records' `$schema`
854
854
  * is — see the note at the `schema` line below.
855
855
  */
856
856
  // ⛔ KEYS THAT MUST NOT REACH THE WIRE. Everything else on an authored declaration
@@ -930,7 +930,7 @@ function queriesNested(declarations, uuids = null, scope = null) {
930
930
  // the runtime fetches it from (never the records service).
931
931
  const source = d.path ? { path: d.path } : d.url !== undefined ? externalSource(d) : d.source
932
932
  setIf(data, 'source', source)
933
- // ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME SCOPE AS THE RECORDS' `$model`
933
+ // ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME SCOPE AS THE RECORDS' `$schema`
934
934
  // (`records.js::buildRecordEntities`) — the site's foundation's. A consumer
935
935
  // answers a query by matching this name against the Models its records were
936
936
  // stored under, so a verbatim `@/member` beside records stored as `@acme/member`
@@ -1242,7 +1242,7 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1242
1242
  * `siteSelfScope`; pass the scope the records are emitted with. With none
1243
1243
  * known, `@/x` ships as written.
1244
1244
  * @returns {Promise<object>} the section-keyed `$`-document:
1245
- * `{ $uuid?, $id, $model, info, settings?, pages, layout_sections, extensions,
1245
+ * `{ $uuid?, $id, $schema, info, settings?, pages, layout_sections, extensions,
1246
1246
  * queries, services?, secrets? }`
1247
1247
  */
1248
1248
  export async function siteProjectToDocument(siteRoot, opts = {}) {
@@ -1518,7 +1518,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1518
1518
  // config (the records themselves are separate entities; this is just the config).
1519
1519
  const colConfig = await resolveQueriesConfig(siteRoot, { siteYml })
1520
1520
 
1521
- // `$uuid?` then `$id` `$model`, then sections in Model-declared order.
1521
+ // `$uuid?` then `$id` `$schema`, then sections in Model-declared order.
1522
1522
  const doc = {}
1523
1523
  // ⭐ From `sync.json`, keyed by the backend this document is being produced FOR.
1524
1524
  // It was `site.yml::$uuid` — one scalar with no way to say which backend minted it.
@@ -1529,7 +1529,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1529
1529
  (opts.backend ? readBackendState(siteRoot, opts.backend).site?.uuid : undefined)
1530
1530
  if (entityUuid) doc.$uuid = entityUuid
1531
1531
  doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
1532
- doc.$model = SITE_MODEL_NAME
1532
+ doc.$schema = SITE_MODEL_NAME
1533
1533
  doc.info = info
1534
1534
  // Emitted only when the file declares something — see `settingsNested`.
1535
1535
  const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations })
@@ -1594,7 +1594,7 @@ export async function emitSiteSyncPackage(siteRoot, opts = {}) {
1594
1594
  entities: [
1595
1595
  {
1596
1596
  id: document.$id,
1597
- model: document.$model,
1597
+ model: document.$schema,
1598
1598
  file: 'entities/site-content.json',
1599
1599
  document,
1600
1600
  },
@@ -71,7 +71,7 @@ function emitLane(entities, exporter, exportedAt, extraModels = []) {
71
71
  return { buffer, entityCount: entities.length, models }
72
72
  }
73
73
 
74
- // Collect the Models referenced by the folder's `ref` leaves (`entry.model`), walking
74
+ // Collect the Models referenced by the folder's `ref` leaves (`entry.schema`), walking
75
75
  // the contents/$children tree. The folder is built from the FULL record set, so it
76
76
  // references every record's Model — including records the send-only-changed filter
77
77
  // drops from THIS package (a re-push where the folder changed but the records didn't).
@@ -79,7 +79,7 @@ function emitLane(entities, exporter, exportedAt, extraModels = []) {
79
79
  // ride even when their record entities don't.
80
80
  function collectReferencedModels(node, acc) {
81
81
  if (!node || typeof node !== 'object') return acc
82
- if (node.entry && typeof node.entry.model === 'string') acc.add(node.entry.model)
82
+ if (node.entry && typeof node.entry.schema === 'string') acc.add(node.entry.schema)
83
83
  for (const key of ['$children', 'contents']) {
84
84
  if (Array.isArray(node[key])) for (const child of node[key]) collectReferencedModels(child, acc)
85
85
  }
@@ -251,9 +251,13 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
251
251
  * @returns {Promise<{
252
252
  * siteContent: { buffer, entityCount, index, models }|null,
253
253
  * records: { buffer, entityCount, index, models }|null,
254
- * hashes: Object<string,string>, warnings: string[], skipped: number,
255
- * schemaless: Array<{name: string, model: string}>, localAssets: string[],
256
- * applied: object }>}
254
+ * hashes: Object<string,string>, warnings: string[], refusals: string[],
255
+ * skipped: number, schemaless: Array<{name: string, model: string}>,
256
+ * localAssets: string[], applied: object }>}
257
+ * `refusals` names the records that cannot be sent as written, one line each
258
+ * (`records.js::recordsToEntities`). A caller that submits must not submit while
259
+ * any is present: the backend would refuse the records lane — or, for a record
260
+ * written by section whose brief requires nothing, store it empty.
257
261
  * `schemaless` lists queries that resolved no data schema (soft-skipped from
258
262
  * the sync) — the composite deploy delivers these statically via the data ball.
259
263
  * `localAssets` lists the site-root local media refs (`/images/x.png`) the deploy
@@ -277,7 +281,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
277
281
  const exportedAt = opts.exportedAt
278
282
  refuseOrgOption(opts, 'emitSyncPackages')
279
283
  // ⭐ ONE SCOPE FOR THE WHOLE EMIT — the site's foundation's, the one `register`
280
- // stored its Models under — so a record's `$model` and a query's `schema` are
284
+ // stored its Models under — so a record's `$schema` and a query's `schema` are
281
285
  // qualified alike. Read once here and handed to both. ⛔ It was the org that owns
282
286
  // the SITE until 2026-09-22 (`self-scope.js`).
283
287
  const scope =
@@ -446,7 +450,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
446
450
  const localAssets = [...localAssetSet]
447
451
 
448
452
  const siteEntity = siteDoc
449
- ? { id: siteDoc.$id, model: siteDoc.$model, file: 'entities/site-content.json', document: siteDoc }
453
+ ? { id: siteDoc.$id, model: siteDoc.$schema, file: 'entities/site-content.json', document: siteDoc }
450
454
  : null
451
455
 
452
456
  // One hash map over every entity (both lanes) — the sync-cache the caller persists.
@@ -519,6 +523,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
519
523
 
520
524
  return {
521
525
  siteContent, records, siteContentUuid, hashes, warnings, skipped,
526
+ refusals: col.refusals || [],
522
527
  schemaless: col.schemaless, localAssets, applied,
523
528
  // { stamped, unknown } when identity was applied; null when the caller passed
524
529
  // no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
@@ -31,15 +31,17 @@ import { YAML_OPTIONS } from './utils/yaml-schema.js'
31
31
  import { queryNameFromUrl, declaredKeys, fillDeclaredKeys, fetchLevels, pageRouteQuery } from '@uniweb/core'
32
32
  import { parentRouteOf } from '@uniweb/core/route-match'
33
33
 
34
- import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
35
- import { validateAndNormalizeSchema } from './resolve-data-schema.js'
34
+ import { validateItem, isStaticallyCheckable, validateBound, flatRecordFields } from '@uniweb/schemas/conform'
35
+ import { validateAndNormalizeSchema, buildDataSchemaMap } from './resolve-data-schema.js'
36
36
 
37
37
  // The pure checker, re-exported so `@uniweb/build/validate` stays the one import
38
38
  // path callers know (`uniweb validate`, the CLI, and the contract tests all
39
39
  // reach it here) even though the implementation moved next to the vocabulary.
40
40
  export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
41
41
  import { buildSchema } from './schema.js'
42
- import { resolveRecordsDir } from './site/entity-pool.js'
42
+ import { resolveRecordsDir, readEntityPool, groupPoolBySchema } from './site/entity-pool.js'
43
+ import { readEntityFile } from './uwx/entity-source.js'
44
+ import { isContentBodyField } from './uwx/data-schema.js'
43
45
  import { toFetchList } from './site/data-fetcher.js'
44
46
  import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
45
47
  import { collectSiteContent } from './site/content-collector.js'
@@ -195,8 +197,11 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
195
197
  const schema = dataSchemas[ref]
196
198
  if (!schema) continue // build guarantees refs resolve; defensive skip
197
199
 
200
+ // ⭐ A sections-form schema is checked like any other now (`isStaticallyCheckable`,
201
+ // 2026-09-24) — a record can be flat or written by section. What is left out is a
202
+ // schema whose ROOT IS A LIST: it describes a whole value, not one record.
198
203
  if (!isStaticallyCheckable(schema)) {
199
- deferred.push({ route: page.route, section: type, key, reason: 'rich sections-form schema', ref })
204
+ deferred.push({ route: page.route, section: type, key, reason: 'its root is a list, not a record', ref })
200
205
  continue
201
206
  }
202
207
 
@@ -215,6 +220,8 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
215
220
  const violations = []
216
221
  const schemasSeen = new Set()
217
222
  let recordCount = 0
223
+ // (schema, slug) of every record this pass checked, so pass 5 checks the rest.
224
+ const checkedRecords = new Set()
218
225
 
219
226
  for (const entry of work.values()) {
220
227
  const { records, error } = await resolveRecords(entry.path, { byQuery, siteRoot })
@@ -227,6 +234,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
227
234
  const items = Array.isArray(records) ? records : [records]
228
235
  items.forEach((item, idx) => {
229
236
  recordCount++
237
+ if (item && typeof item.slug === 'string') checkedRecords.add(recordKey(entry.ref, item.slug))
230
238
  for (const finding of validateItem(entry.schema, item)) {
231
239
  violations.push({
232
240
  file: entry.path,
@@ -253,6 +261,16 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
253
261
  for (const ref of blocks.schemas) schemasSeen.add(ref)
254
262
  recordCount += blocks.checked
255
263
 
264
+ // Pass 5 — every record file no section's binding reached, against the data schema
265
+ // its folder names. ⭐ The set a push sends: every file in the records directory is
266
+ // a record and every record is pushed, whether or not a section reads it — and
267
+ // until 2026-09-24 one that no section read was never checked here.
268
+ const files = await validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths: config.paths, checked: checkedRecords })
269
+ violations.push(...files.violations)
270
+ setupErrors.push(...files.setupErrors)
271
+ for (const ref of files.schemas) schemasSeen.add(ref)
272
+ recordCount += files.checked
273
+
256
274
  return {
257
275
  violations,
258
276
  deferred,
@@ -266,6 +284,69 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
266
284
  }
267
285
  }
268
286
 
287
+ const recordKey = (ref, slug) => `${ref}\u0000${slug}`
288
+
289
+ /**
290
+ * Check the records in the records directory that pass 2 did not, each against the
291
+ * data schema its folder names (`records/member/` → `@/member`).
292
+ *
293
+ * A record is read the way a push reads it (`uwx/entity-source.js`), and a markdown
294
+ * record's body is the value of its schema's content body field unless its
295
+ * frontmatter sets one — which is what a push sends it as (`uwx/records.js`).
296
+ *
297
+ * A folder whose schema resolves to nothing is left out in silence: its records are
298
+ * schema-less, delivered as files, and the push says so itself.
299
+ *
300
+ * @returns {Promise<{ violations: object[], setupErrors: object[], schemas: Set<string>, checked: number }>}
301
+ */
302
+ async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths, checked }) {
303
+ const out = { violations: [], setupErrors: [], schemas: new Set(), checked: 0 }
304
+ const pool = await readEntityPool(siteRoot, { dir: resolveRecordsDir(siteRoot, paths).rel })
305
+ for (const [ref, entities] of groupPoolBySchema(pool.entities)) {
306
+ const schema = await schemaForRecords(ref, { srcDir, dataSchemas })
307
+ if (!schema || !isStaticallyCheckable(schema)) continue
308
+ for (const pooled of entities) {
309
+ let records
310
+ try {
311
+ records = await readEntityFile(pooled.absPath)
312
+ } catch (err) {
313
+ out.setupErrors.push({ file: pooled.relPath, message: err.message, users: [] })
314
+ continue
315
+ }
316
+ for (const r of records) {
317
+ if (!r.slug || checked.has(recordKey(ref, r.slug))) continue
318
+ out.checked++
319
+ out.schemas.add(ref)
320
+ for (const finding of validateItem(schema, withBody(schema, r))) {
321
+ out.violations.push({ file: pooled.relPath, schema: ref, item: r.slug, users: [], ...finding })
322
+ }
323
+ }
324
+ }
325
+ }
326
+ return out
327
+ }
328
+
329
+ // The schema a records folder names — the one a section binds if any does, else
330
+ // resolved the way every other ref is. Null when nothing resolves.
331
+ async function schemaForRecords(ref, { srcDir, dataSchemas }) {
332
+ if (dataSchemas[ref]) return dataSchemas[ref]
333
+ try {
334
+ return (await buildDataSchemaMap([ref], { srcDir }))[ref] || null
335
+ } catch {
336
+ return null
337
+ }
338
+ }
339
+
340
+ // A record as a push sends it: a markdown body fills the schema's content body field
341
+ // when the frontmatter does not set it.
342
+ function withBody(schema, r) {
343
+ const record = { ...(r.data || {}) }
344
+ if (typeof r.body !== 'string' || r.body.trim() === '') return record
345
+ const key = Object.entries(flatRecordFields(schema) || {}).find(([, f]) => isContentBodyField(f))?.[0]
346
+ if (key && record[key] === undefined) record[key] = r.body
347
+ return record
348
+ }
349
+
269
350
  /**
270
351
  * Check each ```md:<tag> concept block against `@std/<tag>`, when that schema
271
352
  * exists.