@uniweb/build 0.56.0 → 0.58.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.
@@ -48,7 +48,7 @@ import { join, resolve } from 'node:path'
48
48
 
49
49
  import { resolveQueriesConfig } from './queries-config.js'
50
50
  import { readEntityFile } from './entity-source.js'
51
- import { readRecordsConfig, resolveFolder, RECORDS_YML_RELPATH } from '../site/records-config.js'
51
+ import { readRecordsConfig, resolveFolder } from '../site/records-config.js'
52
52
  import {
53
53
  readEntityPool,
54
54
  groupPoolBySchema,
@@ -63,6 +63,7 @@ import { markdownToProseMirror } from '@uniweb/content-reader'
63
63
  import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
64
64
  import { localizeScalar, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
65
65
  import { loadFreeformRecord } from '../i18n/freeform.js'
66
+ import { isDraftRecord } from '../site/record-draft.js'
66
67
 
67
68
  const DATE_KINDS = new Set(['date', 'datetime'])
68
69
  // Identity/transport keys on a source record — never Model fields, never warned.
@@ -79,15 +80,22 @@ const SKIP_KEYS = new Set([
79
80
  '$unit',
80
81
  '$meta',
81
82
  '$body',
83
+ // The draft flag (`site/record-draft.js`) — framework's, never a Model field. It
84
+ // travels as the entity's `$disabled` (see the document below).
85
+ 'draft',
82
86
  ])
83
87
 
84
88
  // Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$model`/… — never
85
89
  // field data; the sigil-exclusivity invariant guarantees this) at every level,
86
- // so a back-filled `$uuid` doesn't change the hash. `$children` is the exception:
87
- // it is STRUCTURAL content (a self-nesting record's subtree, e.g. site-content's
88
- // nested pages/sections), so it is KEPT and recursed into — otherwise a nesting
89
- // change would be invisible to "send only changed". Flat records carry no
90
- // `$children`, so this is a no-op for the collection lane.
90
+ // so a back-filled `$uuid` doesn't change the hash. Two are exceptions, because
91
+ // they are content rather than identity:
92
+ // - `$children` is STRUCTURAL content (a self-nesting record's subtree, e.g.
93
+ // site-content's nested pages/sections), so it is KEPT and recursed into —
94
+ // otherwise a nesting change would be invisible to "send only changed". Flat
95
+ // records carry no `$children`, so this is a no-op for the collection lane.
96
+ // - `$disabled` is a record's delivery STATE (`draft: true` on the file side). It
97
+ // is KEPT so that drafting or un-drafting a record with nothing else changed is
98
+ // still sent; stripped, "send only changed" would never send the toggle.
91
99
  function stripSigils(value) {
92
100
  if (Array.isArray(value)) return value.map(stripSigils)
93
101
  if (value && typeof value === 'object') {
@@ -107,11 +115,12 @@ function stripSigils(value) {
107
115
  //
108
116
  // ⭐ Neither encoding is content. What the folder SAYS is "this branch contains
109
117
  // this record, here, in this order" — and that is already hashed: a leaf carries
110
- // `name` (the record's handle) inside a branch carrying the collection's.
111
- // A `folders:` branch's entries are COLLECTION names, so every leaf under one
112
- // comes from a single collection, where a slug is unique. Position plus segment
113
- // therefore identify the record on their own; `$ref` adds a payload-local handle
114
- // and `entry` adds identity, and both are exactly what `$uuid` is stripped for.
118
+ // `name` (the record's handle) at its position inside the branch that holds it.
119
+ // Two siblings may share a name — records of different schemas can — and position
120
+ // still tells them apart; a leaf that comes to reference another record arrives
121
+ // with that record changed, which re-sends the folder anyway. `$ref` adds a
122
+ // payload-local handle and `entry` adds identity, and both are exactly what
123
+ // `$uuid` is stripped for.
115
124
  //
116
125
  // ⚖️ The previous rule kept `$ref` "so a reference change is visible". It still
117
126
  // is: point a leaf at a different record and its `name` moves with it.
@@ -119,9 +128,9 @@ function stripSigils(value) {
119
128
  const out = {}
120
129
  for (const [k, v] of Object.entries(value)) {
121
130
  if (isFolderRefLeaf && (k === '$ref' || k === 'entry')) continue
122
- // `$children` (a self-nesting subtree) is structural CONTENT, not an identity
123
- // sigil — kept and recursed into, so a nesting change stays visible.
124
- if (k === '$children') {
131
+ // `$children` (a self-nesting subtree) and `$disabled` (a delivery state) are
132
+ // CONTENT, not identity sigils — kept, so a change to either stays visible.
133
+ if (k === '$children' || k === '$disabled') {
125
134
  out[k] = stripSigils(v)
126
135
  continue
127
136
  }
@@ -181,13 +190,15 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
181
190
  }
182
191
 
183
192
  /**
184
- * Map one file-based collection's records to entity-content `$`-documents of
193
+ * Map records of one schema to entity-content `$`-documents of
185
194
  * `declaration`'s Model. PURE — records + declaration in, entity descriptors out;
186
195
  * no I/O, no minting. The backend mints `$uuid` on first sync; a record that
187
196
  * already carries `$uuid` (back-filled from a prior sync) round-trips it.
188
197
  *
189
198
  * @param {object} params
190
- * @param {string} params.queryName - the query's name in site.yml
199
+ * @param {string} params.label - what the records are, for messages and the path
200
+ * inside the package — the schema folder they came from (`article`,
201
+ * `std/person`)
191
202
  * @param {object[]} params.records - [{ slug, ...fields }]
192
203
  * @param {object} params.declaration - the `@uniweb/data-schema` declaration
193
204
  * (from toDataSchemaDeclaration): `{ name, brief, sections }`
@@ -198,7 +209,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
198
209
  * `{ id, uuid, model, file, document }` — `document` is the section-keyed body.
199
210
  */
200
211
  export function recordsToEntities({
201
- queryName,
212
+ label,
202
213
  records,
203
214
  declaration,
204
215
  sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale,
@@ -267,7 +278,7 @@ export function recordsToEntities({
267
278
  const warnings = []
268
279
  if (contentMatches.length > 1) {
269
280
  warnings.push(
270
- `${queryName}: ${declaration.name} has more than one content ` +
281
+ `${label}: ${declaration.name} has more than one content ` +
271
282
  `(markdown / html / prosemirror) field — the markdown body maps to ` +
272
283
  `"${bodyTarget.secName}.${bodyTarget.key}"`
273
284
  )
@@ -275,7 +286,7 @@ export function recordsToEntities({
275
286
  for (const record of records || []) {
276
287
  const slug = record.slug
277
288
  if (!slug) {
278
- warnings.push(`${queryName}: a record without a slug was skipped`)
289
+ warnings.push(`${label}: a record without a slug was skipped`)
279
290
  continue
280
291
  }
281
292
  // ⛔ `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
@@ -283,18 +294,16 @@ export function recordsToEntities({
283
294
  // frontmatter `$id` wins.
284
295
  //
285
296
  // ⚠️ The authoritative value is the record's POOL POSITION — `<dirs>/<slug>` —
286
- // and it is set upstream, at the pool walk; see the ⭐ comment there, which is
287
- // where the reasoning lives. `<query>/<slug>` below is only the fallback for a
288
- // record that did not arrive through the pool, and it is explicitly NOT the
289
- // shape identity is meant to take: two queries over one Model would mint two
290
- // identities for one file.
297
+ // and it is set upstream, where the records directory is walked; see the ⭐
298
+ // comment there, which is where the reasoning lives. `<label>/<slug>` below is
299
+ // only the fallback for a record that did not arrive that way.
291
300
  //
292
301
  // The qualification is a CONSTRAINT, not a style: the sync response is keyed per
293
- // (`$model`, `$id`), so a bare slug would collide whenever two queries over the
294
- // same Model reuse one (see the duplicate check below). ⇒ Do not describe this
302
+ // (`$model`, `$id`), so a bare slug would collide whenever two schema folders
303
+ // resolving to one Model reuse one (see the duplicate check). ⇒ Do not describe this
295
304
  // value as "the slug" — the folder leaf's `name` is the bare segment, and
296
305
  // conflating the two has already misdirected a naming decision.
297
- const id = record.$id || `${queryName}/${slug}`
306
+ const id = record.$id || `${label}/${slug}`
298
307
  const uuid = record.$uuid || null
299
308
  const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
300
309
 
@@ -323,24 +332,30 @@ export function recordsToEntities({
323
332
  for (const key of Object.keys(record)) {
324
333
  if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
325
334
  warnings.push(
326
- `${queryName}/${slug}: field "${key}" is not on ` +
335
+ `${label}/${slug}: field "${key}" is not on ` +
327
336
  `${declaration.name} — not synced`
328
337
  )
329
338
  }
330
339
  if (hasBody && !bodyTarget) {
331
340
  warnings.push(
332
- `${queryName}/${slug}: markdown body present but ` +
341
+ `${label}/${slug}: markdown body present but ` +
333
342
  `${declaration.name} has no content body field — body not synced`
334
343
  )
335
344
  }
336
345
 
337
- // The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`, then each
338
- // populated section in declared order (the brief always present as the card).
339
- // `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on its side.
346
+ // The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`, `$disabled?`,
347
+ // then each populated section in declared order (the brief always present as the
348
+ // card). `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
349
+ // its side.
340
350
  const document = {}
341
351
  if (uuid) document.$uuid = uuid
342
352
  document.$id = id
343
353
  document.$model = declaration.name
354
+ // ⭐ A DRAFT IS SENT AS A DISABLED ENTITY: it stays in the folder and is never
355
+ // publicly delivered. Only `true` travels. An absent key means enabled [Diego,
356
+ // 2026-09-21], so a record whose `draft: true` is removed is sent without the key
357
+ // and is delivered again.
358
+ if (isDraftRecord(record, `${label}/${slug}`)) document.$disabled = true
344
359
  document[briefName] = sectionData[briefName] || {}
345
360
  for (const [secName] of recordSections) {
346
361
  if (secName !== briefName && sectionData[secName]) document[secName] = sectionData[secName]
@@ -351,7 +366,10 @@ export function recordsToEntities({
351
366
  uuid,
352
367
  slug,
353
368
  model: declaration.name, // reference the Model BY NAME — importer resolves it
354
- file: `entities/${queryName}/${slug}.json`,
369
+ // The file's place inside the package — opaque to the reader, which follows
370
+ // `entries[].file` (`entity-document.js`). Label + slug, not `$id`: a slug is
371
+ // unique within its schema folder, and an authored `$id` need not be.
372
+ file: `entities/${label}/${slug}.json`,
355
373
  document,
356
374
  })
357
375
  }
@@ -359,14 +377,13 @@ export function recordsToEntities({
359
377
  }
360
378
 
361
379
  // Post-pass: override a collection record's localized CONTENT body with a per-locale
362
- // FREE-FORM body when `locales/freeform/{locale}/entities/<schema>/<slug>.md` exists
380
+ // FREE-FORM body when `locales/freeform/{locale}/records/<schema>/<slug>.md` exists
363
381
  // — the override wins over the structural map, exactly like site-content sections
364
382
  // (site.js localizeContentTree). Only a `format: prosemirror` localized field can
365
383
  // take it (it is a PM doc on the wire; a markup `text` body stays a raw string).
366
384
  // Mutates the entity documents in place. Async — the free-form read hits the disk.
367
385
  async function applyFreeformRecordOverrides({
368
386
  entities,
369
- queryName,
370
387
  declaration,
371
388
  sourceLocale,
372
389
  targetLocales,
@@ -399,15 +416,44 @@ async function applyFreeformRecordOverrides({
399
416
 
400
417
  // --- orchestration (file I/O) ------------------------------------------------
401
418
 
402
- // The collections in site.yml that opt into export (an object decl with `model:`).
403
- // The declared collections that opt into sync: a resolvable data schema present
404
- // (explicit or convention-defaulted) and not opted out (`sync: false`). Takes the
405
- // merged declarations from resolveQueriesConfig (collections.yml over
406
- // site.yml::collections), so collections.yml is honored without re-reading.
407
- function syncableQueries(declarations) {
419
+ // The queries over the SITE'S records — every declaration with a schema, which is
420
+ // every one without `url:` (an external query's records are its address's).
421
+ //
422
+ // ⛔ They do not decide what is pushed: the records directory does. A query matters
423
+ // here for two things only — whether its schema was ASKED FOR (an explicit schema
424
+ // that resolves to nothing is an error, a defaulted one is not), and which queries
425
+ // ship as static files because their schema resolved to nothing.
426
+ function siteQueries(declarations) {
408
427
  const out = []
409
428
  for (const decl of Object.values(declarations)) {
410
- if ((decl.schema || decl.model) && decl.sync !== false) out.push({ name: decl.name, decl })
429
+ if (decl.schema || decl.model) out.push({ name: decl.name, decl })
430
+ }
431
+ return out
432
+ }
433
+
434
+ // Replace each folder leaf — which names a FILE — with one leaf per record that file
435
+ // produced, and drop a leaf whose file produced none.
436
+ //
437
+ // ⭐ A FILE IS NOT ALWAYS ONE RECORD, and a record's `$id` is not always its file's
438
+ // stem. A BibTeX or array-form file holds several; a `.md` whose frontmatter sets
439
+ // `slug:` is one record named for that slug. The folder placed files and the payload
440
+ // carries records, so until 2026-09-21 every such leaf pointed at an id nothing
441
+ // produced and was dropped with a warning — harmless while `records.yml` rarely listed
442
+ // those files, and on every push once every file in the directory is placed.
443
+ //
444
+ // ⚠️ A file that produced nothing is dropped WITHOUT a warning here: its schema did
445
+ // not resolve (reported as schemaless), or its records were skipped (reported while
446
+ // reading them). Saying it a second time, as a folder problem, names the wrong cause.
447
+ function placeProducedRecords(nodes, producedBy) {
448
+ const out = []
449
+ for (const node of nodes || []) {
450
+ if (node?.kind === 'branch') {
451
+ out.push({ ...node, $children: placeProducedRecords(node.$children, producedBy) })
452
+ continue
453
+ }
454
+ for (const r of producedBy.get(node?.$entityId) || []) {
455
+ out.push({ kind: 'ref', name: r.slug, $entityId: r.id })
456
+ }
411
457
  }
412
458
  return out
413
459
  }
@@ -492,38 +538,13 @@ function resolveDeclaration(schema, modelName) {
492
538
  return null
493
539
  }
494
540
 
495
- // Load a query's ORIGINAL source records for export — the author's files,
496
- // untouched (raw frontmatter + raw markdown body, raw YAML/JSON, raw BibTeX). This
497
- // is deliberately NOT `processQueries` (the delivery pipeline that builds
498
- // public/data, converts bodies to ProseMirror, and copies assets). Sync carries
499
- // the source.
500
- //
501
- // ⭐ THE QUERY NAMES A SCHEMA AND THE POOL FOLLOWS. It does not name a directory,
502
- // and there is no disk path for it to name: `entities/{schema}/` declares the
503
- // model, so the entities of a schema ARE its records. That is the de-conflation —
504
- // `collections/<name>/` used to answer "which files", "which schema" and "grouped
505
- // how" with one directory, and only the first two were ever the same question.
506
- //
507
- // Remote (`url:`) queries have no local files; the caller warns and skips.
508
- function loadSourceRecordsFromPool(poolBySchema, decl, placements) {
509
- if (!decl.schema) return null
510
- const entities = poolBySchema.get(decl.schema)
511
- if (!entities) return null
512
- // ⛔ ONLY WHAT `records.yml` REFERENCES. An entity of the right schema that no
513
- // entry places is not a record, so syncing it would create something nobody can
514
- // reach — and would make the payload disagree with the folder describing it.
515
- const placed = entities.filter((e) => placements.has(e.id))
516
- return placed.length ? placed : null
517
- }
518
-
519
541
  /**
520
- * Build the collection entity descriptors + back-fill index for a site's
521
- * `model:`-mapped file collections — PURE assembly (no hashing, no emit), so it
522
- * composes with other entity sources (e.g. site-content) into one sync package.
523
- * First sync sends no `$uuid` (the backend mints); re-sync round-trips the
524
- * back-filled `$uuid`. `mappedCount` lets a caller tell "no `model:` collections
525
- * declared" (0) from "declared but empty". Throws on an unresolvable Model or a
526
- * duplicate ($model, $id) within the submission.
542
+ * Build the record entity descriptors + back-fill index for a site's records —
543
+ * PURE assembly (no hashing, no emit), so it composes with other entity sources
544
+ * (e.g. site-content) into one sync package. First sync sends no `$uuid` (the
545
+ * backend mints); re-sync round-trips the back-filled `$uuid`. Throws on an
546
+ * unresolvable EXPLICIT Model, an invalid `records/folder.yml`, or a duplicate
547
+ * ($model, $id) within the submission.
527
548
  *
528
549
  * @param {string} siteRoot - directory containing site.yml
529
550
  * @param {object} [opts]
@@ -533,51 +554,77 @@ function loadSourceRecordsFromPool(poolBySchema, decl, placements) {
533
554
  * `@uniweb/data-schema` declaration (or null). The verb wires this to the
534
555
  * backend's Model-read route. Without it, the local foundation is required.
535
556
  * @param {string} [opts.sourceLocale] - localized-field wrap locale
536
- * @returns {Promise<{ entities: object[], index: object[], warnings: string[], schemaless: Array<{name: string}>, mappedCount: number }>}
537
- * `schemaless` lists collections that resolved no data schema (the convention-
538
- * default soft-skip) — not synced as entities; the composite deploy delivers
539
- * them statically via the data ball.
557
+ * @returns {Promise<{ entities: object[], index: object[], warnings: string[],
558
+ * schemaless: Array<{name: string, model: string}>, colConfig: object,
559
+ * folder: object, recordsDirExists: boolean }>}
560
+ * `schemaless` lists the QUERIES whose schema resolved to nothing (the
561
+ * convention-default soft-skip) — their records are not pushed as entities, and
562
+ * the composite deploy delivers those queries statically instead.
563
+ * `recordsDirExists` is false for a site with no records directory at all.
564
+ * `sendFolder` is whether a push sends the folder — see `sendsFolder`.
540
565
  */
541
566
  export async function buildRecordEntities(siteRoot, opts = {}) {
542
- // Merged collections config (collections.yml over site.yml::collections). Reused
543
- // from the caller when provided (sync-package shares it with the folder builder).
544
- // ⭐ `records.yml` IS THE FOLDER, AND IT DECIDES WHAT SYNCS. Listing an entity
545
- // is what makes it a record; an entity nothing references exists but cannot be
546
- // publicly fetched — so it is a draft, for free, with no flag to set. This is
547
- // why `collections.yml::sync` is deleted rather than ported: "do not sync" is
548
- // now "reference nothing", which is the actual round trip.
567
+ // ⭐ EVERY FILE IN `records/` IS A RECORD, AND EVERY RECORD IS PUSHED (ruled
568
+ // 2026-09-21 [Diego]). Placing a file in the directory is what makes it one —
569
+ // the file-side counterpart of placing a ref in the backend's folder — so nothing
570
+ // lists it and nothing leaves it out. `records/folder.yml` only sorts records into
571
+ // sub-folders. A file named with a leading `_` is not read, so it is not a record.
572
+ //
573
+ // ⛔ A PUSHED RECORD IS NOT LIVE. It sits in the site's folder on the backend and
574
+ // is served once the SITE is published — publishing the site publishes its folder
575
+ // and what is in it. Nothing here decides that for a record.
549
576
  //
550
- // ⛔ AND `missing` IS NOT `empty`. Missing means do not sync at all and leave
551
- // the server's folder untouched; empty means sync an empty folder, REMOVING
552
- // what is there. The safe state is the absence of a file, so a live folder
553
- // cannot be wiped by deleting one — the destructive act requires affirmatively
554
- // creating one, and the CLI asks before it happens.
577
+ // ⛔ UNTIL 2026-09-21 THE SET WAS `records.yml`'s: an unlisted entity was not
578
+ // pushed, and a missing `records.yml` pushed nothing. The inert state moved with
579
+ // the membership: a site with NO RECORDS DIRECTORY sends no folder and leaves the
580
+ // backend's untouched (`recordsDirExists`), so a site whose records live only on
581
+ // the backend is not emptied by a push of its pages.
555
582
  //
556
- // ⚠️ READ FIRST, ABOVE EVERY EARLY RETURN. `recordsState` has to ride out of
557
- // this function on every path, because its ABSENCE reads as "not missing" to a
558
- // caller — measured: a site with no queries returned no state, the folder
559
- // builder took that for `declared`, and a site with no `records.yml` at all
560
- // emitted an empty folder that would have removed everything.
561
- const recordsCfg = await readRecordsConfig(siteRoot)
583
+ // ⛔ AND THE SET IS NOT A QUERY'S. This walked the queries until 2026-09-21 and
584
+ // mapped each query's records, so two queries over one schema mapped every record
585
+ // twice and the duplicate check refused the push (measured: "appears in more than
586
+ // one query"), and a record no query read was never pushed. Records are walked
587
+ // once, by the schema their folder declares.
588
+ const pool = await readEntityPool(siteRoot)
589
+ const recordsCfg = await readRecordsConfig(siteRoot, { dir: pool.dir })
562
590
  if (recordsCfg.error) throw new Error(`uwx/records: ${recordsCfg.error}`)
563
- const recordsState = recordsCfg.state
591
+ const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })
592
+ if (folder.errors.length) {
593
+ throw new Error(`uwx/records: ${recordsCfg.file} is invalid —\n ${folder.errors.join('\n ')}`)
594
+ }
595
+ const warnings = [...pool.errors, ...folder.warnings]
564
596
 
565
597
  const colConfig = opts.queriesConfig || (await resolveQueriesConfig(siteRoot))
566
- const mapped = syncableQueries(colConfig.declarations)
567
- if (mapped.length === 0) return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig, recordsState, folder: null }
598
+ const queries = siteQueries(colConfig.declarations)
599
+ const poolBySchema = groupPoolBySchema(pool.entities)
600
+ if (poolBySchema.size === 0 && queries.length === 0) {
601
+ return {
602
+ entities: [],
603
+ index: [],
604
+ warnings,
605
+ schemaless: [],
606
+ colConfig,
607
+ folder: { ...folder, nodes: [] },
608
+ recordsDirExists: pool.exists,
609
+ sendFolder: sendsFolder(pool, []),
610
+ }
611
+ }
568
612
 
569
613
  // A Model declaration comes from a LOCAL foundation (offline) or, for a
570
614
  // non-local Model, from the injected async `resolveModel(name)` — the verb wires
571
615
  // that to the backend's Model-read route (declaration form). The local
572
616
  // foundation is required ONLY when no resolver is provided.
573
617
  const resolveModel = typeof opts.resolveModel === 'function' ? opts.resolveModel : null
574
- // The local foundation is REQUIRED only when at least one collection asked for a
575
- // schema EXPLICITLY (and there's no remote resolver). Collections that only got a
576
- // schema from the subfolder-name convention soft-skip when nothing resolves, so a
577
- // delivery-only site with no foundation must not be forced to have one.
578
- const hasExplicit = mapped.some((m) => m.decl.schemaExplicit)
618
+ // The local foundation is REQUIRED only when a query asked for a schema
619
+ // EXPLICITLY (and there's no remote resolver). A schema that only a folder name
620
+ // or a query name supplied soft-skips when nothing resolves, so a delivery-only
621
+ // site with no foundation must not be forced to have one.
622
+ const explicitBy = new Map() // schema (as written) → the first query that asked for it
623
+ for (const { name, decl } of queries) {
624
+ if (decl.schemaExplicit && !explicitBy.has(decl.schema)) explicitBy.set(decl.schema, name)
625
+ }
579
626
  const localSchema = loadLocalFoundationSchema(siteRoot, opts, {
580
- required: !resolveModel && hasExplicit,
627
+ required: !resolveModel && explicitBy.size > 0,
581
628
  })
582
629
 
583
630
  const declCache = new Map()
@@ -590,35 +637,6 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
590
637
  return declaration
591
638
  }
592
639
 
593
- const sourceLocale =
594
- opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
595
-
596
- // Target locales for wrapping localized record fields per-locale: those with a
597
- // structural-translation file (locales/records/{locale}.json) UNIONED with
598
- // those that only have a free-form override dir (locales/freeform/{locale}/) — a
599
- // record localized solely by a free-form body would otherwise go undiscovered.
600
- const targetLocales = [
601
- ...new Set([...discoverLocales(siteRoot, 'records'), ...discoverFreeformLocales(siteRoot)]),
602
- ].filter((l) => l !== sourceLocale)
603
- const translations =
604
- targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales, 'records') : null
605
-
606
- const entities = []
607
- const index = []
608
- const warnings = []
609
-
610
- // ⭐ THE POOL, READ ONCE. A query names a `schema:` and the entities of that
611
- // schema are its records — so the pool is walked once here rather than a
612
- // directory per declaration, and two queries over one schema read one set of
613
- // files instead of two.
614
- const pool = await readEntityPool(siteRoot)
615
- const poolBySchema = groupPoolBySchema(pool.entities)
616
-
617
- const folder = resolveFolder(recordsCfg.entries, pool.entities)
618
- if (folder.errors.length) {
619
- throw new Error(`uwx/records: ${RECORDS_YML_RELPATH} is invalid —\n ${folder.errors.join('\n ')}`)
620
- }
621
-
622
640
  // ⛔ `@/x` IS A FOUNDATION-RELATIVE ALIAS AND MUST BE RESOLVED BEFORE IT SHIPS.
623
641
  //
624
642
  // `register` resolves it (`uwx/registry-package.js` builds `scoped` from the
@@ -640,78 +658,94 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
640
658
  // ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
641
659
  // (`site.js::queriesNested`): a query's `schema` must name exactly the Model
642
660
  // these records are stored under, so both go through one function with one org.
643
- // Collections that resolved no data schema (the convention-default soft-skip
644
- // below) — not synced as folder entities. The composite deploy delivers these
645
- // statically (the "data ball") instead, so the caller can route them there.
646
- const schemaless = []
647
- // The sync response is keyed per ($model, $id), so the pair must be unique
648
- // within one submission (two queries over the same Model could otherwise
649
- // reuse a slug).
650
- const seen = new Set()
651
- for (const { name, decl } of mapped) {
652
- const declaredModel = decl.schema || decl.model
653
- const modelName = resolveSelfScope(declaredModel, opts.org)
661
+ const warnedUnscoped = new Set()
662
+ const modelFor = (schema, where) => {
663
+ const modelName = resolveSelfScope(schema, opts.org)
654
664
  // Unresolvable `@/` — no org is known. Ship it rather than throwing (a `status`
655
665
  // probe on a never-pushed site has no org and must still count), but say so:
656
666
  // the backend's refusal names a missing Model and cannot name this cause.
657
- if (modelName === declaredModel && typeof declaredModel === 'string' && declaredModel.startsWith('@/')) {
667
+ if (modelName === schema && typeof schema === 'string' && schema.startsWith('@/') && !warnedUnscoped.has(schema)) {
668
+ warnedUnscoped.add(schema)
658
669
  warnings.push(
659
- `query "${name}": \`${declaredModel}\` is foundation-relative and no org is known, ` +
670
+ `${where}: \`${schema}\` is foundation-relative and no org is known, ` +
660
671
  `so it ships unresolved. The backend resolves Models by name and will refuse it. ` +
661
672
  `Pass \`--org @handle\`, or push once so the site records its org.`
662
673
  )
663
674
  }
675
+ return modelName
676
+ }
677
+
678
+ // ⚠️ AN EXPLICIT SCHEMA THAT RESOLVES TO NOTHING IS THE AUTHOR'S ERROR — and a
679
+ // depth-2 folder has two readings worth naming. `records/person/2024/ada.md`
680
+ // resolves as `@person/2024` — the rule is total, so it is not ambiguous — but an
681
+ // author who meant "records organised by year inside the `person` schema" needs to
682
+ // be told what the build actually read, not only that something failed to resolve.
683
+ const unresolvedExplicit = (modelName, queryName) => {
684
+ const dirs = poolDirsForSchema(modelName)
685
+ const { alternative } = dirs ? poolPathReadings(dirs) : { alternative: null }
686
+ return new Error(
687
+ `uwx/records: Model "${modelName}" (query "${queryName}") could not be ` +
688
+ 'resolved — not defined by a local foundation' +
689
+ (resolveModel
690
+ ? ', and the backend has no such Model (register it first).'
691
+ : '. Run via `uniweb sync` (which fetches non-local Models from the ' +
692
+ 'registry), or provide a local foundation that defines it.') +
693
+ (alternative
694
+ ? ` If you meant \`${pool.dir}/${dirs[0]}/\` (${alternative}) organised by ` +
695
+ `\`${dirs[1]}\`, note that a folder inside a schema folder is read as an ` +
696
+ `org scope. Organise records in records/folder.yml, not on disk.`
697
+ : '')
698
+ )
699
+ }
700
+
701
+ const sourceLocale =
702
+ opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
703
+
704
+ // Target locales for wrapping localized record fields per-locale: those with a
705
+ // structural-translation file (locales/records/{locale}.json) UNIONED with
706
+ // those that only have a free-form override dir (locales/freeform/{locale}/) — a
707
+ // record localized solely by a free-form body would otherwise go undiscovered.
708
+ const targetLocales = [
709
+ ...new Set([...discoverLocales(siteRoot, 'records'), ...discoverFreeformLocales(siteRoot)]),
710
+ ].filter((l) => l !== sourceLocale)
711
+ const translations =
712
+ targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales, 'records') : null
713
+
714
+ // ⭐ THE FILE'S `$uuid` IS OURS; THE WIRE'S IS THE BACKEND'S (2026-09-20).
715
+ //
716
+ // A record's `$uuid` is its stable identity and travels with the file, so moving
717
+ // or renaming it changes nothing. What goes on the WIRE to backend B is the uuid B
718
+ // minted for it — looked up in `sync.json::backends.<B>.records` — or none, so B
719
+ // mints one. ⛔ Never send a backend a uuid it did not mint: whether it would
720
+ // accept one is the backend's to say, and this is correct either way.
721
+ //
722
+ // For the FIRST backend a record reaches, B's minted uuid is written into the
723
+ // file and the map is identity — exactly what happened before this change, so a
724
+ // single-backend project behaves as it always did.
725
+ const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
726
+
727
+ const entities = []
728
+ const index = []
729
+ // pool id (the FILE) → the records it produced, so the folder places records.
730
+ const producedBy = new Map()
731
+ // Schemas (as written) whose Model resolved to nothing — soft-skipped.
732
+ const unresolved = new Set()
733
+ // The sync response is keyed per ($model, $id), so the pair must be unique
734
+ // within one submission.
735
+ const seen = new Set()
736
+
737
+ for (const [schema, poolEntities] of poolBySchema) {
738
+ const label = poolEntities[0].dirs.join('/')
739
+ const modelName = modelFor(schema, `${pool.dir}/${label}/`)
664
740
  const declaration = await declarationFor(modelName)
665
741
  if (!declaration) {
666
- // A convention-defaulted schema (subfolder-name) that doesn't resolve is a
667
- // soft skip — the collection is delivery-only, not a sync target. Only an
668
- // EXPLICIT schema/model the author asked for is a hard error.
669
- if (!decl.schemaExplicit) {
670
- // ⛔ Deliberately NOT a `warnings` string. This is a product decision the
671
- // author is making — entities or static files — and it needs to be
672
- // reported at a prominence a prose warning cannot carry. Callers get the
673
- // structured entry and say it themselves (`cli/src/commands/{publish,push}.js`).
674
- //
675
- // It used to push `"… — not synced"`, printed dim among everything else.
676
- // That was misleading in the expensive direction: the data IS delivered,
677
- // as static files. An author read "not synced" as "my data did not
678
- // upload" — or skimmed it — and either way could not act on it.
679
- schemaless.push({ name, model: modelName })
680
- continue
681
- }
682
- // ⚠️ NAME BOTH READINGS OF A DEPTH-2 POOL PATH. `entities/person/2024/ada.md`
683
- // resolves as `@person/2024` — the rule is total, so it is not ambiguous —
684
- // but an author who meant "records organised by year inside the `person`
685
- // schema" needs to be told what the build actually read, not only that
686
- // something failed to resolve. The wrong reading is the plausible one.
687
- const dirs = poolDirsForSchema(modelName)
688
- const { alternative } = dirs ? poolPathReadings(dirs) : { alternative: null }
689
- throw new Error(
690
- `uwx/records: Model "${modelName}" (query "${name}") could not be ` +
691
- 'resolved — not defined by a local foundation' +
692
- (resolveModel
693
- ? ', and the backend has no such Model (register it first).'
694
- : '. Run via `uniweb sync` (which fetches non-local Models from the ' +
695
- 'registry), or provide a local foundation that defines it.') +
696
- (alternative
697
- ? ` If you meant \`entities/${dirs[0]}/\` (${alternative}) organised by ` +
698
- `\`${dirs[1]}\`, note that a folder inside a schema folder is read as an ` +
699
- `org scope. Organise records in records.yml, not on disk.`
700
- : '')
701
- )
702
- }
703
- const poolEntities = loadSourceRecordsFromPool(poolBySchema, decl, folder.placements)
704
- if (poolEntities == null) {
705
- // No entities of this schema on disk. A remote (`url:`) query has none by
706
- // definition; a file-based one with an empty pool is an author state worth
707
- // naming, because "nothing synced" and "nothing there" look identical.
708
- warnings.push(
709
- decl.url
710
- ? `${name}: a remote (\`url:\`) query has no local entities — skipped`
711
- : `${name}: no entities of ${decl.schema} in the pool — nothing to sync`
712
- )
742
+ // An explicit schema the author asked for is a hard error; one only the
743
+ // folder's name supplied is a soft skip, reported below.
744
+ if (explicitBy.has(schema)) throw unresolvedExplicit(modelName, explicitBy.get(schema))
745
+ unresolved.add(schema)
713
746
  continue
714
747
  }
748
+
715
749
  // Flatten source records into the mapper's flat shape; the markdown body
716
750
  // rides under `$body` (the mapper maps it to the brief's content body field).
717
751
  // Keep a per-slug pointer back to the source file for `$uuid` write-back —
@@ -720,39 +754,32 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
720
754
  const flat = []
721
755
  const sourceBySlug = new Map()
722
756
  for (const pooled of poolEntities) {
757
+ const produced = []
723
758
  for (const r of await readEntityFile(pooled.absPath)) {
724
759
  if (!r.slug) {
725
- warnings.push(`${name}: a record without a slug was skipped`)
760
+ warnings.push(`${pooled.relPath}: a record without a slug was skipped`)
726
761
  continue
727
762
  }
763
+ // A draft is a record like any other, placed in the folder; the mapper sends it
764
+ // disabled. Checked here too so that a malformed `draft:`, or the retired
765
+ // `published: false`, is refused naming the FILE rather than the record.
766
+ isDraftRecord(r.data, r.multiRecord ? `${pooled.relPath} (${r.slug})` : pooled.relPath)
728
767
  const rec = { ...r.data, slug: r.slug }
729
768
  if (r.body !== undefined) rec.$body = r.body
730
- // ⭐ THE RECORD'S IDENTITY IS ITS POOL POSITION, not `<query>/<slug>`. It
731
- // has to be: the folder places entities and must reference the very ones
732
- // the payload carries, and two queries over one schema would otherwise
733
- // mint two identities for one file. `<dirs>/<slug>` is unique by
734
- // construction and derivable on both sides.
769
+ // ⭐ THE RECORD'S IDENTITY IS ITS POSITION IN THE DIRECTORY — `<dirs>/<slug>`,
770
+ // unique by construction and derivable on both sides. The folder must
771
+ // reference the very records the payload carries.
735
772
  //
736
773
  // ⚠️ A multi-record file (array YAML, BibTeX) contributes several records
737
774
  // from one path, so the slug — not the file stem — completes the id.
738
775
  rec.$id = rec.$id || [...pooled.dirs, r.slug].join('/')
739
776
  flat.push(rec)
740
777
  sourceBySlug.set(r.slug, r)
778
+ produced.push({ id: rec.$id, slug: r.slug })
741
779
  }
780
+ producedBy.set(pooled.id, produced)
742
781
  }
743
782
 
744
- // ⭐ THE FILE'S `$uuid` IS OURS; THE WIRE'S IS THE BACKEND'S (2026-09-20).
745
- //
746
- // A record's `$uuid` is its stable identity and travels with the file, so moving
747
- // or renaming it changes nothing. What goes on the WIRE to backend B is the uuid B
748
- // minted for it — looked up in `sync.json::backends.<B>.records` — or none, so B
749
- // mints one. ⛔ Never send a backend a uuid it did not mint: whether it would
750
- // accept one is the backend's to say, and this is correct either way.
751
- //
752
- // For the FIRST backend a record reaches, B's minted uuid is written into the
753
- // file and the map is identity — exactly what happened before this change, so a
754
- // single-backend project behaves as it always did.
755
- const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
756
783
  const ownIds = new Map()
757
784
  const onWire = flat.map((rec) => {
758
785
  const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
@@ -763,7 +790,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
763
790
  })
764
791
 
765
792
  const mappedOut = recordsToEntities({
766
- queryName: name,
793
+ label,
767
794
  records: onWire,
768
795
  declaration,
769
796
  sourceLocale,
@@ -774,7 +801,6 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
774
801
  if (targetLocales.length > 0) {
775
802
  await applyFreeformRecordOverrides({
776
803
  entities: mappedOut.entities,
777
- queryName: name,
778
804
  declaration,
779
805
  sourceLocale,
780
806
  targetLocales,
@@ -786,8 +812,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
786
812
  if (seen.has(dupKey)) {
787
813
  throw new Error(
788
814
  `uwx/records: duplicate ($model, $id) in one sync — "${e.id}" of ` +
789
- `${e.model} appears in more than one query. Each ($model, $id) ` +
790
- 'must be unique within a sync; make the slugs unique.'
815
+ `${e.model} comes from more than one record. Each record in a schema ` +
816
+ 'folder needs its own slug; make the slugs unique.'
791
817
  )
792
818
  }
793
819
  seen.add(dupKey)
@@ -811,15 +837,87 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
811
837
  // document → authoring shape (variant A): unwrap localized fields, route
812
838
  // the content body field to the md body, drop the brief record `$uuid`.
813
839
  declaration,
840
+ // Sent disabled, so the back-fill can check that the backend kept it that way.
841
+ draft: e.document.$disabled === true,
814
842
  })
815
843
  }
816
844
  entities.push(...mappedOut.entities)
817
845
  warnings.push(...mappedOut.warnings)
818
846
  }
819
847
 
820
- return { entities, index, warnings, schemaless, mappedCount: mapped.length, colConfig, folder, recordsState }
848
+ // Queries whose schema resolved to nothing — not pushed as entities. The composite
849
+ // deploy delivers these statically (the "data ball") instead, so the caller can
850
+ // route them there.
851
+ //
852
+ // ⛔ Deliberately NOT a `warnings` string. This is a product decision the author
853
+ // is making — entities or static files — and it needs to be reported at a
854
+ // prominence a prose warning cannot carry. Callers get the structured entry and
855
+ // say it themselves (`cli/src/commands/{publish,push}.js`). It used to push
856
+ // `"… — not synced"`, printed dim among everything else, and an author read that
857
+ // as "my data did not upload" when it is delivered, as static files.
858
+ const schemaless = []
859
+ for (const { name, decl } of queries) {
860
+ const schema = decl.schema || decl.model
861
+ const hasRecords = poolBySchema.has(schema)
862
+ const resolved = hasRecords
863
+ ? !unresolved.has(schema)
864
+ : Boolean(await declarationFor(modelFor(schema, `query "${name}"`)))
865
+ if (!resolved) {
866
+ if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, opts.org), name)
867
+ schemaless.push({ name, model: resolveSelfScope(schema, opts.org) })
868
+ continue
869
+ }
870
+ if (!hasRecords) {
871
+ // "Nothing pushed" and "nothing there" look identical, so say which.
872
+ const dirs = poolDirsForSchema(schema)
873
+ warnings.push(
874
+ `query "${name}": ${dirs ? `${pool.dir}/${dirs.join('/')}/` : pool.dir + '/'} holds no records of ${schema} — ` +
875
+ 'nothing to push for it.'
876
+ )
877
+ }
878
+ }
879
+ // Records whose schema resolved to nothing and that no query reads: they are not
880
+ // pushed, and no static file carries them either — say so, once per folder.
881
+ for (const schema of unresolved) {
882
+ if (queries.some(({ decl }) => (decl.schema || decl.model) === schema)) continue
883
+ const n = poolBySchema.get(schema).length
884
+ warnings.push(
885
+ `${pool.dir}/${poolBySchema.get(schema)[0].dirs.join('/')}/: no data schema resolves for ${schema}, ` +
886
+ `and no query reads ${n === 1 ? 'this record' : `these ${n} records`} — not pushed.`
887
+ )
888
+ }
889
+
890
+ const nodes = placeProducedRecords(folder.nodes, producedBy)
891
+
892
+ return {
893
+ entities,
894
+ index,
895
+ warnings,
896
+ schemaless,
897
+ colConfig,
898
+ folder: { ...folder, nodes },
899
+ recordsDirExists: pool.exists,
900
+ sendFolder: sendsFolder(pool, entities),
901
+ }
821
902
  }
822
903
 
904
+ // Whether this push sends the site's folder — which REPLACES the backend's.
905
+ //
906
+ // ⭐ Only when the file side has a folder to state: some record was produced, or
907
+ // the records directory is there and holds nothing at all (the deliberate empty,
908
+ // which removes what the backend has — the CLI asks first).
909
+ //
910
+ // ⛔ NOT when every record's schema resolved to nothing, nor when the directory
911
+ // holds only files that are not records (warned). Neither is an author emptying
912
+ // the folder, and sending one would remove the backend's records — including any
913
+ // authored there — for a state nobody chose. Inert instead, like no directory.
914
+ function sendsFolder(pool, entities) {
915
+ if (!pool.exists) return false
916
+ if (entities.length > 0) return true
917
+ return pool.entities.length === 0 && pool.errors.length === 0
918
+ }
919
+
920
+
823
921
  /**
824
922
  * "Send only changed" filter, shared by the collection and combined sync paths.
825
923
  * Hashes each entity's content (identity-independent — `$uuid`/`$id` stripped,
@@ -864,19 +962,13 @@ export function filterChanged(entities, index, { priorHashes = {}, sendAll = fal
864
962
  * skipped: number }>}
865
963
  */
866
964
  export async function emitRecordSyncPackage(siteRoot, opts = {}) {
867
- const { entities, index, warnings, mappedCount } = await buildRecordEntities(siteRoot, opts)
868
- if (mappedCount === 0) {
869
- throw new Error(
870
- 'uwx/records: no query declares a schema — nothing to export. ' +
871
- 'Add a query to queries.yml naming the schema its records use, e.g.\n' +
872
- " articles:\n schema: '@/article'"
873
- )
874
- }
965
+ const { entities, index, warnings, recordsDirExists } = await buildRecordEntities(siteRoot, opts)
875
966
  if (entities.length === 0) {
876
967
  throw new Error(
877
- 'uwx/records: no records to export. Either records.yml references ' +
878
- 'nothing, or every query matched an empty pool — an entity is only a ' +
879
- 'record once records.yml lists it.'
968
+ 'uwx/records: no records to export — ' +
969
+ (recordsDirExists
970
+ ? 'the records directory holds none whose data schema resolves.'
971
+ : 'the site has no records directory. Every file in `records/<schema>/` is a record.')
880
972
  )
881
973
  }
882
974