@uniweb/build 0.57.0 → 0.59.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.
@@ -1,8 +1,8 @@
1
1
  // Records projection — write a pulled folder and its records back to the site's
2
- // `records/**` files and `records.yml`. The inverse of the producer (`records.js`
3
- // + `folder.js`): the producer reads the records directory and emits the
4
- // `@uniweb/folder` entity + one section-keyed `$`-document per record; this takes
5
- // those documents back and renders them to files.
2
+ // `records/**` files and the folder's organization, `records/folder.yml`. The
3
+ // inverse of the producer (`records.js` + `folder.js`): the producer reads the
4
+ // records directory and emits the `@uniweb/folder` entity + one section-keyed
5
+ // `$`-document per record; this takes those documents back and renders them to files.
6
6
  //
7
7
  // Identity & placement:
8
8
  // - a record's slug comes from the FOLDER document — each ref leaf is
@@ -38,9 +38,10 @@ import { parseFrontmatter } from './entity-source.js'
38
38
  import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
39
39
  import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
40
40
  import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
41
- import { recordsYmlPath } from '../site/records-config.js'
41
+ import { folderYmlPath } from '../site/records-config.js'
42
42
  import { isContentBodyField } from './data-schema.js'
43
- import { unresolveSelfScope } from './self-scope.js'
43
+ import { unresolveSelfScope, refuseOrgOption } from './self-scope.js'
44
+ import { parseCatalogRef } from '../site/foundation-ref.js'
44
45
  import { unwrapLocalized } from './backfill.js'
45
46
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
46
47
  import { buildFreeformRecordPath } from '../i18n/freeform.js'
