uniweb 0.59.1 → 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.
- package/package.json +3 -3
- package/partials/agents.md +6 -4
- package/src/backend/asset-download.js +6 -4
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +12 -12
- package/src/backend/service-request.js +12 -8
- package/src/backend/site-sync.js +243 -164
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/forget.js +197 -0
- package/src/commands/publish.js +102 -73
- package/src/commands/pull.js +58 -46
- package/src/commands/push.js +69 -53
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +10 -14
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +4 -4
- package/src/index.js +100 -75
- package/src/utils/args.js +3 -4
- package/src/utils/asset-upload.js +18 -5
- package/src/utils/config.js +79 -42
- package/src/utils/flag-guard.js +70 -27
- package/src/utils/interactive.js +38 -0
- package/src/utils/records-guard.js +3 -2
- package/src/utils/registry-auth.js +150 -28
- package/src/utils/scaffold.js +11 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +4 -3
- package/src/utils/site-identity.js +89 -299
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
200
|
+
function readCacheFile(siteDir) {
|
|
184
201
|
try {
|
|
185
|
-
const obj = JSON.parse(readFileSync(
|
|
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
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
622
|
+
export function readQueryUuids(siteDir, backend) {
|
|
623
|
+
return readBackendState(siteDir, backend).queries || {}
|
|
540
624
|
}
|
|
541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
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
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
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
|
-
|
|
856
|
-
|
|
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
|
|
962
|
-
: `Created the site on
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
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('
|
|
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 —
|
|
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.
|
|
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)
|
|
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
|
|
1562
|
-
//
|
|
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.
|