uniweb 0.59.2 → 0.61.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.
@@ -11,11 +11,10 @@
11
11
  * own way.
12
12
  */
13
13
 
14
- import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
14
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
15
15
  import { join, dirname } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
- import { recordSiteBackend } from '../utils/site-identity.js'
19
18
  import { humanBytes } from '../utils/bytes.js'
20
19
  import { isAuthoredPreview } from '../utils/preview.js'
21
20
  import {
@@ -30,7 +29,10 @@ import {
30
29
  collectUnitUuids,
31
30
  collectFolderItemUuids,
32
31
  collectQueryUuids,
33
- readAssetMap,
32
+ readBackendState,
33
+ updateBackendMap,
34
+ clearBackendSections,
35
+ normalizeBackendOrigin,
34
36
  removeYamlScalar
35
37
  } from '@uniweb/build/uwx'
36
38
 
@@ -75,7 +77,7 @@ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
75
77
  const localDoc = entityDocFromUwx(localBuffer)
76
78
  if (!remoteDoc || !localDoc) return []
77
79
  return describeSiteDiff(
78
- diffSiteUnits(localDoc, remoteDoc, readUnitBases(siteDir))
80
+ diffSiteUnits(localDoc, remoteDoc, readUnitBases(siteDir, client.origin))
79
81
  )
80
82
  } catch {
81
83
  return []
@@ -173,36 +175,100 @@ export function makeModelResolver({ client, offline = false }) {
173
175
  }
174
176
  }
175
177
 