@@ -151,8 +152,8 @@ function indexFolder(folderDoc) {
151
152
  // `schemaForPoolDirs`, and deliberately not a second rule: if the two disagreed,
152
153
  // a pulled record would land somewhere the next build reads as a different
153
154
  // schema — silently, because both paths are well-formed.
154
- function recordDirFor(recordsRoot, model, selfOrg) {
155
- const dirs = poolDirsForSchema(unresolveSelfScope(model, selfOrg))
155
+ function recordDirFor(recordsRoot, model, scope) {
156
+ const dirs = poolDirsForSchema(unresolveSelfScope(model, scope))
156
157
  return dirs ? join(recordsRoot, ...dirs) : null
157
158
  }
158
159
 
@@ -173,16 +174,6 @@ function locate(document, folderIndex) {
173
174
  return fromFolder || null
174
175
  }
175
176
 
176
- /** The site's own org on `backend` — its `site.org` in `sync.json` — bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
177
- // ⛔ `site.yml::$org` IS GONE (2026-09-20) — the org lives in
178
- // `sync.json::backends.<origin>.site.org`. This read `site.yml` and, once step 4
179
- // moved the key, silently returned null: every `@org/x` model a pull met would have
180
- // been placed back as if the site had no org. No test spanned it.
181
- function readSiteOrg(siteRoot, backend) {
182
- const org = backend ? readBackendState(siteRoot, backend).site?.org : null
183
- return typeof org === 'string' && org ? org : null
184
- }
185
-
186
177
  // Skip undefined when copying optional fields into a projected declaration.
187
178
  function setIf(obj, key, value) {
188
179
  if (value !== undefined) obj[key] = value
@@ -239,15 +230,15 @@ function isDerivedDeferred(d, dataSchemas) {
239
230
  return d.deferred.every((f) => a.has(f))
240
231
  }
241
232
 
242
- function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
233
+ function declToFileShape(wire, dataSchemas = null, scope = null) {
243
234
  // ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
244
- // `schema`. The push qualifies a foundation-relative `@/x` to `@org/x`
235
+ // `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
245
236
  // (`site.js::queriesNested`), and both checks below are keyed by the author's
246
- // `@/x`: against `@org/x` the query-name default would never match — writing an
237
+ // `@/x`: against `@scope/x` the query-name default would never match — writing an
247
238
  // explicit schema the author never had — and the derived-`deferred` lookup would
248
239
  // miss, persisting a derivation into their file (the 2026-08-29 defect).
249
- const d = selfOrg && typeof wire.schema === 'string'
250
- ? { ...wire, schema: unresolveSelfScope(wire.schema, selfOrg) }
240
+ const d = scope && typeof wire.schema === 'string'
241
+ ? { ...wire, schema: unresolveSelfScope(wire.schema, scope) }
251
242
  : wire
252
243
  const name = d.name || d.$id
253
244
  const decl = {}
@@ -329,14 +320,14 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
329
320
  * @param {object} params
330
321
  * @param {object} params.document - a site-content `$`-document (`{ queries }`)
331
322
  * @param {string} params.siteRoot
332
- * @param {string} [params.org] - the site's own org, so a `schema` the producer
333
- * qualified from `@/x` is written back as `@/x`. Defaults to the org
334
- * `sync.json` records for `backend`, the same default `recordsToProject`
335
- * places records by.
336
- * @param {string|null} [params.backend] - whose recorded org is that default
323
+ * @param {string|null} [params.scope] - the scope the push qualified a `schema` from
324
+ * `@/x` with — the site's foundation's — so it is written back as `@/x`.
325
+ * Defaults to the scope of the foundation ref the pulled document carries
326
+ * (`info.foundation`, the pinned `@org/name@version`), which is that scope.
337
327
  * @returns {{ collections?: 'updated'|'unchanged' }}
338
328
  */
339
- export function declarationsToQueriesYml({ document, siteRoot, org, backend = null }) {
329
+ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest }) {
330
+ refuseOrgOption(rest, 'declarationsToQueriesYml')
340
331
  const decls = Array.isArray(document?.queries) ? document.queries : []
341
332
  const report = {}
342
333
  if (decls.length === 0) return report
@@ -354,11 +345,12 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
354
345
  siteYml = null
355
346
  }
356
347
  const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
357
- const selfOrg = org ?? readSiteOrg(siteRoot, backend)
348
+ const selfScope =
349
+ scope !== undefined ? scope : (parseCatalogRef(document?.info?.foundation)?.scope ?? null)
358
350
 
359
351
  const queries = {}
360
352
  for (const d of decls) {
361
- const { name, decl } = declToFileShape(d, dataSchemas, selfOrg)
353
+ const { name, decl } = declToFileShape(d, dataSchemas, selfScope)
362
354
  if (!name) continue
363
355
  queries[name] = decl
364
356
  }
@@ -370,18 +362,19 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
370
362
  }
371
363
 
372
364
  /**
373
- * Project a pulled `@uniweb/folder` document back to `records.yml`.
365
+ * Project a pulled `@uniweb/folder` document back to `folder.yml` in the records
366
+ * directory (`records/folder.yml`, or under `site.yml::paths.records`).
374
367
  *
375
368
  * ⭐ THE FOLDER ROUND-TRIPS TRIVIALLY, and that is by design rather than luck:
376
- * `records.yml` holds concrete paths on both sides, so there is nothing to invert.
369
+ * `folder.yml` holds concrete paths on both sides, so there is nothing to invert.
377
370
  * The old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
378
371
  * collections — and inverting a macro is not possible in general. Taking queries
379
372
  * out of the folder is what dissolved that.
380
373
  *
381
374
  * ⭐ ONLY THE SUB-FOLDERS ARE WRITTEN (ruled 2026-09-21 [Diego]). A record at the
382
375
  * top of the folder needs no line — being in the records directory is what puts it
383
- * there — so `records.yml` carries the branches and the records placed in them, and
384
- * a folder with no branches is a site with no `records.yml`: a local one is REMOVED
376
+ * there — so `folder.yml` carries the branches and the records placed in them, and
377
+ * a folder with no branches is a site with no `folder.yml`: a local one is REMOVED
385
378
  * (it could only describe sub-folders the backend's folder no longer has). No state
386
379
  * of the file removes a record, so this cannot empty anything.
387
380
  *
@@ -395,11 +388,13 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
395
388
  * under `records/` of the file just written for it. Supplied by
396
389
  * `recordsToProject`, which is the only thing that knows the extension
397
390
  * each record landed with.
398
- * @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[] }}
391
+ * @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[], file: string }}
392
+ * `file` is where the organization lives, as the author would write it
399
393
  */
