@uniweb/build 0.55.1 → 0.56.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.
@@ -318,13 +318,21 @@ function writeIfChanged(filePath, text) {
318
318
  * order: `{ id, model, slug, sourceFile, format?, multiRecord?, declaration? }`.
319
319
  * @param {object[]} params.finalized - response entries `{ index, uuid, changed?, document? }`.
320
320
  * @param {string} [params.sourceLocale]
321
- * @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[] }}
321
+ * @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[], mapped: Object<string,string> }}
322
+ * `mapped` is own id → the uuid this backend minted, for the caller to record per backend.
322
323
  */
323
324
  export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
324
325
  const updated = []
325
326
  const unchanged = []
326
327
  const deferred = []
327
328
  const warnings = []
329
+ // ⭐ own id → the uuid THIS backend minted. The caller files it under the backend in
330
+ // `sync.json::backends.<origin>.records`; this function knows no backend.
331
+ const mapped = {}
332
+ // Files holding only records that already carry their own id. Counted ONCE per file
333
+ // at the end: an array-form file holds several records, and "examined, nothing
334
+ // written" is a statement about the file, not about each record in it.
335
+ const ownIdFiles = new Set()
328
336
  // Multi-record files are written ONCE per file, applying every (slug → uuid).
329
337
  const arrayFiles = new Map() // array-form YAML/JSON: sourceFile -> Map(slug -> uuid)
330
338
  const bibFiles = new Map() // BibTeX: sourceFile -> Map(cite key -> uuid)
@@ -347,6 +355,22 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
347
355
  deferred.push({ index: i, id: entry.id, reason: 'no source file on disk' })
348
356
  continue
349
357
  }
358
+
359
+ // ⭐ Every record the backend returned is mapped: own id → the uuid it minted.
360
+ // A record new to EVERY backend has no own id yet, so the minted uuid becomes it
361
+ // (written below) and the mapping is identity — which is exactly the behaviour
362
+ // before identity was keyed by backend, so a single-backend project is unchanged.
363
+ mapped[entry.ownId || uuid] = uuid
364
+
365
+ // ⛔ A record that already carries its OWN id is NOT rewritten. Its id is stable
366
+ // and travels with the file; this backend's uuid belongs in the map, not in the
367
+ // author's file. And it must not be: variant A below re-renders the WHOLE file from
368
+ // the backend's document, which carries THIS backend's `$uuid` — so a second
369
+ // backend would silently overwrite the record's identity with its own.
370
+ if (entry.ownId) {
371
+ ownIdFiles.add(entry.sourceFile)
372
+ continue
373
+ }
350
374
  if (entry.multiRecord) {
351
375
  // Many records in one file → group by file, written once. BibTeX and
352
376
  // array-form YAML/JSON both re-render canonically (sync owns the output);
@@ -405,5 +429,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
405
429
  else warnings.push(`${file}: ${res.message}`)
406
430
  }
407
431
 
408
- return { updated, unchanged, deferred, warnings }
432
+ for (const file of ownIdFiles) {
433
+ if (!updated.includes(file) && !unchanged.includes(file)) unchanged.push(file)
434
+ }
435
+ return { updated, unchanged, deferred, warnings, mapped }
409
436
  }
