@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.
@@ -1,45 +1,44 @@
1
- // Collections projection — write a folder + its record entities back to the
2
- // site's `collections/**` source files. The inverse of the collections producer
3
- // (collections.js + folder.js): the producer reads source records and emits the
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
4
  // `@uniweb/folder` entity + one section-keyed `$`-document per record; this takes
5
5
  // those documents back and renders them to files.
6
6
  //
7
- // Identity & placement. A record's on-disk home is `(collection, slug)`:
8
- // - `slug` and `collection` come from the FOLDER document — each ref leaf is
9
- // `{ entry: { model, entity: <uuid> }, name: <slug> }` inside a branch
10
- // (its `$children`) whose `name` names the folder it was placed in.
11
- // The folder is the authoritative organization on a read (the record
12
- // document's own `$id` envelope is not guaranteed to be echoed back), with
13
- // the record document's `$id` (its pool identity) as a fallback when present.
14
- // - the record's directory comes from its `$model` — `entities/{schema}/` is
15
- // where a thing of that model lives. Not from any query: a query has no
16
- // directory, and which query selects a record is not a fact about it.
7
+ // Identity & placement:
8
+ // - a record's slug comes from the FOLDER document — each ref leaf is
9
+ // `{ entry: { model, entity: <uuid> }, name: <slug> }`, at the top of the folder
10
+ // or inside a branch (its `$children`) whose `name` names the sub-folder. The
11
+ // folder is the authoritative organization on a read (the record document's own
12
+ // `$id` envelope is not guaranteed to be echoed back), with the record
13
+ // document's `$id` (its position in the directory) as a fallback when present.
14
+ // - the record's directory comes from its `$model` — `records/{schema}/` is where
15
+ // a record of that model lives (`site.yml::paths.records` moves it). Not from any
16
+ // query: a query has no directory, and which query selects a record is not a
17
+ // fact about it.
17
18
  // - an existing local file carrying the same `$uuid` is re-rendered in place;
18
19
  // otherwise a new single-record file is placed at `<slug>.<ext>`, its format
19
- // matched to the collection's existing files, else markdown when the Model's
20
+ // matched to the schema folder's existing files, else markdown when the Model's
20
21
  // brief has a content body field, else YAML.
21
22
  //
22
23
  // Field rendering reuses renderEntityDocument (via writeRecordFile) — localized
23
24
  // unwrap, date handling, content-body→body are already inverted there.
24
25
  //
25
- // v1 scope / deferred: array-form & BibTeX multi-record files (a pulled record is
26
- // placed as its own single-record file; merging into an existing array file is a
27
- // later nicety); deriving an on-disk collection from a deeply NESTED virtual
28
- // folder org when the record carries no `$id`; and rewriting `collections.yml`'s
29
- // `folders:` organization + synthesizing declarations for newly-introduced collections
30
- // (a comment-preserving config rewrite is a separate quality bar). The folder itself
31
- // carries no `$uuid` — the backend owns it, keyed by the site-content uuid — so
32
- // nothing is written into `collections.yml` here. Nothing is silently dropped: an
33
- // unplaceable or unresolvable record is reported.
34
-
35
- import { readFileSync, readdirSync, existsSync } from 'node:fs'
26
+ // Deferred: array-form & BibTeX multi-record files (a pulled record is placed as
27
+ // its own single-record file; merging into an existing array file is a later
28
+ // nicety). The folder itself carries no `$uuid` — the backend owns it, keyed by the
29
+ // site-content uuid. Nothing is silently dropped: an unplaceable or unresolvable
30
+ // record is reported.
31
+
32
+ import { readBackendState, updateBackendMap } from './sync-store.js'
33
+ import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs'
36
34
  import { join, resolve, relative, extname, basename, sep } from 'node:path'
37
35
  import yaml from 'js-yaml'
38
36
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
39
37
  import { parseFrontmatter } from './entity-source.js'
40
38
  import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
41
39
  import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
42
- import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
40
+ import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
41
+ import { recordsYmlPath } from '../site/records-config.js'
43
42
  import { isContentBodyField } from './data-schema.js'
44
43
  import { unresolveSelfScope } from './self-scope.js'
45
44
  import { unwrapLocalized } from './backfill.js'