400
- export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
394
+ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
401
395
  const warnings = []
402
- if (!folderDoc) return { status: 'skipped', entries: [], warnings }
396
+ const { abs, rel: file } = folderYmlPath(siteRoot)
397
+ if (!folderDoc) return { status: 'skipped', entries: [], warnings, file }
403
398
  let unplaceable = false
404
399
 
405
400
  const walk = (nodes, inBranch) => {
@@ -410,7 +405,7 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
410
405
  const entry = { folder: node.name }
411
406
  // Only a BRANCH takes a label. A record carries its own title; the folder
412
407
  // does not caption its rows. On the wire the label is a localized map;
413
- // records.yml carries the source-locale string (a bare string passes).
408
+ // folder.yml carries the source-locale string (a bare string passes).
414
409
  if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
415
410
  entry.records = walk(node.$children, true)
416
411
  out.push(entry)
@@ -435,7 +430,7 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
435
430
  // means the folder and the directory disagree, and writing the file without
436
431
  // it would move that record to the top of the folder on the next push.
437
432
  warnings.push(
438
- `records.yml: the folder places a record ("${node.name ?? '?'}") that was not ` +
433
+ `${file}: the folder places a record ("${node.name ?? '?'}") that was not ` +
439
434
  `written locally — the file was left unchanged rather than dropping it.`
440
435
  )
441
436
  unplaceable = true
@@ -447,14 +442,13 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
447
442
  }
448
443
 
449
444
  const entries = walk(folderDoc.contents, false)
450
- if (unplaceable) return { status: 'skipped', entries: [], warnings }
445
+ if (unplaceable) return { status: 'skipped', entries: [], warnings, file }
451
446
  if (entries.length === 0) {
452
- const file = recordsYmlPath(siteRoot)
453
- if (!existsSync(file)) return { status: 'unchanged', entries, warnings }
454
- unlinkSync(file)
455
- return { status: 'removed', entries, warnings }
447
+ if (!existsSync(abs)) return { status: 'unchanged', entries, warnings, file }
448
+ unlinkSync(abs)
449
+ return { status: 'removed', entries, warnings, file }
456
450
  }
457
- return { status: writeRecordsConfig(siteRoot, entries), entries, warnings }
451
+ return { status: writeRecordsConfig(siteRoot, entries), entries, warnings, file }
458
452
  }
459
453
 
460
454
  /**
@@ -467,20 +461,21 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
467
461
  * @param {object} params.opts
468
462
  * @param {(modelName: string) => object|null|undefined} params.opts.resolveDeclaration
469
463
  * - resolve a Model's data-schema declaration by name (`$model`).
470
- * @param {string} [params.opts.org] - the site's own org, so a `@org/x` model the
471
- * producer resolved from `@/x` is placed back where the author wrote it.
472
- * Defaults to the org `sync.json` records for `opts.backend`.
473
- * @param {string} [params.opts.backend] - whose record map to read and extend, and
474
- * whose recorded org is that default
464
+ * @param {string|null} [params.opts.scope] - the scope the push qualified a record's
465
+ * `@/x` Model with — the site's foundation's — so a `@scope/x` model is placed
466
+ * back where the author wrote it. The caller resolves it up front, as it does
467
+ * Models (`siteSelfScope`); absent, a model is placed under its own scope.
468
+ * @param {string} [params.opts.backend] - whose record map to read and extend
475
469
  * @param {string} [params.opts.sourceLocale]
476
470
  * @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
477
471
  */
478
472
  export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
479
473
  const { resolveDeclaration, sourceLocale = 'en' } = opts