176
- // "Send only changed" cache: content hashes from the last successful sync, keyed
177
- // `<model> <id>`. Gitignored, per-clone, deletable (a deleted cache just means one full
178
- // re-sync, which the backend then no-ops). NOT identity — the minted `$uuid` lives in
179
- // the source files; this is a pure wire-efficiency cache.
180
- function syncCachePath(siteDir) {
181
- return join(siteDir, '.uniweb', 'sync-cache.json')
178
+ // `.uniweb/backend-cache.json` — what a backend had and what we last sent it, so the
179
+ // next transfer can skip work. Gitignored, per clone, safe to delete.
180
+ //
181
+ // ⭐ **Everything here is regenerable and losing it never produces a WRONG result** —
182
+ // only a slower transfer or a round trip. That is the membership test; anything
183
+ // failing it belongs in `sync.json` (which is where the uuid maps went on
184
+ // 2026-09-20 — see `readItemUuids` below).
185
+ //
186
+ // ⚠️ It was `sync-cache.json` and its header called it "a pure wire-efficiency
187
+ // cache — NOT identity, the minted `$uuid` lives in the source files". That was
188
+ // true of `hashes`, which it was written for, and the uuid maps were added later
189
+ // under the same sentence without anyone revisiting it. They were backend-minted
190
+ // identity in a cache, and the `queryUuids` docblock one screen below said so.
191
+ //
192
+ // ⛔ **Keyed by backend ORIGIN.** Every map describes ONE backend: hashes are what we
193
+ // last sent *to it*, base versions are the versions *it* had. Shared across backends
194
+ // they report every entity as changed against the second one.
195
+ //
196
+ // Spec: kb/framework/reference/backend-cache-json.md
197
+ function backendCachePath(siteDir) {
198
+ return join(siteDir, '.uniweb', 'backend-cache.json')
182
199
  }
183
- function readSyncCacheFile(siteDir) {
200
+ function readCacheFile(siteDir) {
184
201
  try {
185
- const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
186
- return obj && typeof obj === 'object' ? obj : {}
202
+ const obj = JSON.parse(readFileSync(backendCachePath(siteDir), 'utf8'))
203
+ return obj && typeof obj === 'object' && obj.backends ? obj : { backends: {} }
187
204
  } catch {
188
- return {} // missing / unreadable → treat everything as changed
205
+ return { backends: {} } // missing / unreadable → treat everything as changed
189
206
  }
190
207
  }
208
+ const cacheKeyFor = (backend) => normalizeBackendOrigin(backend) || ''
209
+ function readSyncCacheFile(siteDir, backend) {
210
+ const key = cacheKeyFor(backend)
211
+ if (!key) return {}
212
+ const section = readCacheFile(siteDir).backends[key]
213
+ return section && typeof section === 'object' ? section : {}
214
+ }
191
215
  // The cache holds several maps written on DIFFERENT events — content hashes and the
192
216
  // injections that produced them on a successful push, base versions on push AND
193
217
  // pull, unit hashes on push and pull — so every writer must preserve the ones it
194
218
  // isn't touching. One merge point rather than a hand-rolled preserve per writer,
195
219
  // because getting that wrong silently disarms whichever map got clobbered.
196
- function updateSyncCache(siteDir, patch) {
197
- const p = syncCachePath(siteDir)
220
+ function updateSyncCache(siteDir, backend, patch) {
221
+ const key = cacheKeyFor(backend)
222
+ // ⛔ No backend, no cache. Writing under a blank key would pool every backend's
223
+ // state into one section, which is the bug this keying exists to remove.
224
+ if (!key) return
225
+ const p = backendCachePath(siteDir)
198
226
  mkdirSync(dirname(p), { recursive: true })
199
- const prior = readSyncCacheFile(siteDir)
200
- const out = { version: 1, ...prior, ...patch }
201
- delete out.version
202
- writeFileSync(p, JSON.stringify({ version: 1, ...out }, null, 2) + '\n')
227
+ const file = readCacheFile(siteDir)
228
+ file.backends[key] = { ...(file.backends[key] || {}), ...patch }
229
+ const sorted = {}
230
+ for (const k of Object.keys(file.backends).sort()) sorted[k] = file.backends[k]
231
+ writeFileSync(p, JSON.stringify({ version: 1, backends: sorted }, null, 2) + '\n')
203
232
  }
204
- const readMap = (siteDir, key) => {
205
- const v = readSyncCacheFile(siteDir)[key]
233
+ /**
234
+ * Remove ONE backend's section from `.uniweb/backend-cache.json`. Every other
235
+ * backend's section is untouched — which is the point of keying by origin.
236
+ *
237
+ * @returns {boolean} whether a section was removed
238
+ */
239
+ export function forgetBackendCache(siteDir, backend) {
240
+ const key = cacheKeyFor(backend)
241
+ if (!key) return false
242
+ const file = readCacheFile(siteDir)
243
+ if (!file.backends[key]) return false
244
+ delete file.backends[key]
245
+ const p = backendCachePath(siteDir)
246
+ // The last section gone ⇒ the file goes, as `sync.json` does in the same case.
247
+ if (!Object.keys(file.backends).length) {
248
+ unlinkSync(p)
249
+ return true
250
+ }
251
+ const sorted = {}
252
+ for (const k of Object.keys(file.backends).sort()) sorted[k] = file.backends[k]
253
+ writeFileSync(p, JSON.stringify({ version: 1, backends: sorted }, null, 2) + '\n')
254
+ return true
255
+ }
256
+
257
+ /**
258
+ * Delete `.uniweb/backend-cache.json` outright — `uniweb forget --all`. Every section
259
+ * describes a site the COPY does not own; the file is regenerable by definition.
260
+ *
261
+ * @returns {boolean} whether there was a file to remove
262
+ */
263
+ export function forgetAllBackendCaches(siteDir) {
264
+ const p = backendCachePath(siteDir)
265
+ if (!existsSync(p)) return false
266
+ unlinkSync(p)
267
+ return true
268
+ }
269
+
270
+ const readMap = (siteDir, backend, key) => {
271
+ const v = readSyncCacheFile(siteDir, backend)[key]
206
272
  return v && typeof v === 'object' ? v : {}
207
273
  }
208
274
 
@@ -210,17 +276,17 @@ const readMap = (siteDir, key) => {
210
276
  * Drop every cache map that describes a BACKEND site, when this clone is bound to
211
277
  * none. Returns the names dropped (empty when there was nothing to do).
212
278
  *
213
- * `.uniweb/sync-cache.json` holds five maps keyed by *unit path* — item uuids,
214
- * content hashes, the injections those hashes were taken over, entity base versions,
215
- * unit bases — and a unit path (`site.yml`, `pages/about/about.md`) is the same
216
- * string for every site. So the cache does not
217
- * self-invalidate when `site.yml::$uuid` goes away: it keeps describing the site
279
+ * Five maps are keyed by *unit path* — item uuids (`sync.json`'s `items`) and, in
280
+ * `.uniweb/backend-cache.json`, content hashes, the injections those hashes were
281
+ * taken over, entity base versions and unit bases — and a unit path (`site.yml`,
282
+ * `pages/about/about.md`) is the same string for every site. So nothing in them
283
+ * self-invalidates when the site's uuid goes away: they keep describing the site
218
284
  * this folder used to be.
219
285
  *
220
- * Which is a state we ACTIVELY TELL PEOPLE TO ENTER. The 404 guidance on a
221
- * uuid-bound lane says to clear `$uuid` to re-publish as a new site — the documented
222
- * recovery after a site is deleted in the app. Following it left the stale maps in
223
- * place, and the next publish failed two ways:
286
+ * Which was a state we ACTIVELY TOLD PEOPLE TO ENTER. Until 2026-09-21 the 404
287
+ * guidance on a uuid-bound lane said to clear `$uuid` to re-publish as a new site —
288
+ * the documented recovery after a site is deleted in the app. Following it left the
289
+ * stale maps in place, and the next publish failed two ways:
224
290
  *
225
291
  * - **item uuids** — the new site's document carried the OLD site's item
226
292
  * identities, and the backend correctly refused: *"item uuid … is already
@@ -232,6 +298,9 @@ const readMap = (siteDir, key) => {
232
298
  * not change** — a partial site, published successfully, with nothing to
233
299
  * indicate it.
234
300
  *
301
+ * The guidance is `uniweb forget --backend <url>` now, which removes the uuid and the
302
+ * maps together, so following it no longer leads here.
303
+ *
235
304
  * Call this BEFORE `ensureSiteExists`, which mints a uuid and would otherwise make
236
305
  * the clone look bound before the check runs.
237
306
  */
@@ -241,21 +310,12 @@ const readMap = (siteDir, key) => {
241
310
  * guard and the `item_uuid_conflict` recovery — the guard decides WHETHER, this
242
311
  * decides WHAT, and they must not drift.
243
312
  */
244
- export function clearRemoteSyncState(siteDir, siteUuid = null) {
245
- const prior = readSyncCacheFile(siteDir)
246
- const dropped = [
247
- 'itemUuids',
248
- 'queryUuids',
249
- 'hashes',
250
- 'baseVersions',
251
- 'unitBases',
252
- 'applied'
253
- ].filter((k) => prior[k] && Object.keys(prior[k]).length)
254
- updateSyncCache(siteDir, {
255
- itemUuids: {},
256
- // Remote-derived exactly like itemUuids — it holds the OLD site's collection
257
- // ids, and surviving the drop it would offer them for the new site's sections.
258
- queryUuids: {},
313
+ export function clearRemoteSyncState(siteDir, backend, siteUuid = null) {
314
+ const prior = readSyncCacheFile(siteDir, backend)
315
+ const dropped = ['hashes', 'baseVersions', 'unitBases', 'applied'].filter(
316
+ (k) => prior[k] && Object.keys(prior[k]).length
317
+ )
318
+ updateSyncCache(siteDir, backend, {
259
319
  hashes: {},
260
320
  baseVersions: {},
261
321
  unitBases: {},
@@ -265,19 +325,40 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
265
325
  applied: {},
266
326
  siteUuid: siteUuid || null
267
327
  })
268
- return dropped
328
+ // ⛔ THE IDENTITY MAPS ARE CLEARED WITH THE REST, and must be. They live in
329
+ // `sync.json` now rather than the cache, but they describe the same dead site:
330
+ // leaving them behind is what makes the backend refuse — "item uuid … is already
331
+ // stored on entity N; cross-entity move is not supported" — on the very next push.
332
+ // Two files, one invalidation.
333
+ const identity = clearBackendSections(siteDir, backend, [
334
+ 'items',
335
+ 'queries',
336
+ 'folders'
337
+ ])
338
+ // Reported under the names the cache used, so the message a user sees is the same
339
+ // one it always was.
340
+ const NAMED = { items: 'itemUuids', queries: 'queryUuids', folders: 'folderItemUuids' }
341
+ return [...dropped, ...identity.map((k) => NAMED[k])]
269
342
  }
270
343
 
271
- export function clearRemoteSyncStateIfUnbound(siteDir) {
272
- let current = null
273
- try {
274
- const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
275
- if (typeof y?.$uuid === 'string') current = y.$uuid
276
- } catch {
277
- /* unreadable site.yml — treat as unbound and clear, which is the safe side */
278
- }
344
+ export function clearRemoteSyncStateIfUnbound(siteDir, backend) {
345
+ // ⭐ "Bound" is a question about THIS backend: the identity is `sync.json`'s, keyed
346
+ // by origin. A project bound on one backend and not another is an ordinary state
347
+ // now, not a contradiction — which is why the stamp below still earns its keep even
348
+ // though cross-backend confusion became unrepresentable. What it still catches is a
349
+ // site DELETED on this backend and re-created: same origin, new uuid.
350
+ const current = readBackendState(siteDir, backend).site?.uuid || null
279
351
 
280
- const prior = readSyncCacheFile(siteDir)
352
+ const prior = readSyncCacheFile(siteDir, backend)
353
+ // ⛔ TWO FILES, ONE QUESTION. The cache holds the speed-ups and `sync.json` holds
354
+ // the identity maps, but "is any of this describing a site this clone is not
355
+ // working with?" is asked of BOTH — a clone whose only stale state is identity is
356
+ // exactly the one the backend refuses on the next push.
357
+ const identityPrior = {
358
+ itemUuids: readItemUuids(siteDir, backend),
359
+ queryUuids: readQueryUuids(siteDir, backend),
360
+ folderItemUuids: readFolderItemUuids(siteDir, backend)
361
+ }
281
362
  const REMOTE_MAPS = [
282
363
  'itemUuids',
283
364
  'hashes',
@@ -285,26 +366,27 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
285
366
  'unitBases',
286
367
  'applied'
287
368
  ]
369
+ const lookup = { ...prior, ...identityPrior }
288
370
  const populated = REMOTE_MAPS.filter(
289
- (k) => prior[k] && Object.keys(prior[k]).length
371
+ (k) => lookup[k] && Object.keys(lookup[k]).length
290
372
  )
291
373
  if (!populated.length) {
292
374
  // Nothing to invalidate, but still stamp identity so a LATER divergence is
293
375
  // detectable. A cache that never records its site can only be checked by the
294
376
  // unbound rule, which misses the bound-but-wrong case below.
295
377
  if (current && prior.siteUuid !== current) {
296
- updateSyncCache(siteDir, { siteUuid: current })
378
+ updateSyncCache(siteDir, backend, { siteUuid: current })
297
379
  }
298
380
  return []
299
381
  }
300
382
 
301
- // Two ways the cache can describe a site this clone is not working with:
383
+ // Two ways the maps can describe a site this clone is not working with:
302
384
  //
303
- // 1. UNBOUND — no `$uuid` at all, so there is no backend site for any of it to
304
- // be about. This is the state the documented "clear `$uuid` to re-publish as
305
- // a new site" recovery puts you in.
306
- // 2. BOUND TO A DIFFERENT SITE — `$uuid` names one site and the cache was
307
- // written for another. Reachable in one step: the create mints a uuid and
385
+ // 1. UNBOUND — no site uuid for this backend, so there is no backend site for any
386
+ // of it to be about. This was the state the old "clear `$uuid` to re-publish
387
+ // as a new site" recovery put you in; `uniweb forget` clears the maps with it.
388
+ // 2. BOUND TO A DIFFERENT SITE — this backend's site uuid names one site and the
389
+ // cache was written for another. Reachable in one step: the create mints a uuid and
308
390
  // writes it BEFORE the push, so a push that then fails leaves exactly this.
309
391
  // Without the identity stamp it is invisible, and every later publish fails
310
392
  // the same way with no path out but deleting `.uniweb/` by hand.
@@ -316,12 +398,12 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
316
398
  !current || (prior.siteUuid && prior.siteUuid !== current) ? populated : []
317
399
  if (!stale.length) {
318
400
  if (current && prior.siteUuid !== current) {
319
- updateSyncCache(siteDir, { siteUuid: current })
401
+ updateSyncCache(siteDir, backend, { siteUuid: current })
320
402
  }
321
403
  return []
322
404
  }
323
405
 
324
- clearRemoteSyncState(siteDir, current)
406
+ clearRemoteSyncState(siteDir, backend, current)
325
407
  return stale
326
408
  }
327
409
 
@@ -345,7 +427,7 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
345
427
  * @param {string} siteDir
346
428
  * @returns {string[]} the keys removed
347
429
  */
348
- export function dropSiteBoundValues(siteDir) {
430
+ export function dropSiteBoundValues(siteDir, backend) {
349
431
  const file = join(siteDir, 'site.yml')
350
432
  let y
351
433
  try {
@@ -353,7 +435,12 @@ export function dropSiteBoundValues(siteDir) {
353
435
  } catch {
354
436
  return []
355
437
  }
356
- if (!y || typeof y !== 'object' || typeof y.$uuid === 'string') return []
438
+ // ⛔ "Bound" is a question about THIS backend, answered by sync.json. This read
439
+ // `site.yml::$uuid` until 2026-09-20 and kept doing so after step 4 moved the key —
440
+ // so every project read as unbound and every push stripped a bound site's
441
+ // generated preview. A write, from a stale read, with nothing to notice it.
442
+ if (!y || typeof y !== 'object') return []
443
+ if (readBackendState(siteDir, backend).site?.uuid) return []
357
444
  const dropped = []
358
445
  if (
359
446
  y.preview !== undefined &&
@@ -365,8 +452,8 @@ export function dropSiteBoundValues(siteDir) {
365
452
  return dropped
366
453
  }
367
454
 
368
- export function readSyncCache(siteDir) {
369
- return readMap(siteDir, 'hashes')
455
+ export function readSyncCache(siteDir, backend) {
456
+ return readMap(siteDir, backend, 'hashes')
370
457
  }
371
458
 
372
459
  /**
@@ -374,10 +461,11 @@ export function readSyncCache(siteDir) {
374
461
  * exact shape `emitSyncPackages` takes back as opts.
375
462
  *
376
463
  * ⛔ **Only the ones nothing else records.** `assetIds` is deliberately NOT banked
377
- * here even though the emit applies it: `assets.json` is COMMITTED project state
378
- * holding exactly that map (local ref → `{id, ext}`), written by the same push, and
379
- * a gitignored second copy would be a second thing to disagree — the reason that
380
- * file itself refuses to hold a serve URL. It also has the worse lifetime of the
464
+ * here even though the emit applies it: this backend's `assets` in `sync.json` is
465
+ * COMMITTED project state holding exactly that map (local ref → `{id, ext}`), written
466
+ * by the same push, and a gitignored second copy would be a second thing to
467
+ * disagree — the reason that map itself holds a fingerprint of a serve URL and never
468
+ * the URL. It also has the worse lifetime of the
381
469
  * two: `clearRemoteSyncState` wipes this cache, and the committed map correctly
382
470
  * survives. ⇒ Bank what a reader cannot re-derive; re-derive the rest.
383
471
  *
@@ -395,11 +483,11 @@ export function readSyncCache(siteDir) {
395
483
  * zero HTTP requests, a property the cross-client flows rely on). ⚠️ Note the serve
396
484
  * URL is REPLAYED, never composed: we re-use the string the host handed us, which is
397
485
  * a different act from reconstructing one, and the distinction is the same one that
398
- * keeps `assets.json` id-only. An asset genuinely new to the site has no recorded
486
+ * keeps the asset map in `sync.json` to ids and a fingerprint. An asset genuinely new to the site has no recorded
399
487
  * mapping and still reads as changed, which is correct.
400
488
  */
401
- export function readAppliedInjections(siteDir) {
402
- return readMap(siteDir, 'applied')
489
+ export function readAppliedInjections(siteDir, backend) {
490
+ return readMap(siteDir, backend, 'applied')
403
491
  }
404
492
 
405
493
  /**
@@ -410,11 +498,12 @@ export function readAppliedInjections(siteDir) {
410
498
  * leftover from an earlier push describing hashes it no longer matches.
411
499
  *
412
500
  * `assetIds` is dropped rather than stored — see readAppliedInjections: it has a
413
- * committed source of truth in `assets.json`, and the reader re-derives it there.
501
+ * committed source of truth in `sync.json` (this backend's `assets`), and the reader
502
+ * re-derives it there.
414
503
  */
415
- export function writeSyncCache(siteDir, hashes, applied) {
416
- const { assetIds: _inAssetsJson, ...bankable } = applied || {}
417
- updateSyncCache(siteDir, { hashes, applied: bankable })
504
+ export function writeSyncCache(siteDir, backend, hashes, applied) {
505
+ const { assetIds: _inSyncJson, ...bankable } = applied || {}
506
+ updateSyncCache(siteDir, backend, { hashes, applied: bankable })
418
507
  }
419
508
 
420
509
  /**
@@ -431,8 +520,8 @@ export function writeSyncCache(siteDir, hashes, applied) {
431
520
  * post-write `finalized[].version` the backend returns. Without the push half a
432
521
  * second consecutive push would be stale by construction and refused.
433
522
  */
434
- export function readBaseVersions(siteDir) {
435
- return readMap(siteDir, 'baseVersions')
523
+ export function readBaseVersions(siteDir, backend) {
524
+ return readMap(siteDir, backend, 'baseVersions')
436
525
  }
437
526
 
438
527
  /**
@@ -447,19 +536,19 @@ export function readBaseVersions(siteDir) {
447
536
  * reports tokens for a subset of the site. Replacing would drop the tokens of every
448
537
  * record that wasn't in this package and silently degrade those to ungated.
449
538
  */
450
- export function readItemBaseVersions(siteDir) {
451
- return readMap(siteDir, 'itemBaseVersions')
539
+ export function readItemBaseVersions(siteDir, backend) {
540
+ return readMap(siteDir, backend, 'itemBaseVersions')
452
541
  }
453
- export function mergeItemBaseVersions(siteDir, versions) {
542
+ export function mergeItemBaseVersions(siteDir, backend, versions) {
454
543
  if (!versions || !Object.keys(versions).length) return
455
- updateSyncCache(siteDir, {
544
+ updateSyncCache(siteDir, backend, {
456
545
  itemBaseVersions: { ...readItemBaseVersions(siteDir), ...versions }
457
546
  })
458
547
  }
459
- export function mergeBaseVersions(siteDir, versions) {
548
+ export function mergeBaseVersions(siteDir, backend, versions) {
460
549
  if (!versions || !Object.keys(versions).length) return
461
- updateSyncCache(siteDir, {
462
- baseVersions: { ...readBaseVersions(siteDir), ...versions }
550
+ updateSyncCache(siteDir, backend, {
551
+ baseVersions: { ...readBaseVersions(siteDir, backend), ...versions }
463
552
  })
464
553
  }
465
554
 
@@ -503,8 +592,8 @@ export function mergeBaseVersions(siteDir, versions) {
503
592
  * refuses an all-blank `multi` section, so a producer that skips that step fails
504
593
  * loudly instead of quietly rebuilding the site's identity.
505
594
  */
506
- export function readItemUuids(siteDir) {
507
- return readMap(siteDir, 'itemUuids')
595
+ export function readItemUuids(siteDir, backend) {
596
+ return readBackendState(siteDir, backend).items || {}
508
597
  }
509
598
  /**
510
599
  * Placement identity for the site's `@uniweb/folder` — path chain → `$uuid`.
@@ -513,8 +602,8 @@ export function readItemUuids(siteDir) {
513
602
  * different trees: `pages/…/page.yml` there, `members/alice` here. One map with
514
603
  * two key languages is a map nobody can validate.
515
604
  */
516
- export function readFolderItemUuids(siteDir) {
517
- return readMap(siteDir, 'folderItemUuids')
605
+ export function readFolderItemUuids(siteDir, backend) {
606
+ return readBackendState(siteDir, backend).folders || {}
518
607
  }
519
608
 
520
609
  /**
@@ -535,45 +624,45 @@ export function readFolderItemUuids(siteDir) {
535
624
  * its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
536
625
  * handle the backend skips on parse and never stores.
537
626
  */
538
- export function readQueryUuids(siteDir) {
539
- return readMap(siteDir, 'queryUuids')
627
+ export function readQueryUuids(siteDir, backend) {
628
+ return readBackendState(siteDir, backend).queries || {}
540
629
  }
541
- export function writeQueryUuids(siteDir, map) {
630
+ // ⭐ THE THREE UUID MAPS LIVE IN `sync.json`, NOT THE CACHE (2026-09-20). They are
631
+ // backend-minted identity: without them a push re-sends a section uuid-less and the
632
+ // backend refuses an all-blank section over stored rows rather than replacing every
633
+ // one. A refusal is the good case; the bad one is duplication. Losing them costs a
634
+ // round trip, not a slow push — which is the line between the two files.
635
+ export function writeQueryUuids(siteDir, backend, map) {
542
636
  if (!map || !Object.keys(map).length) return
543
- updateSyncCache(siteDir, { queryUuids: map })
637
+ updateBackendMap(siteDir, backend, 'queries', map)
544
638
  }
545
- export function writeFolderItemUuids(siteDir, map) {
639
+ export function writeFolderItemUuids(siteDir, backend, map) {
546
640
  if (!map || !Object.keys(map).length) return
547
- updateSyncCache(siteDir, { folderItemUuids: map })
641
+ updateBackendMap(siteDir, backend, 'folders', map)
548
642
  }
549
- export function writeItemUuids(siteDir, map) {
643
+ export function writeItemUuids(siteDir, backend, map) {
550
644
  if (!map || !Object.keys(map).length) return
551
- updateSyncCache(siteDir, { itemUuids: map })
645
+ updateBackendMap(siteDir, backend, 'items', map)
552
646
  }
553
647
 
554
648
  /**
555
649
  * The org this site was created under, as `@handle`, or null.
556
650
  *
557
- * Read back from `site.yml::$org` (stored bare — see `writeSiteOrg`) and re-dressed
558
- * with the `@` the CLI and the wire both use. Callers pass it as `--as-org`'s default
559
- * so an org named once, at create, does not have to be re-typed on every later push.
651
+ * Read back from `backend`'s `site.org` in `sync.json` (stored bare — see
652
+ * `writeSiteOrg`) and re-dressed with the `@` the CLI and the wire both use. Callers
653
+ * pass it as `--as-org`'s default so an org named once, at create, does not have to
654
+ * be re-typed on every later push.
560
655
  *
561
656
  * Deliberately NOT a fallback for the flag: an explicit `--as-org` always wins and
562
657
  * rides verbatim, so this can only add a value where the CLI previously sent none.
563
658
  *
564
659
  * @param {string} siteDir
660
+ * @param {string} backend - the origin whose record to read
565
661
  * @returns {string|null}
566
662
  */
567
- export function readSiteOrg(siteDir) {
568
- try {
569
- const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
570
- const h = y && typeof y === 'object' ? y.$org : null
571
- return typeof h === 'string' && h.trim()
572
- ? `@${h.trim().replace(/^@/, '')}`
573
- : null
574
- } catch {
575
- return null
576
- }
663
+ export function readSiteOrg(siteDir, backend) {
664
+ const org = readBackendState(siteDir, backend).site?.org
665
+ return typeof org === 'string' && org ? `@${org.replace(/^@/, '')}` : null
577
666
  }
578
667
 
579
668
  /**
@@ -588,14 +677,14 @@ export function readSiteOrg(siteDir) {
588
677
  * @param {string|null|undefined} asOrg - the `--as-org` value, `@handle` or bare
589
678
  * @returns {string|null}
590
679
  */
591
- function recordSiteOrg(siteDir, asOrg) {
680
+ function recordSiteOrg(siteDir, backend, asOrg) {
592
681
  const handle = String(asOrg || '')
593
682
  .replace(/^@/, '')
594
683
  .replace(/\/.*$/, '')
595
684
  .trim()
596
685
  if (!handle) return null
597
686
  try {
598
- writeSiteOrg(siteDir, handle)
687
+ writeSiteOrg(siteDir, backend, handle)
599
688
  return `@${handle}`
600
689
  } catch {
601
690
  // The uuid is the load-bearing back-fill; losing the org note must never
@@ -615,15 +704,20 @@ function recordSiteOrg(siteDir, asOrg) {
615
704
  * to several orgs could put a company site, and the storage it bills, somewhere
616
705
  * they never named.
617
706
  *
618
- * `register` has refused to guess a scope for the foundation lane since it shipped
619
- * (`deriveScope` → `package.json::uniweb.scope`). This is the same refusal, one
620
- * lane over.
707
+ * `register` resolves a foundation's scope in the same order — explicit, then
708
+ * recorded (`package.json::uniweb.scope`), then asked (`deriveScope`) — with one
709
+ * difference: non-interactive, it PICKS the one org you belong to, else your
710
+ * personal org, else the primary, and says which. This refuses instead, because a
711
+ * site's owner is the one-shot decision above. ⚠️ Until 2026-09-21 this said
712
+ * `register` "has refused to guess a scope", which is wrong for a non-interactive
713
+ * run by anyone with an org.
621
714
  *
622
715
  * Order, and only the last step is new:
623
716
  * 1. `--as-org @org` — explicit, rides verbatim
624
717
  * 2. `--personal` — explicit "no org, I mean it" → sends NO `as_org`
625
- * 3. `site.yml::$org` — recorded at this site's own create
626
- * 4. the site already exists (`$uuid`) → null; ownership is settled, ask nothing
718
+ * 3. the recorded org — `sync.json`, written at this site's own create
719
+ * 4. the site already exists on this backend (a recorded uuid) → null; ownership
720
+ * is settled, ask nothing
627
721
  * 5. otherwise ASK (TTY) or REFUSE (non-interactive)
628
722
  *
629
723
  * ⛔ **`--personal` sends no `as_org`, and is NOT the same as `--as-org @<handle>`.**
@@ -648,19 +742,13 @@ export async function resolveSiteOrgForCreate({
648
742
  if (flag) return { asOrg: flag }
649
743
  if (personal) return { asOrg: null }
650
744
 
651
- const recorded = readSiteOrg(siteDir)
745
+ const recorded = readSiteOrg(siteDir, client?.origin)
652
746
  if (recorded) return { asOrg: recorded }
653
747
 
654
- // Already created ⇒ nothing to decide. This is what keeps every existing site
655
- // silent: ownership was settled at its create, and re-asking would be theatre.
656
- let siteYml = {}
657
- try {
658
- const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
659
- if (y && typeof y === 'object') siteYml = y
660
- } catch {
661
- /* unreadable — treat as un-created and let the resolution below decide */
662
- }
663
- if (typeof siteYml.$uuid === 'string') return { asOrg: null }
748
+ // Already created ⇒ nothing to decide: ownership was settled at its create and
749
+ // re-asking would be theatre. ⭐ "Created" is a question about THIS backend now —
750
+ // the same project may be brand new on one and long-established on another.
751
+ if (readBackendState(siteDir, client?.origin).site?.uuid) return { asOrg: null }
664
752
 
665
753
  // An offline preview (`--dry-run` / `-o`) must never authenticate, and it is
666
754
  // creating nothing, so there is no decision to force. Say what is unresolved
@@ -825,8 +913,8 @@ function describeCreateRefusal(body) {
825
913
  * keep and can be cleared. (The reverse ordering was justified by a claim that
826
914
  * `ensureItemUuids` mints on the backend, which it does not — see push.js.)
827
915
  *
828
- * A no-op when `site.yml::$uuid` is already set, so only the first publish of a
829
- * site pays for it. The uuid is written back immediately, keeping the window in
916
+ * A no-op when the site already has a uuid on this backend (sync.json), so only the
917
+ * first publish of a site pays for it. The uuid is written back immediately, keeping the window in
830
918
  * which a crash could strand a site as small as one file write.
831
919
  *
832
920
  * @returns {Promise<{ uuid: string|null, created: boolean, reason?: string }>}
@@ -852,8 +940,9 @@ export async function ensureSiteExists({
852
940
  } catch {
853
941
  /* unreadable site.yml — treat as un-synced and let the create decide */
854
942
  }
855
- if (typeof siteYml.$uuid === 'string') {
856
- return { uuid: siteYml.$uuid, created: false, org: readSiteOrg(siteDir) }
943
+ const known = readBackendState(siteDir, client.origin).site?.uuid
944
+ if (known) {
945
+ return { uuid: known, created: false, org: readSiteOrg(siteDir, client.origin) }
857
946
  }
858
947
 
859
948
  // Both are required by the create. Catching it here turns a 400 into a sentence
@@ -905,11 +994,11 @@ export async function ensureSiteExists({
905
994
  reason: 'the create returned no site uuid'
906
995
  }
907
996
  }
908
- writeSiteEntityUuid(siteDir, minted)
997
+ writeSiteEntityUuid(siteDir, client.origin, minted)
909
998
  // Stamp the cache with the site it now describes, so a push that fails after
910
999
  // this point cannot leave a cache pointing at a different site with no way to
911
1000
  // detect it.
912
- updateSyncCache(siteDir, { siteUuid: minted })
1001
+ updateSyncCache(siteDir, client.origin, { siteUuid: minted })
913
1002
  const org = await recordAndDescribeOwner({
914
1003
  client,
915
1004
  siteDir,
@@ -943,23 +1032,16 @@ async function recordAndDescribeOwner({
943
1032
  const echoed = payload && 'org' in payload ? payload.org : undefined
944
1033
  const owner =
945
1034
  echoed === undefined ? asOrg : typeof echoed === 'string' ? echoed : null
946
- const org = recordSiteOrg(siteDir, owner)
947
-
948
- // The SYNC SCOPE, recorded here for the same reason `$org` is: this function is the
949
- // one place BOTH create paths meet (`ensureSiteExists` for a site with local media,
950
- // the content-lane create for one without). Recording it at either call site instead
951
- // would make `$backend` present or absent depending on whether the site happens to
952
- // have images — the exact drift the comment at the second call site warns about.
953
- //
954
- // A no-op on the default backend, so the common case writes nothing.
955
- const scope = await recordSiteBackend(siteDir, client.origin)
956
- if (scope)
957
- note?.(`Bound this project to ${scope} (recorded $backend in site.yml).`)
1035
+ const org = recordSiteOrg(siteDir, client.origin, owner)
958
1036
 
1037
+ // ⛔ No sync scope to record. It WAS `site.yml::$backend`, written here because this
1038
+ // function is the one place both create paths meet. The scope is now the KEY the
1039
+ // identity is stored under (`sync.json::backends.<origin>`), so recording it
1040
+ // separately would be recording the name of the drawer inside the drawer.
959
1041
  note?.(
960
1042
  org
961
- ? `Created the site on the backend under ${org} (recorded $uuid + $org in site.yml).`
962
- : `Created the site on the backend, owned personally (recorded $uuid in site.yml).`
1043
+ ? `Created the site on ${client.origin} under ${org} — recorded in sync.json.`
1044
+ : `Created the site on ${client.origin}, owned personally — recorded in sync.json.`
963
1045
  )
964
1046
 
965
1047
  // What the create echoed about this site's OWNER, and nothing beyond it.
@@ -992,25 +1074,23 @@ async function recordAndDescribeOwner({
992
1074
  /**
993
1075
  * Guarantee we have per-item identity before an identity-bearing push.
994
1076
  *
995
- * Fires only when the site HAS been pushed before (`$uuid` in site.yml) but the
996
- * cache is empty — a fresh `git clone`, or a deleted `.uniweb/`. One read of the
997
- * backend's current document, no file writes, no `uniweb pull`. A first-ever push
998
- * legitimately has nothing to fetch and is left alone.
1077
+ * Fires only when the site HAS been pushed to this backend before (its uuid is in
1078
+ * `sync.json`) but that backend's item map there is empty. One read of the
1079
+ * backend's current document, recorded into that map — no content file is written,
1080
+ * and no `uniweb pull`. A first-ever push legitimately has nothing to fetch and is
1081
+ * left alone. (The map sat in the gitignored cache until 2026-09-20, so a fresh
1082
+ * `git clone` or a deleted `.uniweb/` was what emptied it; it is committed now.)
999
1083
  *
1000
1084
  * @returns {Promise<Object<string,string>>} the map (possibly empty)
1001
1085
  */
1002
1086
  export async function ensureItemUuids({ client, siteDir, note }) {
1003
- const cached = readItemUuids(siteDir)
1087
+ const cached = readItemUuids(siteDir, client.origin)
1004
1088
  if (Object.keys(cached).length) return cached
1005
1089
  // A site that has never been pushed has no identity to recover — and nothing to
1006
1090
  // lose, since every item is genuinely new.
1007
- let siteContentUuid = null
1008
- try {
1009
- const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
1010
- siteContentUuid = typeof y?.$uuid === 'string' ? y.$uuid : null
1011
- } catch {
1012
- /* unreadable site.yml — nothing to recover against */
1013
- }
1091
+ // The site to recover identity against — this backend's, from sync.json. It read
1092
+ // `site.yml::$uuid`, so after step 4 it found nothing and never recovered.
1093
+ const siteContentUuid = readBackendState(siteDir, client.origin).site?.uuid || null
1014
1094
  if (!siteContentUuid) return cached
1015
1095
  try {
1016
1096
  const res = await client.pullSiteContent(siteContentUuid)
@@ -1019,7 +1099,7 @@ export async function ensureItemUuids({ client, siteDir, note }) {
1019
1099
  if (!doc) return cached
1020
1100
  const harvested = collectUnitUuids(doc)
1021
1101
  if (Object.keys(harvested).length) {
1022
- writeItemUuids(siteDir, harvested)
1102
+ writeItemUuids(siteDir, client.origin, harvested)
1023
1103
  note?.(
1024
1104
  `Recovered identity for ${Object.keys(harvested).length} item(s) from the backend.`
1025
1105
  )
@@ -1032,14 +1112,14 @@ export async function ensureItemUuids({ client, siteDir, note }) {
1032
1112
  }
1033
1113
  }
1034
1114
 
1035
- export function readUnitBases(siteDir) {
1036
- const v = readMap(siteDir, 'unitBases')
1115
+ export function readUnitBases(siteDir, backend) {
1116
+ const v = readMap(siteDir, backend, 'unitBases')
1037
1117
  return { local: v.local || {}, remote: v.remote || {} }
1038
1118
  }
1039
- export function writeUnitBases(siteDir, patch) {
1119
+ export function writeUnitBases(siteDir, backend, patch) {
1040
1120
  if (!patch) return
1041
- updateSyncCache(siteDir, {
1042
- unitBases: { ...readUnitBases(siteDir), ...patch }
1121
+ updateSyncCache(siteDir, backend, {
1122
+ unitBases: { ...readUnitBases(siteDir, backend), ...patch }
1043
1123
  })
1044
1124
  }
1045
1125
 
@@ -1055,20 +1135,19 @@ export function writeUnitBases(siteDir, patch) {
1055
1135
  * @param {string} siteDir
1056
1136
  * @returns {Promise<{ changed: number, unchanged: number, warnings: string[] }>}
1057
1137
  */
1058
- export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
1059
- const priorHashes = readSyncCache(siteDir)
1138
+ export async function probeUnpushed(siteDir, { backend = null, sendAll = false } = {}) {
1139
+ const priorHashes = readSyncCache(siteDir, backend)
1060
1140
  // Re-emit the document the last push HASHED, not the one the author wrote — see
1061
1141
  // readAppliedInjections. Two sources, on purpose:
1062
1142
  // · BANKED — the serve URLs and pinned refs only a round-trip produces. Empty
1063
1143
  // for a cache written before this was banked (and for a never-pushed site),
1064
1144
  // which is the pre-fix behaviour and self-heals on the next push. It can never
1065
1145
  // point at the wrong document: it is written with the hashes it belongs to.
1066
- // · RE-DERIVED — asset identity, from the COMMITTED `assets.json` the same push
1067
- // wrote. Reading the live file rather than a snapshot is what makes a moved
1068
- // map (a teammate's push, a pull) read as changed instead of matching a copy
1069
- // of itself.
1070
- // · RECORDED — the site's own org, from `site.yml::$org`, written by the push
1071
- // that banked these hashes.
1146
+ // · RE-DERIVED — asset identity, from this backend's COMMITTED asset map in
1147
+ // `sync.json`, which the same push wrote. Reading the live file rather than a
1148
+ // snapshot is what makes a moved map (a teammate's push, a pull) read as
1149
+ // changed instead of matching a copy of itself.
1150
+ // · RECORDED — the site's own org, from `sync.json` (this backend's `site.org`).
1072
1151
  //
1073
1152
  // ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
1074
1153
  // a foundation-relative `@/member` into the `@org/member` the push shipped and
@@ -1083,7 +1162,7 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
1083
1162
  // and others never settling, which reads like a content problem rather than a
1084
1163
  // resolution one. Measured on matinee 2026-08-29: `status` reported 4 changed
1085
1164
  // immediately after a successful push; passing the org took it to 1.
1086
- const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
1165
+ const pkg = await comparisonEmit(siteDir, { backend, priorHashes, sendAll })
1087
1166
  const changed =
1088
1167
  (pkg.siteContent?.entityCount || 0) + (pkg.records?.entityCount || 0)
1089
1168
  return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
@@ -1098,25 +1177,34 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
1098
1177
  *
1099
1178
  * · BANKED the injections the push applied before hashing — serve URLs and
1100
1179
  * the pinned foundation ref, which only a round trip produces.
1101
- * · RE-DERIVED asset identity, from the COMMITTED `assets.json`, so a moved map
1102
- * reads as changed rather than matching a copy of itself.
1180
+ * · RE-DERIVED asset identity, from this backend's COMMITTED asset map in
1181
+ * `sync.json`, so a moved map reads as changed rather than matching
1182
+ * a copy of itself.
1103
1183
  * · RECORDED the site's org, which resolves a foundation-relative `@/x` into
1104
1184
  * the `@org/x` the push keyed its hashes by, and the collection
1105
- * identity a push stamps — so `status` hashes the same document a
1106
- * push would send rather than one missing a section's `$uuid`s.
1185
+ * identity a push stamps, so the document is the one a push builds.
1186
+ * ⚠️ Identity is parity, not correctness: `entityContentHash` strips
1187
+ * `$`-sigils, so a query's `$uuid` never moves a hash (measured
1188
+ * 2026-09-21). This read no backend from 2026-09-20 to 2026-09-21 —
1189
+ * an empty map — which is why no `status` ever showed it, and why this
1190
+ * bullet was then misread as naming a live defect.
1107
1191
  *
1108
1192
  * Offline by design — measured at zero HTTP requests, a property the cross-client
1109
1193
  * flows rely on.
1110
1194
  */
1111
1195
  async function comparisonEmit(
1112
1196
  siteDir,
1113
- { priorHashes = {}, sendAll = false } = {}
1197
+ { backend = null, priorHashes = {}, sendAll = false } = {}
1114
1198
  ) {
1115
- const applied = readAppliedInjections(siteDir)
1116
- const assetIds = readAssetMap(siteDir)
1117
- const org = readSiteOrg(siteDir)
1118
- const queryUuids = readQueryUuids(siteDir)
1199
+ const applied = readAppliedInjections(siteDir, backend)
1200
+ // ⛔ Per backend: an asset id is minted by one and means nothing to another, so a
1201
+ // comparison against the wrong one reports every media ref as changed. Absent is
1202
+ // honest rather than a default — no backend, no known ids.
1203
+ const assetIds = backend ? readBackendState(siteDir, backend).assets || {} : {}
1204
+ const org = readSiteOrg(siteDir, backend)
1205
+ const queryUuids = readQueryUuids(siteDir, backend)
1119
1206
  return emitSyncPackages(siteDir, {
1207
+ backend,
1120
1208
  resolveModel: makeModelResolver({ client: null, offline: true }),
1121
1209
  priorHashes,
1122
1210
  sendAll,
@@ -1148,9 +1236,9 @@ async function comparisonEmit(
1148
1236
  * on-disk state IS the agreed state: it came from the backend. A push with no edits
1149
1237
  * in between should send nothing, and clearing would make it send everything.
1150
1238
  */
1151
- export async function rebankSyncHashes(siteDir) {
1152
- const pkg = await comparisonEmit(siteDir, { sendAll: true })
1153
- writeSyncCache(siteDir, pkg.hashes || {}, pkg.applied || {})
1239
+ export async function rebankSyncHashes(siteDir, backend = null) {
1240
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true })
1241
+ writeSyncCache(siteDir, backend, pkg.hashes || {}, pkg.applied || {})
1154
1242
  return Object.keys(pkg.hashes || {}).length
1155
1243
  }
1156
1244
 
@@ -1204,7 +1292,7 @@ export async function pushSyncPackages({
1204
1292
  res = await doRequest()
1205
1293
  } catch (err) {
1206
1294
  error(`Could not reach the backend at ${client.origin}: ${err.message}`)
1207
- note('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
1295
+ note('Is that the backend you meant? Switch with: uniweb login --backend <url>')
1208
1296
  return null
1209
1297
  }
1210
1298
  if (!res.ok) {
@@ -1231,7 +1319,7 @@ export async function pushSyncPackages({
1231
1319
  // an unstamped legacy cache on a bound site, which the guard deliberately
1232
1320
  // leaves alone rather than wiping every existing clone. This is the exit.
1233
1321
  if (problem?.reason === 'item_uuid_conflict') {
1234
- const dropped = clearRemoteSyncState(siteDir, boundUuid)
1322
+ const dropped = clearRemoteSyncState(siteDir, client.origin, boundUuid)
1235
1323
  error(
1236
1324
  `${label} push refused — this copy's sync cache is describing a different site.`
1237
1325
  )
@@ -1344,7 +1432,7 @@ export async function pushSyncPackages({
1344
1432
  error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
1345
1433
  if (res.status === 401 || res.status === 403) {
1346
1434
  note(
1347
- "Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN)."
1435
+ "Credentials weren't accepted — log in again (`uniweb login --backend <url>`), or check UNIWEB_TOKEN."
1348
1436
  )
1349
1437
  } else if (res.status === 404 && boundUuid) {
1350
1438
  // The clone is bound to a site the backend does not have. There is no CLI
@@ -1356,10 +1444,17 @@ export async function pushSyncPackages({
1356
1444
  // this branch named only the deletion and went straight to "clear `$uuid`" — and
1357
1445
  // that advice, followed for the OTHER cause, destroys a live binding: the site is
1358
1446
  // fine, you are simply pointed at the wrong backend, and clearing the uuid orphans
1359
- // it. `assertSiteBackendScope` now catches most of that before any request goes
1360
- // out, so reaching here usually does mean a deletion; "usually" is not "always"
1361
- // (a project predating `$backend` records no scope to check), which is why the
1362
- // cheap cause is still named before the destructive fix.
1447
+ // it. The uuid sent here is the one this project recorded for THIS origin
1448
+ // (sync.json), so another backend's uuid cannot reach it; what does is a site
1449
+ // this origin no longer has — deleted there, or a backend rebuilt at the same
1450
+ // address. Being logged in to a backend you did not mean is still the cheaper
1451
+ // thing to rule out, so it still comes first. (Until 2026-09-21 this paragraph
1452
+ // leaned on a scope check against `site.yml::$backend`; both were deleted on
1453
+ // 2026-09-20.)
1454
+ //
1455
+ // ⛔ The fix for a deletion was "clear `$uuid` from site.yml" until 2026-09-21 — a
1456
+ // key that left site.yml the day before, so the advice named nothing to clear.
1457
+ // The binding is this backend's section of sync.json, and `forget` drops it.
1363
1458
  note(
1364
1459
  `The backend at ${client.origin} has no site with uuid ${boundUuid}.`
1365
1460
  )
@@ -1370,7 +1465,7 @@ export async function pushSyncPackages({
1370
1465
  ` wrong backend → uniweb login --backend <the right one> (nothing is lost)`
1371
1466
  )
1372
1467
  note(
1373
- ` deleted in the app → clearing \`$uuid\` from site.yml re-publishes it as a NEW site`
1468
+ ` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
1374
1469
  )
1375
1470
  note('Deleting this folder removes only your local copy, either way.')
1376
1471
  } else if (res.status === 409) {
@@ -1379,8 +1474,8 @@ export async function pushSyncPackages({
1379
1474
  note(
1380
1475
  "This site's record structure is already established on the backend and can't be changed " +
1381
1476
  'in place — e.g. adding or removing a schema-backed query, or switching one between ' +
1382
- 'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
1383
- 'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
1477
+ 'static (data-bundle) and schema-backed delivery. To change it, push it as a new site: ' +
1478
+ `uniweb forget --backend ${client.origin}, then push again (the old site stays until it is deleted).`
1384
1479
  )
1385
1480
  }
1386
1481
  if (body) note(body.slice(0, 800))
@@ -1415,8 +1510,8 @@ export async function pushSyncPackages({
1415
1510
 
1416
1511
  // Lane 1 — site-content (the site is born here; it must exist before its folder). A
1417
1512
  // known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the site
1418
- // and returns its uuid, which we record into site.yml). `boundSiteUuid` carries the
1419
- // minted/known uuid forward to key the folder push.
1513
+ // and returns its uuid, which we record in sync.json, under this backend).
1514
+ // `boundSiteUuid` carries the minted/known uuid forward to key the folder push.
1420
1515
  let boundSiteUuid = siteContentUuid
1421
1516
  // Post-write tokens harvested from every lane, persisted once at the end so a
1422
1517
  // partial push (lane 1 ok, lane 2 refused) still banks what actually landed —
@@ -1452,8 +1547,8 @@ export async function pushSyncPackages({
1452
1547
  }
1453
1548
  // Both grains land together, at every point the old code banked the entity one.
1454
1549
  const mergeHarvested = () => {
1455
- mergeBaseVersions(siteDir, newVersions)
1456
- mergeItemBaseVersions(siteDir, newItemVersions)
1550
+ mergeBaseVersions(siteDir, client.origin, newVersions)
1551
+ mergeItemBaseVersions(siteDir, client.origin, newItemVersions)
1457
1552
  }
1458
1553
  // The backend's post-write copy of the site-content document, kept for the
1459
1554
  // remote-side unit base (see writeUnitBases).
@@ -1484,12 +1579,13 @@ export async function pushSyncPackages({
1484
1579
  // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
1485
1580
  // placements below. These items have no file to back-fill into — they all
1486
1581
  // come from one `collections/collections.yml` — so the only place their
1487
- // `$uuid` can live is the cache, keyed by the name the backend enforces
1488
- // unique. Without it every push after the first re-sends the whole
1582
+ // `$uuid` can live is `sync.json` (this backend's `queries` map), keyed by the
1583
+ // name the backend enforces unique. Without it every push after the first re-sends the whole
1489
1584
  // `collections` section uuid-less and is refused.
1490
1585
  if (siteFinalizedDoc) {
1491
1586
  const recordIds = collectQueryUuids(siteFinalizedDoc)
1492
- if (Object.keys(recordIds).length) writeQueryUuids(siteDir, recordIds)
1587
+ if (Object.keys(recordIds).length)
1588
+ writeQueryUuids(siteDir, client.origin, recordIds)
1493
1589
  }
1494
1590
  finalizedTotal += finalized.length
1495
1591
  } else {
@@ -1505,14 +1601,14 @@ export async function pushSyncPackages({
1505
1601
  note(JSON.stringify(payload).slice(0, 800))
1506
1602
  return { exitCode: 1, finalizedTotal, wrote }
1507
1603
  }
1508
- writeSiteEntityUuid(siteDir, minted)
1509
- updateSyncCache(siteDir, { siteUuid: minted })
1604
+ writeSiteEntityUuid(siteDir, client.origin, minted)
1605
+ updateSyncCache(siteDir, client.origin, { siteUuid: minted })
1510
1606
  boundSiteUuid = minted
1511
- wrote.push('recorded site $uuid in site.yml')
1607
+ wrote.push('recorded the site in sync.json')
1512
1608
  // The OTHER create path (a media-less push never reaches `ensureSiteExists`,
1513
1609
  // which is gated on the site having local media). Both mint a site, so both
1514
- // owe the same record — recording it in only one place would make `$org`
1515
- // present or absent depending on whether the site happens to have images.
1610
+ // owe the same record — recording it in only one place would make the recorded
1611
+ // owner present or absent depending on whether the site happens to have images.
1516
1612
  // The backend echoes `org`/`hosts_free` top-level here too, beside `report`
1517
1613
  // and `site` (NOT beside `finalized`, which lives at report.finalized).
1518
1614
  const createdOrg = await recordAndDescribeOwner({
@@ -1523,7 +1619,7 @@ export async function pushSyncPackages({
1523
1619
  note
1524
1620
  })
1525
1621
  if (createdOrg)
1526
- wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
1622
+ wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
1527
1623
  const createdFinalized = extractFinalized(payload)
1528
1624
  harvest(createdFinalized)
1529
1625
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
@@ -1554,12 +1650,18 @@ export async function pushSyncPackages({
1554
1650
  }
1555
1651
  harvest(finalized)
1556
1652
  const bf = backfillEntityUuids({ index: records.index, finalized })
1653
+ // ⭐ Record which uuid THIS backend minted for each record, keyed by the record's
1654
+ // own id. The file keeps that own id; the next push to this backend sends the
1655
+ // mapped uuid, and a push to another backend sends none so that one mints its own.
1656
+ if (bf.mapped && Object.keys(bf.mapped).length) {
1657
+ updateBackendMap(siteDir, client.origin, 'records', bf.mapped)
1658
+ }
1557
1659
  for (const w of bf.warnings) note(`! ${w}`)
1558
1660
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
1559
1661
  if (bf.updated.length)
1560
1662
  wrote.push(`wrote ${bf.updated.length} record file(s)`)
1561
- // ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records back-fill their own
1562
- // `$uuid` into their source files (above); the folder's ITEMS have nowhere to
1663
+ // ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records carry their own `$uuid`
1664
+ // in their source files and map it per backend (above); the folder's ITEMS have nowhere to
1563
1665
  // be written, so they are banked here from the document the backend just
1564
1666
  // returned.
1565
1667
  //
@@ -1572,7 +1674,7 @@ export async function pushSyncPackages({
1572
1674
  if (folderDoc) {
1573
1675
  const placements = collectFolderItemUuids(folderDoc)
1574
1676
  if (Object.keys(placements).length)
1575
- writeFolderItemUuids(siteDir, placements)
1677
+ writeFolderItemUuids(siteDir, client.origin, placements)
1576
1678
  }
1577
1679
  finalizedTotal += finalized.length
1578
1680
  }
@@ -1581,7 +1683,7 @@ export async function pushSyncPackages({
1581
1683
  // then bank the post-write tokens so the NEXT push carries a current base.
1582
1684
  // Entities absent from finalized[] (skipped, or not editable) keep their cached
1583
1685
  // value — absence is not invalidation.
1584
- writeSyncCache(siteDir, hashes, applied)
1686
+ writeSyncCache(siteDir, client.origin, hashes, applied)
1585
1687
  mergeHarvested()
1586
1688
  // Re-base the page attribution: our emitted document and the backend's post-write
1587
1689
  // copy of it are the two sides' new agreed state. Only when the site-content lane
@@ -1594,12 +1696,12 @@ export async function pushSyncPackages({
1594
1696
  // the stored row — the only remote-side base a push can produce.
1595
1697
  const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
1596
1698
  if (theirs) patch.remote = computeUnitHashes(theirs)
1597
- if (Object.keys(patch).length) writeUnitBases(siteDir, patch)
1699
+ if (Object.keys(patch).length) writeUnitBases(siteDir, client.origin, patch)
1598
1700
  // The backend's post-write document carries `$uuid` per item at every nesting
1599
1701
  // level, so the push that just landed also re-arms identity for the next one.
1600
1702
  // Replaced wholesale: an item that no longer exists must not keep a uuid that
1601
1703
  // would re-target something else.
1602
- if (theirs) writeItemUuids(siteDir, collectUnitUuids(theirs))
1704
+ if (theirs) writeItemUuids(siteDir, client.origin, collectUnitUuids(theirs))
1603
1705
  else {
1604
1706
  // ⛔ BANKING IS BEST-EFFORT AND ITS FAILURE USED TO BE SILENT — say it here,
1605
1707
  // because the cost lands two commands away and names something else.