@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.
@@ -26,7 +26,7 @@
26
26
  // (the foundation's scope, not the site's publish org) and stays there.
27
27
 
28
28
  /**
29
- * An org handle as `--org` or `site.yml::$org` gives it — `@acme`, `acme`, or
29
+ * An org handle as `--org` or `sync.json` gives it — `@acme`, `acme`, or
30
30
  * `@acme/…` — reduced to the bare handle, or `''`.
31
31
  *
32
32
  * @param {unknown} org
@@ -56,15 +56,15 @@ export function resolveSelfScope(ref, org) {
56
56
  * The inverse, for a pull: `@<org>/x` → `@/x` for the site's own org.
57
57
  *
58
58
  * ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
59
- * on both ends. A record authored under `entities/article/` comes back as
60
- * `@acme/article` and, placed literally, lands under `entities/acme/article/` — a
59
+ * on both ends. A record authored under `records/article/` comes back as
60
+ * `@acme/article` and, placed literally, lands under `records/acme/article/` — a
61
61
  * different schema folder, which the next build reads as a different schema. A
62
62
  * query declared `schema: '@/member'` comes back as `@acme/member`, and one that
63
63
  * relied on the query-name default comes back with an explicit schema it never
64
64
  * had.
65
65
  *
66
- * ⭐ The site records its own org at create (`site.yml::$org` — "whose this is"),
67
- * which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
66
+ * ⭐ The site records its own org at create (`site.org` in `sync.json`, per backend —
67
+ * "whose this is"), which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
68
68
  * genuinely is that org's, and `@/` would be a lie.
69
69
  *
70
70
  * @param {unknown} ref
@@ -33,7 +33,8 @@
33
33
  // the i18n pipeline). Absent `info` keys are left untouched on disk.
34
34
 
35
35
  import { join, relative, extname, basename, dirname } from 'node:path'
36
- import { readAssetMap, restoreAssetRefs } from './asset-map.js'
36
+ import { restoreAssetRefs } from './asset-map.js'
37
+ import { readBackendState, updateBackendState } from './sync-store.js'
37
38
  import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync, mkdirSync } from 'node:fs'
38
39
  import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
39
40
  import { createHash } from 'node:crypto'
@@ -150,7 +151,7 @@ const INFO_TO_SITE_YML = {
150
151
  // ⭐ `preview` — the card image, verbatim: the app's timestamp, an author's URL, or
151
152
  // the path an author wrote for an image in the project, which `restoreAssetRefs`
152
153
  // has already put back by recognizing the serve URL the push recorded in
153
- // `assets.json` (asset-map.js). [Diego, 2026-09-10]
154
+ // `sync.json`'s asset map (asset-map.js). [Diego, 2026-09-10]
154
155
  //
155
156
  // ⚠️ It was listed here as "DELIBERATELY ABSENT… BACKEND-STAMPED" until
156
157
  // 2026-09-10. Leaving an app-written field out of this map is what lets the next
@@ -225,7 +226,7 @@ function readAuthoredYaml(filePath) {
225
226
  * @returns {{ siteConfig: string, theme?: string, headHtml?: string }} per-file
226
227
  * write status ('updated' | 'unchanged')
227
228
  */
228
- export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
229
+ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
229
230
  const info = document?.info || {}
230
231
  const settingsSection = document?.settings || {}
231
232
 
@@ -295,7 +296,7 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
295
296
  : []
296
297
  if (extensions.length > 0) siteChanges.extensions = extensions
297
298
 
298
- // services[] → site.yml::$services · secrets[] → site.yml::$secrets.
299
+ // services[] / secrets[] → sync.json::backends.<origin>.{services,secrets}.
299
300
  //
300
301
  // ⭐ The `$` prefix, and not the bare name, for the reason spelled out in
301
302
  // `uwx/site.js`: `site.yml::services` already means "pretend a host offers these"
@@ -315,10 +316,20 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
315
316
  // state — "this site has no service rows" — and it is the state a `pull` must be
316
317
  // able to deliver after the last one was removed. Skipping would leave a stale
317
318
  // `$services` on disk that the next push would resurrect.