@@ -144,7 +143,7 @@ function indexFolder(folderDoc) {
144
143
  // Where a pulled record is written: the pool folder its MODEL names.
145
144
  //
146
145
  // ⛔ NOT THE QUERY'S DIRECTORY — a query has none. A record's home is decided by
147
- // what it IS, and `entities/{schema}/` is the one place a thing of that model
146
+ // what it IS, and `records/{schema}/` is the one place a record of that model
148
147
  // lives. That is also why the placement survives a query being renamed, added or
149
148
  // deleted, none of which is a fact about the record.
150
149
  //
@@ -152,9 +151,9 @@ function indexFolder(folderDoc) {
152
151
  // `schemaForPoolDirs`, and deliberately not a second rule: if the two disagreed,
153
152
  // a pulled record would land somewhere the next build reads as a different
154
153
  // schema — silently, because both paths are well-formed.
155
- function recordDirFor(siteRoot, model, selfOrg) {
154
+ function recordDirFor(recordsRoot, model, selfOrg) {
156
155
  const dirs = poolDirsForSchema(unresolveSelfScope(model, selfOrg))
157
- return dirs ? join(siteRoot, ENTITIES_DIR, ...dirs) : null
156
+ return dirs ? join(recordsRoot, ...dirs) : null
158
157
  }
159
158
 
160
159
  // ⚠️ Undoing the producer's self-scope resolution (`unresolveSelfScope`) lives in
@@ -174,14 +173,14 @@ function locate(document, folderIndex) {
174
173
  return fromFolder || null
175
174
  }
176
175
 
177
- /** `site.yml::$org`, bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
178
- function readSiteOrg(siteRoot) {
179
- try {
180
- const y = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
181
- return typeof y.$org === 'string' && y.$org ? y.$org : null
182
- } catch {
183
- return null
184
- }
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
185
184
  }
186
185
 
187
186
  // Skip undefined when copying optional fields into a projected declaration.
@@ -262,8 +261,8 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
262
261
  setIf(decl, 'transform', source.transform)
263
262
  setIf(decl, 'record', source.record)
264
263
  } else if (typeof source.path === 'string') {
265
- // ⛔ A FILE-BASED QUERY HAS NO PATH TO WRITE BACK. `entities/{schema}/` is the
266
- // pool and `schema:` addresses it, so a `path` arriving on the wire is either
264
+ // ⛔ A FILE-BASED QUERY HAS NO PATH TO WRITE BACK. `records/{schema}/` holds its
265
+ // records and `schema:` addresses them, so a `path` arriving on the wire is either
267
266
  // stale storage or something only a remote source could have meant. Dropping
268
267
  // it keeps the author's file saying what the build actually reads.
269
268
  decl.path = source.path
@@ -331,11 +330,13 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
331
330
  * @param {object} params.document - a site-content `$`-document (`{ queries }`)
332
331
  * @param {string} params.siteRoot
333
332
  * @param {string} [params.org] - the site's own org, so a `schema` the producer
334
- * qualified from `@/x` is written back as `@/x`. Defaults to `site.yml::$org`,
335
- * the same default `recordsToProject` places records by.
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
336
337
  * @returns {{ collections?: 'updated'|'unchanged' }}
337
338
  */
338
- export function declarationsToQueriesYml({ document, siteRoot, org }) {
339
+ export function declarationsToQueriesYml({ document, siteRoot, org, backend = null }) {
339
340
  const decls = Array.isArray(document?.queries) ? document.queries : []
340
341
  const report = {}
341
342
  if (decls.length === 0) return report
@@ -353,7 +354,7 @@ export function declarationsToQueriesYml({ document, siteRoot, org }) {
353
354
  siteYml = null
354
355
  }
355
356
  const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
356
- const selfOrg = org ?? readSiteOrg(siteRoot)
357
+ const selfOrg = org ?? readSiteOrg(siteRoot, backend)
357
358
 
358
359
  const queries = {}
359
360
  for (const d of decls) {
@@ -371,31 +372,37 @@ export function declarationsToQueriesYml({ document, siteRoot, org }) {
371
372
  /**
372
373
  * Project a pulled `@uniweb/folder` document back to `records.yml`.
373
374
  *
374
- * ⭐ THE FOLDER IS THE ONE THING THAT ROUND-TRIPS TRIVIALLY, and that is by design
375
- * rather than luck: `records.yml` holds concrete refs on both sides, so there is
376
- * nothing to invert. The old shape put QUERY MACROS in the folder — a virtual
377
- * `folders:` tree naming collections — and inverting a macro is not possible in
378
- * general, which is why rewriting it stayed deferred for as long as it existed.
379
- * Taking queries out of the folder is what dissolved that.
375
+ * ⭐ 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.
377
+ * The old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
378
+ * collections — and inverting a macro is not possible in general. Taking queries
379
+ * out of the folder is what dissolved that.
380
+ *
381
+ * ⭐ ONLY THE SUB-FOLDERS ARE WRITTEN (ruled 2026-09-21 [Diego]). A record at the
382
+ * 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
385
+ * (it could only describe sub-folders the backend's folder no longer has). No state
386
+ * of the file removes a record, so this cannot empty anything.
380
387
  *
381
- * ⛔ AN EMPTY RESULT IS NOT WRITTEN. An empty `records.yml` means "the folder holds
382
- * nothing" and REMOVES on the next push, so a pull that carried no folder — or one
383
- * whose leaves could not be placed — must leave the file alone rather than author
384
- * the destructive state on the author's behalf.
388
+ * ⛔ A PULL THAT CARRIED NO FOLDER, or one with a placed record that was not written
389
+ * locally, leaves the file alone: it has nothing true to write.
385
390
  *
386
391
  * @param {object} params
387
392
  * @param {object} params.folderDoc - the stored `@uniweb/folder` document
388
393
  * @param {string} params.siteRoot
389
394
  * @param {Map<string,string>} params.poolPathByUuid - record `$uuid` → the path
390
- * under `entities/` of the file just written for it. Supplied by
395
+ * under `records/` of the file just written for it. Supplied by
391
396
  * `recordsToProject`, which is the only thing that knows the extension
392
397
  * each record landed with.
393
- * @returns {{ status: 'updated'|'unchanged'|'skipped', entries: Array, warnings: string[] }}
398
+ * @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[] }}
394
399
  */
395
400
  export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
396
401
  const warnings = []
402
+ if (!folderDoc) return { status: 'skipped', entries: [], warnings }
403
+ let unplaceable = false
397
404
 
398
- const walk = (nodes) => {
405
+ const walk = (nodes, inBranch) => {
399
406
  const out = []
400
407
  for (const node of nodes || []) {
401
408
  if (!node || typeof node !== 'object') continue
@@ -405,35 +412,53 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
405
412
  // does not caption its rows. On the wire the label is a localized map;
406
413
  // records.yml carries the source-locale string (a bare string passes).
407
414
  if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
408
- entry.records = walk(node.$children)
415
+ entry.records = walk(node.$children, true)
409
416
  out.push(entry)
410
417
  continue
411
418
  }
412
419
  const uuid = node.entry?.entity ?? node.entry
413
420
  const rel = typeof uuid === 'string' ? poolPathByUuid.get(uuid) : null
421
+ // A record at the top of the folder needs no line — but one that did not land
422
+ // is still said: the next push sends the folder the directory holds, which
423
+ // would not have it.
424
+ if (!inBranch) {
425
+ if (!rel) {
426
+ warnings.push(
427
+ `the folder holds a record ("${node.name ?? '?'}") that was not written locally — ` +
428
+ `a push from here would remove it from the folder.`
429
+ )
430
+ }
431
+ continue
432
+ }
414
433
  if (!rel) {
415
- // ⚠️ Reported, never dropped in silence. A leaf whose record did not land
416
- // means the folder and the pool disagree, and writing the file without it
417
- // would quietly unpublish that record on the next push.
434
+ // ⚠️ Reported, never dropped in silence. A placed record that did not land
435
+ // means the folder and the directory disagree, and writing the file without
436
+ // it would move that record to the top of the folder on the next push.
418
437
  warnings.push(
419
- `records.yml: a folder leaf ("${node.name ?? '?'}") references a record that ` +
420
- `was not written locally — the file was left unchanged rather than dropping it.`
438
+ `records.yml: the folder places a record ("${node.name ?? '?'}") that was not ` +
439
+ `written locally — the file was left unchanged rather than dropping it.`
421
440
  )
422
- return null
441
+ unplaceable = true
442
+ continue
423
443
  }
424
444
  out.push(rel)
425
445
  }
426
446
  return out
427
447
  }
428
448
 
429
- const entries = walk(folderDoc?.contents)
430
- if (entries === null) return { status: 'skipped', entries: [], warnings }
431
- if (entries.length === 0) return { status: 'skipped', entries: [], warnings }
449
+ const entries = walk(folderDoc.contents, false)
450
+ if (unplaceable) return { status: 'skipped', entries: [], warnings }
451
+ 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 }
456
+ }
432
457
  return { status: writeRecordsConfig(siteRoot, entries), entries, warnings }
433
458
  }
434
459
 
435
460
  /**
436
- * Project a pulled folder + its record entities to `entities/**` files.
461
+ * Project a pulled folder + its record entities to `records/**` files.
437
462
  *
438
463
  * @param {object} params
439
464
  * @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
@@ -444,31 +469,43 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
444
469
  * - resolve a Model's data-schema declaration by name (`$model`).
445
470
  * @param {string} [params.opts.org] - the site's own org, so a `@org/x` model the
446
471
  * producer resolved from `@/x` is placed back where the author wrote it.
447
- * Defaults to `site.yml::$org`.
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
448
475
  * @param {string} [params.opts.sourceLocale]
449
476
  * @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
450
477
  */
451
478
  export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
452
479
  const { resolveDeclaration, sourceLocale = 'en' } = opts
453
480
  // The site's own org, so a `@org/x` model the producer resolved from `@/x` is
454
- // placed back where the author wrote it. Read from `site.yml::$org` unless the
455
- // caller already has it.
456
- const selfOrg = opts.org ?? readSiteOrg(siteRoot)
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)
457
484
  if (typeof resolveDeclaration !== 'function') {
458
485
  throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
459
486
  }
460
487
 
461
488
  const folderIndex = indexFolder(folderDoc)
489
+ // Where records land — `site.yml::paths.records`, else `records/`: the one resolver
490
+ // the build and the push read too, so a pulled record lands where the next build
491
+ // looks. ⛔ This wrote into the default directory until 2026-09-21, whatever the
492
+ // site had moved its records to.
493
+ const recordsRoot = resolveRecordsDir(siteRoot).abs
462
494
  // Captures target-locale translations of localized record fields: SCALARs →
463
495
  // locales/records/{locale}.json (structural maps too), and a prosemirror
464
- // BODY's free-form per-locale override → locales/freeform/{locale}/collections/.
496
+ // BODY's free-form per-locale override → locales/freeform/{locale}/records/.
465
497
  const collector = createTranslationCollector(sourceLocale)
466
498
  const updated = []
467
499
  const placed = []
468
500
  const unchanged = []
469
501
  const skipped = []
470
502
  const warnings = []
471
- // uuid → the path under `entities/` the record landed at. Only this loop knows
503
+ // This backend's record map, inverted: its uuid → the record's own id.
504
+ const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
505
+ const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
506
+ // own id → their uuid, for every record this pull wrote. Recorded at the end.
507
+ const learned = {}
508
+ // uuid → the path under `records/` the record landed at. Only this loop knows
472
509
  // the extension each one got, so `records.yml` is written from it rather than
473
510
  // re-derived (a second rule could pick a different extension and the folder
474
511
  // would name a file that is not there).
@@ -486,7 +523,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
486
523
  continue
487
524
  }
488
525
 
489
- const poolDir = recordDirFor(siteRoot, document.$model, selfOrg)
526
+ const poolDir = recordDirFor(recordsRoot, document.$model, selfOrg)
490
527
  if (!poolDir) {
491
528
  skipped.push({
492
529
  uuid: document.$uuid,
@@ -495,7 +532,13 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
495
532
  })
496
533
  continue
497
534
  }
498
- const existing = document.$uuid ? findRecordFileByUuid(poolDir, document.$uuid) : null
535
+ // ⭐ The incoming `$uuid` is the BACKEND's. The file holds the record's OWN id, so
536
+ // reverse this backend's map to find it. With no mapping the two are the same —
537
+ // the first backend a record reaches minted the id it keeps — so the backend's
538
+ // uuid is the own id, exactly as before identity was keyed by backend.
539
+ const theirs = document.$uuid || null
540
+ const own = theirs ? ownIdByTheirs.get(theirs) || theirs : null
541
+ const existing = own ? findRecordFileByUuid(poolDir, own) : null
499
542
 
500
543
  let filePath
501
544
  let format
@@ -514,9 +557,12 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
514
557
  // target-locale full-doc body is written under locales/freeform/{locale}/here.
515
558
  const freeformRelPath = buildFreeformRecordPath(document.$model, where.slug)
516
559
 
560
+ // ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
561
+ // backend must not overwrite the identity the record already has.
562
+ const toWrite = own && own !== theirs ? { ...document, $uuid: own } : document
517
563
  let status
518
564
  try {
519
- status = writeRecordFile({ filePath, document, declaration, format, sourceLocale, collector, freeformRelPath })
565
+ status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath })
520
566
  } catch (err) {
521
567
  warnings.push(`${where.slug}: ${err.message}`)
522
568
  continue
@@ -524,8 +570,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
524
570
  if (status === 'unchanged') unchanged.push(filePath)
525
571
  else if (isNew) placed.push(filePath)
526
572
  else updated.push(filePath)
527
- if (document.$uuid) {
528
- poolPathByUuid.set(document.$uuid, relative(join(siteRoot, ENTITIES_DIR), filePath).split(sep).join('/'))
573
+ // 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.
575
+ if (theirs) {
576
+ poolPathByUuid.set(theirs, relative(recordsRoot, filePath).split(sep).join('/'))
577
+ if (own) learned[own] = theirs
529
578
  }
530
579
  }
531
580
 
@@ -543,5 +592,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
543
592
  const locales = writeLocaleTranslations(siteRoot, collector.byLocale, 'records')
544
593
  const freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
545
594
 
595
+ if (opts.backend && Object.keys(learned).length) {
596
+ updateBackendMap(siteRoot, opts.backend, 'records', learned)
597
+ }
598
+
546
599
  return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status }
547
600
  }