480
- // The site's own org, so a `@org/x` model the producer resolved from `@/x` is
481
- // placed back where the author wrote it. Read from `sync.json` (this backend's
482
- // `site.org`) unless the caller already has it.
483
- const selfOrg = opts.org ?? readSiteOrg(siteRoot, opts.backend)
474
+ refuseOrgOption(opts, 'recordsToProject')
475
+ // The scope the push qualified `@/x` with — the site's foundation's — so a
476
+ // `@scope/x` model is placed back where the author wrote it. ⛔ It was the site
477
+ // owner's org, read from `sync.json`, until 2026-09-22 (`self-scope.js`).
478
+ const selfScope = opts.scope ?? null
484
479
  if (typeof resolveDeclaration !== 'function') {
485
480
  throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
486
481
  }
@@ -506,7 +501,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
506
501
  // own id → their uuid, for every record this pull wrote. Recorded at the end.
507
502
  const learned = {}
508
503
  // uuid → the path under `records/` the record landed at. Only this loop knows
509
- // the extension each one got, so `records.yml` is written from it rather than
504
+ // the extension each one got, so `folder.yml` is written from it rather than
510
505
  // re-derived (a second rule could pick a different extension and the folder
511
506
  // would name a file that is not there).
512
507
  const poolPathByUuid = new Map()
@@ -523,7 +518,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
523
518
  continue
524
519
  }
525
520
 
526
- const poolDir = recordDirFor(recordsRoot, document.$model, selfOrg)
521
+ const poolDir = recordDirFor(recordsRoot, document.$model, selfScope)
527
522
  if (!poolDir) {
528
523
  skipped.push({
529
524
  uuid: document.$uuid,
@@ -571,20 +566,20 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
571
566
  else if (isNew) placed.push(filePath)
572
567
  else updated.push(filePath)
573
568
  // Keyed by THEIR uuid: the folder document references records in the backend's
574
- // terms, so that is what `folderToRecordsYml` will look them up by.
569
+ // terms, so that is what `folderToFolderYml` will look them up by.
575
570
  if (theirs) {
576
571
  poolPathByUuid.set(theirs, relative(recordsRoot, filePath).split(sep).join('/'))
577
572
  if (own) learned[own] = theirs
578
573
  }
579
574
  }
580
575
 
581
- // ⭐ THE FOLDER ITSELF, written back as `records.yml`. Steps that only touched the
576
+ // ⭐ THE FOLDER ITSELF, written back as `folder.yml`. Steps that only touched the
582
577
  // READ path would leave every pull authoring the old shape — the site would build
583
578
  // from the new layout and be projected back into the one it replaced.
584
579
  //
585
580
  // The folder ENTITY still carries no `$uuid` we persist: the backend owns the
586
581
  // site's folder, keyed by the site-content uuid.
587
- const records = folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
582
+ const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
588
583
  warnings.push(...records.warnings)
589
584
 
590
585
  // Flush localized record-field translations to locales/records/{locale}.json,
@@ -596,5 +591,5 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
596
591
  updateBackendMap(siteRoot, opts.backend, 'records', learned)
597
592
  }
598
593
 
599
- return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status }
594
+ return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status, recordsFile: records.file }
600
595
  }
@@ -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,
@@ -57,7 +57,7 @@ import {
57
57
  } from '../site/entity-pool.js'
58
58
  import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isContentBodyField } from './data-schema.js'
59
59
  import { emitEntitySyncPackage } from './entity-document.js'
60
- import { resolveSelfScope } from './self-scope.js'
60
+ import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
61
61
  import { sha256Hex, toJsonBuffer } from './manifest.js'
62
62
  import { markdownToProseMirror } from '@uniweb/content-reader'
63
63
  import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
@@ -80,17 +80,22 @@ const SKIP_KEYS = new Set([
80
80
  '$unit',
81
81
  '$meta',
82
82
  '$body',
83
- // The draft flag (`site/record-draft.js`) — framework's, never a Model field.
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).
84
85
  'draft',
85
86
  ])