318
- for (const [section, ymlKey] of [['services', '$services'], ['secrets', '$secrets']]) {
319
- const records = document?.[section]
320
- if (!Array.isArray(records)) continue
321
- siteChanges[ymlKey] = records.map(({ $id: _id, ...fields }) => fields)
319
+ // ⭐ To `sync.json`, under the backend that provisioned them — not `site.yml`.
320
+ // A service is bought on one backend, so its rows describe that backend alone.
321
+ // ⛔ Needs a backend: with none there is nowhere coherent to file them, and
322
+ // writing them to a shared place is exactly the single-copy bug this removed.
323
+ if (backend) {
324
+ const provisioned = {}
325
+ for (const section of ['services', 'secrets']) {
326
+ const records = document?.[section]
327
+ if (!Array.isArray(records)) continue
328
+ provisioned[section] = records.map(({ $id: _id, ...fields }) => fields)
329
+ }
330
+ if (Object.keys(provisioned).length) {
331
+ updateBackendState(siteRoot, backend, provisioned)
332
+ }
322
333
  }
323
334
 
324
335
  const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
@@ -338,8 +349,8 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
338
349
  // `data_bundle` and `foundation_schema`. It had been a build-derived upload
339
350
  // manifest that a pull deliberately never wrote back; framework never populated
340
351
  // it. Nothing to project, and nothing here to remove — the note is kept because
341
- // `assets.json` (the committed local path → id map) is a DIFFERENT thing and the
342
- // two get confused.
352
+ // the committed local path → id map (each backend's `assets` in `sync.json`;
353
+ // `assets.json` until 2026-09-20) is a DIFFERENT thing and the two get confused.
343
354
 
344
355
  return result
345
356
  }
@@ -859,7 +870,7 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
859
870
  * empty set.
860
871
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
861
872
  */
