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.
- package/package.json +7 -7
- package/partials/agents.md +24 -32
- package/src/backend/asset-download.js +11 -9
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +19 -14
- package/src/backend/service-request.js +14 -9
- package/src/backend/site-media.js +6 -6
- package/src/backend/site-sync.js +323 -221
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/doctor.js +6 -7
- package/src/commands/forget.js +197 -0
- package/src/commands/i18n.js +19 -10
- package/src/commands/publish.js +112 -77
- package/src/commands/pull.js +63 -49
- package/src/commands/push.js +76 -60
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +37 -23
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +7 -7
- package/src/index.js +101 -76
- 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/git.js +1 -1
- package/src/utils/interactive.js +38 -0
- package/src/utils/records-guard.js +38 -30
- package/src/utils/registry-auth.js +150 -28
- package/src/utils/registry-orgs.js +19 -0
- 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/templates/site/site.yml.hbs +11 -9
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')
|
|
203
232
|
}
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
*
|
|
214
|
-
* content hashes, the injections those hashes were
|
|
215
|
-
* unit bases — and a unit path (`site.yml`,
|
|
216
|
-
* string for every site. So
|
|
217
|
-
* self-
|
|
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
|
|
221
|
-
* uuid-bound lane
|
|
222
|
-
* recovery after a site is deleted in the app. Following it left the
|
|
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
|
-
|
|
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: {},
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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) =>
|
|
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
|
|
383
|
+
// Two ways the maps can describe a site this clone is not working with:
|
|
302
384
|
//
|
|
303
|
-
// 1. UNBOUND — no
|
|
304
|
-
// be about. This
|
|
305
|
-
// a new site" recovery
|
|
306
|
-
// 2. BOUND TO A DIFFERENT SITE —
|
|
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
|
-
|
|
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
|
|
378
|
-
* holding exactly that map (local ref → `{id, ext}`), written
|
|
379
|
-
* a gitignored second copy would be a second thing to
|
|
380
|
-
*
|
|
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 `
|
|
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 `
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
627
|
+
export function readQueryUuids(siteDir, backend) {
|
|
628
|
+
return readBackendState(siteDir, backend).queries || {}
|
|
540
629
|
}
|
|
541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
558
|
-
* with the `@` the CLI and the wire both use. Callers
|
|
559
|
-
* so an org named once, at create, does not have to
|
|
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
|
-
|
|
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
|
-
}
|
|
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`
|
|
619
|
-
* (`
|
|
620
|
-
*
|
|
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.
|
|
626
|
-
* 4. the site already exists (
|
|
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
|
|
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 }
|
|
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
|
|
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
|
-
|
|
856
|
-
|
|
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
|
|
962
|
-
: `Created the site on
|
|
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 (
|
|
996
|
-
*
|
|
997
|
-
* backend's current document,
|
|
998
|
-
* legitimately has nothing to fetch and is
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
|
1067
|
-
// wrote. Reading the live file rather than a
|
|
1068
|
-
// map (a teammate's push, a pull) read as
|
|
1069
|
-
// of itself.
|
|
1070
|
-
// · RECORDED — the site's own org, from `
|
|
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
|
|
1102
|
-
* reads as changed rather than matching
|
|
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
|
|
1106
|
-
*
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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('
|
|
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 —
|
|
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.
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
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
|
|
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
|
|
1383
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1562
|
-
//
|
|
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.
|