86
87
 
87
88
  // Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$model`/… — never
88
89
  // field data; the sigil-exclusivity invariant guarantees this) at every level,
89
- // so a back-filled `$uuid` doesn't change the hash. `$children` is the exception:
90
- // it is STRUCTURAL content (a self-nesting record's subtree, e.g. site-content's
91
- // nested pages/sections), so it is KEPT and recursed into — otherwise a nesting
92
- // change would be invisible to "send only changed". Flat records carry no
93
- // `$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.
94
99
  function stripSigils(value) {
95
100
  if (Array.isArray(value)) return value.map(stripSigils)
96
101
  if (value && typeof value === 'object') {
@@ -123,9 +128,9 @@ function stripSigils(value) {
123
128
  const out = {}
124
129
  for (const [k, v] of Object.entries(value)) {
125
130
  if (isFolderRefLeaf && (k === '$ref' || k === 'entry')) continue
126
- // `$children` (a self-nesting subtree) is structural CONTENT, not an identity
127
- // sigil — kept and recursed into, so a nesting change stays visible.
128
- 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') {
129
134
  out[k] = stripSigils(v)
130
135
  continue
131
136
  }
@@ -338,13 +343,19 @@ export function recordsToEntities({
338
343
  )
339
344
  }
340
345
 
341
- // The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`, then each
342
- // populated section in declared order (the brief always present as the card).
343
- // `$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.
344
350
  const document = {}
345
351
  if (uuid) document.$uuid = uuid
346
352
  document.$id = id
347
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
348
359
  document[briefName] = sectionData[briefName] || {}
349
360
  for (const [secName] of recordSections) {
350
361
  if (secName !== briefName && sectionData[secName]) document[secName] = sectionData[secName]
@@ -532,7 +543,7 @@ function resolveDeclaration(schema, modelName) {
532
543
  * PURE assembly (no hashing, no emit), so it composes with other entity sources
533
544
  * (e.g. site-content) into one sync package. First sync sends no `$uuid` (the
534
545
  * backend mints); re-sync round-trips the back-filled `$uuid`. Throws on an
535
- * unresolvable EXPLICIT Model, an invalid `records.yml`, or a duplicate
546
+ * unresolvable EXPLICIT Model, an invalid `records/folder.yml`, or a duplicate
536
547
  * ($model, $id) within the submission.
537
548
  *
538
549
  * @param {string} siteRoot - directory containing site.yml
@@ -543,6 +554,9 @@ function resolveDeclaration(schema, modelName) {
543
554
  * `@uniweb/data-schema` declaration (or null). The verb wires this to the
544
555
  * backend's Model-read route. Without it, the local foundation is required.
545
556
  * @param {string} [opts.sourceLocale] - localized-field wrap locale
557
+ * @param {string|null} [opts.scope] - the scope a record's `@/x` Model resolves into.
558
+ * Defaults to the site's foundation's (`siteSelfScope`); a caller that has it
559
+ * already passes it, so one emit reads it once.
546
560
  * @returns {Promise<{ entities: object[], index: object[], warnings: string[],
547
561
  * schemaless: Array<{name: string, model: string}>, colConfig: object,
548
562
  * folder: object, recordsDirExists: boolean }>}
@@ -556,7 +570,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
556
570
  // ⭐ EVERY FILE IN `records/` IS A RECORD, AND EVERY RECORD IS PUSHED (ruled
557
571
  // 2026-09-21 [Diego]). Placing a file in the directory is what makes it one —
558
572
  // 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
573
+ // lists it and nothing leaves it out. `records/folder.yml` only sorts records into
560
574
  // sub-folders. A file named with a leading `_` is not read, so it is not a record.
561
575
  //
562
576
  // ⛔ A PUSHED RECORD IS NOT LIVE. It sits in the site's folder on the backend and
@@ -574,12 +588,13 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
574
588
  // twice and the duplicate check refused the push (measured: "appears in more than
575
589
  // one query"), and a record no query read was never pushed. Records are walked
576
590
  // once, by the schema their folder declares.
591
+ refuseOrgOption(opts, 'uwx/records')
577
592
  const pool = await readEntityPool(siteRoot)
578
- const recordsCfg = await readRecordsConfig(siteRoot)
593
+ const recordsCfg = await readRecordsConfig(siteRoot, { dir: pool.dir })
579
594
  if (recordsCfg.error) throw new Error(`uwx/records: ${recordsCfg.error}`)
580
595
  const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })
581
596
  if (folder.errors.length) {
582
- throw new Error(`uwx/records: ${RECORDS_YML_RELPATH} is invalid —\n ${folder.errors.join('\n ')}`)
597
+ throw new Error(`uwx/records: ${recordsCfg.file} is invalid —\n ${folder.errors.join('\n ')}`)
583
598
  }
584
599
  const warnings = [...pool.errors, ...folder.warnings]
585
600
 
@@ -646,19 +661,25 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
646
661
  //
647
662
  // ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
648
663
  // (`site.js::queriesNested`): a query's `schema` must name exactly the Model
649
- // these records are stored under, so both go through one function with one org.
664
+ // these records are stored under, so both go through one function with one scope.
665
+ //
666
+ // ⭐ THE SCOPE IS THE FOUNDATION'S — the one `register` stored these Models under —
667
+ // never the site owner's (2026-09-22; `self-scope.js` has the record).
668
+ const scope =
669
+ opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot, { foundationDir: opts.foundationDir })
650
670
  const warnedUnscoped = new Set()
651
671
  const modelFor = (schema, where) => {
652
- const modelName = resolveSelfScope(schema, opts.org)
653
- // Unresolvable `@/` — no org is known. Ship it rather than throwing (a `status`
654
- // probe on a never-pushed site has no org and must still count), but say so:
655
- // the backend's refusal names a missing Model and cannot name this cause.
672
+ const modelName = resolveSelfScope(schema, scope)
673
+ // Unresolvable `@/` — the foundation has no scope yet. Ship it rather than
674
+ // throwing (a `status` probe on a never-registered foundation must still count),
675
+ // but say so: the backend's refusal names a missing Model and cannot name this cause.
656
676
  if (modelName === schema && typeof schema === 'string' && schema.startsWith('@/') && !warnedUnscoped.has(schema)) {
657
677
  warnedUnscoped.add(schema)
658
678
  warnings.push(
659
- `${where}: \`${schema}\` is foundation-relative and no org is known, ` +
679
+ `${where}: \`${schema}\` is foundation-relative and the foundation has no scope yet, ` +
660
680
  `so it ships unresolved. The backend resolves Models by name and will refuse it. ` +
661
- `Pass \`--org @handle\`, or push once so the site records its org.`
681
+ `A foundation's scope is part of its name — \`name: '@org/<name>'\` in its main.js, ` +
682
+ `which \`uniweb register\` writes.`
662
683
  )
663
684
  }
664
685
  return modelName
@@ -682,7 +703,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
682
703
  (alternative
683
704
  ? ` If you meant \`${pool.dir}/${dirs[0]}/\` (${alternative}) organised by ` +
684
705
  `\`${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.`
706
+ `org scope. Organise records in records/folder.yml, not on disk.`
686
707
  : '')
687
708
  )
688
709
  }
@@ -717,8 +738,6 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
717
738
  const index = []
718
739
  // pool id (the FILE) → the records it produced, so the folder places records.
719
740
  const producedBy = new Map()
720
- // `draft: true` records found on the way — the push is refused if there are any.
721
- const drafts = []
722
741
  // Schemas (as written) whose Model resolved to nothing — soft-skipped.
723
742
  const unresolved = new Set()
724
743
  // The sync response is keyed per ($model, $id), so the pair must be unique
@@ -751,10 +770,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
751
770
  warnings.push(`${pooled.relPath}: a record without a slug was skipped`)
752
771
  continue
753
772
  }
754
- if (isDraftRecord(r.data, pooled.relPath)) {
755
- drafts.push(r.multiRecord ? `${pooled.relPath} (${r.slug})` : pooled.relPath)
756
- continue
757
- }
773
+ // A draft is a record like any other, placed in the folder; the mapper sends it
774
+ // disabled. Checked here too so that a malformed `draft:`, or the retired
775
+ // `published: false`, is refused naming the FILE rather than the record.
776
+ isDraftRecord(r.data, r.multiRecord ? `${pooled.relPath} (${r.slug})` : pooled.relPath)
758
777
  const rec = { ...r.data, slug: r.slug }
759
778
  if (r.body !== undefined) rec.$body = r.body
760
779
  // ⭐ THE RECORD'S IDENTITY IS ITS POSITION IN THE DIRECTORY — `<dirs>/<slug>`,
@@ -828,27 +847,14 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
828
847
  // document → authoring shape (variant A): unwrap localized fields, route
829
848
  // the content body field to the md body, drop the brief record `$uuid`.
830
849
  declaration,
850
+ // Sent disabled, so the back-fill can check that the backend kept it that way.
851
+ draft: e.document.$disabled === true,
831
852
  })
