uniweb 0.59.2 → 0.60.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')
232
+ }
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
203
268
  }
204
- const readMap = (siteDir, key) => {
205
- const v = readSyncCacheFile(siteDir)[key]
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
 
@@ -214,8 +280,8 @@ const readMap = (siteDir, key) => {
214
280
  * content hashes, the injections those hashes were taken over, entity base versions,
215
281
  * unit bases — and a unit path (`site.yml`, `pages/about/about.md`) is the same
216
282
  * string for every site. So the cache does not
217
- * self-invalidate when `site.yml::$uuid` goes away: it keeps describing the site
218
- * this folder used to be.
283
+ * self-invalidate when the site's uuid goes away (a delete in the app, or
284
+ * `uniweb forget`): it keeps describing the site this folder used to be.
219
285
  *
220
286
  * Which is a state we ACTIVELY TELL PEOPLE TO ENTER. The 404 guidance on a
221
287
  * uuid-bound lane says to clear `$uuid` to re-publish as a new site — the documented
@@ -241,21 +307,12 @@ const readMap = (siteDir, key) => {
241
307
  * guard and the `item_uuid_conflict` recovery — the guard decides WHETHER, this
242
308
  * decides WHAT, and they must not drift.
243
309
  */
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: {},
310
+ export function clearRemoteSyncState(siteDir, backend, siteUuid = null) {
311
+ const prior = readSyncCacheFile(siteDir, backend)
312
+ const dropped = ['hashes', 'baseVersions', 'unitBases', 'applied'].filter(
313
+ (k) => prior[k] && Object.keys(prior[k]).length
314
+ )
315
+ updateSyncCache(siteDir, backend, {
259
316
  hashes: {},
260
317
  baseVersions: {},
261
318
  unitBases: {},
@@ -265,19 +322,40 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
265
322
  applied: {},
266
323
  siteUuid: siteUuid || null
267
324
  })
268
- return dropped
325
+ // ⛔ THE IDENTITY MAPS ARE CLEARED WITH THE REST, and must be. They live in
326
+ // `sync.json` now rather than the cache, but they describe the same dead site:
327
+ // leaving them behind is what makes the backend refuse — "item uuid … is already
328
+ // stored on entity N; cross-entity move is not supported" — on the very next push.
329
+ // Two files, one invalidation.
330
+ const identity = clearBackendSections(siteDir, backend, [
331
+ 'items',
332
+ 'queries',
333
+ 'folders'
334
+ ])
335
+ // Reported under the names the cache used, so the message a user sees is the same
336
+ // one it always was.
337
+ const NAMED = { items: 'itemUuids', queries: 'queryUuids', folders: 'folderItemUuids' }
338
+ return [...dropped, ...identity.map((k) => NAMED[k])]
269
339
  }
270
340
 
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
- }
341
+ export function clearRemoteSyncStateIfUnbound(siteDir, backend) {
342
+ // ⭐ "Bound" is a question about THIS backend: the identity is `sync.json`'s, keyed
343
+ // by origin. A project bound on one backend and not another is an ordinary state
344
+ // now, not a contradiction — which is why the stamp below still earns its keep even
345
+ // though cross-backend confusion became unrepresentable. What it still catches is a
346
+ // site DELETED on this backend and re-created: same origin, new uuid.
347
+ const current = readBackendState(siteDir, backend).site?.uuid || null
279
348
 
280
- const prior = readSyncCacheFile(siteDir)
349
+ const prior = readSyncCacheFile(siteDir, backend)
350
+ // ⛔ TWO FILES, ONE QUESTION. The cache holds the speed-ups and `sync.json` holds
351
+ // the identity maps, but "is any of this describing a site this clone is not
352
+ // working with?" is asked of BOTH — a clone whose only stale state is identity is
353
+ // exactly the one the backend refuses on the next push.
354
+ const identityPrior = {
355
+ itemUuids: readItemUuids(siteDir, backend),
356
+ queryUuids: readQueryUuids(siteDir, backend),
357
+ folderItemUuids: readFolderItemUuids(siteDir, backend)
358
+ }
281
359
  const REMOTE_MAPS = [
282
360
  'itemUuids',
283
361
  'hashes',
@@ -285,15 +363,16 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
285
363
  'unitBases',
286
364
  'applied'
287
365
  ]
366
+ const lookup = { ...prior, ...identityPrior }
288
367
  const populated = REMOTE_MAPS.filter(
289
- (k) => prior[k] && Object.keys(prior[k]).length
368
+ (k) => lookup[k] && Object.keys(lookup[k]).length
290
369
  )
291
370
  if (!populated.length) {
292
371
  // Nothing to invalidate, but still stamp identity so a LATER divergence is
293
372
  // detectable. A cache that never records its site can only be checked by the
294
373
  // unbound rule, which misses the bound-but-wrong case below.
295
374
  if (current && prior.siteUuid !== current) {
296
- updateSyncCache(siteDir, { siteUuid: current })
375
+ updateSyncCache(siteDir, backend, { siteUuid: current })
297
376
  }
298
377
  return []
299
378
  }
@@ -316,12 +395,12 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
316
395
  !current || (prior.siteUuid && prior.siteUuid !== current) ? populated : []
317
396
  if (!stale.length) {
318
397
  if (current && prior.siteUuid !== current) {
319
- updateSyncCache(siteDir, { siteUuid: current })
398
+ updateSyncCache(siteDir, backend, { siteUuid: current })
320
399
  }
321
400
  return []
322
401
  }
323
402
 
324
- clearRemoteSyncState(siteDir, current)
403
+ clearRemoteSyncState(siteDir, backend, current)
325
404
  return stale
326
405
  }
327
406
 
@@ -345,7 +424,7 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
345
424
  * @param {string} siteDir
346
425
  * @returns {string[]} the keys removed
347
426
  */
348
- export function dropSiteBoundValues(siteDir) {
427
+ export function dropSiteBoundValues(siteDir, backend) {
349
428
  const file = join(siteDir, 'site.yml')
350
429
  let y
351
430
  try {
@@ -353,7 +432,12 @@ export function dropSiteBoundValues(siteDir) {
353
432
  } catch {
354
433
  return []
355
434
  }
356
- if (!y || typeof y !== 'object' || typeof y.$uuid === 'string') return []
435
+ // ⛔ "Bound" is a question about THIS backend, answered by sync.json. This read
436
+ // `site.yml::$uuid` until 2026-09-20 and kept doing so after step 4 moved the key —
437
+ // so every project read as unbound and every push stripped a bound site's
438
+ // generated preview. A write, from a stale read, with nothing to notice it.
439
+ if (!y || typeof y !== 'object') return []
440
+ if (readBackendState(siteDir, backend).site?.uuid) return []
357
441
  const dropped = []
358
442
  if (
359
443
  y.preview !== undefined &&
@@ -365,8 +449,8 @@ export function dropSiteBoundValues(siteDir) {
365
449
  return dropped
366
450
  }
367
451
 
368
- export function readSyncCache(siteDir) {
369
- return readMap(siteDir, 'hashes')
452
+ export function readSyncCache(siteDir, backend) {
453
+ return readMap(siteDir, backend, 'hashes')
370
454
  }
371
455
 
372
456
  /**
@@ -398,8 +482,8 @@ export function readSyncCache(siteDir) {
398
482
  * keeps `assets.json` id-only. An asset genuinely new to the site has no recorded
399
483
  * mapping and still reads as changed, which is correct.
400
484
  */
401
- export function readAppliedInjections(siteDir) {
402
- return readMap(siteDir, 'applied')
485
+ export function readAppliedInjections(siteDir, backend) {
486
+ return readMap(siteDir, backend, 'applied')
403
487
  }
404
488
 
405
489
  /**
@@ -412,9 +496,9 @@ export function readAppliedInjections(siteDir) {
412
496
  * `assetIds` is dropped rather than stored — see readAppliedInjections: it has a
413
497
  * committed source of truth in `assets.json`, and the reader re-derives it there.
414
498
  */
415
- export function writeSyncCache(siteDir, hashes, applied) {
499
+ export function writeSyncCache(siteDir, backend, hashes, applied) {
416
500
  const { assetIds: _inAssetsJson, ...bankable } = applied || {}
417
- updateSyncCache(siteDir, { hashes, applied: bankable })
501
+ updateSyncCache(siteDir, backend, { hashes, applied: bankable })
418
502
  }
419
503
 
420
504
  /**
@@ -431,8 +515,8 @@ export function writeSyncCache(siteDir, hashes, applied) {
431
515
  * post-write `finalized[].version` the backend returns. Without the push half a
432
516
  * second consecutive push would be stale by construction and refused.
433
517
  */
434
- export function readBaseVersions(siteDir) {
435
- return readMap(siteDir, 'baseVersions')
518
+ export function readBaseVersions(siteDir, backend) {
519
+ return readMap(siteDir, backend, 'baseVersions')
436
520
  }
437
521
 
438
522
  /**
@@ -447,19 +531,19 @@ export function readBaseVersions(siteDir) {
447
531
  * reports tokens for a subset of the site. Replacing would drop the tokens of every
448
532
  * record that wasn't in this package and silently degrade those to ungated.
449
533
  */
450
- export function readItemBaseVersions(siteDir) {
451
- return readMap(siteDir, 'itemBaseVersions')
534
+ export function readItemBaseVersions(siteDir, backend) {
535
+ return readMap(siteDir, backend, 'itemBaseVersions')
452
536
  }
453
- export function mergeItemBaseVersions(siteDir, versions) {
537
+ export function mergeItemBaseVersions(siteDir, backend, versions) {
454
538
  if (!versions || !Object.keys(versions).length) return
455
- updateSyncCache(siteDir, {
539
+ updateSyncCache(siteDir, backend, {
456
540
  itemBaseVersions: { ...readItemBaseVersions(siteDir), ...versions }
457
541
  })
458
542
  }
459
- export function mergeBaseVersions(siteDir, versions) {
543
+ export function mergeBaseVersions(siteDir, backend, versions) {
460
544
  if (!versions || !Object.keys(versions).length) return
461
- updateSyncCache(siteDir, {
462
- baseVersions: { ...readBaseVersions(siteDir), ...versions }
545
+ updateSyncCache(siteDir, backend, {
546
+ baseVersions: { ...readBaseVersions(siteDir, backend), ...versions }
463
547
  })
464
548
  }
465
549
 
@@ -503,8 +587,8 @@ export function mergeBaseVersions(siteDir, versions) {
503
587
  * refuses an all-blank `multi` section, so a producer that skips that step fails
504
588
  * loudly instead of quietly rebuilding the site's identity.
505
589
  */
506
- export function readItemUuids(siteDir) {
507
- return readMap(siteDir, 'itemUuids')
590
+ export function readItemUuids(siteDir, backend) {
591
+ return readBackendState(siteDir, backend).items || {}
508
592
  }
509
593
  /**
510
594
  * Placement identity for the site's `@uniweb/folder` — path chain → `$uuid`.
@@ -513,8 +597,8 @@ export function readItemUuids(siteDir) {
513
597
  * different trees: `pages/…/page.yml` there, `members/alice` here. One map with
514
598
  * two key languages is a map nobody can validate.
515
599
  */
516
- export function readFolderItemUuids(siteDir) {
517
- return readMap(siteDir, 'folderItemUuids')
600
+ export function readFolderItemUuids(siteDir, backend) {
601
+ return readBackendState(siteDir, backend).folders || {}
518
602
  }
519
603
 
520
604
  /**
@@ -535,20 +619,25 @@ export function readFolderItemUuids(siteDir) {
535
619
  * its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
536
620
  * handle the backend skips on parse and never stores.
537
621
  */
538
- export function readQueryUuids(siteDir) {
539
- return readMap(siteDir, 'queryUuids')
622
+ export function readQueryUuids(siteDir, backend) {
623
+ return readBackendState(siteDir, backend).queries || {}
540
624
  }
541
- export function writeQueryUuids(siteDir, map) {
625
+ // ⭐ THE THREE UUID MAPS LIVE IN `sync.json`, NOT THE CACHE (2026-09-20). They are
626
+ // backend-minted identity: without them a push re-sends a section uuid-less and the
627
+ // backend refuses an all-blank section over stored rows rather than replacing every
628
+ // one. A refusal is the good case; the bad one is duplication. Losing them costs a
629
+ // round trip, not a slow push — which is the line between the two files.
630
+ export function writeQueryUuids(siteDir, backend, map) {
542
631
  if (!map || !Object.keys(map).length) return
543
- updateSyncCache(siteDir, { queryUuids: map })
632
+ updateBackendMap(siteDir, backend, 'queries', map)
544
633
  }
545
- export function writeFolderItemUuids(siteDir, map) {
634
+ export function writeFolderItemUuids(siteDir, backend, map) {
546
635
  if (!map || !Object.keys(map).length) return
547
- updateSyncCache(siteDir, { folderItemUuids: map })
636
+ updateBackendMap(siteDir, backend, 'folders', map)
548
637
  }
549
- export function writeItemUuids(siteDir, map) {
638
+ export function writeItemUuids(siteDir, backend, map) {
550
639
  if (!map || !Object.keys(map).length) return
551
- updateSyncCache(siteDir, { itemUuids: map })
640
+ updateBackendMap(siteDir, backend, 'items', map)
552
641
  }
553
642
 
554
643
  /**
@@ -564,16 +653,9 @@ export function writeItemUuids(siteDir, map) {
564
653
  * @param {string} siteDir
565
654
  * @returns {string|null}
566
655
  */
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
- }
656
+ export function readSiteOrg(siteDir, backend) {
657
+ const org = readBackendState(siteDir, backend).site?.org
658
+ return typeof org === 'string' && org ? `@${org.replace(/^@/, '')}` : null
577
659
  }
578
660
 
579
661
  /**
@@ -588,14 +670,14 @@ export function readSiteOrg(siteDir) {
588
670
  * @param {string|null|undefined} asOrg - the `--as-org` value, `@handle` or bare
589
671
  * @returns {string|null}
590
672
  */
591
- function recordSiteOrg(siteDir, asOrg) {
673
+ function recordSiteOrg(siteDir, backend, asOrg) {
592
674
  const handle = String(asOrg || '')
593
675
  .replace(/^@/, '')
594
676
  .replace(/\/.*$/, '')
595
677
  .trim()
596
678
  if (!handle) return null
597
679
  try {
598
- writeSiteOrg(siteDir, handle)
680
+ writeSiteOrg(siteDir, backend, handle)
599
681
  return `@${handle}`
600
682
  } catch {
601
683
  // The uuid is the load-bearing back-fill; losing the org note must never
@@ -648,19 +730,13 @@ export async function resolveSiteOrgForCreate({
648
730
  if (flag) return { asOrg: flag }
649
731
  if (personal) return { asOrg: null }
650
732
 
651
- const recorded = readSiteOrg(siteDir)
733
+ const recorded = readSiteOrg(siteDir, client?.origin)
652
734
  if (recorded) return { asOrg: recorded }
653
735
 
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 }
736
+ // Already created ⇒ nothing to decide: ownership was settled at its create and
737
+ // re-asking would be theatre. ⭐ "Created" is a question about THIS backend now —
738
+ // the same project may be brand new on one and long-established on another.
739
+ if (readBackendState(siteDir, client?.origin).site?.uuid) return { asOrg: null }
664
740
 
665
741
  // An offline preview (`--dry-run` / `-o`) must never authenticate, and it is
666
742
  // creating nothing, so there is no decision to force. Say what is unresolved
@@ -825,8 +901,8 @@ function describeCreateRefusal(body) {
825
901
  * keep and can be cleared. (The reverse ordering was justified by a claim that
826
902
  * `ensureItemUuids` mints on the backend, which it does not — see push.js.)
827
903
  *
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
904
+ * A no-op when the site already has a uuid on this backend (sync.json), so only the
905
+ * first publish of a site pays for it. The uuid is written back immediately, keeping the window in
830
906
  * which a crash could strand a site as small as one file write.
831
907
  *
832
908
  * @returns {Promise<{ uuid: string|null, created: boolean, reason?: string }>}
@@ -852,8 +928,9 @@ export async function ensureSiteExists({
852
928
  } catch {
853
929
  /* unreadable site.yml — treat as un-synced and let the create decide */
854
930
  }
855
- if (typeof siteYml.$uuid === 'string') {
856
- return { uuid: siteYml.$uuid, created: false, org: readSiteOrg(siteDir) }
931
+ const known = readBackendState(siteDir, client.origin).site?.uuid
932
+ if (known) {
933
+ return { uuid: known, created: false, org: readSiteOrg(siteDir, client.origin) }
857
934
  }
858
935
 
859
936
  // Both are required by the create. Catching it here turns a 400 into a sentence
@@ -905,11 +982,11 @@ export async function ensureSiteExists({
905
982
  reason: 'the create returned no site uuid'
906
983
  }
907
984
  }
908
- writeSiteEntityUuid(siteDir, minted)
985
+ writeSiteEntityUuid(siteDir, client.origin, minted)
909
986
  // Stamp the cache with the site it now describes, so a push that fails after
910
987
  // this point cannot leave a cache pointing at a different site with no way to
911
988
  // detect it.
912
- updateSyncCache(siteDir, { siteUuid: minted })
989
+ updateSyncCache(siteDir, client.origin, { siteUuid: minted })
913
990
  const org = await recordAndDescribeOwner({
914
991
  client,
915
992
  siteDir,
@@ -943,23 +1020,16 @@ async function recordAndDescribeOwner({
943
1020
  const echoed = payload && 'org' in payload ? payload.org : undefined
944
1021
  const owner =
945
1022
  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).`)
1023
+ const org = recordSiteOrg(siteDir, client.origin, owner)
958
1024
 
1025
+ // ⛔ No sync scope to record. It WAS `site.yml::$backend`, written here because this
1026
+ // function is the one place both create paths meet. The scope is now the KEY the
1027
+ // identity is stored under (`sync.json::backends.<origin>`), so recording it
1028
+ // separately would be recording the name of the drawer inside the drawer.
959
1029
  note?.(
960
1030
  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).`
1031
+ ? `Created the site on ${client.origin} under ${org} — recorded in sync.json.`
1032
+ : `Created the site on ${client.origin}, owned personally — recorded in sync.json.`
963
1033
  )
964
1034
 
965
1035
  // What the create echoed about this site's OWNER, and nothing beyond it.
@@ -1000,17 +1070,13 @@ async function recordAndDescribeOwner({
1000
1070
  * @returns {Promise<Object<string,string>>} the map (possibly empty)
1001
1071
  */
1002
1072
  export async function ensureItemUuids({ client, siteDir, note }) {
1003
- const cached = readItemUuids(siteDir)
1073
+ const cached = readItemUuids(siteDir, client.origin)
1004
1074
  if (Object.keys(cached).length) return cached
1005
1075
  // A site that has never been pushed has no identity to recover — and nothing to
1006
1076
  // 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
- }
1077
+ // The site to recover identity against — this backend's, from sync.json. It read
1078
+ // `site.yml::$uuid`, so after step 4 it found nothing and never recovered.
1079
+ const siteContentUuid = readBackendState(siteDir, client.origin).site?.uuid || null
1014
1080
  if (!siteContentUuid) return cached
1015
1081
  try {
1016
1082
  const res = await client.pullSiteContent(siteContentUuid)
@@ -1019,7 +1085,7 @@ export async function ensureItemUuids({ client, siteDir, note }) {
1019
1085
  if (!doc) return cached
1020
1086
  const harvested = collectUnitUuids(doc)
1021
1087
  if (Object.keys(harvested).length) {
1022
- writeItemUuids(siteDir, harvested)
1088
+ writeItemUuids(siteDir, client.origin, harvested)
1023
1089
  note?.(
1024
1090
  `Recovered identity for ${Object.keys(harvested).length} item(s) from the backend.`
1025
1091
  )
@@ -1032,14 +1098,14 @@ export async function ensureItemUuids({ client, siteDir, note }) {
1032
1098
  }
1033
1099
  }
1034
1100
 
1035
- export function readUnitBases(siteDir) {
1036
- const v = readMap(siteDir, 'unitBases')
1101
+ export function readUnitBases(siteDir, backend) {
1102
+ const v = readMap(siteDir, backend, 'unitBases')
1037
1103
  return { local: v.local || {}, remote: v.remote || {} }
1038
1104
  }
1039
- export function writeUnitBases(siteDir, patch) {
1105
+ export function writeUnitBases(siteDir, backend, patch) {
1040
1106
  if (!patch) return
1041
- updateSyncCache(siteDir, {
1042
- unitBases: { ...readUnitBases(siteDir), ...patch }
1107
+ updateSyncCache(siteDir, backend, {
1108
+ unitBases: { ...readUnitBases(siteDir, backend), ...patch }
1043
1109
  })
1044
1110
  }
1045
1111
 
@@ -1055,8 +1121,8 @@ export function writeUnitBases(siteDir, patch) {
1055
1121
  * @param {string} siteDir
1056
1122
  * @returns {Promise<{ changed: number, unchanged: number, warnings: string[] }>}
1057
1123
  */
1058
- export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
1059
- const priorHashes = readSyncCache(siteDir)
1124
+ export async function probeUnpushed(siteDir, { backend = null, sendAll = false } = {}) {
1125
+ const priorHashes = readSyncCache(siteDir, backend)
1060
1126
  // Re-emit the document the last push HASHED, not the one the author wrote — see
1061
1127
  // readAppliedInjections. Two sources, on purpose:
1062
1128
  // · BANKED — the serve URLs and pinned refs only a round-trip produces. Empty
@@ -1083,7 +1149,7 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
1083
1149
  // and others never settling, which reads like a content problem rather than a
1084
1150
  // resolution one. Measured on matinee 2026-08-29: `status` reported 4 changed
1085
1151
  // immediately after a successful push; passing the org took it to 1.
1086
- const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
1152
+ const pkg = await comparisonEmit(siteDir, { backend, priorHashes, sendAll })
1087
1153
  const changed =
1088
1154
  (pkg.siteContent?.entityCount || 0) + (pkg.records?.entityCount || 0)
1089
1155
  return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
@@ -1110,13 +1176,17 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
1110
1176
  */
1111
1177
  async function comparisonEmit(
1112
1178
  siteDir,
1113
- { priorHashes = {}, sendAll = false } = {}
1179
+ { backend = null, priorHashes = {}, sendAll = false } = {}
1114
1180
  ) {
1115
- const applied = readAppliedInjections(siteDir)
1116
- const assetIds = readAssetMap(siteDir)
1117
- const org = readSiteOrg(siteDir)
1181
+ const applied = readAppliedInjections(siteDir, backend)
1182
+ // ⛔ Per backend: an asset id is minted by one and means nothing to another, so a
1183
+ // comparison against the wrong one reports every media ref as changed. Absent is
1184
+ // honest rather than a default — no backend, no known ids.
1185
+ const assetIds = backend ? readBackendState(siteDir, backend).assets || {} : {}
1186
+ const org = readSiteOrg(siteDir, backend)
1118
1187
  const queryUuids = readQueryUuids(siteDir)
1119
1188
  return emitSyncPackages(siteDir, {
1189
+ backend,
1120
1190
  resolveModel: makeModelResolver({ client: null, offline: true }),
1121
1191
  priorHashes,
1122
1192
  sendAll,
@@ -1148,9 +1218,9 @@ async function comparisonEmit(
1148
1218
  * on-disk state IS the agreed state: it came from the backend. A push with no edits
1149
1219
  * in between should send nothing, and clearing would make it send everything.
1150
1220
  */
1151
- export async function rebankSyncHashes(siteDir) {
1152
- const pkg = await comparisonEmit(siteDir, { sendAll: true })
1153
- writeSyncCache(siteDir, pkg.hashes || {}, pkg.applied || {})
1221
+ export async function rebankSyncHashes(siteDir, backend = null) {
1222
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true })
1223
+ writeSyncCache(siteDir, backend, pkg.hashes || {}, pkg.applied || {})
1154
1224
  return Object.keys(pkg.hashes || {}).length
1155
1225
  }
1156
1226
 
@@ -1204,7 +1274,7 @@ export async function pushSyncPackages({
1204
1274
  res = await doRequest()
1205
1275
  } catch (err) {
1206
1276
  error(`Could not reach the backend at ${client.origin}: ${err.message}`)
1207
- note('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
1277
+ note('Is that the backend you meant? Switch with: uniweb login --backend <url>')
1208
1278
  return null
1209
1279
  }
1210
1280
  if (!res.ok) {
@@ -1231,7 +1301,7 @@ export async function pushSyncPackages({
1231
1301
  // an unstamped legacy cache on a bound site, which the guard deliberately
1232
1302
  // leaves alone rather than wiping every existing clone. This is the exit.
1233
1303
  if (problem?.reason === 'item_uuid_conflict') {
1234
- const dropped = clearRemoteSyncState(siteDir, boundUuid)
1304
+ const dropped = clearRemoteSyncState(siteDir, client.origin, boundUuid)
1235
1305
  error(
1236
1306
  `${label} push refused — this copy's sync cache is describing a different site.`
1237
1307
  )
@@ -1344,7 +1414,7 @@ export async function pushSyncPackages({
1344
1414
  error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
1345
1415
  if (res.status === 401 || res.status === 403) {
1346
1416
  note(
1347
- "Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN)."
1417
+ "Credentials weren't accepted — log in again (`uniweb login --backend <url>`), or check UNIWEB_TOKEN."
1348
1418
  )
1349
1419
  } else if (res.status === 404 && boundUuid) {
1350
1420
  // The clone is bound to a site the backend does not have. There is no CLI
@@ -1356,7 +1426,9 @@ export async function pushSyncPackages({
1356
1426
  // this branch named only the deletion and went straight to "clear `$uuid`" — and
1357
1427
  // that advice, followed for the OTHER cause, destroys a live binding: the site is
1358
1428
  // 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
1429
+ // it. (A wrong-BACKEND binding can no longer occur — identity is read per
1430
+ // origin from sync.json — so what reaches here is a site deleted on this backend.)
1431
+ // Before any request goes
1360
1432
  // out, so reaching here usually does mean a deletion; "usually" is not "always"
1361
1433
  // (a project predating `$backend` records no scope to check), which is why the
1362
1434
  // cheap cause is still named before the destructive fix.
@@ -1452,8 +1524,8 @@ export async function pushSyncPackages({
1452
1524
  }
1453
1525
  // Both grains land together, at every point the old code banked the entity one.
1454
1526
  const mergeHarvested = () => {
1455
- mergeBaseVersions(siteDir, newVersions)
1456
- mergeItemBaseVersions(siteDir, newItemVersions)
1527
+ mergeBaseVersions(siteDir, client.origin, newVersions)
1528
+ mergeItemBaseVersions(siteDir, client.origin, newItemVersions)
1457
1529
  }
1458
1530
  // The backend's post-write copy of the site-content document, kept for the
1459
1531
  // remote-side unit base (see writeUnitBases).
@@ -1489,7 +1561,8 @@ export async function pushSyncPackages({
1489
1561
  // `collections` section uuid-less and is refused.
1490
1562
  if (siteFinalizedDoc) {
1491
1563
  const recordIds = collectQueryUuids(siteFinalizedDoc)
1492
- if (Object.keys(recordIds).length) writeQueryUuids(siteDir, recordIds)
1564
+ if (Object.keys(recordIds).length)
1565
+ writeQueryUuids(siteDir, client.origin, recordIds)
1493
1566
  }
1494
1567
  finalizedTotal += finalized.length
1495
1568
  } else {
@@ -1505,8 +1578,8 @@ export async function pushSyncPackages({
1505
1578
  note(JSON.stringify(payload).slice(0, 800))
1506
1579
  return { exitCode: 1, finalizedTotal, wrote }
1507
1580
  }
1508
- writeSiteEntityUuid(siteDir, minted)
1509
- updateSyncCache(siteDir, { siteUuid: minted })
1581
+ writeSiteEntityUuid(siteDir, client.origin, minted)
1582
+ updateSyncCache(siteDir, client.origin, { siteUuid: minted })
1510
1583
  boundSiteUuid = minted
1511
1584
  wrote.push('recorded site $uuid in site.yml')
1512
1585
  // The OTHER create path (a media-less push never reaches `ensureSiteExists`,
@@ -1554,12 +1627,18 @@ export async function pushSyncPackages({
1554
1627
  }
1555
1628
  harvest(finalized)
1556
1629
  const bf = backfillEntityUuids({ index: records.index, finalized })
1630
+ // ⭐ Record which uuid THIS backend minted for each record, keyed by the record's
1631
+ // own id. The file keeps that own id; the next push to this backend sends the
1632
+ // mapped uuid, and a push to another backend sends none so that one mints its own.
1633
+ if (bf.mapped && Object.keys(bf.mapped).length) {
1634
+ updateBackendMap(siteDir, client.origin, 'records', bf.mapped)
1635
+ }
1557
1636
  for (const w of bf.warnings) note(`! ${w}`)
1558
1637
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
1559
1638
  if (bf.updated.length)
1560
1639
  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
1640
+ // ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records carry their own `$uuid`
1641
+ // in their source files and map it per backend (above); the folder's ITEMS have nowhere to
1563
1642
  // be written, so they are banked here from the document the backend just
1564
1643
  // returned.
1565
1644
  //
@@ -1572,7 +1651,7 @@ export async function pushSyncPackages({
1572
1651
  if (folderDoc) {
1573
1652
  const placements = collectFolderItemUuids(folderDoc)
1574
1653
  if (Object.keys(placements).length)
1575
- writeFolderItemUuids(siteDir, placements)
1654
+ writeFolderItemUuids(siteDir, client.origin, placements)
1576
1655
  }
1577
1656
  finalizedTotal += finalized.length
1578
1657
  }
@@ -1581,7 +1660,7 @@ export async function pushSyncPackages({
1581
1660
  // then bank the post-write tokens so the NEXT push carries a current base.
1582
1661
  // Entities absent from finalized[] (skipped, or not editable) keep their cached
1583
1662
  // value — absence is not invalidation.
1584
- writeSyncCache(siteDir, hashes, applied)
1663
+ writeSyncCache(siteDir, client.origin, hashes, applied)
1585
1664
  mergeHarvested()
1586
1665
  // Re-base the page attribution: our emitted document and the backend's post-write
1587
1666
  // copy of it are the two sides' new agreed state. Only when the site-content lane
@@ -1594,12 +1673,12 @@ export async function pushSyncPackages({
1594
1673
  // the stored row — the only remote-side base a push can produce.
1595
1674
  const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
1596
1675
  if (theirs) patch.remote = computeUnitHashes(theirs)
1597
- if (Object.keys(patch).length) writeUnitBases(siteDir, patch)
1676
+ if (Object.keys(patch).length) writeUnitBases(siteDir, client.origin, patch)
1598
1677
  // The backend's post-write document carries `$uuid` per item at every nesting
1599
1678
  // level, so the push that just landed also re-arms identity for the next one.
1600
1679
  // Replaced wholesale: an item that no longer exists must not keep a uuid that
1601
1680
  // would re-target something else.
1602
- if (theirs) writeItemUuids(siteDir, collectUnitUuids(theirs))
1681
+ if (theirs) writeItemUuids(siteDir, client.origin, collectUnitUuids(theirs))
1603
1682
  else {
1604
1683
  // ⛔ BANKING IS BEST-EFFORT AND ITS FAILURE USED TO BE SILENT — say it here,
1605
1684
  // because the cost lands two commands away and names something else.