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