832
853
  }
833
854
  entities.push(...mappedOut.entities)
834
855
  warnings.push(...mappedOut.warnings)
835
856
  }
836
857
 
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
858
  // Queries whose schema resolved to nothing — not pushed as entities. The composite
853
859
  // deploy delivers these statically (the "data ball") instead, so the caller can
854
860
  // route them there.
@@ -867,8 +873,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
867
873
  ? !unresolved.has(schema)
868
874
  : Boolean(await declarationFor(modelFor(schema, `query "${name}"`)))
869
875
  if (!resolved) {
870
- if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, opts.org), name)
871
- schemaless.push({ name, model: resolveSelfScope(schema, opts.org) })
876
+ if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
877
+ schemaless.push({ name, model: resolveSelfScope(schema, scope) })
872
878
  continue
873
879
  }
874
880
  if (!hasRecords) {
@@ -17,15 +17,17 @@
17
17
  * `@uniweb/data-schema` entities, no foundation-schema — for a schemas-only
18
18
  * package (the standard schemas under `@std`, or an org's own `@org/schemas`).
19
19
  *
20
- * Scope: pass `scope` ('@acme' or 'acme') to resolve a schema's own `@/x` (and the
21
- * foundation's `data-schemas.refs`) to a concrete `@acme/x` for submission. With no
22
- * `scope`, names stay `@/x` (local preview / dry-run). See `uwx-format.md`.
20
+ * Scope: a foundation whose name is scoped (`@acme/fnd`) registers under that scope —
21
+ * its name and its schemas' own `@/x` alike. A bare name takes `scope` ('@acme' or
22
+ * 'acme'). With neither, names stay `@/x` (local preview / dry-run). A schemas-only
23
+ * package has no name to carry one, so it always takes `scope`. See `uwx-format.md`.
23
24
  */
24
25
 
25
26
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
26
27
  import { join } from 'node:path'
27
28
 
28
29
  import { toDataSchemaDeclaration } from './data-schema.js'
30
+ import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
29
31
 
30
32
  const FOUNDATION_SCHEMA = '@uniweb/foundation-schema'
31
33
  const DATA_SCHEMA = '@uniweb/data-schema'
@@ -45,7 +47,9 @@ try {
45
47
  * @param {Object} params
46
48
  * @param {Object} params.schema - parsed `dist/meta/schema.json`.
47
49
  * @param {string} [params.foundationDir] - foundation root, for the `i18n/` bundles.
48
- * @param {string} [params.scope] - org scope (`@acme` or `acme`) resolving `@/x` -> `@acme/x`.
50
+ * @param {string} [params.scope] - org scope (`@acme` or `acme`) resolving `@/x` -> `@acme/x`,
51
+ * for a foundation whose name is bare. A scoped name's own scope is used instead, and
52
+ * a `scope` that contradicts it throws.
49
53
  * @param {Object} [params.exporter] - `{ tool, version, instance }` for the envelope.
50
54
  * @param {string} [params.exportedAt] - ISO timestamp (default: now).
51
55
  * @param {string} [params.runtime] - the `@uniweb/runtime` version this build
@@ -69,7 +73,10 @@ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, e
69
73
  // own `@/x`), each resolved to the concrete publish scope. Shared refs
70
74
  // (`@std/x`, `@other/x`) are named in the foundation's data-schemas.refs but
71
75
  // their declarations are not bundled — already published.
72
- const { entities: dataSchemaEntities, scoped, org } = buildDataSchemaEntities(dataSchemas, scope)
76
+ const { entities: dataSchemaEntities, scoped, org } = buildDataSchemaEntities(
77
+ dataSchemas,
78
+ registrationScope(self.name, scope)
79
+ )
73
80
 
74
81
  const foundationEntity = {
75
82
  model: FOUNDATION_SCHEMA,
@@ -107,6 +114,27 @@ export function buildSchemaOnlyPackage({ schemas, scope, exporter, exportedAt }
107
114
  return wrapEntities(entities, exporter, exportedAt)
108
115
  }
109
116
 
117
+ // The scope a foundation registers under: the one in its name, else `scope`.
118
+ //
119
+ // ⭐ A SCOPED NAME IS THE SCOPE (2026-09-22). `@acme/fnd` registers under `@acme` —
120
+ // the foundation and its data schemas both, because a site's records name those
121
+ // schemas by the same scope. ⛔ Until then the schemas took `scope` while the name
122
+ // kept its own, so one foundation registered `@acme/fnd` beside `@proximify/member`.
123
+ // A `scope` that contradicts the name is refused rather than chosen between: which
124
+ // org was meant is not ours to guess.
125
+ function registrationScope(name, scope) {
126
+ const { scope: nameScope } = splitFoundationName(name)
127
+ const handle = scope ? String(scope).replace(/^@/, '').replace(/\/.*$/, '') : ''
128
+ const given = handle ? `@${handle}` : null
129
+ if (nameScope && given && given !== nameScope) {
130
+ throw new Error(
131
+ `buildRegistryPackage: the foundation is named ${name}, so it registers under ` +
132
+ `${nameScope} — not ${given}. A foundation's scope is the one in its name.`
133
+ )
134
+ }
135
+ return nameScope || scope
136
+ }
137
+
110
138
  // Lower a `{ '@/<name>': normalizedSchema }` map into the sorted
111
139
  // `@uniweb/data-schema` entity list, resolving each own `@/x` to the publish
112
140
  // scope. Shared by the foundation publish (buildRegistryPackage) and the
@@ -148,8 +176,14 @@ function wrapEntities(entities, exporter, exportedAt) {
148
176
  // optional `digest` (sha256:…) is the foundation's content fingerprint; the
149
177
  // backend stores it opaque and returns it on the foundation-latest read.
150
178
  function buildInfo(self, org, digest, runtime) {
151
- // Scope a bare foundation name (`src` -> `@acme/src`); leave an already-scoped name.
179
+ // Scope a bare foundation name (`marketing` -> `@acme/marketing`); leave an
180
+ // already-scoped name.
152
181
  const name = org && !String(self.name).startsWith('@') ? `@${org}/${self.name}` : self.name
182
+ // ⛔ The producer's own guard. `uniweb register` asks for a name before it gets
183
+ // here; this refuses for any other caller, so nothing registers as `@org/src`
184
+ // (`foundation-name.js`).
185
+ const problem = checkFoundationName(name)
186
+ if (problem) throw new Error(`buildRegistryPackage: ${problem}`)
153
187
  const info = { name, version: self.version, role: self.role || 'foundation' }
154
188
  if (self.description !== undefined) info.description = self.description
155
189
  if (digest) info.digest = digest