862
- export function siteContentDocumentToProject({ document, siteRoot, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false }) {
873
+ export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false }) {
863
874
  const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
864
875
 
865
876
  // Collects target-locale translations of localized scalars as they're projected;
@@ -871,10 +882,18 @@ export function siteContentDocumentToProject({ document, siteRoot, sourceLocale
871
882
  // the author wrote. Without this a push/pull cycle rewrites every image in a
872
883
  // developer's source to a backend route — a mangling of files they own, by a
873
884
  // round trip that changed nothing.
874
- report.assets = restoreAssetRefs(document, readAssetMap(siteRoot))
875
-
876
- report.config = siteInfoToConfig({ document, siteRoot, sourceLocale, collector, keepAuthoredFoundation })
877
- report.queries = declarationsToQueriesYml({ document, siteRoot })
885
+ // ⛔ `backend` is required to restore anything, and its absence is HONEST rather
886
+ // than a default: asset ids are minted per backend (`sync.json`), so a projection
887
+ // not tied to one has no known assets and restores nothing. Every caller that
888
+ // pulls has an origin; the ones that do not are projecting a document in the
889
+ // abstract.
890
+ report.assets = restoreAssetRefs(
891
+ document,
892
+ backend ? readBackendState(siteRoot, backend).assets || {} : {}
893
+ )
894
+
895
+ report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation })
896
+ report.queries = declarationsToQueriesYml({ document, siteRoot, backend })
878
897
 
879
898
  // The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
880
899
  // rename detection, build a fresh one as we project, then persist it. Items not
package/src/uwx/site.js CHANGED
@@ -14,13 +14,16 @@
14
14
  // carries the verbatim `site.yml::foundation` string (the round-trip source of
15
15
  // truth).
16
16
  //
17
- // IDENTITY. The ENTITY `$uuid` lives in `site.yml` (top-level `$uuid`); we read it,
18
- // send it, and back-fill the minted value there. Nested pages/sections carry a `$id`
19
- // handle AND a per-item `$uuid`.
17
+ // IDENTITY. The ENTITY `$uuid` is a backend's, recorded per backend in `sync.json`
18
+ // (`backends.<origin>.site.uuid`): we read the one for the backend a document is
19
+ // produced FOR, send it, and record the minted value there. Nested pages/sections
20
+ // carry a `$id` handle AND a per-item `$uuid`. ⚠️ The entity `$uuid` lived in
21
+ // `site.yml` until 2026-09-20 — one value, with no way to say which backend minted it.
20
22
  //
21
23
  // The per-item uuid is NOT authored — author files never carry sync uuids. It is
22
- // stamped at emit (`stampUnitUuids`) from an out-of-band cache populated by whatever
23
- // the backend last reported: a pull, or a push response's `finalized[].document`.
24
+ // stamped at emit (`stampUnitUuids`) from that backend's `items` map in `sync.json`,
25
+ // written from whatever the backend last reported: a pull, or a push response's
26
+ // `finalized[].document`. (The map sat in the gitignored sync cache until 2026-09-20.)
24
27
  // This is load-bearing, not bookkeeping: the backend matches records by uuid, and
25
28
  // `pages` / `page_sections` / `layout_sections` are all `multi` sections, where a
26
29
  // uuid-less record is read as NEW — inserted, with its stored counterpart deleted as
@@ -67,6 +70,7 @@ import { emitEntitySyncPackage } from './entity-document.js'
67
70
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
68
71
  import { unwrapLocalized } from './backfill.js'
69
72
  import { loadFreeformTranslation } from '../i18n/freeform.js'
73
+ import { updateBackendState, readBackendState } from './sync-store.js'
70
74
  import { upsertYamlScalar } from './yaml-upsert.js'
71
75
  import { resolveQueriesConfig } from './queries-config.js'
72
76
  import { resolveSelfScope } from './self-scope.js'
@@ -384,7 +388,7 @@ const CATCH_ALL_MARKER = '...path'
384
388
  // `page_sections`). Cross-section parentage is pure structure, never `$parent`.
385
389
  // - `$id` (the stableId — the in-file handle) rides at every item level as the
386
390
  // wire-only closure handle. Per-item `$uuid` is NOT authored here: it is
387
- // stamped on by `stampUnitUuids` at emit, from the out-of-band identity cache,
391
+ // stamped on by `stampUnitUuids` at emit, from the backend's `items` map in `sync.json`,
388
392
  // because the backend matches records by uuid and a uuid-less record in a
389
393
  // `multi` section is read as new (inserted, stored counterpart deleted). See
390
394
  // the IDENTITY note in the file header and `site-diff.js`.
@@ -400,8 +404,8 @@ const SITE_MODEL_NAME = '@uniweb/site-content'
400
404
  // `fields` already carries `stable_id` (the Model field); `$id` is the same value.
401
405
  // Both are kept: `$id` is the sync handle, `stable_id` is the declared content field
402
406
  // the editor/render reads. Per-item `$uuid` is added later by `stampUnitUuids` (it
403
- // comes from the identity cache, not from the authored files) — see the IDENTITY
404
- // note in the file header.
407
+ // comes from `sync.json`, not from the authored files) — see the IDENTITY note in
408
+ // the file header.
405
409
  function withIdentity(id, fields) {
406
410
  return Object.assign({ $id: id }, fields)
407
411
  }
@@ -974,8 +978,14 @@ function queriesNested(declarations, uuids = null, org = null) {
974
978
 
975
979
  // ── `services` + `secrets` — a site's own service records ─────────────────────
976
980
  //
977
- // ⭐ THE FILE KEYS ARE `$services` / `$secrets`, NOT `services` / `secrets`, and the
978
- // `$` is load-bearing rather than decorative. `site.yml::services` is ALREADY TAKEN,
981
+ // ⚠️ THESE WERE `site.yml::$services` / `$secrets` UNTIL 2026-09-20. They now live in
982
+ // `sync.json::backends.<origin>.{services,secrets}`, because what a site is PROVISIONED
983
+ // with is a fact about one backend — a service is bought on the backend that sold it.
984
+ // The `$` prefix existed to keep them off `site.yml::services`, which means something
985
+ // else on the bundle lane (the HOST tier); out of `site.yml` entirely, that collision
986
+ // is gone too.
987
+ //
988
+ // The original note, kept because it explains why the names could not simply be reused: `site.yml::services` is ALREADY TAKEN,
979
989
  // on the other lane: the bundle lane spreads site.yml whole into the payload, so a
980
990
  // `services:` block there lands at `config.services` — the HOST tier — which is the
981
991
  // documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
@@ -1056,9 +1066,9 @@ function serviceRecords(declared, identify, label) {
1056
1066
  }
1057
1067
 
1058
1068
  /** One service per `name` — the same keyspace `config.services` uses at runtime. */
1059
- function servicesNested(siteYml) {
1069
+ function servicesNested(provisioned) {
1060
1070
  return serviceRecords(
1061
- siteYml.$services,
1071
+ provisioned?.services,
1062
1072
  (e) => (typeof e.name === 'string' && e.name ? e.name : null),
1063
1073
  '$services'
1064
1074
  )
@@ -1076,9 +1086,9 @@ function servicesNested(siteYml) {
1076
1086
  * judgement belongs; framework does not strip it, because silently dropping a value
1077
1087
  * an author typed would leave them believing a secret was set.
1078
1088
  */
1079
- function secretsNested(siteYml) {
1089
+ function secretsNested(provisioned) {
1080
1090
  return serviceRecords(
1081
- siteYml.$secrets,
1091
+ provisioned?.secrets,
1082
1092
  (e) => {
1083
1093
  if (typeof e.name !== 'string' || !e.name) return null
1084
1094
  return typeof e.service === 'string' && e.service
@@ -1207,13 +1217,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1207
1217
  /**
1208
1218
  * Map a file site project to the nested `@uniweb/site-content` `$`-document
1209
1219
  * (see the lane header above). PURE — reads the project, never mints, never writes.
1210
- * The entity `$uuid` comes from `site.yml::$uuid` (back-filled after first sync);
1211
- * nested items carry `$id` only.
1220
+ * The entity `$uuid` is `opts.backend`'s, from `sync.json` (recorded after the first
1221
+ * sync to it); nested items carry `$id` only.
1212
1222
  *
1213
1223
  * @param {string} siteRoot - directory containing site.yml
1214
1224
  * @param {object} [opts]
1225
+ * @param {string} [opts.backend] - whose `$uuid` to put on the wire (sync.json)
1215
1226
  * @param {string} [opts.entityUuid] - override the entity `$uuid` (tests); default
1216
- * is `site.yml::$uuid` (absent on first sync — `$id`-only document).
1227
+ * is `opts.backend`'s site uuid in `sync.json` (absent on a first sync to it,
1228
+ * or with no backend — a `$id`-only document).
1217
1229
  * @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
1218
1230
  * the site's effective default locale (`defaultLanguage || languages[0] ||
1219
1231
  * 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
@@ -1300,9 +1312,10 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1300
1312
  // deploy-derived `info` field framework sends is `foundation`, via `injectInfo`
1301
1313
  // in `publish`.
1302
1314
  //
1303
- // ⚠️ The note stays because `assets.json` — the COMMITTED local map from an
1304
- // author's asset path to the backend's content-addressed id — is a different
1305
- // thing with a similar name, and it is very much alive.
1315
+ // ⚠️ The note stays because the COMMITTED local map from an author's asset path
1316
+ // to the backend's content-addressed id — each backend's `assets` in `sync.json`,
1317
+ // `assets.json` until 2026-09-20 — is a different thing with a similar name, and
1318
+ // it is very much alive.
1306
1319
  setIf(info, 'favicon', siteYml.favicon)
1307
1320
  // Site-level SEO/social metadata — the same shape as page.yml's `seo:` + the
1308
1321
  // top-level `keywords`, hoisted to the site root so the homepage social card
@@ -1413,7 +1426,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1413
1426
  // a site-root path to an image in the project, which push uploads like any
1414
1427
  // content image and sends as its serve URL, and which pull puts back as the
1415
1428
  // path the author wrote (`restoreAssetRefs` recognizes the URL by the
1416
- // fingerprint `assets.json` recorded for it).
1429
+ // fingerprint recorded for it in `sync.json`'s asset map).
1417
1430
  // The app leaves an author's value alone.
1418
1431
  //
1419
1432
  // ⚠️ Until 2026-09-10 framework emitted no preview, on the premise that the host
@@ -1494,11 +1507,15 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1494
1507
  // config (the records themselves are separate entities; this is just the config).
1495
1508
  const colConfig = await resolveQueriesConfig(siteRoot, { siteYml })
1496
1509
 
1497
- // `$uuid?` then `$id` `$model`, then sections in Model-declared order. The entity
1498
- // `$uuid` lives in site.yml (back-filled after first sync); absent on first sync.
1510
+ // `$uuid?` then `$id` `$model`, then sections in Model-declared order.
1499
1511
  const doc = {}
1512
+ // ⭐ From `sync.json`, keyed by the backend this document is being produced FOR.
1513
+ // It was `site.yml::$uuid` — one scalar with no way to say which backend minted it.
1514
+ // A producer with no backend emits no `$uuid`, which is exactly right: the wire
1515
+ // value is that backend's name for this site, and there is no such name yet.
1500
1516
  const entityUuid =
1501
- opts.entityUuid || (typeof siteYml.$uuid === 'string' ? siteYml.$uuid : undefined)
1517
+ opts.entityUuid ||
1518
+ (opts.backend ? readBackendState(siteRoot, opts.backend).site?.uuid : undefined)
1502
1519
  if (entityUuid) doc.$uuid = entityUuid
1503
1520
  doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
1504
1521
  doc.$model = SITE_MODEL_NAME
@@ -1537,9 +1554,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1537
1554
  // ⚖️ Default is to declare, so every existing caller is unchanged and the
1538
1555
  // withholding is opt-in.
1539
1556
  const declare = opts.declareServices !== false
1540
- const services = declare ? servicesNested(siteYml) : undefined
1557
+ // ⭐ Provisioned per backend — a service is bought on the backend that sold it,
1558
+ // so which rows exist is a question about ONE backend. They were `site.yml::$services`
1559
+ // / `$secrets`, a single copy that could only ever describe one.
1560
+ const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
1561
+ const services = declare ? servicesNested(provisioned) : undefined
1541
1562
  if (services) doc.services = services
1542
- const secrets = declare ? secretsNested(siteYml) : undefined
1563
+ const secrets = declare ? secretsNested(provisioned) : undefined
1543
1564
  if (secrets) doc.secrets = secrets
1544
1565
  return doc
1545
1566
  }
@@ -1576,25 +1597,30 @@ export async function emitSiteSyncPackage(siteRoot, opts = {}) {
1576
1597
  // Identity back-fill.
1577
1598
  //
1578
1599
  // The ENTITY `$uuid` (the backend's identity for the whole site-content entity) is
1579
- // back-filled into `site.yml::$uuid` after the first sync. That is the ONLY backend
1580
- // uuid for site-content — its nested pages/sections sync wholesale (collision=force),
1581
- // so there is no per-item uuid round-trip. Per-item identity for the eventual PULL is
1582
- // recovered by in-file `stableId` + content-match (Plan D), not a local id store.
1600
+ // recorded after the first sync to a backend, in that backend's section of
1601
+ // `sync.json`, beside the org the site was created under. Per-item uuids round-trip
1602
+ // too, through the same section's `items` map — see the IDENTITY note in the file
1603
+ // header. ⚠️ Until 2026-09-21 this note put the entity `$uuid` in `site.yml`, where it
1604
+ // lived until 2026-09-20, and said nested items have no per-item uuid round-trip,
1605
+ // which the file header says is not so.
1583
1606
  // ===========================================================================
1584
1607
 
1585
1608
  /**
1586
- * Back-fill the minted site-content entity `$uuid` into `site.yml` (top-level
1587
- * `$uuid`), preserving the file's comments and key order.
1609
+ * Record the minted site-content entity `$uuid` as `backend`'s site, in `sync.json`
1610
+ * (`backends.<origin>.site.uuid`). Until 2026-09-20 it was back-filled into
1611
+ * `site.yml` as a top-level `$uuid`.
1588
1612
  * @param {string} siteRoot
1613
+ * @param {string} backend - the origin of the backend that minted it
1589
1614
  * @param {string} uuid - the entity uuid the backend minted/echoed
1590
- * @returns {boolean} true if site.yml changed
1615
+ * @returns {boolean} true if sync.json changed
1591
1616
  */
1592
- export function writeSiteEntityUuid(siteRoot, uuid) {
1593
- return upsertYamlScalar(join(siteRoot, 'site.yml'), '$uuid', uuid)
1617
+ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1618
+ return updateBackendState(siteRoot, backend, { site: { uuid } })
1594
1619
  }
1595
1620
 
1596
1621
  /**
1597
- * Record the org the site was CREATED under (`site.yml::$org`), beside `$uuid`.
1622
+ * Record the org the site was CREATED under — `backend`'s `site.org` in `sync.json`,
1623
+ * beside the site's uuid. (It was `site.yml::$org` until 2026-09-20.)
1598
1624
  *
1599
1625
  * The org is consumed at exactly one moment — the `as_org` on the create that
1600
1626
  * mints `$uuid` — and after that the uuid carries the ownership binding. So this
@@ -1602,15 +1628,11 @@ export function writeSiteEntityUuid(siteRoot, uuid) {
1602
1628
  * this site's storage charged to?"*, which `$uuid` alone cannot answer and which
1603
1629
  * otherwise costs a backend round-trip (or is simply unknowable from the repo).
1604
1630
  *
1605
- * Stored as the BARE handle, never `@handle`: `upsertYamlScalar` writes the value
1606
- * verbatim, and `@` is a reserved YAML indicator, so a plain scalar may not start
1607
- * with one — `$org: @acme` is a parse error. The bare form is also the canonical
1608
- * one everywhere else (`deriveScope` returns it, `createOrg` echoes it as
1609
- * `org.handle`, `validateHandle` validates it); the `@` is display sugar the
1610
- * reader re-adds.
1611
- *
1612
- * Safe to add to `site.yml` because the sync lane is an explicit allowlist
1613
- * (`info.*` above is built key by key), so this never rides the wire.
1631
+ * Stored as the BARE handle, never `@handle` — the canonical form everywhere else
1632
+ * (`deriveScope` returns it, `createOrg` echoes it as `org.handle`,
1633
+ * `validateHandle` validates it); the `@` is display sugar the reader re-adds. In
1634
+ * `site.yml` it was also the only form that parsed: `@` is a reserved YAML
1635
+ * indicator, so `$org: @acme` is an error.
1614
1636
  *
1615
1637
  * ⛔ NOT `deploy.yml`, though that file already holds the bound `backend` and the
1616
1638
  * two look like the same class of fact. Four reasons, and the first is the one that
@@ -1618,9 +1640,9 @@ export function writeSiteEntityUuid(siteRoot, uuid) {
1618
1640
  *
1619
1641
  * 1. CARDINALITY. `deploy.yml` is multi-TARGET and `backend` sits *under* a target,
1620
1642
  * so its shape says "this may vary per target." An org may not: one site has one
1621
- * owning org, fixed at create and preserved on replace. `$org` is a property of
1622
- * `$uuid`, which is singular and lives here — filing it under a target would
1623
- * encode a freedom that does not exist.
1643
+ * owning org, fixed at create and preserved on replace. The org is a property of
1644
+ * the site's uuid, and sits beside it in the same backend's section — filing it
1645
+ * under a target would encode a freedom that does not exist.
1624
1646
  * 2. WHO WRITES. Three paths mint a site (`ensureSiteExists`, the media-less push's
1625
1647
  * content-lane create, and `clone` seeding an existing one) and **none of them
1626
1648
  * write `deploy.yml`** — only `deploy` and `publish` call `recordLastDeploy`. The
@@ -1633,51 +1655,19 @@ export function writeSiteEntityUuid(siteRoot, uuid) {
1633
1655
  * ever being deployed. Ownership does not belong in a record of a deploy that
1634
1656
  * may not have happened.
1635
1657
  *
1636
- * `backend` answers *where this ships*; `$org` answers *whose this is*.
1658
+ * `backend` answers *where this ships*; `site.org` answers *whose this is*.
1637
1659
  *
1638
1660
  * @param {string} siteRoot
1661
+ * @param {string} backend - the origin of the backend the site was created on
1639
1662
  * @param {string} handle - the bare org handle (no leading `@`)
1640
- * @returns {boolean} true if site.yml changed
1663
+ * @returns {boolean} true if sync.json changed
1641
1664
  */
1642
- export function writeSiteOrg(siteRoot, handle) {
1643
- return upsertYamlScalar(join(siteRoot, 'site.yml'), '$org', handle)
1665
+ export function writeSiteOrg(siteRoot, backend, handle) {
1666
+ return updateBackendState(siteRoot, backend, { site: { org: handle } })
1644
1667
  }
1645
1668
 
1646
- /**
1647
- * Record the backend this project SYNCS WITH (`site.yml::$backend`), beside `$uuid`/`$org`.
1648
- *
1649
- * ⭐ It is not a tag on the site uuid — it is the project's **sync scope**. Four surfaces
1650
- * hold backend-minted identity, and this one fact is what makes all of them meaningful:
1651
- * `site.yml::$uuid`, every collection record's `$uuid` in its own source file,
1652
- * `assets.json`, and `.uniweb/sync-cache.json`. Change the backend and every one of them
1653
- * is foreign at once — which is why a mismatch is a stop, not a fallback
1654
- * (`assertSiteBackendScope` in the CLI).
1655
- *
1656
- * ⛔ **Written ONLY for a non-default backend.** The 98% case stays out of the file, and an
1657
- * absent value reads as the default — correct both for a project written before this key
1658
- * existed and for one synced against the default. Callers decide; this writer does not know
1659
- * the CLI's default. See `recordSiteBackend` (cli/src/backend/site-sync.js), which is where
1660
- * the "is it the default?" test lives.
1661
- *
1662
- * ⚠️ **The name is load-bearing. Five alternatives were considered and rejected (2026-08-24);
1663
- * do not re-propose one.** `origin` is wrong twice over — a site HAS an origin (its own
1664
- * served domain), and this lane is git-modeled, where `origin` names a remote rather than a
1665
- * URL. `remote` implies a multiplicity this design closes. `hosting` and `platform` name the
1666
- * hosting edge, which this package has no client for. `host` collides with
1667
- * `deploy.yml::targets.<n>.host` (the deploy adapter). A `uuid: <id>@<backend>` suffix was
1668
- * rejected too: it would make "this never reaches the backend" a discipline at every read
1669
- * site instead of a property, and `siteProjectToDocument` assigns `$uuid` straight onto the
1670
- * wire document.
1671
- *
1672
- * Safe as a plain YAML scalar: a URL's `:` is followed by `/` or a digit, never a space, so
1673
- * it needs no quoting (verified against js-yaml for apex, `localhost:8080` and host:port+path
1674
- * forms, 2026-08-24). This is the same class of hazard as `$org`'s leading `@`, which is
1675
- * stripped for exactly that reason — but it lands on the safe side.
1676
- *
1677
- * @param {string} siteRoot
1678
- * @param {string} origin - a bare origin, no trailing slash
1679
- * @returns {boolean} true if site.yml changed
1680
- */
1681
- export function writeSiteBackend(siteRoot, origin) {
1682
- return upsertYamlScalar(join(siteRoot, 'site.yml'), '$backend', origin)
1683
- }
1669
+ // ⛔ `writeSiteBackend` and `site.yml::$backend` are GONE (2026-09-20). The scope is
1670
+ // no longer a value recorded beside the uuid — it is the KEY the identity is stored
1671
+ // under (`sync.json::backends.<origin>`), so there is nothing to record and nothing
1672
+ // that can disagree with it. `kb/framework/reference/sync-json.md`.
1673
+
@@ -273,6 +273,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
273
273
  const exportedAt = opts.exportedAt
274
274
 
275
275
  const col = await buildRecordEntities(siteRoot, {
276
+ // Which backend's record uuids go on the wire (sync.json) — see records.js.
277
+ ...(opts.backend ? { backend: opts.backend } : {}),
276
278
  ...(opts.foundationDir ? { foundationDir: opts.foundationDir } : {}),
277
279
  ...(opts.resolveModel ? { resolveModel: opts.resolveModel } : {}),
278
280
  ...(sourceLocale ? { sourceLocale } : {}),
@@ -288,16 +290,20 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
288
290
  const folder = buildFolderEntity({
289
291
  recordEntities: col.entities,
290
292
  ...(sourceLocale ? { sourceLocale } : {}),
291
- // ⭐ AUTHORED, from `records.yml`. It used to be derived — one branch per
292
- // collection — which made the folder a shadow of a directory layout rather
293
- // than something the author states.
294
- folderNodes: col.folder?.nodes ?? [],
295
- // ⛔ Whether `records.yml` EXISTS — not whether it holds anything. Missing is
296
- // inert; empty is a folder that REMOVES. Compared against the affirmative
297
- // value, never `!== 'missing'`: an absent state would read as declared, and
298
- // that is precisely how a site with no records.yml once emitted a folder that
299
- // would have emptied the live one.
300
- declared: col.recordsState === 'empty' || col.recordsState === 'declared',
293
+ // ⭐ Every record in the directory, at the top of the folder or in the
294
+ // sub-folder `records.yml` places it in — and NOTHING when there is no folder to
295
+ // state. ⛔ Gated here as well as by `declared`, because the builder sends any
296
+ // non-empty tree: a `records.yml` whose folders hold only records that resolved
297
+ // no schema is a tree of empty branches, and sending it would replace the
298
+ // backend's folder with them (measured on the `dynamic` template, 2026-09-21).
299
+ folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],
300
+ // ⛔ Whether the file side HAS a folder to state (`records.js::sendsFolder`) —
301
+ // not whether it holds anything. A site with no records directory sends none
302
+ // and leaves the backend's alone; an empty directory sends an empty folder,
303
+ // which REMOVES (the CLI asks first). Compared as `=== true`: an absent value
304
+ // must read as "nothing to state", which is how a site with no records.yml once
305
+ // emitted a folder that would have emptied the backend's.
306
+ declared: col.sendFolder === true,
301
307
  // Placement identity from the folder document a previous push returned.
302
308
  // Absent on a first push — every item is genuinely new then. Its ABSENCE on a
303
309
  // later push is what made `publish` after `push` fail: send-only-changed skips
@@ -306,9 +312,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
306
312
  ...(opts.folderItemUuids ? { itemUuids: opts.folderItemUuids } : {}),
307
313
  })
308
314
  // ⚠️ A placement that produced no entity is dropped by the builder rather than
309
- // sent pointing at nothing — but it must still be SAID. It means an entity was
310
- // placed in records.yml and then skipped upstream (a schema that did not
311
- // resolve, most often), and the record is simply absent from the site.
315
+ // sent pointing at nothing — but it must still be SAID. `buildRecordEntities`
316
+ // places only records it produced, so this is a guard on the pairing rather than
317
+ // an expected path.
312
318
  if (folder?.warnings?.length) warnings.push(...folder.warnings)
313
319
 
314
320
  // `queryUuids` — identity for the `queries` section, keyed by query
@@ -316,6 +322,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
316
322
  const siteDoc = includeSite
317
323
  ? await siteProjectToDocument(siteRoot, {
318
324
  sourceLocale,
325
+ // Whose $uuid belongs on this wire document — see siteProjectToDocument.
326
+ ...(opts.backend ? { backend: opts.backend } : {}),
319
327
  ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
320
328
  // ⛔ THE SAME ORG `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
321
329
  // must name the Model its records are stored under, and both are qualified
@@ -400,8 +408,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
400
408
  // ⛔ No identity attrs on `info`: it is a Section whose fields the host declares,
401
409
  // and `preview` is a real ASSET_SLOTS slot, so stamping would add
402
410
  // `previewAssetId`/`previewAssetExt` — fields the host refuses. Its bare strings
403
- // are still swapped for their serve URLs, which `assets.json` recognizes on pull
404
- // by fingerprint (asset-map.js → `servedFingerprint`).
411
+ // are still swapped for their serve URLs, which the asset map in `sync.json`
412
+ // recognizes on pull by fingerprint (asset-map.js → `servedFingerprint`).
405
413
  if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite, assetIds, siteDoc.info)
406
414
  for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite, assetIds)
407
415
  }