package/src/uwx/index.js CHANGED
@@ -44,7 +44,6 @@ export {
44
44
  emitSiteSyncPackage,
45
45
  writeSiteEntityUuid,
46
46
  writeSiteOrg,
47
- writeSiteBackend,
48
47
  extensionDeclaration,
49
48
  isExtensionUrl,
50
49
  isSiteRelativeExtensionUrl,
@@ -77,14 +76,27 @@ export {
77
76
  localeFilePath,
78
77
  } from './locale-sync.js'
79
78
  export { emitSyncPackages } from './sync-package.js'
80
- export {
81
- readAssetMap,
82
- updateAssetMap,
79
+ // ⚠️ The asset MAP is `sync.json::backends.<origin>.assets` (sync-store.js, below).
80
+ // `readAssetMap` / `updateAssetMap` / `refForAssetId` / `ASSET_MAP_FILE` were removed
81
+ // on 2026-09-20: a single flat file cannot hold ids minted by more than one backend.
82
+ export { restoreAssetRefs, servedFingerprint } from './asset-map.js'
83
+ // `sync.json` — what each backend minted, keyed by origin. Absorbs `assets.json`
84
+ // above, `site.yml`'s identity keys and three maps out of the sync cache.
85
+ // Spec: kb/framework/reference/sync-json.md
86
+ export {
87
+ SYNC_STORE_FILE,
88
+ readSyncStore,
89
+ readBackendState,
90
+ listSyncedBackends,
91
+ updateBackendState,
92
+ updateBackendMap,
93
+ carryServed,
94
+ clearBackend,
95
+ clearBackendSections,
96
+ forgetSyncStore,
83
97
  refForAssetId,
84
- restoreAssetRefs,
85
- ASSET_MAP_FILE,
86
- servedFingerprint,
87
- } from './asset-map.js'
98
+ normalizeOrigin as normalizeBackendOrigin,
99
+ } from './sync-store.js'
88
100
  export {
89
101
  diffSiteUnits,
90
102
  describeSiteDiff,
@@ -32,6 +32,7 @@
32
32
  // nothing is written into `collections.yml` here. Nothing is silently dropped: an
33
33
  // unplaceable or unresolvable record is reported.
34
34
 
35
+ import { readBackendState, updateBackendMap } from './sync-store.js'
35
36
  import { readFileSync, readdirSync, existsSync } from 'node:fs'
36
37
  import { join, resolve, relative, extname, basename, sep } from 'node:path'
37
38
  import yaml from 'js-yaml'
@@ -175,13 +176,13 @@ function locate(document, folderIndex) {
175
176
  }
176
177
 
177
178
  /** `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
- }
179
+ // ⛔ `site.yml::$org` IS GONE (2026-09-20) — the org lives in
180
+ // `sync.json::backends.<origin>.site.org`. This read `site.yml` and, once step 4
181
+ // moved the key, silently returned null: every `@org/x` model a pull met would have
182
+ // been placed back as if the site had no org. No test spanned it.
183
+ function readSiteOrg(siteRoot, backend) {
184
+ const org = backend ? readBackendState(siteRoot, backend).site?.org : null
185
+ return typeof org === 'string' && org ? org : null
185
186
  }
186
187
 
187
188
  // Skip undefined when copying optional fields into a projected declaration.
@@ -335,7 +336,7 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
335
336
  * the same default `recordsToProject` places records by.
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) {
@@ -453,7 +454,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
453
454
  // The site's own org, so a `@org/x` model the producer resolved from `@/x` is
454
455
  // placed back where the author wrote it. Read from `site.yml::$org` unless the
455
456
  // caller already has it.
456
- const selfOrg = opts.org ?? readSiteOrg(siteRoot)
457
+ const selfOrg = opts.org ?? readSiteOrg(siteRoot, opts.backend)
457
458
  if (typeof resolveDeclaration !== 'function') {
458
459
  throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
459
460
  }
@@ -468,6 +469,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
468
469
  const unchanged = []
469
470
  const skipped = []
470
471
  const warnings = []
472
+ // This backend's record map, inverted: its uuid → the record's own id.
473
+ const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
474
+ const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
475
+ // own id → their uuid, for every record this pull wrote. Recorded at the end.
476
+ const learned = {}
471
477
  // uuid → the path under `entities/` the record landed at. Only this loop knows
472
478
  // the extension each one got, so `records.yml` is written from it rather than
473
479
  // re-derived (a second rule could pick a different extension and the folder
@@ -495,7 +501,13 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
495
501
  })
496
502
  continue
497
503
  }
498
- const existing = document.$uuid ? findRecordFileByUuid(poolDir, document.$uuid) : null
504
+ // ⭐ The incoming `$uuid` is the BACKEND's. The file holds the record's OWN id, so
505
+ // reverse this backend's map to find it. With no mapping the two are the same —
506
+ // the first backend a record reaches minted the id it keeps — so the backend's
507
+ // uuid is the own id, exactly as before identity was keyed by backend.
508
+ const theirs = document.$uuid || null
509
+ const own = theirs ? ownIdByTheirs.get(theirs) || theirs : null
510
+ const existing = own ? findRecordFileByUuid(poolDir, own) : null
499
511
 
500
512
  let filePath
501
513
  let format
@@ -514,9 +526,12 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
514
526
  // target-locale full-doc body is written under locales/freeform/{locale}/here.
515
527
  const freeformRelPath = buildFreeformRecordPath(document.$model, where.slug)
516
528
 
529
+ // ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
530
+ // backend must not overwrite the identity the record already has.
531
+ const toWrite = own && own !== theirs ? { ...document, $uuid: own } : document
517
532
  let status
518
533
  try {
519
- status = writeRecordFile({ filePath, document, declaration, format, sourceLocale, collector, freeformRelPath })
534
+ status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath })
520
535
  } catch (err) {
521
536
  warnings.push(`${where.slug}: ${err.message}`)
522
537
  continue
@@ -524,8 +539,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
524
539
  if (status === 'unchanged') unchanged.push(filePath)
525
540
  else if (isNew) placed.push(filePath)
526
541
  else updated.push(filePath)
527
- if (document.$uuid) {
528
- poolPathByUuid.set(document.$uuid, relative(join(siteRoot, ENTITIES_DIR), filePath).split(sep).join('/'))
542
+ // Keyed by THEIR uuid: the folder document references records in the backend's
543
+ // terms, so that is what `folderToRecordsYml` will look them up by.
544
+ if (theirs) {
545
+ poolPathByUuid.set(theirs, relative(join(siteRoot, ENTITIES_DIR), filePath).split(sep).join('/'))
546
+ if (own) learned[own] = theirs
529
547
  }
530
548
  }
531
549
 
@@ -543,5 +561,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
543
561
  const locales = writeLocaleTranslations(siteRoot, collector.byLocale, 'records')
544
562
  const freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
545
563
 
564
+ if (opts.backend && Object.keys(learned).length) {
565
+ updateBackendMap(siteRoot, opts.backend, 'records', learned)
566
+ }
567
+
546
568
  return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status }
547
569
  }
@@ -39,6 +39,7 @@
39
39
  // injected `opts.resolveModel`. A scope note that under-claims is worse than none:
40
40
  // it sends a reader to build what is already there.
41
41
 
42
+ import { readBackendState } from './sync-store.js'
42
43
  import { readFileSync, existsSync } from 'node:fs'
43
44
  import yaml from 'js-yaml'
44
45
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
@@ -740,9 +741,30 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
740
741
  }
741
742
  }
742
743
 
744
+ // ⭐ THE FILE'S `$uuid` IS OURS; THE WIRE'S IS THE BACKEND'S (2026-09-20).
745
+ //
746
+ // A record's `$uuid` is its stable identity and travels with the file, so moving
747
+ // or renaming it changes nothing. What goes on the WIRE to backend B is the uuid B
748
+ // minted for it — looked up in `sync.json::backends.<B>.records` — or none, so B
749
+ // mints one. ⛔ Never send a backend a uuid it did not mint: whether it would
750
+ // accept one is the backend's to say, and this is correct either way.
751
+ //
752
+ // For the FIRST backend a record reaches, B's minted uuid is written into the
753
+ // file and the map is identity — exactly what happened before this change, so a
754
+ // single-backend project behaves as it always did.
755
+ const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
756
+ const ownIds = new Map()
757
+ const onWire = flat.map((rec) => {
758
+ const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
759
+ ownIds.set(rec.slug, own)
760
+ const minted = own ? recordMap[own] : undefined
761
+ const { $uuid: _own, ...rest } = rec
762
+ return minted ? { ...rest, $uuid: minted } : rest
763
+ })
764
+
743
765
  const mappedOut = recordsToEntities({
744
766
  queryName: name,
745
- records: flat,
767
+ records: onWire,
746
768
  declaration,
747
769
  sourceLocale,
748
770
  translations,
@@ -780,6 +802,9 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
780
802
  // per-entry `$uuid` write keyed by slug (see backfill.js). `format` lets
781
803
  // the writer route (array-form vs BibTeX, the latter still deferred).
782
804
  sourceFile: src ? src.sourceFile : null,
805
+ // The record's OWN id (the file's `$uuid`), or null for one never synced.
806
+ // The back-fill maps it to what the backend returns; see above.
807
+ ownId: ownIds.get(e.slug) ?? null,
783
808
  format: src ? src.format : null,
784
809
  multiRecord: src ? src.multiRecord : false,
785
810
  // The Model declaration, so the back-fill can render the finalized
@@ -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'
@@ -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) }
@@ -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
@@ -67,6 +67,7 @@ import { emitEntitySyncPackage } from './entity-document.js'
67
67
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
68
68
  import { unwrapLocalized } from './backfill.js'
69
69
  import { loadFreeformTranslation } from '../i18n/freeform.js'
70
+ import { updateBackendState, readBackendState } from './sync-store.js'
70
71
  import { upsertYamlScalar } from './yaml-upsert.js'
71
72
  import { resolveQueriesConfig } from './queries-config.js'
72
73
  import { resolveSelfScope } from './self-scope.js'
@@ -974,8 +975,14 @@ function queriesNested(declarations, uuids = null, org = null) {
974
975
 
975
976
  // ── `services` + `secrets` — a site's own service records ─────────────────────
976
977
  //
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,
978
+ // ⚠️ THESE WERE `site.yml::$services` / `$secrets` UNTIL 2026-09-20. They now live in
979
+ // `sync.json::backends.<origin>.{services,secrets}`, because what a site is PROVISIONED
980
+ // with is a fact about one backend — a service is bought on the backend that sold it.
981
+ // The `$` prefix existed to keep them off `site.yml::services`, which means something
982
+ // else on the bundle lane (the HOST tier); out of `site.yml` entirely, that collision
983
+ // is gone too.
984
+ //
985
+ // The original note, kept because it explains why the names could not simply be reused: `site.yml::services` is ALREADY TAKEN,
979
986
  // on the other lane: the bundle lane spreads site.yml whole into the payload, so a
980
987
  // `services:` block there lands at `config.services` — the HOST tier — which is the
981
988
  // documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
@@ -1056,9 +1063,9 @@ function serviceRecords(declared, identify, label) {
1056
1063
  }
1057
1064
 
1058
1065
  /** One service per `name` — the same keyspace `config.services` uses at runtime. */
1059
- function servicesNested(siteYml) {
1066
+ function servicesNested(provisioned) {
1060
1067
  return serviceRecords(
1061
- siteYml.$services,
1068
+ provisioned?.services,
1062
1069
  (e) => (typeof e.name === 'string' && e.name ? e.name : null),
1063
1070
  '$services'
1064
1071
  )
@@ -1076,9 +1083,9 @@ function servicesNested(siteYml) {
1076
1083
  * judgement belongs; framework does not strip it, because silently dropping a value
1077
1084
  * an author typed would leave them believing a secret was set.
1078
1085
  */
1079
- function secretsNested(siteYml) {
1086
+ function secretsNested(provisioned) {
1080
1087
  return serviceRecords(
1081
- siteYml.$secrets,
1088
+ provisioned?.secrets,
1082
1089
  (e) => {
1083
1090
  if (typeof e.name !== 'string' || !e.name) return null
1084
1091
  return typeof e.service === 'string' && e.service
@@ -1212,6 +1219,7 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1212
1219
  *
1213
1220
  * @param {string} siteRoot - directory containing site.yml
1214
1221
  * @param {object} [opts]
1222
+ * @param {string} [opts.backend] - whose `$uuid` to put on the wire (sync.json)
1215
1223
  * @param {string} [opts.entityUuid] - override the entity `$uuid` (tests); default
1216
1224
  * is `site.yml::$uuid` (absent on first sync — `$id`-only document).
1217
1225
  * @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
@@ -1497,8 +1505,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1497
1505
  // `$uuid?` then `$id` `$model`, then sections in Model-declared order. The entity
1498
1506
  // `$uuid` lives in site.yml (back-filled after first sync); absent on first sync.
1499
1507
  const doc = {}
1508
+ // ⭐ From `sync.json`, keyed by the backend this document is being produced FOR.
1509
+ // It was `site.yml::$uuid` — one scalar with no way to say which backend minted it.
1510
+ // A producer with no backend emits no `$uuid`, which is exactly right: the wire
1511
+ // value is that backend's name for this site, and there is no such name yet.
1500
1512
  const entityUuid =
1501
- opts.entityUuid || (typeof siteYml.$uuid === 'string' ? siteYml.$uuid : undefined)
1513
+ opts.entityUuid ||
1514
+ (opts.backend ? readBackendState(siteRoot, opts.backend).site?.uuid : undefined)
1502
1515
  if (entityUuid) doc.$uuid = entityUuid
1503
1516
  doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
1504
1517
  doc.$model = SITE_MODEL_NAME
@@ -1537,9 +1550,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1537
1550
  // ⚖️ Default is to declare, so every existing caller is unchanged and the
1538
1551
  // withholding is opt-in.
1539
1552
  const declare = opts.declareServices !== false
1540
- const services = declare ? servicesNested(siteYml) : undefined
1553
+ // ⭐ Provisioned per backend — a service is bought on the backend that sold it,
1554
+ // so which rows exist is a question about ONE backend. They were `site.yml::$services`
1555
+ // / `$secrets`, a single copy that could only ever describe one.
1556
+ const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
1557
+ const services = declare ? servicesNested(provisioned) : undefined
1541
1558
  if (services) doc.services = services
1542
- const secrets = declare ? secretsNested(siteYml) : undefined
1559
+ const secrets = declare ? secretsNested(provisioned) : undefined
1543
1560
  if (secrets) doc.secrets = secrets
1544
1561
  return doc
1545
1562
  }
@@ -1589,8 +1606,8 @@ export async function emitSiteSyncPackage(siteRoot, opts = {}) {
1589
1606
  * @param {string} uuid - the entity uuid the backend minted/echoed
1590
1607
  * @returns {boolean} true if site.yml changed
1591
1608
  */
1592
- export function writeSiteEntityUuid(siteRoot, uuid) {
1593
- return upsertYamlScalar(join(siteRoot, 'site.yml'), '$uuid', uuid)
1609
+ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1610
+ return updateBackendState(siteRoot, backend, { site: { uuid } })
1594
1611
  }
1595
1612
 
1596
1613
  /**
@@ -1639,45 +1656,12 @@ export function writeSiteEntityUuid(siteRoot, uuid) {
1639
1656
  * @param {string} handle - the bare org handle (no leading `@`)
1640
1657
  * @returns {boolean} true if site.yml changed
1641
1658
  */
1642
- export function writeSiteOrg(siteRoot, handle) {
1643
- return upsertYamlScalar(join(siteRoot, 'site.yml'), '$org', handle)
1659
+ export function writeSiteOrg(siteRoot, backend, handle) {
1660
+ return updateBackendState(siteRoot, backend, { site: { org: handle } })
1644
1661
  }
1645
1662
 
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
- }
1663
+ // ⛔ `writeSiteBackend` and `site.yml::$backend` are GONE (2026-09-20). The scope is
1664
+ // no longer a value recorded beside the uuid — it is the KEY the identity is stored
1665
+ // under (`sync.json::backends.<origin>`), so there is nothing to record and nothing
1666
+ // that can disagree with it. `kb/framework/reference/sync-json.md`.
1667
+
@@ -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 } : {}),
@@ -316,6 +318,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
316
318
  const siteDoc = includeSite
317
319
  ? await siteProjectToDocument(siteRoot, {
318
320
  sourceLocale,
321
+ // Whose $uuid belongs on this wire document — see siteProjectToDocument.
322
+ ...(opts.backend ? { backend: opts.backend } : {}),
319
323
  ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
320
324
  // ⛔ THE SAME ORG `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
321
325
  // must name the Model its records are stored under, and both are qualified