@uniweb/build 0.59.1 → 0.61.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.
@@ -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