uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,2515 @@
1
+ /**
2
+ * site-sync — the reusable core of `uniweb push`: given a site's emitted sync
3
+ * packages, submit them over the two directional lanes (site-content first, then the
4
+ * folder keyed by the site's uuid), back-fill the minted uuids into the source files,
5
+ * and persist the send-only-changed cache. Extracted from the push command so
6
+ * `uniweb publish` (the composite path) reuses the exact same lane submission.
7
+ *
8
+ * The command keeps flag parsing, the emit, and the `-o`/`--dry-run` preview;
9
+ * everything from "the packages are built, now POST them" lives here. Logging is
10
+ * injected via `report` ({ info, note, error, dim }) so each caller styles output its
11
+ * own way.
12
+ */
13
+
14
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
15
+ import { join, dirname, relative, isAbsolute, basename } from 'node:path'
16
+ import yaml from 'js-yaml'
17
+ import { hasUncommittedContent } from '../utils/git.js'
18
+ import { recordWritten } from '../utils/pull-written.js'
19
+ import { humanBytes } from '../utils/bytes.js'
20
+ import { isAuthoredPreview } from '../utils/preview.js'
21
+ import {
22
+ describeRequestError,
23
+ describeWorkspace,
24
+ WorkspaceMismatchError,
25
+ workspaceHandle,
26
+ workspaceHeader
27
+ } from './client.js'
28
+ import {
29
+ backfillEntityUuids,
30
+ writeSiteEntityUuid,
31
+ updateBackendState,
32
+ emitSyncPackages,
33
+ readZip,
34
+ diffSiteUnits,
35
+ describeSiteDiff,
36
+ computeUnitHashes,
37
+ collectUnitUuids,
38
+ collectFolderItemUuids,
39
+ collectQueryUuids,
40
+ siteItemsByKey,
41
+ sameServiceRow,
42
+ heldServices,
43
+ readBackendState,
44
+ updateBackendMap,
45
+ clearBackendSections,
46
+ normalizeBackendOrigin,
47
+ writeSiteConfig,
48
+ harvestRecordItems,
49
+ storedRecordItems,
50
+ reprintRecordItems,
51
+ matchStoredRecords,
52
+ parseCatalogRef,
53
+ readRegisteredFoundation,
54
+ writeRegisteredFoundation,
55
+ backfillLinkUuids,
56
+ LINK_MODEL,
57
+ bankedFileUuids,
58
+ existingSectionFile,
59
+ layoutUnitFile
60
+ } from '@uniweb/build/uwx'
61
+
62
+ // First entity `$`-document out of a `.uwx` we produced or the backend served.
63
+ // Deliberately NOT pull.js's `readPullDocuments`: that one is tolerant of JSON
64
+ // envelopes for a lane whose shape may change, and importing it here would be a
65
+ // cycle (pull.js already imports this module). Both lanes here are always ZIPs.
66
+ function entityDocFromUwx(buf) {
67
+ if (!buf || buf.length < 2 || buf[0] !== 0x50 || buf[1] !== 0x4b) return null
68
+ for (const [name, data] of readZip(buf)) {
69
+ if (name === 'manifest.json' || !name.endsWith('.json')) continue
70
+ try {
71
+ return JSON.parse(data.toString('utf8'))
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+ return null
77
+ }
78
+
79
+ // Every entity `$`-document of a `.uwx` — the folder lane holds the folder and its records.
80
+ function entityDocsFromUwx(buf) {
81
+ const docs = []
82
+ if (!buf || buf.length < 2 || buf[0] !== 0x50 || buf[1] !== 0x4b) return docs
83
+ for (const [name, data] of readZip(buf)) {
84
+ if (name === 'manifest.json' || !name.endsWith('.json')) continue
85
+ try {
86
+ docs.push(JSON.parse(data.toString('utf8')))
87
+ } catch {
88
+ // not a document
89
+ }
90
+ }
91
+ return docs
92
+ }
93
+
94
+ /**
95
+ * Turn an entity-grained staleness refusal into a file-level account.
96
+ *
97
+ * The gate can only report that the site-content document moved. What the user
98
+ * needs is which FILES — content is split one section per file, so two people
99
+ * editing different sections of the same page have not conflicted — and, since we
100
+ * cache per-unit hashes of the last agreed state, which side moved them. Fetches the backend's current document (a plain
101
+ * read; it writes nothing locally, unlike `uniweb pull`) and diffs.
102
+ *
103
+ * Best-effort by design: this runs on a path that has ALREADY failed, so any
104
+ * problem fetching or parsing must degrade to the plain refusal rather than
105
+ * replace a clear error with a confusing one.
106
+ *
107
+ * @returns {string[]} lines to print, or [] when nothing useful can be said.
108
+ */
109
+ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
110
+ try {
111
+ if (!uuid) return []
112
+ const res = await client.pullSiteContent(uuid)
113
+ if (!res?.ok) return []
114
+ const remoteDoc = entityDocFromUwx(Buffer.from(await res.arrayBuffer()))
115
+ const localDoc = entityDocFromUwx(localBuffer)
116
+ if (!remoteDoc || !localDoc) return []
117
+ return describeSiteDiff(
118
+ diffSiteUnits(localDoc, remoteDoc, readUnitBases(siteDir, client.origin))
119
+ )
120
+ } catch {
121
+ return []
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The items a `stale_base` refusal names by key — `stale_keys`, one
127
+ * `{ section, key }` per clashing item of a Section matched by a field of its own
128
+ * (`services` and `queries` by `name`) — as one line per Section.
129
+ *
130
+ * @param {*} staleKeys - the refusal's `stale_keys`
131
+ * @returns {string[]}
132
+ */
133
+ export function describeStaleKeys(staleKeys) {
134
+ const bySection = new Map()
135
+ for (const entry of Array.isArray(staleKeys) ? staleKeys : []) {
136
+ const section = typeof entry?.section === 'string' && entry.section ? entry.section : null
137
+ if (!section) continue
138
+ const key = entry?.key
139
+ // A single-item Section (`settings`) is named by the Section alone, `key: {}`.
140
+ const single = key && typeof key === 'object' && !Object.keys(key).length
141
+ const name =
142
+ key && typeof key === 'object'
143
+ ? typeof key.name === 'string' ? key.name : Object.values(key).filter((v) => typeof v === 'string').join(' ')
144
+ : typeof key === 'string' ? key : null
145
+ if (!single && !name) continue
146
+ if (!bySection.has(section)) bySection.set(section, [])
147
+ if (name) bySection.get(section).push(name)
148
+ }
149
+ return [...bySection].map(([section, names]) =>
150
+ `Changed on your site since your last pull — ${section}${names.length ? `: ${names.join(', ')}` : ''}`
151
+ )
152
+ }
153
+
154
+ // A unit in the form it is compared in across the two representations: keys sorted,
155
+ // `$`-keys dropped (`$uuid`, `$id` — identity and payload handles, not content).
156
+ const canonicalUnit = (v) =>
157
+ Array.isArray(v)
158
+ ? v.map(canonicalUnit)
159
+ : v && typeof v === 'object'
160
+ ? Object.fromEntries(
161
+ Object.keys(v)
162
+ .filter((k) => !k.startsWith('$'))
163
+ .sort()
164
+ .map((k) => [k, canonicalUnit(v[k])])
165
+ )
166
+ : v
167
+
168
+ /**
169
+ * Whether the backend's post-write copy of a unit is the content we sent.
170
+ *
171
+ * Compared on OUR keys only. The two are not byte-comparable — the backend's copy
172
+ * carries fields we don't emit (`params`, `theme_override`, its own `$uuid`) and
173
+ * orders keys its own way, which is why `site-diff.js` never hashes one side against
174
+ * the other — but what we emit, it stores verbatim: measured 2026-09-23 against a
175
+ * live `uniwebd`, 15 of 15 units equal on our keys after a push.
176
+ */
177
+ function writtenAsSent(sent, written) {
178
+ if (!sent || !written) return false
179
+ const onOurKeys = {}
180
+ for (const k of Object.keys(sent)) if (!k.startsWith('$')) onOurKeys[k] = written[k]
181
+ return JSON.stringify(canonicalUnit(sent)) === JSON.stringify(canonicalUnit(onOurKeys))
182
+ }
183
+
184
+ /**
185
+ * The post-write tokens a site-content push may bank: only those that describe
186
+ * content this copy HOLDS.
187
+ *
188
+ * ⛔ BANKING EVERY RETURNED TOKEN LOST OTHER WRITERS' WORK — measured 2026-09-23 on a
189
+ * live `uniwebd`, two copies of one site:
190
+ *
191
+ * 1. B edits section X and pushes.
192
+ * 2. A, which has not pulled, pushes an edit to section Y. The per-item gate reads
193
+ * A's X as untouched (`pkg == base`) and keeps B's X — correct. But the response
194
+ * carries X's token for B's content, and A banked it.
195
+ * 3. A's next push of X then reads as "only you changed it" (`host == base`) and
196
+ * overwrites B's edit, with no refusal.
197
+ *
198
+ * The entity token did the same to ADDITIONS: a section B added survived A's first
199
+ * push (created after A's base ⇒ KEEP), A banked a base newer than it, and A's next
200
+ * push deleted it (created at-or-before the base ⇒ "you dropped it").
201
+ *
202
+ * So a unit's token is banked only when its post-write copy is what we sent — our own
203
+ * write, or a unit nobody touched. A unit the backend kept for someone else keeps the
204
+ * token we already hold, which is the one for OUR content, so the next push that
205
+ * changes it is `pkg != base, host != base` and refused. And while the backend holds
206
+ * units we don't, the entity token stays where it was, so the next push keeps them.
207
+ *
208
+ * ⭐ SINCE 2026-10-07 A HELD VERSION IS ALSO WHAT LETS A PUSH DELETE, so the rule covers
209
+ * every item, not only units. The site-content entity sends every version this copy
210
+ * holds, for items present and items the files dropped (`withBaseVersion`), and the
211
+ * backend deletes only the items a push holds a version for, while the site left them
212
+ * unchanged [Diego: "yes, apply it to pages too"; kb/framework/plans/services-exchange.md].
213
+ * A version banked for an item this copy never saw would therefore delete it. The map
214
+ * returned is the WHOLE set this copy now holds, in every Section but `secrets`
215
+ * (`siteItemsByKey`), and it replaces the one held before:
216
+ *
217
+ * - an item the backend holds as we sent it → its new version;
218
+ * - an item we sent that it kept for someone else → the version we held, for OUR content;
219
+ * - an item it holds that we did not send → the version we held, if we had seen it, and
220
+ * none if we had not (a unit of it is `foreign`);
221
+ * - an item we held that it no longer holds → none: the push deleted it, or the site did.
222
+ *
223
+ * ⛔ Until then only units were compared, and every other item's version was banked as
224
+ * the backend returned it — harmless only while none of them was ever sent.
225
+ *
226
+ * A service is compared by its switch and its whole `config` (`sameServiceRow`), every
227
+ * other item on the keys we sent (`writtenAsSent`).
228
+ *
229
+ * With either document missing there is nothing to compare, and with no versions
230
+ * returned (an older backend) nothing to bank: `itemVersions` is null, and the versions
231
+ * held stay as they were. The contract is that `finalized[].document` is always there.
232
+ *
233
+ * @param {object} p
234
+ * @param {object|null} p.sent - the site-content document this push sent
235
+ * @param {object|null} p.written - the backend's post-write copy of it
236
+ * @param {string|null} [p.version] - the entity's post-write version
237
+ * @param {Object<string,string>|null} [p.itemVersions] - the post-write version of every item
238
+ * @param {Object<string,string>} [p.held] - the versions this copy held before the push
239
+ * @returns {{ version: string|null, itemVersions: Object<string,string>|null,
240
+ * held: string[], kept: string[], foreign: string[] }} `itemVersions`: the versions
241
+ * this copy now holds, or null to leave them as they were; `held`: units the backend
242
+ * holds as we sent them; `kept`: units it kept someone else's content for;
243
+ * `foreign`: units it holds that we did not send. All paths.
244
+ */
245
+ export function heldTokens({ sent, written, version = null, itemVersions = null, held: before = {} }) {
246
+ if (!sent || !written) return { version, itemVersions: null, held: [], kept: [], foreign: [] }
247
+ const prior = before && typeof before === 'object' ? before : {}
248
+ const ours = siteItemsByKey(sent)
249
+ const theirs = siteItemsByKey(written)
250
+ const same = (key, a, b) => (key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b))
251
+ const held = []
252
+ const kept = []
253
+ const foreign = []
254
+ const items = {}
255
+ for (const [key, item] of theirs) {
256
+ if (!item.uuid) continue
257
+ const unit = key.startsWith('unit:') ? key.slice('unit:'.length) : null
258
+ const mine = ours.get(key)
259
+ if (mine && same(key, mine.record, item.record)) {
260
+ if (unit) held.push(unit)
261
+ const token = itemVersions?.[item.uuid] ?? prior[item.uuid]
262
+ if (token) items[item.uuid] = token
263
+ continue
264
+ }
265
+ if (unit) (mine ? kept : foreign).push(unit)
266
+ if (prior[item.uuid]) items[item.uuid] = prior[item.uuid]
267
+ }
268
+ return {
269
+ version: foreign.length ? null : version,
270
+ itemVersions: itemVersions ? items : null,
271
+ held: held.sort(),
272
+ kept: kept.sort(),
273
+ foreign: foreign.sort()
274
+ }
275
+ }
276
+
277
+ /**
278
+ * The versions a pull leaves this copy holding: one for every site-content item the
279
+ * pulled document carries, in every Section but `secrets` (`siteItemsByKey`) — the
280
+ * whole set, replacing what was held. A pull is how a copy sees the site, so an item
281
+ * the site no longer has is forgotten, and the next push does not send its version.
282
+ *
283
+ * @param {object|null} doc - the pulled site-content document
284
+ * @param {Object<string,string>|null} itemVersions - the pull manifest's `item_versions`
285
+ * @returns {Object<string,string>|null} null when there is nothing to bank (no document, or
286
+ * an older backend that sends no item versions)
287
+ */
288
+ export function pulledItemVersions(doc, itemVersions) {
289
+ if (!doc || !itemVersions || typeof itemVersions !== 'object') return null
290
+ const out = {}
291
+ for (const { uuid } of siteItemsByKey(doc).values()) {
292
+ if (uuid && typeof itemVersions[uuid] === 'string') out[uuid] = itemVersions[uuid]
293
+ }
294
+ return out
295
+ }
296
+
297
+ // The files a unit projects to, under the site's own roots — `site.yml::paths` can
298
+ // relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
299
+ // to three files.
300
+ //
301
+ // ⭐ A section unit's path names it by its id (`pages/about/about.md`), and the author's
302
+ // file may carry an ordering prefix or a child mark (`1-about.md`, `@about.md`), which a
303
+ // pull writes back into in place. So the unit is looked up as a pull finds it
304
+ // (`existingSectionFile`). ⛔ Until 2026-10-07 the id path was taken as the file: a push
305
+ // recorded no file for a numbered section, and the next `pull --merge` merged against an
306
+ // older version — a false conflict on a line only the other side had changed (measured
307
+ // on the starter's `1-welcome.md`).
308
+ export function unitFilesOf(siteDir, unitPath) {
309
+ if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
310
+ let paths = {}
311
+ try {
312
+ paths = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths || {}
313
+ } catch {
314
+ /* no or unreadable site.yml — the defaults are right */
315
+ }
316
+ // A layout unit names its layout and area (`footer.md`, `default/header/1-alert.md`); the author may
317
+ // keep it as `default/footer.md` or `default/header/0-alert.md`, where a pull writes it back
318
+ // (`layoutUnitFile`). ⛔ Until 2026-10-08 it was looked up beside the unit's own path only.
319
+ if (unitPath.startsWith('layout/')) {
320
+ const base = join(siteDir, paths.layout || 'layout')
321
+ return [relative(siteDir, layoutUnitFile(base, unitPath.slice('layout/'.length)))]
322
+ }
323
+ if (unitPath.startsWith('pages/')) {
324
+ const rel = `${paths.pages || 'pages'}${unitPath.slice('pages'.length)}`
325
+ if (!rel.endsWith('.md') || existsSync(join(siteDir, rel))) return [rel]
326
+ const id = basename(rel, '.md')
327
+ const found = existingSectionFile(join(siteDir, dirname(rel)), id, id, { child: true })
328
+ return [found ? relative(siteDir, found) : rel]
329
+ }
330
+ return []
331
+ }
332
+
333
+ /**
334
+ * Record, as synced, the files of the units a push left on the backend as we sent them.
335
+ *
336
+ * ⭐ WHAT A PUSH DELIVERED IS THE NEXT MERGE'S ANCESTOR. `pull --merge` finds the
337
+ * version a file last synced at by the hash in `pull-written.json`. A pull records what
338
+ * it wrote; this records what a push delivered — the units `heldTokens` says the
339
+ * backend holds as ours, never one it kept for someone else. Without it, after a merge
340
+ * and a push the ancestor was the pull's raw output, which nobody commits: the next
341
+ * merge found no common version and put both whole files under conflict markers, for
342
+ * edits on different lines (measured 2026-09-23).
343
+ */
344
+ function recordPushedUnits(siteDir, unitPaths) {
345
+ const files = new Set()
346
+ for (const p of unitPaths) for (const f of unitFilesOf(siteDir, p)) files.add(f)
347
+ const present = [...files].map((f) => join(siteDir, f)).filter((abs) => existsSync(abs))
348
+ if (present.length) recordWritten(siteDir, present)
349
+ }
350
+
351
+ // Pull the finalized entities out of the restore response. The backend returns
352
+ // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
353
+ // carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
354
+ // is not echoed), the minted entity `uuid`, a `changed` flag, and the full `document`
355
+ // (verbatim stored content with every `$uuid` filled in). A couple of shapes are
356
+ // tolerated; only entries with a valid `index` + `uuid` are usable.
357
+ export function extractFinalized(payload) {
358
+ const list = Array.isArray(payload?.report?.finalized)
359
+ ? payload.report.finalized
360
+ : Array.isArray(payload?.finalized)
361
+ ? payload.finalized
362
+ : Array.isArray(payload)
363
+ ? payload
364
+ : null
365
+ if (!list) return null
366
+ return list
367
+ .map((d) => ({
368
+ index: d?.index,
369
+ uuid: d?.uuid ?? d?.document?.$uuid ?? null,
370
+ changed: d?.changed,
371
+ // Post-write optimistic-concurrency token, read back from the stored row.
372
+ // Do not branch on `changed`: the backend pins "zero-write ⇒ version unmoved"
373
+ // with a test, so a no-op resubmit returns the value we already hold. ⛔ But a
374
+ // site-content push banks it only while the stored document holds nothing we
375
+ // don't — see `heldTokens`, and what banking it unconditionally deleted.
376
+ version: typeof d?.version === 'string' ? d.version : null,
377
+ // Per-item post-write tokens, `{ recordUuid: <opaque> }` (backend `d7e46335`).
378
+ // Same map shape and same key name the PULL manifest entry stamps, deliberately
379
+ // — one kind of value, cached from whichever lane last handed it over, echoed
380
+ // as `item_base_versions`. Absent on an older backend ⇒ null ⇒ the cached
381
+ // tokens are left alone and gating degrades to the entity grain.
382
+ itemVersions:
383
+ d?.item_versions && typeof d.item_versions === 'object'
384
+ ? d.item_versions
385
+ : null,
386
+ document: d?.document ?? null
387
+ }))
388
+ .filter((e) => Number.isInteger(e.index) && e.uuid)
389
+ }
390
+
391
+ // Pull the minted site-content uuid out of a CREATE response. The exact shape is an
392
+ // open backend item, so the extractor is deliberately tolerant of a bare
393
+ // `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
394
+ // envelope the update/folder lanes return (the site entity is submitted alone, so its
395
+ // minted uuid is the first finalized entry). Returns null if none is present.
396
+ export function extractMintedSiteUuid(payload) {
397
+ // `POST /dev/site` (the empty-site create) answers with the snake_case spelling;
398
+ // the content-lane CREATE has historically used the others. Both are the same
399
+ // identity — the uuid `/dev/site/content/pull/{uuid}` accepts.
400
+ if (typeof payload?.site_content_uuid === 'string')
401
+ return payload.site_content_uuid
402
+ if (typeof payload?.siteContentUuid === 'string')
403
+ return payload.siteContentUuid
404
+ if (typeof payload?.$uuid === 'string') return payload.$uuid
405
+ if (typeof payload?.uuid === 'string') return payload.uuid
406
+ const finalized = extractFinalized(payload)
407
+ if (finalized && finalized.length) {
408
+ const site = finalized.find((f) => f.index === 0) || finalized[0]
409
+ return site?.uuid ?? null
410
+ }
411
+ return null
412
+ }
413
+
414
+ // One-line summary from the authoritative per-entity `changed` flag (`false` = a true
415
+ // no-op). Falls back silently when the backend omits it.
416
+ function changedSummary(finalized) {
417
+ const changed = finalized.filter((f) => f.changed === true).length
418
+ const unchanged = finalized.filter((f) => f.changed === false).length
419
+ const parts = []
420
+ if (changed) parts.push(`${changed} changed`)
421
+ if (unchanged) parts.push(`${unchanged} unchanged`)
422
+ return parts.length ? parts.join(', ') : null
423
+ }
424
+
425
+ // Resolve a Model NOT defined by the local foundation by reading its declaration (the
426
+ // `@uniweb/data-schema` form) from the backend via the client. Cached per run; HTTP
427
+ // 404 → null (the emitter then says "register it first"). The bearer is acquired lazily
428
+ // by the client, so a fully-local sync never authenticates.
429
+ //
430
+ // `offline` (set for `-o` / `--dry-run`, and for `status`) never touches the backend — an
431
+ // offline emit must never authenticate. It answers with the Models a push or a pull KEPT for
432
+ // this backend (`keptModels`), or null. ⭐ With `siteDir`, an online read keeps each Model it
433
+ // reads, per backend — so a clone, whose foundation is not in the project to declare them, can
434
+ // still compare offline. ⛔ Until 2026-09-26 nothing was kept, and `uniweb status` in a clone
435
+ // could not resolve a Model ("… could not be resolved").
436
+ export function makeModelResolver({ client, offline = false, siteDir = null, backend = null }) {
437
+ const origin = backend ?? client?.origin ?? null
438
+ const kept = siteDir && origin ? keptModels(siteDir, origin) : {}
439
+ const cache = new Map()
440
+ return async (modelName) => {
441
+ if (cache.has(modelName)) return cache.get(modelName)
442
+ let decl
443
+ if (offline) {
444
+ decl = kept[modelName] ?? null
445
+ } else {
446
+ decl = await client.readDataSchema(modelName)
447
+ if (decl && siteDir && origin) keepModels(siteDir, origin, { [modelName]: decl })
448
+ }
449
+ cache.set(modelName, decl)
450
+ return decl
451
+ }
452
+ }
453
+
454
+ /**
455
+ * The Models a push or a pull read from this backend, by name — kept in `.uniweb/backend-cache.json`
456
+ * so an offline emit resolves what an online one did. Regenerable: losing them costs `status` its
457
+ * comparison until the next push or pull, never a wrong answer.
458
+ */
459
+ export function keptModels(siteDir, backend) {
460
+ const models = readSyncCacheFile(siteDir, backend).models
461
+ return models && typeof models === 'object' ? models : {}
462
+ }
463
+
464
+ /** Keep Models read from this backend, beside the ones already kept. */
465
+ export function keepModels(siteDir, backend, models) {
466
+ if (!models || !Object.keys(models).length) return
467
+ updateSyncCache(siteDir, backend, { models: { ...keptModels(siteDir, backend), ...models } })
468
+ }
469
+
470
+ // `.uniweb/backend-cache.json` — what a backend had and what we last sent it, so the
471
+ // next transfer can skip work. Gitignored, per clone, safe to delete.
472
+ //
473
+ // ⭐ **Everything here is regenerable and losing it never produces a WRONG result** —
474
+ // only a slower transfer or a round trip. That is the membership test; anything
475
+ // failing it belongs in `sync.json` (which is where the uuid maps went on
476
+ // 2026-09-20 — see `readItemUuids` below). ⚠️ One exception since 2026-10-07, and it
477
+ // is said where it lives: the item versions are what lets a push delete, so without
478
+ // them a dropped page is kept until the next pull (`readItemBaseVersions`).
479
+ //
480
+ // ⚠️ It was `sync-cache.json` and its header called it "a pure wire-efficiency
481
+ // cache — NOT identity, the minted `$uuid` lives in the source files". That was
482
+ // true of `hashes`, which it was written for, and the uuid maps were added later
483
+ // under the same sentence without anyone revisiting it. They were backend-minted
484
+ // identity in a cache, and the `queryUuids` docblock one screen below said so.
485
+ //
486
+ // ⛔ **Keyed by backend ORIGIN.** Every map describes ONE backend: hashes are what we
487
+ // last sent *to it*, base versions are the versions *it* had. Shared across backends
488
+ // they report every entity as changed against the second one.
489
+ //
490
+ // Spec: kb/framework/reference/backend-cache-json.md
491
+ function backendCachePath(siteDir) {
492
+ return join(siteDir, '.uniweb', 'backend-cache.json')
493
+ }
494
+ function readCacheFile(siteDir) {
495
+ try {
496
+ const obj = JSON.parse(readFileSync(backendCachePath(siteDir), 'utf8'))
497
+ return obj && typeof obj === 'object' && obj.backends ? obj : { backends: {} }
498
+ } catch {
499
+ return { backends: {} } // missing / unreadable → treat everything as changed
500
+ }
501
+ }
502
+ const cacheKeyFor = (backend) => normalizeBackendOrigin(backend) || ''
503
+ function readSyncCacheFile(siteDir, backend) {
504
+ const key = cacheKeyFor(backend)
505
+ if (!key) return {}
506
+ const section = readCacheFile(siteDir).backends[key]
507
+ return section && typeof section === 'object' ? section : {}
508
+ }
509
+ // The cache holds several maps written on DIFFERENT events — content hashes and the
510
+ // injections that produced them on a successful push, base versions on push AND
511
+ // pull, unit hashes on push and pull — so every writer must preserve the ones it
512
+ // isn't touching. One merge point rather than a hand-rolled preserve per writer,
513
+ // because getting that wrong silently disarms whichever map got clobbered.
514
+ function updateSyncCache(siteDir, backend, patch) {
515
+ const key = cacheKeyFor(backend)
516
+ // ⛔ No backend, no cache. Writing under a blank key would pool every backend's
517
+ // state into one section, which is the bug this keying exists to remove.
518
+ if (!key) return
519
+ const p = backendCachePath(siteDir)
520
+ mkdirSync(dirname(p), { recursive: true })
521
+ const file = readCacheFile(siteDir)
522
+ file.backends[key] = { ...(file.backends[key] || {}), ...patch }
523
+ const sorted = {}
524
+ for (const k of Object.keys(file.backends).sort()) sorted[k] = file.backends[k]
525
+ writeFileSync(p, JSON.stringify({ version: 1, backends: sorted }, null, 2) + '\n')
526
+ }
527
+ /**
528
+ * Remove ONE backend's section from `.uniweb/backend-cache.json`. Every other
529
+ * backend's section is untouched — which is the point of keying by origin.
530
+ *
531
+ * @returns {boolean} whether a section was removed
532
+ */
533
+ export function forgetBackendCache(siteDir, backend) {
534
+ const key = cacheKeyFor(backend)
535
+ if (!key) return false
536
+ const file = readCacheFile(siteDir)
537
+ if (!file.backends[key]) return false
538
+ delete file.backends[key]
539
+ const p = backendCachePath(siteDir)
540
+ // The last section gone ⇒ the file goes, as `sync.json` does in the same case.
541
+ if (!Object.keys(file.backends).length) {
542
+ unlinkSync(p)
543
+ return true
544
+ }
545
+ const sorted = {}
546
+ for (const k of Object.keys(file.backends).sort()) sorted[k] = file.backends[k]
547
+ writeFileSync(p, JSON.stringify({ version: 1, backends: sorted }, null, 2) + '\n')
548
+ return true
549
+ }
550
+
551
+ /**
552
+ * Delete `.uniweb/backend-cache.json` outright — `uniweb forget --all`. Every section
553
+ * describes a site the COPY does not own; the file is regenerable by definition.
554
+ *
555
+ * @returns {boolean} whether there was a file to remove
556
+ */
557
+ export function forgetAllBackendCaches(siteDir) {
558
+ const p = backendCachePath(siteDir)
559
+ if (!existsSync(p)) return false
560
+ unlinkSync(p)
561
+ return true
562
+ }
563
+
564
+ const readMap = (siteDir, backend, key) => {
565
+ const v = readSyncCacheFile(siteDir, backend)[key]
566
+ return v && typeof v === 'object' ? v : {}
567
+ }
568
+
569
+ /**
570
+ * Drop every cache map that describes a BACKEND site, when this clone is bound to
571
+ * none. Returns the names dropped (empty when there was nothing to do).
572
+ *
573
+ * Five maps are keyed by *unit path* — item uuids (`sync.json`'s `items`) and, in
574
+ * `.uniweb/backend-cache.json`, content hashes, the injections those hashes were
575
+ * taken over, entity base versions and unit bases — and a unit path (`site.yml`,
576
+ * `pages/about/about.md`) is the same string for every site. So nothing in them
577
+ * self-invalidates when the site's uuid goes away: they keep describing the site
578
+ * this folder used to be.
579
+ *
580
+ * Which was a state we ACTIVELY TOLD PEOPLE TO ENTER. Until 2026-09-21 the 404
581
+ * guidance on a uuid-bound lane said to clear `$uuid` to re-publish as a new site —
582
+ * the documented recovery after a site is deleted in the app. Following it left the
583
+ * stale maps in place, and the next publish failed two ways:
584
+ *
585
+ * - **item uuids** — the new site's document carried the OLD site's item
586
+ * identities, and the backend correctly refused: *"item uuid … is already
587
+ * stored on entity 156 — item uuids are globally unique; cross-entity move is
588
+ * not supported"*. A raw 400 for following our own advice.
589
+ * - **content hashes** — worse, because it is silent. Send-only-changed would
590
+ * skip every entity whose content had not changed since the OLD site's last
591
+ * push, so the NEW site would come up **missing exactly the content that did
592
+ * not change** — a partial site, published successfully, with nothing to
593
+ * indicate it.
594
+ *
595
+ * The guidance is `uniweb forget --server <url>` now, which removes the uuid and the
596
+ * maps together, so following it no longer leads here.
597
+ *
598
+ * Call this BEFORE `ensureSiteExists`, which mints a uuid and would otherwise make
599
+ * the clone look bound before the check runs.
600
+ */
601
+ /**
602
+ * Drop the five remote-derived maps unconditionally, stamping the site they now
603
+ * describe (or clearing the stamp when there is none). Shared by the pre-flight
604
+ * guard and the `item_uuid_conflict` recovery — the guard decides WHETHER, this
605
+ * decides WHAT, and they must not drift.
606
+ */
607
+ export function clearRemoteSyncState(siteDir, backend, siteUuid = null) {
608
+ const prior = readSyncCacheFile(siteDir, backend)
609
+ const dropped = ['hashes', 'baseVersions', 'unitBases', 'applied'].filter(
610
+ (k) => prior[k] && Object.keys(prior[k]).length
611
+ )
612
+ updateSyncCache(siteDir, backend, {
613
+ hashes: {},
614
+ baseVersions: {},
615
+ unitBases: {},
616
+ // Remote-derived like the rest, and doubly so: it holds the OLD site's asset
617
+ // serve URLs. Surviving the drop, it would rewrite the new site's media to
618
+ // bytes owned by the site this folder used to be.
619
+ applied: {},
620
+ siteUuid: siteUuid || null
621
+ })
622
+ // ⛔ THE IDENTITY MAPS ARE CLEARED WITH THE REST, and must be. They live in
623
+ // `sync.json` now rather than the cache, but they describe the same dead site:
624
+ // leaving them behind is what makes the backend refuse — "item uuid … is already
625
+ // stored on entity N; cross-entity move is not supported" — on the very next push.
626
+ // Two files, one invalidation.
627
+ const identity = clearBackendSections(siteDir, backend, [
628
+ 'items',
629
+ 'queries',
630
+ 'folders'
631
+ ])
632
+ // Reported under the names the cache used, so the message a user sees is the same
633
+ // one it always was.
634
+ const NAMED = { items: 'itemUuids', queries: 'queryUuids', folders: 'folderItemUuids' }
635
+ return [...dropped, ...identity.map((k) => NAMED[k])]
636
+ }
637
+
638
+ export function clearRemoteSyncStateIfUnbound(siteDir, backend) {
639
+ // ⭐ "Bound" is a question about THIS backend: the identity is `sync.json`'s, keyed
640
+ // by origin. A project bound on one backend and not another is an ordinary state
641
+ // now, not a contradiction — which is why the stamp below still earns its keep even
642
+ // though cross-backend confusion became unrepresentable. What it still catches is a
643
+ // site DELETED on this backend and re-created: same origin, new uuid.
644
+ const current = readBackendState(siteDir, backend).site?.uuid || null
645
+
646
+ const prior = readSyncCacheFile(siteDir, backend)
647
+ // ⛔ TWO FILES, ONE QUESTION. The cache holds the speed-ups and `sync.json` holds
648
+ // the identity maps, but "is any of this describing a site this clone is not
649
+ // working with?" is asked of BOTH — a clone whose only stale state is identity is
650
+ // exactly the one the backend refuses on the next push.
651
+ const identityPrior = {
652
+ itemUuids: readItemUuids(siteDir, backend),
653
+ queryUuids: readQueryUuids(siteDir, backend),
654
+ folderItemUuids: readFolderItemUuids(siteDir, backend)
655
+ }
656
+ const REMOTE_MAPS = [
657
+ 'itemUuids',
658
+ 'hashes',
659
+ 'baseVersions',
660
+ 'unitBases',
661
+ 'applied'
662
+ ]
663
+ const lookup = { ...prior, ...identityPrior }
664
+ const populated = REMOTE_MAPS.filter(
665
+ (k) => lookup[k] && Object.keys(lookup[k]).length
666
+ )
667
+ if (!populated.length) {
668
+ // Nothing to invalidate, but still stamp identity so a LATER divergence is
669
+ // detectable. A cache that never records its site can only be checked by the
670
+ // unbound rule, which misses the bound-but-wrong case below.
671
+ if (current && prior.siteUuid !== current) {
672
+ updateSyncCache(siteDir, backend, { siteUuid: current })
673
+ }
674
+ return []
675
+ }
676
+
677
+ // Two ways the maps can describe a site this clone is not working with:
678
+ //
679
+ // 1. UNBOUND — no site uuid for this backend, so there is no backend site for any
680
+ // of it to be about. This was the state the old "clear `$uuid` to re-publish
681
+ // as a new site" recovery put you in; `uniweb forget` clears the maps with it.
682
+ // 2. BOUND TO A DIFFERENT SITE — this backend's site uuid names one site and the
683
+ // cache was written for another. Reachable in one step: the create mints a uuid and
684
+ // writes it BEFORE the push, so a push that then fails leaves exactly this.
685
+ // Without the identity stamp it is invisible, and every later publish fails
686
+ // the same way with no path out but deleting `.uniweb/` by hand.
687
+ //
688
+ // A cache with NO `siteUuid` and a bound site is left alone: that is every
689
+ // pre-existing clone, and assuming it matches is right far more often than
690
+ // wiping it would be.
691
+ const stale =
692
+ !current || (prior.siteUuid && prior.siteUuid !== current) ? populated : []
693
+ if (!stale.length) {
694
+ if (current && prior.siteUuid !== current) {
695
+ updateSyncCache(siteDir, backend, { siteUuid: current })
696
+ }
697
+ return []
698
+ }
699
+
700
+ clearRemoteSyncState(siteDir, backend, current)
701
+ return stale
702
+ }
703
+
704
+ /**
705
+ * Drop the `site.yml` values that describe ONE PARTICULAR backend site, when this
706
+ * project is bound to none (no `$uuid`) — a brand-new site, or the state our own
707
+ * "clear `$uuid` to re-publish as a new site" recovery puts you in. Today that is
708
+ * one value: `preview` in the APP's form — a timestamp naming the previous site's
709
+ * generated card image, which is keyed by that site's uuid and would dangle on this
710
+ * one.
711
+ *
712
+ * ⛔ An AUTHOR's preview is theirs and stays: a URL, or a path to an image in the
713
+ * project, is as good for the new site as for the old.
714
+ *
715
+ * (`$url`, where the previous site was live, was dropped here too until it was
716
+ * retired on 2026-09-17. A leftover line is inert — nothing reads or sends it.)
717
+ *
718
+ * The site.yml sibling of `clearRemoteSyncStateIfUnbound`, called beside it for the
719
+ * same reason — before the create mints a uuid and makes the project look bound.
720
+ *
721
+ * @param {string} siteDir
722
+ * @returns {string[]} the keys removed
723
+ */
724
+ export function dropSiteBoundValues(siteDir, backend) {
725
+ const file = join(siteDir, 'site.yml')
726
+ let y
727
+ try {
728
+ y = yaml.load(readFileSync(file, 'utf8'))
729
+ } catch {
730
+ return []
731
+ }
732
+ // ⛔ "Bound" is a question about THIS backend, answered by sync.json. This read
733
+ // `site.yml::$uuid` until 2026-09-20 and kept doing so after step 4 moved the key —
734
+ // so every project read as unbound and every push stripped a bound site's
735
+ // generated preview. A write, from a stale read, with nothing to notice it.
736
+ if (!y || typeof y !== 'object') return []
737
+ if (readBackendState(siteDir, backend).site?.uuid) return []
738
+ const dropped = []
739
+ if (
740
+ y.preview !== undefined &&
741
+ !isAuthoredPreview(y.preview) &&
742
+ writeSiteConfig(siteDir, { preview: null }) === 'updated'
743
+ ) {
744
+ dropped.push('preview')
745
+ }
746
+ return dropped
747
+ }
748
+
749
+ export function readSyncCache(siteDir, backend) {
750
+ return readMap(siteDir, backend, 'hashes')
751
+ }
752
+
753
+ /**
754
+ * The hash-affecting injections the emit that produced those hashes applied, in the
755
+ * exact shape `emitSyncPackages` takes back as opts.
756
+ *
757
+ * ⛔ **Only the ones nothing else records.** `assetIds` is deliberately NOT banked
758
+ * here even though the emit applies it: this backend's `assets` in `sync.json` is
759
+ * COMMITTED project state holding exactly that map (local ref → `{id, ext}`), written
760
+ * by the same push, and a gitignored second copy would be a second thing to
761
+ * disagree — the reason that map itself holds a fingerprint of a serve URL and never
762
+ * the URL. It also has the worse lifetime of the
763
+ * two: `clearRemoteSyncState` wipes this cache, and the committed map correctly
764
+ * survives. ⇒ Bank what a reader cannot re-derive; re-derive the rest.
765
+ *
766
+ * ⛔ Read this whenever you re-emit to COMPARE against `hashes`. A push hashes the
767
+ * DELIVERED document — local `/images/x.png` rewritten to the backend serve URL it
768
+ * just uploaded to, `info.foundation` replaced by the version-pinned ref — and banks
769
+ * that. An offline re-emit produces the AUTHORED document, which is a different
770
+ * document, so it matches nothing and every entity reads as changed forever. That is
771
+ * not hypothetical: it is what `uniweb status` did on any site with one local image
772
+ * (backend↔framework, 2026-08-19) — `push` said "1 entity unchanged" and
773
+ * `status --json` said `changed: 1`, from the same cache, seconds apart.
774
+ *
775
+ * ⭐ What is left is what only a backend round-trip produces — an asset **serve URL**
776
+ * and a released foundation version — and `status` is offline by design (measured at
777
+ * zero HTTP requests, a property the cross-client flows rely on). ⚠️ Note the serve
778
+ * URL is REPLAYED, never composed: we re-use the string the host handed us, which is
779
+ * a different act from reconstructing one, and the distinction is the same one that
780
+ * keeps the asset map in `sync.json` to ids and a fingerprint. An asset genuinely new to the site has no recorded
781
+ * mapping and still reads as changed, which is correct.
782
+ */
783
+ export function readAppliedInjections(siteDir, backend) {
784
+ return readMap(siteDir, backend, 'applied')
785
+ }
786
+
787
+ /**
788
+ * ⭐ THE REGISTERED FOUNDATION A CLONE KEEPS. A site whose foundation is a catalog ref has no build of
789
+ * it in the project, and the section types' `data:` it declares are what type a query named for a
790
+ * data key — `team:` read as `@acme/member` (`typed_by_data_key`). Read once from the backend,
791
+ * through the site, and kept under `.uniweb/` (`@uniweb/build`'s `registered-foundation.js`), where
792
+ * the build, the push and the pull read it; a registered version never changes, so a kept copy is
793
+ * never read again.
794
+ *
795
+ * Null when the foundation is not a catalog ref, the site is not on this backend yet, or the read
796
+ * fails: nothing is then typed by it — a pull writes such a query's schema out, and a push sends
797
+ * what that says — so both go on.
798
+ */
799
+ export async function ensureRegisteredFoundation({ client, siteDir, siteUuid, ref }) {
800
+ if (!parseCatalogRef(ref)) return null
801
+ const kept = readRegisteredFoundation(siteDir, ref)
802
+ if (kept) return kept
803
+ const reply = siteUuid ? await client.readRegisteredFoundation(siteUuid, ref) : null
804
+ if (!reply) return null
805
+ // ⛔ ONLY WHAT WE READ — the section types. The reply carries more than we asked for, a serve
806
+ // location among it, and a serve location is decided at publish and read from the response that
807
+ // carries it, never kept. Until 2026-09-26 the whole reply was kept.
808
+ const sectionTypes = { schema: reply.schema }
809
+ writeRegisteredFoundation(siteDir, ref, sectionTypes)
810
+ return sectionTypes
811
+ }
812
+
813
+ // The Sections whose declared keys a push reads before it sends an optional one.
814
+ const DEPLOYMENT_FIELD_LISTS = ['queryFields', 'pageFields', 'settingsFields']
815
+
816
+ /**
817
+ * The keys this deployment's `queries`, page and `settings` Sections declare, as it says so itself —
818
+ * `GET /dev/config` → `siteContent.queryFields` / `pageFields` / `settingsFields` — so a push sends
819
+ * an optional key only to a deployment that takes it (`typed_by_data_key`; `og_title` and
820
+ * `og_description`): one that does not declare a key refuses a push carrying it. Offline — a dry
821
+ * run, `--output` — the lists the last push to this backend banked, so the package is the one that
822
+ * push would build.
823
+ *
824
+ * Only the lists in hand are returned, as emit options: with none, nothing optional is sent, which
825
+ * every deployment accepts.
826
+ */
827
+ export async function deploymentFields({ client, siteDir, offline = false }) {
828
+ const source = offline
829
+ ? readAppliedInjections(siteDir, client.origin)
830
+ : (await client.discover())?.siteContent
831
+ const out = {}
832
+ for (const name of DEPLOYMENT_FIELD_LISTS) if (Array.isArray(source?.[name])) out[name] = source[name]
833
+ return out
834
+ }
835
+
836
+ /**
837
+ * Bank the content hashes and the injections that produced them. ⛔ ONE call, both
838
+ * maps: they describe the same document, so writing either alone leaves the cache
839
+ * self-inconsistent — and the failure is silent, since a hash never says which
840
+ * document it is of. `applied` is written even when empty, so it can never be a
841
+ * leftover from an earlier push describing hashes it no longer matches.
842
+ *
843
+ * `assetIds` is dropped rather than stored — see readAppliedInjections: it has a
844
+ * committed source of truth in `sync.json` (this backend's `assets`), and the reader
845
+ * re-derives it there.
846
+ */
847
+ export function writeSyncCache(siteDir, backend, hashes, applied) {
848
+ const { assetIds: _inSyncJson, ...bankable } = applied || {}
849
+ updateSyncCache(siteDir, backend, { hashes, applied: bankable })
850
+ }
851
+
852
+ /**
853
+ * The push gate's optimistic-concurrency tokens: `{ <backend-uuid>: <version> }`.
854
+ *
855
+ * Lives beside the content hashes because it is the same kind of thing — "the
856
+ * backend state this clone last agreed with" — and is already per-entity, which
857
+ * the per-lane `pull-cache.json` (two ETags) is not. Gitignored, per-clone,
858
+ * deletable: losing it means the next push is unconditional, i.e. today's
859
+ * behavior, never a wrong-but-plausible token.
860
+ *
861
+ * Fed from BOTH directions, which is what makes the gate usable: `pull` records
862
+ * the manifest's `extra.version`, and every successful push records the
863
+ * post-write `finalized[].version` the backend returns. Without the push half a
864
+ * second consecutive push would be stale by construction and refused.
865
+ */
866
+ export function readBaseVersions(siteDir, backend) {
867
+ return readMap(siteDir, backend, 'baseVersions')
868
+ }
869
+
870
+ /**
871
+ * Per-ITEM staleness tokens: `{ <item $uuid>: <opaque version> }` — one for every item
872
+ * of the site-content entity this copy has seen.
873
+ *
874
+ * The entity token gates the whole document, so a stale base on any one record
875
+ * refuses the entire push — which fires on the common case of two people editing
876
+ * different sections and teaches them to reach for `--force`. These gate per item
877
+ * instead. Same contract as the entity token: opaque, cached, echoed, never parsed.
878
+ *
879
+ * ⭐ AND THEY ARE WHAT LETS A PUSH DELETE (2026-10-07): the backend deletes only the
880
+ * items a push holds a version for, so every one held is sent, present or dropped
881
+ * (`withBaseVersion`). Hence the set is REPLACED, never merged — by a pull
882
+ * (`pulledItemVersions`) and by a push of the site-content lane (`heldTokens`) — and
883
+ * holds only items this copy has seen. ⚠️ The one thing losing this cache now costs that
884
+ * is not a round trip: until the next pull, a page or section the files dropped is kept
885
+ * on the site rather than deleted, since there is no version to send for it.
886
+ * ⛔ *It was merged until then, across both lanes, for a push that sent only units.*
887
+ */
888
+ export function readItemBaseVersions(siteDir, backend) {
889
+ return readMap(siteDir, backend, 'itemBaseVersions')
890
+ }
891
+ /**
892
+ * Replace the item versions this copy holds — a push's (`heldTokens`) or a pull's
893
+ * (`pulledItemVersions`) whole set. ⛔ Not a merge: an item that left the set must
894
+ * leave the map, or its version is sent again and asks the backend to delete it.
895
+ */
896
+ export function writeItemBaseVersions(siteDir, backend, versions) {
897
+ if (!versions || typeof versions !== 'object') return
898
+ updateSyncCache(siteDir, backend, { itemBaseVersions: { ...versions } })
899
+ }
900
+ export function mergeItemBaseVersions(siteDir, backend, versions) {
901
+ if (!versions || !Object.keys(versions).length) return
902
+ // ⛔ The read names the backend. Without it the read keyed nothing, came back `{}`,
903
+ // and this "merge" replaced the map with the last response — dropping the token
904
+ // `heldTokens` withholds for a unit the backend kept for someone else, and with it
905
+ // the precondition that refuses the next push of that unit (2026-09-23).
906
+ updateSyncCache(siteDir, backend, {
907
+ itemBaseVersions: { ...readItemBaseVersions(siteDir, backend), ...versions }
908
+ })
909
+ }
910
+ export function mergeBaseVersions(siteDir, backend, versions) {
911
+ if (!versions || !Object.keys(versions).length) return
912
+ updateSyncCache(siteDir, backend, {
913
+ baseVersions: { ...readBaseVersions(siteDir, backend), ...versions }
914
+ })
915
+ }
916
+
917
+ /**
918
+ * Per-unit content hashes of the last synced state, kept in BOTH representations —
919
+ * the bases for the file-level attribution shown after a staleness refusal
920
+ * (`diffSiteUnits`). A unit is a projected file: a page's `page.yml`, each section
921
+ * `.md`, each layout section.
922
+ *
923
+ * Two maps, not one, because our document and the backend's are not byte-comparable
924
+ * (they carry fields we don't emit and their own key order). A hash from one side
925
+ * compared against a base from the other differs for a unit nobody touched, so each
926
+ * side gets a base in its own representation:
927
+ * `local` ← our emitted document
928
+ * `remote` ← the backend's own copy (`finalized[].document`, or a pull)
929
+ *
930
+ * Replaced wholesale rather than merged: a unit that no longer exists is a unit with
931
+ * no base, and a stale entry would attribute its next difference to the wrong side.
932
+ *
933
+ * Purely an explanation aid — losing either map costs detail in one message, never a
934
+ * wrong push.
935
+ */
936
+ /**
937
+ * Per-item identity: `{ <unit path>: <backend $uuid> }`.
938
+ *
939
+ * The backend's reconcile matches a record by `$uuid`. `pages`, `page_sections` and
940
+ * `layout_sections` are all `multi` sections, where a record WITHOUT a uuid is read
941
+ * as new — inserted, with its stored counterpart deleted as host-only. So pushing
942
+ * without this map replaces every page and section row on every push. The content
943
+ * still lands, which is why it went unnoticed, but the app's per-item concurrency
944
+ * handles are left pointing at rows that no longer exist.
945
+ *
946
+ * Cached here rather than written into `page.yml` / section frontmatter: identity is
947
+ * a sync concern and author files should not carry sync uuids. Populated from
948
+ * whatever the backend last told us — a pull, or a push response's
949
+ * `finalized[].document`, which carries `$uuid` for every item at every nesting
950
+ * level, so a push alone bootstraps it and no pull is required.
951
+ *
952
+ * Losing it (gitignored, per-clone) means the next push would be identity-blind,
953
+ * which is why `push` repopulates it first — see `ensureItemUuids`. The backend also
954
+ * refuses an all-blank `multi` section, so a producer that skips that step fails
955
+ * loudly instead of quietly rebuilding the site's identity.
956
+ */
957
+ export function readItemUuids(siteDir, backend) {
958
+ return readBackendState(siteDir, backend).items || {}
959
+ }
960
+ /**
961
+ * Placement identity for the site's `@uniweb/folder` — path chain → `$uuid`.
962
+ *
963
+ * Kept separate from `itemUuids` (which is site-content units) because they key
964
+ * different trees: `pages/…/page.yml` there, `members/alice` here. One map with
965
+ * two key languages is a map nobody can validate.
966
+ */
967
+ export function readFolderItemUuids(siteDir, backend) {
968
+ return readBackendState(siteDir, backend).folders || {}
969
+ }
970
+
971
+ /**
972
+ * Collection-declaration identity: `{ <collection name>: <backend $uuid> }`.
973
+ *
974
+ * ⛔ THE THIRD MAP, AND EACH ONE EXISTS FOR THE SAME REASON. `itemUuids` is keyed by
975
+ * the file an item projects to. Two kinds of item have no file of their own — a
976
+ * folder's placements, and a collection DECLARATION (they all live in one
977
+ * `collections/collections.yml`) — so a path-keyed map has no shape either could
978
+ * occupy, and a push re-sends that whole section uuid-less.
979
+ *
980
+ * The backend refuses an all-blank section over stored items rather than applying it,
981
+ * because applying it would insert every record fresh and delete every stored row —
982
+ * content survives, identity does not. So `push` worked once and every push after was
983
+ * refused. Measured 2026-08-29; collab framework↔backend.
984
+ *
985
+ * ⭐ Keyed by NAME, which the backend enforces unique within the section and uses as
986
+ * its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
987
+ * handle the backend skips on parse and never stores.
988
+ */
989
+ export function readQueryUuids(siteDir, backend) {
990
+ return readBackendState(siteDir, backend).queries || {}
991
+ }
992
+ // ⭐ THE THREE UUID MAPS LIVE IN `sync.json`, NOT THE CACHE (2026-09-20). They are
993
+ // backend-minted identity: without them a push re-sends a section uuid-less and the
994
+ // backend refuses an all-blank section over stored rows rather than replacing every
995
+ // one. A refusal is the good case; the bad one is duplication. Losing them costs a
996
+ // round trip, not a slow push — which is the line between the two files.
997
+ export function writeQueryUuids(siteDir, backend, map) {
998
+ if (!map || !Object.keys(map).length) return
999
+ updateBackendMap(siteDir, backend, 'queries', map)
1000
+ }
1001
+ export function writeFolderItemUuids(siteDir, backend, map) {
1002
+ if (!map || !Object.keys(map).length) return
1003
+ updateBackendMap(siteDir, backend, 'folders', map)
1004
+ }
1005
+ export function writeItemUuids(siteDir, backend, map) {
1006
+ if (!map || !Object.keys(map).length) return
1007
+ updateBackendMap(siteDir, backend, 'items', map)
1008
+ }
1009
+
1010
+ /**
1011
+ * The identity of each record's list items:
1012
+ * `{ <record $uuid>: { <place>: "<item $uuid> <fingerprint>" } }`.
1013
+ *
1014
+ * ⛔ THE FOURTH MAP, FOR THE SAME REASON AS THE OTHER THREE. An item of a record's `many`
1015
+ * section — or of a list nested in one, or of a tree's `$children` — is a `multi` row with no
1016
+ * file and no name, so neither a path-keyed map nor the record's own file can hold it. Sent
1017
+ * without it, the record is refused whole (`identity_required`), since applying it would
1018
+ * replace every stored item: measured 2026-09-25 on an edit of a talk's title. Banked per
1019
+ * record from a push's finalized documents (paired by place with what was sent) and from a
1020
+ * pull; matched on the next send by content, then by place (`@uniweb/build`'s
1021
+ * `record-items.js`). Each record's entry is replaced whole.
1022
+ */
1023
+ export function readRecordItemUuids(siteDir, backend) {
1024
+ return readBackendState(siteDir, backend).recordItems || {}
1025
+ }
1026
+ export function writeRecordItemUuids(siteDir, backend, map) {
1027
+ if (!map || !Object.keys(map).length) return
1028
+ updateBackendMap(siteDir, backend, 'recordItems', map)
1029
+ }
1030
+
1031
+ // The records' list items, banked from the documents the backend returned for them —
1032
+ // `returned` by `$uuid` — paired by place with the lists as sent, which ride on each record's
1033
+ // index entry (`lists`). A record keeps an entry only while it has list items, or had one.
1034
+ function bankRecordItems(siteDir, backend, pairs) {
1035
+ const prior = readRecordItemUuids(siteDir, backend)
1036
+ const bank = {}
1037
+ for (const { uuid, lists, returned } of pairs) {
1038
+ if (!uuid || !lists || !returned) continue
1039
+ const items = harvestRecordItems(lists, returned)
1040
+ if (Object.keys(items).length || prior[uuid]) bank[uuid] = items
1041
+ }
1042
+ writeRecordItemUuids(siteDir, backend, bank)
1043
+ return Object.keys(bank).length
1044
+ }
1045
+
1046
+ /**
1047
+ * Recover the list-item identity of records the backend holds and this copy never banked —
1048
+ * `uuids`, the emit's `recordItemIdentity.unbanked` — from the backend's own documents: one read
1049
+ * of the folder lane, and nothing written but `sync.json`. The send that follows matches their
1050
+ * items by place alone (`storedRecordItems`): what was sent before is unknown here.
1051
+ *
1052
+ * Best-effort, like `ensureItemUuids`: a failure leaves them unbanked, and the backend refuses
1053
+ * the send rather than replace their items.
1054
+ *
1055
+ * @returns {Promise<number>} how many records were recovered
1056
+ */
1057
+ export async function recoverRecordItemUuids({ client, siteDir, uuids, note }) {
1058
+ const siteUuid = readBackendState(siteDir, client.origin).site?.uuid || null
1059
+ if (!siteUuid || !uuids?.length) return 0
1060
+ try {
1061
+ const res = await client.pullFolder(siteUuid)
1062
+ if (!res?.ok) return 0
1063
+ const wanted = new Set(uuids)
1064
+ const bank = {}
1065
+ for (const doc of entityDocsFromUwx(Buffer.from(await res.arrayBuffer()))) {
1066
+ if (wanted.has(doc?.$uuid)) bank[doc.$uuid] = storedRecordItems(doc)
1067
+ }
1068
+ writeRecordItemUuids(siteDir, client.origin, bank)
1069
+ const n = Object.keys(bank).length
1070
+ if (n) note?.(`Recovered the identity of the list items of ${n} record(s) from the backend.`)
1071
+ return n
1072
+ } catch {
1073
+ return 0
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * Recover the records lane's identity this copy lacks for the site it is bound to — which stored
1079
+ * record each of its records is (`records`), each placement's in the folder (`folders`), and the
1080
+ * list items of the records it matches (`recordItems`) — from one read of the backend's folder
1081
+ * lane. What the copy already holds is kept.
1082
+ *
1083
+ * ⛔ Until 2026-09-25 nothing recovered these, and a copy whose `sync.json` had lost them — a
1084
+ * blog site whose push was refused after its content landed, with an older CLI — was refused on
1085
+ * every push after: its folder sent three placements with no `$uuid` while three were stored
1086
+ * ("one section carries no item identity (section contents)"). Unrefused, its records would have
1087
+ * gone up with none, and the backend would have made a second entity of each.
1088
+ *
1089
+ * Reads only when the package names a record the map does not, or the folder map is empty; a
1090
+ * record new to the backend costs that read and finds nothing (`matchStoredRecords`).
1091
+ *
1092
+ * @param {object} params
1093
+ * @param {object[]} params.index - the records the package sends (`pkg.records.index`)
1094
+ * @returns {Promise<number>} how many records and placements were recovered
1095
+ */
1096
+ export async function recoverRecordsIdentity({ client, siteDir, index, note }) {
1097
+ const state = readBackendState(siteDir, client.origin)
1098
+ const siteUuid = state.site?.uuid || null
1099
+ const recordMap = state.records || {}
1100
+ const records = (index || []).filter((e) => e?.ownId && e.model && e.slug)
1101
+ const folders = readFolderItemUuids(siteDir, client.origin)
1102
+ const unmapped = records.some((e) => !recordMap[e.ownId])
1103
+ if (!siteUuid || !records.length || (!unmapped && Object.keys(folders).length)) return 0
1104
+ try {
1105
+ const res = await client.pullFolder(siteUuid)
1106
+ if (!res?.ok) return 0
1107
+ const docs = entityDocsFromUwx(Buffer.from(await res.arrayBuffer()))
1108
+ const folderDoc = docs.find((d) => d?.$schema === '@uniweb/folder')
1109
+ if (!folderDoc) return 0
1110
+ const learned = matchStoredRecords({ index: records, recordMap, folderDoc })
1111
+ const matched = Object.keys(learned).length
1112
+ if (matched) {
1113
+ updateBackendMap(siteDir, client.origin, 'records', learned)
1114
+ const stored = new Map(docs.map((d) => [d?.$uuid, d]))
1115
+ const items = {}
1116
+ for (const theirs of Object.values(learned)) {
1117
+ const doc = stored.get(theirs)
1118
+ if (doc) items[theirs] = storedRecordItems(doc)
1119
+ }
1120
+ writeRecordItemUuids(siteDir, client.origin, items)
1121
+ }
1122
+ const placements = collectFolderItemUuids(folderDoc)
1123
+ const missing = Object.fromEntries(Object.entries(placements).filter(([k]) => !folders[k]))
1124
+ writeFolderItemUuids(siteDir, client.origin, missing)
1125
+ const placed = Object.keys(missing).length
1126
+ if (matched || placed) {
1127
+ note?.(`Recovered the identity of ${matched} record(s) and ${placed} folder placement(s) from the backend.`)
1128
+ }
1129
+ return matched + placed
1130
+ } catch {
1131
+ return 0
1132
+ }
1133
+ }
1134
+
1135
+ /**
1136
+ * The workspace this site was created in on `backend` — its owner, as the create
1137
+ * answered it — in the header's form: `@handle`, a unit's bare uuid when that unit has
1138
+ * no handle, or null for the personal workspace (or no record).
1139
+ *
1140
+ * ⭐ A RECORD, NOT A ROUTE (2026-09-23). A command works in the workspace chosen with the
1141
+ * login (`workspace.js`); this says where the site was made, and routes nothing. (Until
1142
+ * then it was the workspace every request named, adopted from a `409` when stale.)
1143
+ *
1144
+ * Read from `backend`'s section of `sync.json`: `site.org` holds a handle, stored bare
1145
+ * and re-dressed with its `@`; `site.unit` holds a unit's uuid. At most one is set
1146
+ * (`writeSiteWorkspace`). ⛔ Two keys, because the `@` is the only thing telling the two
1147
+ * apart — our own handle grammar admits a uuid-shaped handle — and a bare value at rest
1148
+ * has lost it.
1149
+ *
1150
+ * @param {string} siteDir
1151
+ * @param {string} backend - the origin whose record to read
1152
+ * @returns {string|null}
1153
+ */
1154
+ export function readSiteWorkspace(siteDir, backend) {
1155
+ const site = readBackendState(siteDir, backend).site || {}
1156
+ const org = typeof site.org === 'string' ? site.org.trim().replace(/^@/, '') : ''
1157
+ if (org) return `@${org}`
1158
+ const unit = typeof site.unit === 'string' ? site.unit.trim() : ''
1159
+ return unit || null
1160
+ }
1161
+
1162
+ /**
1163
+ * Record the workspace this site was created in on `backend` — a handle in `site.org`
1164
+ * (bare), a handle-less unit in `site.unit`, clearing the other; null clears both (the
1165
+ * personal workspace, named by naming none). Returns what was recorded, in the header's
1166
+ * form.
1167
+ *
1168
+ * @param {string} siteDir
1169
+ * @param {string} backend
1170
+ * @param {string|null|undefined} workspace - `@handle`, a unit's uuid, or null
1171
+ * @returns {string|null}
1172
+ */
1173
+ function writeSiteWorkspace(siteDir, backend, workspace) {
1174
+ const w = workspaceHeader(workspace)
1175
+ updateBackendState(siteDir, backend, {
1176
+ site: {
1177
+ org: w?.startsWith('@') ? w.slice(1) : undefined,
1178
+ unit: w && !w.startsWith('@') ? w : undefined
1179
+ }
1180
+ })
1181
+ return w
1182
+ }
1183
+
1184
+ /**
1185
+ * Record the workspace a just-minted site was created under.
1186
+ *
1187
+ * Only what we were TOLD is recorded — the create's echo, else the workspace the
1188
+ * create named; a personal site has none, and guessing one would be worse than the
1189
+ * gap. Returns the recorded workspace for the caller's "here's what resolved" line,
1190
+ * or null when there is none.
1191
+ *
1192
+ * @param {string} siteDir
1193
+ * @param {string|null|undefined} owner - in the header's form, or null
1194
+ * @returns {string|null}
1195
+ */
1196
+ function recordSiteWorkspace(siteDir, backend, owner) {
1197
+ try {
1198
+ return writeSiteWorkspace(siteDir, backend, owner)
1199
+ } catch {
1200
+ // The uuid is the load-bearing back-fill; losing the org note must never
1201
+ // fail a push that already succeeded on the backend.
1202
+ return null
1203
+ }
1204
+ }
1205
+
1206
+ /**
1207
+ * Turn a typed site-create refusal into a sentence, or null to fall through to the
1208
+ * generic HTTP line.
1209
+ *
1210
+ * ⭐ Why this exists separately from `describeAssetRefusal`. The asset lane grew a
1211
+ * typed describer because a raw status dump is useless to the person who has to act
1212
+ * on it; the create lane had the same hole and nobody had met it, because until the
1213
+ * backend shipped account capacity a create could not be refused for space at all.
1214
+ * So this is the same fix on the second door, written before anyone hits it rather
1215
+ * than after.
1216
+ *
1217
+ * ⛔ Branch on `reason`, never on the status. `507` alone cannot be told from any
1218
+ * other 507 and carries none of the numbers a user needs, and `detail` is prose the
1219
+ * backend may reword.
1220
+ *
1221
+ * ⚖️ The wording deliberately DIVERGES from the asset lane's on one point. There the
1222
+ * allowance belongs to the site's owner, who may not be the person pushing. Here
1223
+ * there is no site yet, so the only workspace in play is the one the site would be
1224
+ * created in — one named with `--org` included. Saying "the site owner's workspace"
1225
+ * would be incoherent for a site that does not exist.
1226
+ *
1227
+ * @param {string} body - the raw response body
1228
+ * @returns {string|null}
1229
+ */
1230
+ function describeCreateRefusal(body) {
1231
+ if (!body) return null
1232
+ let p
1233
+ try {
1234
+ p = JSON.parse(body)
1235
+ } catch {
1236
+ return null // prose refusal, or an upstream error page
1237
+ }
1238
+ if (!p || typeof p !== 'object' || typeof p.reason !== 'string') return null
1239
+
1240
+ const attach = describeAttachRefusal(p)
1241
+ if (attach) return [attach.headline, ...attach.steps].join('\n ')
1242
+
1243
+ if (p.reason === 'storage_quota_exceeded') {
1244
+ const parts = []
1245
+ const used = humanBytes(p.used_bytes)
1246
+ const limit = humanBytes(p.limit_bytes)
1247
+ const needed = humanBytes(p.needed_bytes)
1248
+ if (used && limit) parts.push(`${used} of ${limit} used`)
1249
+ if (needed) parts.push(`a new site needs ${needed}`)
1250
+ return (
1251
+ 'storage quota reached — the workspace this site would belong to has no room for it' +
1252
+ (parts.length ? ` (${parts.join('; ')})` : '') +
1253
+ '. Quota is returned by deleting a site or entity, not by editing content.'
1254
+ )
1255
+ }
1256
+ // An unrecognised typed reason still beats a status dump: name it, and let the
1257
+ // backend's own prose follow when it sent any.
1258
+ const detail = typeof p.detail === 'string' ? p.detail : ''
1259
+ return `the backend refused the site create (${p.reason})${detail ? ` — ${detail}` : ''}`
1260
+ }
1261
+
1262
+ /**
1263
+ * The backend's refusals to ATTACH a foundation to a site, as what happened and what to
1264
+ * do next — or null for any other problem document.
1265
+ *
1266
+ * A site takes any version of a foundation it already uses, and a new one only from a
1267
+ * scope its author owns. The create, the first push and a push that changes the site's
1268
+ * foundation can each be refused:
1269
+ *
1270
+ * `foundation_not_licensed` not yours to attach. `templates` names the app
1271
+ * templates that carry it: start from one in the app,
1272
+ * then clone the site you made. With none, register a
1273
+ * foundation under a scope you own.
1274
+ * `foundation_not_registered` this backend has no such version.
1275
+ *
1276
+ * ⛔ Branch on `reason`, never on the status: a `403` is also a rejected credential, and
1277
+ * "log in again" is the wrong advice for a foundation that is not yours. ⚠️ Until
1278
+ * 2026-10-06 a push printed exactly that for this refusal, and the create printed the
1279
+ * reason's bare name.
1280
+ *
1281
+ * @param {object|null} problem - a parsed problem document
1282
+ * @returns {{ headline: string, steps: string[] } | null}
1283
+ */
1284
+ export function describeAttachRefusal(problem) {
1285
+ if (!problem || typeof problem !== 'object') return null
1286
+ const named = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
1287
+
1288
+ if (problem.reason === 'foundation_not_licensed') {
1289
+ const pkg = named(problem.package) || 'This foundation'
1290
+ const templates = (Array.isArray(problem.templates) ? problem.templates : [])
1291
+ .map((t) => named(t?.name))
1292
+ .filter(Boolean)
1293
+ const SHOWN = 5
1294
+ const list =
1295
+ templates
1296
+ .slice(0, SHOWN)
1297
+ .map((n) => `“${n}”`)
1298
+ .join(', ') + (templates.length > SHOWN ? ` and ${templates.length - SHOWN} more` : '')
1299
+ const steps = templates.length
1300
+ ? [
1301
+ `Start from ${templates.length === 1 ? list : `one of ${list}`} in the app — ` +
1302
+ `${templates.length === 1 ? 'that template carries' : 'those templates carry'} it — ` +
1303
+ "then `uniweb clone <your site's uuid>`."
1304
+ ]
1305
+ : ['Register a foundation under a scope you own, and name it in site.yml.']
1306
+ return { headline: `${pkg} is not yours to attach.`, steps }
1307
+ }
1308
+
1309
+ if (problem.reason === 'foundation_not_registered') {
1310
+ const f = named(problem.foundation) || 'The foundation this site names'
1311
+ return {
1312
+ headline: `${f} is not registered on this backend.`,
1313
+ steps: ['Register it here first (`uniweb register` in its directory), or name a version this backend has.']
1314
+ }
1315
+ }
1316
+ return null
1317
+ }
1318
+
1319
+ /**
1320
+ * Guarantee the site EXISTS on the backend before anything is uploaded against it.
1321
+ *
1322
+ * Ordering is the point, and it is load-bearing rather than incidental. Uploaded
1323
+ * bytes are metered against an owning entity and reclaimed by deleting it, so an
1324
+ * upload made before any site exists is charged and can never be freed — there is
1325
+ * nothing to delete. Creating the site first turns the artifact of a failed first
1326
+ * publish from *unfreeable bytes* into *an empty site*, which costs nothing to
1327
+ * keep and can be cleared. (The reverse ordering was justified by a claim that
1328
+ * `ensureItemUuids` mints on the backend, which it does not — see push.js.)
1329
+ *
1330
+ * A no-op when the site already has a uuid on this backend (sync.json), so only the
1331
+ * first publish of a site pays for it. The uuid is written back immediately, keeping the window in
1332
+ * which a crash could strand a site as small as one file write.
1333
+ *
1334
+ * @returns {Promise<{ uuid: string|null, created: boolean, reason?: string }>}
1335
+ * `uuid: null` means the site could not be created; the caller decides whether
1336
+ * that is fatal (it is, for any flow that uploads).
1337
+ */
1338
+ export async function ensureSiteExists({
1339
+ client,
1340
+ siteDir,
1341
+ name,
1342
+ foundation,
1343
+ note
1344
+ }) {
1345
+ // One read serves both the binding check and the create's defaults, so callers
1346
+ // that have no reason to hold site.yml (push) need not load it just to pass it
1347
+ // back. An explicit argument still wins — publish supplies the PINNED foundation
1348
+ // ref from the bring-along, which site.yml may not carry.
1349
+ let siteYml = {}
1350
+ try {
1351
+ const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
1352
+ if (y && typeof y === 'object') siteYml = y
1353
+ } catch {
1354
+ /* unreadable site.yml — treat as un-synced and let the create decide */
1355
+ }
1356
+ const known = readBackendState(siteDir, client.origin).site?.uuid
1357
+ if (known) {
1358
+ return { uuid: known, created: false, org: readSiteWorkspace(siteDir, client.origin) }
1359
+ }
1360
+
1361
+ // Both are required by the create. Catching it here turns a 400 into a sentence
1362
+ // naming the file and the key — the difference between "fix this line of
1363
+ // site.yml" and reading a status code.
1364
+ const siteName = name ?? siteYml.name
1365
+ const siteFoundation = foundation ?? siteYml.foundation
1366
+ const missing = [!siteName && 'name', !siteFoundation && 'foundation'].filter(
1367
+ Boolean
1368
+ )
1369
+ if (missing.length) {
1370
+ return {
1371
+ uuid: null,
1372
+ created: false,
1373
+ reason: `site.yml is missing ${missing.join(' and ')} — the backend requires ${missing.length > 1 ? 'them' : 'it'} to create a site`
1374
+ }
1375
+ }
1376
+
1377
+ let res
1378
+ try {
1379
+ // The owner is the workspace the client names — the command's (`workspace.js`).
1380
+ res = await client.createSite({
1381
+ name: siteName,
1382
+ foundation: siteFoundation
1383
+ })
1384
+ } catch (err) {
1385
+ return { uuid: null, created: false, reason: err.message }
1386
+ }
1387
+ if (!res?.ok) {
1388
+ const body = await res?.text?.().catch(() => '')
1389
+ return {
1390
+ uuid: null,
1391
+ created: false,
1392
+ // 404 here means the backend predates the route, which is a materially
1393
+ // different problem from being refused — say which.
1394
+ reason:
1395
+ res?.status === 404
1396
+ ? 'this backend has no /dev/site route (it predates the empty-site create)'
1397
+ : (describeCreateRefusal(body) ??
1398
+ `HTTP ${res?.status} ${res?.statusText || ''}${body ? ` — ${body.slice(0, 200)}` : ''}`)
1399
+ }
1400
+ }
1401
+ const payload = await res.json().catch(() => null)
1402
+ const minted = extractMintedSiteUuid(payload)
1403
+ if (!minted) {
1404
+ return {
1405
+ uuid: null,
1406
+ created: false,
1407
+ reason: 'the create returned no site uuid'
1408
+ }
1409
+ }
1410
+ writeSiteEntityUuid(siteDir, client.origin, minted)
1411
+ // Stamp the cache with the site it now describes, so a push that fails after
1412
+ // this point cannot leave a cache pointing at a different site with no way to
1413
+ // detect it.
1414
+ updateSyncCache(siteDir, client.origin, { siteUuid: minted })
1415
+ const org = await recordAndDescribeOwner({ client, siteDir, payload, note })
1416
+ return { uuid: minted, created: true, org }
1417
+ }
1418
+
1419
+ /**
1420
+ * Record and announce who owns a just-created site, from the backend's own echo.
1421
+ *
1422
+ * The echo reports what the site **is**; the workspace the create named
1423
+ * (`client.workspace`) is only what we asked for. They agree in the normal case and the
1424
+ * echo is the one to trust — it is read back off the created entity.
1425
+ *
1426
+ * ⚠️ `org: null` is MEANINGFUL (personal), not missing. An older backend omits the
1427
+ * key entirely, which is the only case that falls back to what we asked for.
1428
+ *
1429
+ * @returns {Promise<string|null>} the display handle recorded, or null for personal
1430
+ */
1431
+ async function recordAndDescribeOwner({ client, siteDir, payload, note }) {
1432
+ const echoed = payload && 'org' in payload ? payload.org : undefined
1433
+ const owner =
1434
+ echoed === undefined
1435
+ ? client.workspace
1436
+ : typeof echoed === 'string'
1437
+ ? workspaceHandle(echoed)
1438
+ : null
1439
+ const org = recordSiteWorkspace(siteDir, client.origin, owner)
1440
+
1441
+ // ⛔ No sync scope to record. It WAS `site.yml::$backend`, written here because this
1442
+ // function is the one place both create paths meet. The scope is now the KEY the
1443
+ // identity is stored under (`sync.json::backends.<origin>`), so recording it
1444
+ // separately would be recording the name of the drawer inside the drawer.
1445
+ note?.(
1446
+ org
1447
+ ? `Created the site on ${client.origin} under ${describeWorkspace(org)} — recorded in sync.json.`
1448
+ : `Created the site on ${client.origin}, owned personally — recorded in sync.json.`
1449
+ )
1450
+
1451
+ // What the create echoed about this site's OWNER, and nothing beyond it.
1452
+ //
1453
+ // `hosts_free` is a property of the SCOPE — is this owner exempt? — and it is the only
1454
+ // billing fact the CLI holds. This used to JOIN it with `siteSubscriptionRequired`
1455
+ // from the discovery document, a property of the DEPLOYMENT. That leaf left the wire:
1456
+ // every deployment charges, so it read true everywhere and the join was testing a
1457
+ // constant.
1458
+ //
1459
+ // ⛔ AND THE WARNING WENT WITH IT — this is NOT a fallback to keying on the scope
1460
+ // alone, which is the exact thing the join existed to prevent. Whether a given publish
1461
+ // is charged is derived per-site at publish time, on a side the CLI cannot see, so any
1462
+ // prediction made here can only be approximately right and would go stale silently.
1463
+ // The backend's typed 402 (`reason: "no_subscription"`) is exact, per-site, and
1464
+ // arrives when it matters; `backend/payment-handoff.js` already turns it into a
1465
+ // checkout. Deciding whether payment is due is not the CLI's job.
1466
+ //
1467
+ // ⇒ What survives is the reassuring direction only, and only when it was stated:
1468
+ // `false` is an answer we deliberately do not speak to, and missing is not an answer
1469
+ // at all. Do not reintroduce a "you will be charged" line here.
1470
+ if (payload?.hosts_free === true) {
1471
+ note?.(
1472
+ 'This owner is hosted free — publishing will not require a subscription.'
1473
+ )
1474
+ }
1475
+ return org
1476
+ }
1477
+
1478
+ /**
1479
+ * Stop a push or publish before it sends anything, when a record cannot be sent as
1480
+ * written — the emit's `refusals` (`@uniweb/build`'s `recordsToEntities`).
1481
+ *
1482
+ * ⛔ Checked BEFORE the site is created or a byte uploaded, not only before the send:
1483
+ * both are writes of their own, which a refused records lane does not undo. (The lane
1484
+ * itself is one transaction on the backend since 2026-09-24, so a refusal there writes
1485
+ * nothing. Before that, a refused lane left the folder's entries and entities with no
1486
+ * data behind, and every later push of the site's records was refused — measured
1487
+ * 2026-09-23, from one record written by section.)
1488
+ *
1489
+ * @param {string[]|undefined} refusals
1490
+ * @param {{ error: (m: string) => void, note: (m: string) => void }} report
1491
+ * @returns {boolean} true when the caller must stop
1492
+ */
1493
+ export function refuseUnsendableRecords(refusals, { error, note }) {
1494
+ if (!refusals?.length) return false
1495
+ const n = refusals.length
1496
+ error(
1497
+ `${n === 1 ? 'A record' : `${n} records`} cannot be pushed as written — no content was sent.`
1498
+ )
1499
+ for (const r of refusals) note(` ${r}`)
1500
+ return true
1501
+ }
1502
+
1503
+ /**
1504
+ * Guarantee we have per-item identity before an identity-bearing push.
1505
+ *
1506
+ * Fires only when the site HAS been pushed to this backend before (its uuid is in
1507
+ * `sync.json`) but that backend's item map there is empty. One read of the
1508
+ * backend's current document, recorded into that map — no content file is written,
1509
+ * and no `uniweb pull`. A first-ever push legitimately has nothing to fetch and is
1510
+ * left alone. (The map sat in the gitignored cache until 2026-09-20, so a fresh
1511
+ * `git clone` or a deleted `.uniweb/` was what emptied it; it is committed now.)
1512
+ *
1513
+ * ⚠️ A PARTIAL map is not this function's: it runs before the package is built, when
1514
+ * nothing yet says which items went without identity. `recoverUnbankedIdentity` runs
1515
+ * after, and fills in what is missing.
1516
+ *
1517
+ * @returns {Promise<Object<string,string>>} the map (possibly empty)
1518
+ */
1519
+ export async function ensureItemUuids({ client, siteDir, note }) {
1520
+ const cached = readItemUuids(siteDir, client.origin)
1521
+ if (Object.keys(cached).length) return cached
1522
+ const harvested = await storedItemUuids({ client, siteDir })
1523
+ if (!harvested || !Object.keys(harvested).length) return cached
1524
+ writeItemUuids(siteDir, client.origin, harvested)
1525
+ note?.(`Recovered identity for ${Object.keys(harvested).length} item(s) from the backend.`)
1526
+ return harvested
1527
+ }
1528
+
1529
+ /**
1530
+ * Each page and section `$uuid` the backend holds for the site this copy is bound to, by
1531
+ * the path it projects to — one read of its own document. Null when the copy is bound to
1532
+ * no site there (a site never pushed has no identity to recover, and nothing to lose,
1533
+ * since every item is genuinely new) or the read fails.
1534
+ *
1535
+ * Best-effort: a failure leaves the push identity-blind, which the backend refuses rather
1536
+ * than silently applying. Better to hit that than to guess.
1537
+ *
1538
+ * @returns {Promise<Object<string,string>|null>}
1539
+ */
1540
+ async function storedItemUuids({ client, siteDir }) {
1541
+ // The site to recover identity against — this backend's, from sync.json. It read
1542
+ // `site.yml::$uuid`, so after step 4 it found nothing and never recovered.
1543
+ const siteContentUuid = readBackendState(siteDir, client.origin).site?.uuid || null
1544
+ if (!siteContentUuid) return null
1545
+ try {
1546
+ const res = await client.pullSiteContent(siteContentUuid)
1547
+ if (!res?.ok) return null
1548
+ const doc = entityDocFromUwx(Buffer.from(await res.arrayBuffer()))
1549
+ return doc ? collectUnitUuids(doc) : null
1550
+ } catch {
1551
+ return null
1552
+ }
1553
+ }
1554
+
1555
+ /**
1556
+ * Fill in the page and section identity this copy LACKS for the site it is bound to, from
1557
+ * the backend's own document. What the copy already holds is kept.
1558
+ *
1559
+ * ⛔ `ensureItemUuids` recovers only an EMPTY map, and a map can be partial: until
1560
+ * 2026-09-25 a push whose records were refused after its pages landed saved the pages'
1561
+ * identity nowhere (`pushSyncPackages`), leaving the map with `site.yml` alone. Every push
1562
+ * after that sent the pages with no `$uuid` while the backend held them, and was refused —
1563
+ * "one section carries no item identity (section pages)" — with nothing ever recovering it.
1564
+ *
1565
+ * @returns {Promise<number>} how many pages and sections were recovered
1566
+ */
1567
+ export async function recoverItemUuids({ client, siteDir, note }) {
1568
+ const cached = readItemUuids(siteDir, client.origin)
1569
+ const harvested = await storedItemUuids({ client, siteDir })
1570
+ if (!harvested) return 0
1571
+ const missing = Object.keys(harvested).filter((path) => !cached[path])
1572
+ if (!missing.length) return 0
1573
+ writeItemUuids(siteDir, client.origin, { ...harvested, ...cached })
1574
+ note?.(`Recovered identity for ${missing.length} item(s) from the backend.`)
1575
+ return missing.length
1576
+ }
1577
+
1578
+ /**
1579
+ * Recover the identity a package just built went out without, when the backend may hold it —
1580
+ * a page or section (`itemIdentity.unknown`), a record or a folder placement
1581
+ * (`recoverRecordsIdentity`), or a record's list items (`recordItemIdentity.unbanked`) — and say
1582
+ * what to build the package again with.
1583
+ *
1584
+ * A page added since the last push is also without identity, and costs one read that finds
1585
+ * nothing. That is the price of never sending as new an item the backend already holds.
1586
+ *
1587
+ * @returns {Promise<object|null>} emit options to build the package again with, or null
1588
+ * when nothing was recovered
1589
+ */
1590
+ export async function recoverUnbankedIdentity({ client, siteDir, pkg, note }) {
1591
+ let again = null
1592
+ if (pkg?.itemIdentity?.unknown > 0 && (await recoverItemUuids({ client, siteDir, note }))) {
1593
+ again = { ...again, itemUuids: readItemUuids(siteDir, client.origin) }
1594
+ }
1595
+ // The records the package sends, and the folder placing them. The emit reads the `records`
1596
+ // map from sync.json itself, so it needs no option — only the build again.
1597
+ // Link records are matched as records are — by Model and name (`@uniweb/link`, `link-records.js`).
1598
+ const links = (pkg?.records?.links || []).map((l) => ({ ownId: l.ownId, model: LINK_MODEL, slug: l.slug }))
1599
+ if (await recoverRecordsIdentity({ client, siteDir, index: [...(pkg?.records?.index || []), ...links], note })) {
1600
+ again = {
1601
+ ...again,
1602
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
1603
+ recordItemUuids: readRecordItemUuids(siteDir, client.origin)
1604
+ }
1605
+ }
1606
+ const unbanked = pkg?.recordItemIdentity?.unbanked
1607
+ if (unbanked?.length && (await recoverRecordItemUuids({ client, siteDir, uuids: unbanked, note }))) {
1608
+ again = { ...again, recordItemUuids: readRecordItemUuids(siteDir, client.origin) }
1609
+ }
1610
+ return again
1611
+ }
1612
+
1613
+ export function readUnitBases(siteDir, backend) {
1614
+ const v = readMap(siteDir, backend, 'unitBases')
1615
+ return { local: v.local || {}, remote: v.remote || {} }
1616
+ }
1617
+ export function writeUnitBases(siteDir, backend, patch) {
1618
+ if (!patch) return
1619
+ updateSyncCache(siteDir, backend, {
1620
+ unitBases: { ...readUnitBases(siteDir, backend), ...patch }
1621
+ })
1622
+ }
1623
+
1624
+ /**
1625
+ * Offline-probe how many of a site's entities differ from the last successful push.
1626
+ * Runs the SAME emit + send-only-changed diff `uniweb push` runs, but with an
1627
+ * OFFLINE Model resolver — no auth, no submit, no backend round-trip. Used by
1628
+ * `uniweb status` only. (It was also `uniweb publish`'s pre-flight back when publish
1629
+ * went live WITHOUT pushing and so had to warn about unpushed content; publish now
1630
+ * always pushes, which makes the warning moot.) Throws if the producer can't build
1631
+ * the sync packages (e.g. an unresolved data Model); callers report it.
1632
+ *
1633
+ * @param {string} siteDir
1634
+ * @returns {Promise<{ changed: number, unchanged: number, warnings: string[] }>}
1635
+ */
1636
+ export async function probeUnpushed(siteDir, { backend = null, sendAll = false } = {}) {
1637
+ const priorHashes = readSyncCache(siteDir, backend)
1638
+ // Re-emit the document the last push HASHED, not the one the author wrote — see
1639
+ // readAppliedInjections. Two sources, on purpose:
1640
+ // · BANKED — the serve URLs and pinned refs only a round-trip produces. Empty
1641
+ // for a cache written before this was banked (and for a never-pushed site),
1642
+ // which is the pre-fix behaviour and self-heals on the next push. It can never
1643
+ // point at the wrong document: it is written with the hashes it belongs to.
1644
+ // · RE-DERIVED — asset identity, from this backend's COMMITTED asset map in
1645
+ // `sync.json`, which the same push wrote. Reading the live file rather than a
1646
+ // snapshot is what makes a moved map (a teammate's push, a pull) read as
1647
+ // changed instead of matching a copy of itself.
1648
+ //
1649
+ // ⛔ A site's `@/member` must resolve to the same `@scope/member` the push keyed its
1650
+ // hashes by, or every record of a `@/`-scoped query reads as changed forever — and
1651
+ // silently, since `@std/…` queries are absolute and settle while the others never
1652
+ // do (measured on matinee 2026-08-29: 4 changed right after a push). The scope is
1653
+ // the FOUNDATION's, which the emit reads itself, so both sides resolve alike.
1654
+ // ⚠️ Until 2026-09-23 this said the site's recorded ORG resolves it and must be
1655
+ // passed in — true before 2026-09-22, and read as a reason to pass it.
1656
+ const pkg = await comparisonEmit(siteDir, { backend, priorHashes, sendAll })
1657
+ const changed =
1658
+ (pkg.siteContent?.entityCount || 0) +
1659
+ (pkg.records?.entityCount || 0)
1660
+ return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
1661
+ }
1662
+
1663
+ /**
1664
+ * The emit whose hashes are comparable with the banked ones — ONE definition.
1665
+ *
1666
+ * ⛔ EVERY DEFECT IN THIS AREA HAS BEEN A WRITER AND A READER DISAGREEING ABOUT
1667
+ * WHICH DOCUMENT A HASH DESCRIBES, so the option set that makes them agree must
1668
+ * have a single home. Assembled from three sources, each for a stated reason:
1669
+ *
1670
+ * · BANKED the injections the push applied before hashing — serve URLs and
1671
+ * the pinned foundation ref, which only a round trip produces.
1672
+ * · RE-DERIVED asset identity, from this backend's COMMITTED asset map in
1673
+ * `sync.json`, so a moved map reads as changed rather than matching
1674
+ * a copy of itself.
1675
+ * · RECORDED the collection identity a push stamps, so the document is the
1676
+ * one a push builds. (A site's `@/x` resolves into its foundation's
1677
+ * scope, which the emit reads itself — not the site's org, which this
1678
+ * bullet named until 2026-09-23.)
1679
+ * ⚠️ Identity is parity, not correctness: `entityContentHash` strips
1680
+ * `$`-sigils, so a query's `$uuid` never moves a hash (measured
1681
+ * 2026-09-21). This read no backend from 2026-09-20 to 2026-09-21 —
1682
+ * an empty map — which is why no `status` ever showed it, and why this
1683
+ * bullet was then misread as naming a live defect.
1684
+ *
1685
+ * Offline by design — measured at zero HTTP requests, a property the cross-client
1686
+ * flows rely on.
1687
+ */
1688
+ async function comparisonEmit(
1689
+ siteDir,
1690
+ { backend = null, priorHashes = {}, sendAll = false, declarations = null, fields = null } = {}
1691
+ ) {
1692
+ const applied = readAppliedInjections(siteDir, backend)
1693
+ // ⛔ Per backend: an asset id is minted by one and means nothing to another, so a
1694
+ // comparison against the wrong one reports every media ref as changed. Absent is
1695
+ // honest rather than a default — no backend, no known ids.
1696
+ const assetIds = backend ? readBackendState(siteDir, backend).assets || {} : {}
1697
+ const queryUuids = readQueryUuids(siteDir, backend)
1698
+ return emitSyncPackages(siteDir, {
1699
+ backend,
1700
+ // Offline, always. A caller that has just read the Models from the backend — a pull —
1701
+ // may hand them in: the declarations a push would resolve, so the hashes still compare.
1702
+ // Without them a site whose foundation is not in the project (a clone's) resolves none
1703
+ // of its Models here, and nothing is banked.
1704
+ resolveModel: declarations
1705
+ ? async (name) => declarations.get(name) ?? null
1706
+ : makeModelResolver({ client: null, offline: true, siteDir, backend }),
1707
+ priorHashes,
1708
+ sendAll,
1709
+ ...applied,
1710
+ // The deployment's own answer (`deploymentFields`), when the caller has just read it, over what
1711
+ // the last push banked.
1712
+ ...(fields || {}),
1713
+ ...(Object.keys(queryUuids).length ? { queryUuids } : {}),
1714
+ ...(Object.keys(assetIds).length ? { assetIds } : {})
1715
+ // ⛔ No `org`: a site's `@/x` refs resolve into its FOUNDATION's scope, which the
1716
+ // emit reads itself — the same read a push makes, so the hashes compare alike.
1717
+ })
1718
+ }
1719
+
1720
+ /**
1721
+ * Re-bank the send-only-changed hashes over the files as they NOW stand.
1722
+ *
1723
+ * ⛔ FOR A WRITER THAT REWRITES THE WORKING TREE — today, `uniweb pull`.
1724
+ *
1725
+ * A pull projects the backend's document into source files, and that projection is
1726
+ * canonical rather than byte-identical to what was there: it moves section ordering
1727
+ * out of filename prefixes (`1-hero.md` → `hero.md` plus an explicit `sections:`
1728
+ * list) and stamps each section's `id`. Lossless, and a different document.
1729
+ *
1730
+ * ⚠️ `pull` already knew this for the OTHER map — it clears the `local` unit base
1731
+ * because "what we would emit from them is not byte-identical to it, so the old
1732
+ * local base no longer describes anything". That reasoning was never carried to the
1733
+ * hashes, which were left STALE rather than unknown: the next `uniweb status`
1734
+ * reported the site as having unpushed content immediately after a pull, forever.
1735
+ * Measured on matinee 2026-08-29 — `push → pull` reported 1 changed of 8.
1736
+ *
1737
+ * ⭐ Re-banking is the correct answer rather than clearing, because after a pull the
1738
+ * on-disk state IS the agreed state: it came from the backend. A push with no edits
1739
+ * in between should send nothing, and clearing would make it send everything.
1740
+ *
1741
+ * @param {object} [opts]
1742
+ * @param {object[]} [opts.recordDocs] - the record documents the pull took; their list items'
1743
+ * identity is banked against the files just written (`readRecordItemUuids`)
1744
+ * @param {Map<string,object>} [opts.declarations] - the Models the pull read, by name — so a
1745
+ * site whose foundation is not in the project (a clone's) can be re-banked offline
1746
+ */
1747
+ export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, declarations, fields = null } = {}) {
1748
+ // ⛔ With the keys this deployment's Sections take, when the caller has them (`deploymentFields`):
1749
+ // they shape the document the next push sends (`typed_by_data_key`, `og_title`), so a hash banked
1750
+ // without them marks a clone's first push as changed. Until 2026-09-26 a pull banked none.
1751
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations, fields })
1752
+ writeSyncCache(siteDir, backend, onlyWhatThePullTook(siteDir, backend, pkg, recordDocs), pkg.applied || {})
1753
+ // ⭐ And the identity of the records' list items, from the documents the pull just took:
1754
+ // the files it wrote hold the stored items in stored order, so the emit over them pairs
1755
+ // with each document item for item.
1756
+ if (recordDocs?.length) {
1757
+ const byUuid = new Map(recordDocs.filter((d) => d?.$uuid).map((d) => [d.$uuid, d]))
1758
+ const recordUuids = readBackendState(siteDir, backend).records || {}
1759
+ bankRecordItems(
1760
+ siteDir,
1761
+ backend,
1762
+ (pkg.records?.index || []).map((entry) => {
1763
+ const uuid = entry.ownId ? recordUuids[entry.ownId] : null
1764
+ return { uuid, lists: entry.lists, returned: uuid ? byUuid.get(uuid) : null }
1765
+ })
1766
+ )
1767
+ }
1768
+ return Object.keys(pkg.hashes || {}).length
1769
+ }
1770
+
1771
+ /**
1772
+ * The hashes a pull may bank: the tree's, except for a record the pull did not take.
1773
+ *
1774
+ * ⛔ ONLY WHAT CAME FROM THE BACKEND IS AGREED. A record on disk that the backend did not
1775
+ * hand back — written since the last push, or one a push had refused — is not in sync
1776
+ * because a pull ran. Until 2026-09-26 it was banked with the rest, so every later push
1777
+ * skipped it and `uniweb status` said "in sync": a record written before a `uniweb sync`
1778
+ * (which pulls, then pushes) never reached the backend. One this backend never accepted —
1779
+ * no identity from it — is always sent: a hash it has banked can only be that mistake's.
1780
+ * One it did accept keeps what the last push banked. The folder keeps its hash too while
1781
+ * it places such a record. A pull that took no records (`--no-records`, a 304) leaves
1782
+ * every record's hash as it was.
1783
+ *
1784
+ * @param {object[]|undefined} recordDocs - the record documents the pull took
1785
+ */
1786
+ function onlyWhatThePullTook(siteDir, backend, pkg, recordDocs) {
1787
+ const hashes = { ...(pkg.hashes || {}) }
1788
+ const prior = readSyncCache(siteDir, backend)
1789
+ const keep = (key) => {
1790
+ if (Object.hasOwn(prior, key)) hashes[key] = prior[key]
1791
+ else delete hashes[key]
1792
+ }
1793
+ const taken = new Set((recordDocs || []).map((d) => d?.$uuid).filter(Boolean))
1794
+ const recordUuids = readBackendState(siteDir, backend).records || {}
1795
+ let notTaken = 0
1796
+ for (const entry of pkg.records?.index || []) {
1797
+ if (entry.kind === 'folder') continue // the folder itself — held below while it places one
1798
+ const theirs = entry.ownId ? recordUuids[entry.ownId] : null
1799
+ if (theirs && taken.has(theirs)) continue
1800
+ notTaken++
1801
+ const key = `${entry.model} ${entry.id}`
1802
+ if (theirs) keep(key)
1803
+ else delete hashes[key]
1804
+ }
1805
+ if (notTaken) for (const key of Object.keys(hashes)) if (key.startsWith('@uniweb/folder ')) keep(key)
1806
+ return hashes
1807
+ }
1808
+
1809
+ /**
1810
+ * Re-bank the hashes of the records a push sent naming a record it created — `keys`,
1811
+ * the emit's `namesNew` — as the next push will hash them, and fingerprint their list
1812
+ * items the same way.
1813
+ *
1814
+ * ⭐ Such a record names its new record by `$ref`, and the backend stores the uuid it
1815
+ * mints. The next push names it by that uuid: the same reference, and another hash — so
1816
+ * the hash the push banked describes a document no later emit builds, and the next push
1817
+ * would send every one of these records again. Re-sending is not harmless: a record's
1818
+ * list-section items go without the identity a re-send needs (`identity_required`).
1819
+ *
1820
+ * ⚖️ Only these keys, over the hashes the push just banked: the site-content lane and
1821
+ * the folder are banked as that push sent them, and a caller's own emit options (a
1822
+ * publish that does not declare services) are not the comparison emit's.
1823
+ */
1824
+ export async function rebankRecordHashes(siteDir, backend, keys) {
1825
+ if (!keys?.length) return
1826
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true })
1827
+ const hashes = { ...readSyncCache(siteDir, backend) }
1828
+ for (const key of keys) if (pkg.hashes?.[key]) hashes[key] = pkg.hashes[key]
1829
+ writeSyncCache(siteDir, backend, hashes, readAppliedInjections(siteDir, backend))
1830
+ // Their list items too: an item that named a new record is fingerprinted anew, each uuid kept
1831
+ // at its place (`reprintRecordItems`) — or the next send finds it by place alone.
1832
+ const wanted = new Set(keys)
1833
+ const recordUuids = readBackendState(siteDir, backend).records || {}
1834
+ const bank = readRecordItemUuids(siteDir, backend)
1835
+ const reprinted = {}
1836
+ for (const entry of pkg.records?.index || []) {
1837
+ if (!wanted.has(`${entry.model} ${entry.id}`)) continue
1838
+ const uuid = entry.ownId ? recordUuids[entry.ownId] : null
1839
+ if (uuid && bank[uuid]) reprinted[uuid] = reprintRecordItems(bank[uuid], entry.lists)
1840
+ }
1841
+ writeRecordItemUuids(siteDir, backend, reprinted)
1842
+ }
1843
+
1844
+ /**
1845
+ * What a push did with the site's `template:` intent — read from the backend's own
1846
+ * reply, never inferred from what we sent.
1847
+ *
1848
+ * The field is `template`, carrying `designated` or `undesignated`, and it is ABSENT
1849
+ * when the designation did not move (backend, collab framework↔backend #6, 2026-09-23;
1850
+ * kb/framework/reference/template-designation-on-push.md E6).
1851
+ *
1852
+ * ⛔ TWO STATES, NOT THREE — and silence means "unchanged", never "failed". A push that
1853
+ * asks to designate where the backend will not is REFUSED WHOLE, and that refusal
1854
+ * carries its own message (§6), so there is no asked-but-skipped state to print here.
1855
+ *
1856
+ * ⚠️ Backend named the field, not its nesting, so the top level and `report` are both
1857
+ * read; and an unrecognized value is printed verbatim rather than dropped, because a
1858
+ * state we do not know about rendering as SILENCE is the exact failure this line exists
1859
+ * to prevent.
1860
+ *
1861
+ * @param {object|null} payload - the parsed push/create response
1862
+ * @returns {string|null} the state as the backend spelled it
1863
+ */
1864
+ export function templateOutcome(payload) {
1865
+ const v = payload?.template ?? payload?.report?.template
1866
+ return typeof v === 'string' && v.trim() ? v.trim() : null
1867
+ }
1868
+
1869
+ /** The sentence for each state the backend can report. */
1870
+ const TEMPLATE_SAID = {
1871
+ designated: 'The backend designated this site as a template.',
1872
+ undesignated: 'The backend undesignated this site — it is no longer a template.'
1873
+ }
1874
+
1875
+ /**
1876
+ * Submit a site's emitted sync packages over both directional lanes, back-fill the
1877
+ * minted uuids, and persist the send-only-changed cache. The HTTP + file-write-back
1878
+ * half that `emitSyncPackages` (producer-pure) deliberately omits.
1879
+ *
1880
+ * @param {object} params
1881
+ * @param {object} params.client - BackendClient (carries the origin + the lane methods)
1882
+ * @param {string} params.siteDir - the site root (for $uuid write-back + the cache)
1883
+ * @param {object} params.pkg - the `emitSyncPackages` result
1884
+ * ({ siteContent, collections, siteContentUuid, hashes })
1885
+ * @param {{info,note,error,dim?:Function}} params.report - injected logging
1886
+ * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
1887
+ * exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
1888
+ */
1889
+ export async function pushSyncPackages({
1890
+ client,
1891
+ siteDir,
1892
+ pkg,
1893
+ report
1894
+ }) {
1895
+ const { siteContent, records, siteContentUuid, hashes, applied } = pkg
1896
+ const { info, note, error } = report
1897
+ const dim = report.dim || ((s) => s)
1898
+
1899
+ const wrote = []
1900
+ let finalizedTotal = 0
1901
+ // Records sent as drafts that the backend stored enabled — reported at the end.
1902
+ let draftsNotKept = []
1903
+
1904
+ // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
1905
+ // returning the client's Response promise (so the "Pushing …" line prints before the
1906
+ // request fires). The client carries `collision=force` (last-push-wins) and names the
1907
+ // site's workspace. Returns the parsed payload, or null on any transport/HTTP/parse
1908
+ // failure (already reported).
1909
+ // `boundUuid` is set only for the lanes that address an EXISTING site by uuid
1910
+ // (content UPDATE, folder push) — never for the CREATE, which has no uuid to be
1911
+ // wrong about. It is what lets a 404 be read as "the site this clone is bound to
1912
+ // is gone" rather than "the route is missing".
1913
+ const postLane = async (
1914
+ label,
1915
+ doRequest,
1916
+ explainStale = async () => [],
1917
+ { boundUuid = null, reportTemplate = false } = {}
1918
+ ) => {
1919
+ info(`Pushing ${label} to ${dim(client.origin)} …`)
1920
+ let res
1921
+ try {
1922
+ res = await doRequest()
1923
+ } catch (err) {
1924
+ error(describeRequestError(err, client.origin))
1925
+ if (!(err instanceof WorkspaceMismatchError))
1926
+ note('Is that the backend you meant? Switch with: uniweb login --server <url>')
1927
+ return null
1928
+ }
1929
+ if (!res.ok) {
1930
+ const body = await res.text().catch(() => '')
1931
+ // Two unrelated conflicts share HTTP 409, so branch on the machine-readable
1932
+ // `reason` — never on `detail`, which is prose the backend may reword.
1933
+ let problem = null
1934
+ if ((res.status === 409 || res.status === 400 || res.status === 403 || res.status === 422) && body) {
1935
+ try {
1936
+ problem = JSON.parse(body)
1937
+ } catch {
1938
+ /* not a problem document */
1939
+ }
1940
+ }
1941
+ // A foundation the site may not take (`foundation_not_licensed`, a 403) or that this
1942
+ // backend does not have (`foundation_not_registered`) — said, with what to do next.
1943
+ // Before the credential branch below, which a 403 would otherwise reach.
1944
+ const attach = describeAttachRefusal(problem)
1945
+ if (attach) {
1946
+ error(`${label} push refused — ${attach.headline}`)
1947
+ for (const step of attach.steps) note(step)
1948
+ return null
1949
+ }
1950
+ // The package carried no identity for records the backend already stores, so
1951
+ // applying it would replace every one of them. `ensureItemUuids` is supposed
1952
+ // to make this unreachable, so reaching it means that recovery failed — say so
1953
+ // rather than surfacing a raw 400.
1954
+ // The cache is offering another site's item identities. Recoverable without
1955
+ // the user knowing what a sync cache is: the stale maps are unambiguously
1956
+ // wrong (they describe a different entity), so clear them and say re-run.
1957
+ //
1958
+ // Reachable only for a clone broken BEFORE the pre-flight guard existed —
1959
+ // an unstamped legacy cache on a bound site, which the guard deliberately
1960
+ // leaves alone rather than wiping every existing clone. This is the exit.
1961
+ if (problem?.reason === 'item_uuid_conflict') {
1962
+ const dropped = clearRemoteSyncState(siteDir, client.origin, boundUuid)
1963
+ error(
1964
+ `${label} push refused — this copy's sync cache is describing a different site.`
1965
+ )
1966
+ note('Nothing was written.')
1967
+ if (Number.isInteger(problem.stored_entity_id)) {
1968
+ note(
1969
+ `Item ${problem.item_uuid ?? '(unnamed)'} already belongs to entity ${problem.stored_entity_id}; this push is for entity ${problem.document_entity_id}.`
1970
+ )
1971
+ }
1972
+ if (dropped.length) {
1973
+ note(
1974
+ `Cleared the stale cache (${dropped.join(', ')}) — re-run the same command and it will push cleanly.`
1975
+ )
1976
+ } else {
1977
+ note(
1978
+ 'The cache held nothing to clear, so this is not the usual cause — please report it.'
1979
+ )
1980
+ }
1981
+ return null
1982
+ }
1983
+ if (problem?.reason === 'identity_required') {
1984
+ // ⛔ THE REFUSAL IS PER-SECTION, NOT PER-PUSH, AND WE USED TO SAY OTHERWISE.
1985
+ //
1986
+ // The backend asks, of each section independently: is this section's ENTIRE
1987
+ // incoming record set uuid-less while the stored entity already has items in
1988
+ // it? So a push can carry correct identity for most of the document and still
1989
+ // be refused over one section it stamps none of — and the old wording ("this
1990
+ // copy has no record of the site's item identity", "the recovery … could not
1991
+ // reach the backend", "`uniweb pull` also restores it") was wrong on all
1992
+ // three counts in exactly that case: the copy has identity, no recovery was
1993
+ // attempted, and a pull changes nothing.
1994
+ //
1995
+ // ⭐ THE BACKEND ALREADY SENDS WHAT LOCATES IT — `section_id`,
1996
+ // `records_without_uuid`, `stored_items` — and this branch discarded every
1997
+ // one of them, so every refusal anyone collected was missing the only fields
1998
+ // that say WHERE. (Named by backend in collab framework↔backend,
1999
+ // 2026-08-28: "the offending section and both counts have been in the body of
2000
+ // every refusal you have collected".)
2001
+ const n = problem.records_without_uuid
2002
+ const stored = problem.stored_items
2003
+ // `section_name` landed backend-side 2026-08-28 (`90e7cd7e`), replacing an
2004
+ // i64 row id we could not resolve. The fallback stays for an older backend,
2005
+ // not because the swap is pending — it is done.
2006
+ const where = problem.section_name ?? problem.section_id
2007
+ error(
2008
+ `${label} push refused — one section carries no item identity` +
2009
+ (where === undefined ? '.' : ` (section ${where}).`)
2010
+ )
2011
+ if (Number.isInteger(n) && Number.isInteger(stored)) {
2012
+ note(
2013
+ `That section sent ${n} record(s), none carrying a \`$uuid\`, while ${stored} item(s) are already stored there.`
2014
+ )
2015
+ }
2016
+ note(
2017
+ 'Nothing was written. Applying it would have replaced the identity of every stored item in that section.'
2018
+ )
2019
+ // ⚠️ Deliberately NOT "run `uniweb pull`". A pull re-harvests identity from
2020
+ // the backend's own document, so it fixes a LOST cache — and does nothing at
2021
+ // all when the cache is intact and one section simply has no entry in it,
2022
+ // which is the case this message now names. Suggesting it there sends the
2023
+ // user to re-fetch a map they already have.
2024
+ if (problem.detail) note(String(problem.detail))
2025
+ return null
2026
+ }
2027
+ // ⭐ A RECORD KEEPS ITS TYPE. A record file moved to another type's folder keeps its `$uuid`, and
2028
+ // the backend refuses it as that type (`type_changed`, naming the record and both types), before
2029
+ // writing anything. Storing it as the new type makes it a new record.
2030
+ if (problem?.reason === 'type_changed') {
2031
+ error(`${label} push refused — a stored record would change its type.`)
2032
+ if (problem.detail) note(String(problem.detail))
2033
+ note(
2034
+ 'A record keeps its type. To store it as the new type, make it a new record: delete the ' +
2035
+ '`$uuid` line from its file, then push again.'
2036
+ )
2037
+ return null
2038
+ }
2039
+ if (problem?.reason === 'stale_base') {
2040
+ error(
2041
+ `${label} push refused — the backend has newer content than your last pull.`
2042
+ )
2043
+ // Page-level account, when we can get one. The gate is entity-grained, so
2044
+ // without this the user only learns "the document moved" and has no basis
2045
+ // for choosing between pulling and forcing.
2046
+ const detail = await explainStale()
2047
+ for (const line of detail) note(line)
2048
+ // ⭐ Items no file path names — a service, a query — come named by their Section's
2049
+ // key (`stale_keys`, `{ section, key: { name } }`), so say them by name.
2050
+ const keyed = describeStaleKeys(problem.stale_keys)
2051
+ for (const line of keyed) note(line)
2052
+ if (!detail.length && !keyed.length) {
2053
+ const stale = Array.isArray(problem.stale_entities)
2054
+ ? problem.stale_entities
2055
+ : []
2056
+ if (stale.length) {
2057
+ note(
2058
+ `Changed upstream: ${stale.length} entit${stale.length === 1 ? 'y' : 'ies'} (${stale.join(', ')})`
2059
+ )
2060
+ }
2061
+ }
2062
+ note(
2063
+ 'Nothing was written — the whole push was refused before any change.'
2064
+ )
2065
+ // Say what will actually work from HERE. Anyone hitting this has local
2066
+ // work — it is why they pushed — and if it is uncommitted then `pull`
2067
+ // refuses too, so bare "run pull" advice walks them into a dead end.
2068
+ if (hasUncommittedContent(siteDir)) {
2069
+ // `--merge` is the recovery that keeps both sides: most of these are two
2070
+ // people editing different parts of one section, which merges silently.
2071
+ // Offer it first, and keep the take-theirs route for anyone who wants it.
2072
+ note(
2073
+ "Commit your changes, then `uniweb pull --merge` to combine them with the backend's."
2074
+ )
2075
+ note(
2076
+ '(Plain `uniweb pull` declines while the work is unsaved — it overwrites rather than merges.)'
2077
+ )
2078
+ } else {
2079
+ note(
2080
+ 'Run `uniweb pull --merge` to combine the changes, or `uniweb pull` to take the backend version.'
2081
+ )
2082
+ }
2083
+ note(
2084
+ 'To overwrite them anyway, re-run with --force (this discards the upstream edits).'
2085
+ )
2086
+ return null
2087
+ }
2088
+ error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
2089
+ if (res.status === 401 || res.status === 403) {
2090
+ note(
2091
+ "Credentials weren't accepted — log in again (`uniweb login --server <url>`), or check UNIWEB_TOKEN."
2092
+ )
2093
+ } else if (res.status === 404 && boundUuid) {
2094
+ // The clone is bound to a site the backend does not have. There is no CLI
2095
+ // verb that could have removed it — a site is deleted in the Uniweb app,
2096
+ // and that is what severs the sync — so the raw 404 leaves the user with
2097
+ // no idea that the fix is local and one line.
2098
+ //
2099
+ // ⛔ ORDER THESE TWO CAUSES, and put the RECOVERABLE one first. Until 2026-08-24
2100
+ // this branch named only the deletion and went straight to "clear `$uuid`" — and
2101
+ // that advice, followed for the OTHER cause, destroys a live binding: the site is
2102
+ // fine, you are simply pointed at the wrong backend, and clearing the uuid orphans
2103
+ // it. The uuid sent here is the one this project recorded for THIS origin
2104
+ // (sync.json), so another backend's uuid cannot reach it; what does is a site
2105
+ // this origin no longer has — deleted there, or a backend rebuilt at the same
2106
+ // address. Being logged in to a backend you did not mean is still the cheaper
2107
+ // thing to rule out, so it still comes first. (Until 2026-09-21 this paragraph
2108
+ // leaned on a scope check against `site.yml::$backend`; both were deleted on
2109
+ // 2026-09-20.)
2110
+ //
2111
+ // ⛔ The fix for a deletion was "clear `$uuid` from site.yml" until 2026-09-21 — a
2112
+ // key that left site.yml the day before, so the advice named nothing to clear.
2113
+ // The binding is this backend's section of sync.json, and `forget` drops it.
2114
+ note(
2115
+ `The backend at ${client.origin} has no site with uuid ${boundUuid}.`
2116
+ )
2117
+ note(
2118
+ 'Two causes. Check the cheap one first: is this the backend the site lives on?'
2119
+ )
2120
+ note(
2121
+ ` wrong backend → uniweb login --server <the right one> (nothing is lost)`
2122
+ )
2123
+ note(
2124
+ ` deleted there → uniweb forget --server ${client.origin}, then push again: it creates a NEW site`
2125
+ )
2126
+ note('Deleting this folder removes only your local copy, either way.')
2127
+ } else if (problem?.reason === 'template_records_not_copyable' && Array.isArray(problem.records)) {
2128
+ // ⭐ A push asking to designate the site a template is refused whole when a record in its
2129
+ // folder cannot be copied into the sites made from it — each named, with a `detail` written
2130
+ // to be shown. Until 2026-09-28 this printed the raw problem document, cut at 800 characters.
2131
+ note(problem.title || 'These records cannot be copied into a template:')
2132
+ for (const r of problem.records) {
2133
+ note(` ${r?.name ?? '(unnamed)'} — ${r?.detail || r?.reason || 'no reason given'}`)
2134
+ }
2135
+ return null
2136
+ } else if (res.status === 409 && (problem?.title || problem?.detail)) {
2137
+ // ⛔ A 409 names its own cause — a reference the store refuses, a rule of the record's type,
2138
+ // an append-only section — so it is shown as the backend put it, not explained by us. Until
2139
+ // 2026-09-26 every one was explained as "this site's record structure is already established
2140
+ // … push it as a new site", a rule the backend no longer has, and it misled over a refused
2141
+ // reference. (`stale_base` and `item_uuid_conflict` have their own explanations, above.)
2142
+ note([problem.title, problem.detail].filter(Boolean).join(' — '))
2143
+ return null
2144
+ }
2145
+ if (body) note(body.slice(0, 800))
2146
+ return null
2147
+ }
2148
+ let payload
2149
+ try {
2150
+ payload = await res.json()
2151
+ } catch (err) {
2152
+ error(`Could not parse the ${label} response as JSON: ${err.message}`)
2153
+ return null
2154
+ }
2155
+ // The designation outcome rides on the site-content lanes only — the one place
2156
+ // the author's `template:` intent was sent.
2157
+ if (reportTemplate) {
2158
+ const state = templateOutcome(payload)
2159
+ if (state) {
2160
+ note(
2161
+ TEMPLATE_SAID[state] ||
2162
+ `The backend reported the template state as "${state}".`
2163
+ )
2164
+ }
2165
+ }
2166
+ return payload
2167
+ }
2168
+
2169
+ // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
2170
+ // finalized list (for record back-fill + the changed summary). Returns the finalized
2171
+ // array, or null on failure (already reported).
2172
+ const pushLane = async (label, doRequest, explainStale, opts) => {
2173
+ const payload = await postLane(label, doRequest, explainStale, opts)
2174
+ if (payload === null) return null
2175
+ const finalized = extractFinalized(payload)
2176
+ if (!finalized) {
2177
+ error(
2178
+ `The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`
2179
+ )
2180
+ note(JSON.stringify(payload).slice(0, 800))
2181
+ return null
2182
+ }
2183
+ const summary = changedSummary(finalized)
2184
+ if (summary) note(`${label}: ${summary}`)
2185
+ return finalized
2186
+ }
2187
+
2188
+ // Lane 1 — site-content (the site is born here; it must exist before its folder). A
2189
+ // known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the site
2190
+ // and returns its uuid, which we record in sync.json, under this backend).
2191
+ // `boundSiteUuid` carries the minted/known uuid forward to key the folder push.
2192
+ let boundSiteUuid = siteContentUuid
2193
+ // Post-write tokens harvested from every lane, persisted once at the end so a
2194
+ // partial push (lane 1 ok, lane 2 refused) still banks what actually landed —
2195
+ // otherwise the next attempt would re-send lane 1 with a base the backend has
2196
+ // already moved past, and refuse a push the user just made.
2197
+ const newVersions = {}
2198
+ // Per-item tokens from the SAME response — the site-content entity's, keyed by item
2199
+ // `$uuid`: the whole set this copy now holds (`heldTokens`), which replaces the one
2200
+ // it held. Null until that lane lands, which leaves the held set as it was.
2201
+ // ⛔ The records lane's are not kept: that lane is gated by its entity version, and
2202
+ // until 2026-10-07 they were merged into the same map, which was sent for nothing.
2203
+ //
2204
+ // ⛔ Both grains must be re-armed from the push, for one reason: a push writes,
2205
+ // so every token this clone holds for a record it just changed is now stale. Read
2206
+ // on pull only, push 2 classifies that record `pkg != base, host != base` and the
2207
+ // backend refuses it — a conflict naming records nobody else touched, on a second
2208
+ // push with no edit in between. And it is unrecoverable locally, because the
2209
+ // tokens are opaque by contract: a pull would be the only other source, and a
2210
+ // pull rewrites the working tree. That is precisely the `--force` habit the gate
2211
+ // exists to prevent. Returning the entity `version` on push was added to fix this
2212
+ // exact shape one grain up (see delivery-lane.md "Both feed directions are
2213
+ // load-bearing"); the item grain had the same hole until backend `d7e46335`
2214
+ // started echoing `item_versions` here.
2215
+ let heldItemVersions = null
2216
+ const harvest = (finalized) => {
2217
+ for (const f of finalized || []) {
2218
+ // NOT gated on `changed`: the backend pins "zero-write ⇒ version unmoved", so a
2219
+ // no-op resubmit hands back the value we already hold. (The site-content lane
2220
+ // filters first, `harvestSiteContent`.)
2221
+ if (f.uuid && f.version) newVersions[f.uuid] = f.version
2222
+ }
2223
+ }
2224
+ // Both grains land together, at every point the old code banked the entity one.
2225
+ const mergeHarvested = () => {
2226
+ mergeBaseVersions(siteDir, client.origin, newVersions)
2227
+ if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
2228
+ }
2229
+ // ⛔ The site-content lane banks only the tokens for content this copy holds — the
2230
+ // document we sent against the one the backend stored (`heldTokens`, and the two
2231
+ // losses banking everything caused). What the backend kept for someone else is
2232
+ // collected here, to re-base attribution and to say so.
2233
+ const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
2234
+ const notHeld = { kept: [], foreign: [] }
2235
+ const heldUnits = []
2236
+ const harvestSiteContent = (finalized) => {
2237
+ for (const f of finalized || []) {
2238
+ const held = heldTokens({
2239
+ sent: sentSiteDoc,
2240
+ written: f.document,
2241
+ version: f.version,
2242
+ itemVersions: f.itemVersions,
2243
+ held: heldItemVersions ?? readItemBaseVersions(siteDir, client.origin)
2244
+ })
2245
+ harvest([{ ...f, version: held.version }])
2246
+ if (held.itemVersions) heldItemVersions = held.itemVersions
2247
+ heldUnits.push(...held.held)
2248
+ notHeld.kept.push(...held.kept)
2249
+ notHeld.foreign.push(...held.foreign)
2250
+ }
2251
+ }
2252
+ // The backend's post-write copy of the site-content document, kept for the
2253
+ // remote-side unit base (see writeUnitBases).
2254
+ let siteFinalizedDoc = null
2255
+ // ⭐ WHAT THE SITE-CONTENT LANE LANDED IS SETTLED THE MOMENT IT LANDS — its query and item
2256
+ // identity, its unit bases and the files it now holds as synced — on either path to it.
2257
+ // ⛔ Until 2026-09-25 the item identity and the bases were settled at the END, after the
2258
+ // records lane, so a records lane that was refused returned before them: the pages were
2259
+ // stored, their identity was saved nowhere, and every later push sent them without it and
2260
+ // was refused — "one section carries no item identity (section pages)" — while the
2261
+ // queries, banked at once, were kept. And the queries were banked on the update path
2262
+ // only, so a site created with no media never saved theirs.
2263
+ const settleSiteContent = () => {
2264
+ // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's placements
2265
+ // below. These items have no file to back-fill into — they all come from one
2266
+ // `collections/collections.yml` — so the only place their `$uuid` can live is
2267
+ // `sync.json` (this backend's `queries` map), keyed by the name the backend enforces
2268
+ // unique. Without it every push after the first re-sends the whole `collections`
2269
+ // section uuid-less and is refused.
2270
+ if (siteFinalizedDoc) {
2271
+ const recordIds = collectQueryUuids(siteFinalizedDoc)
2272
+ if (Object.keys(recordIds).length) writeQueryUuids(siteDir, client.origin, recordIds)
2273
+ // ⭐ THE SERVICES THIS COPY NOW HOLDS, `{ name: $uuid }`: those the push stated and
2274
+ // those held before, as the site returned them (`heldServices`). A service added on
2275
+ // the site since this copy's last pull is left out — held, the next push would state
2276
+ // it off, switching off a service nobody here has seen. Replaced, not merged.
2277
+ updateBackendState(siteDir, client.origin, {
2278
+ services: heldServices({
2279
+ written: siteFinalizedDoc.services,
2280
+ sent: Array.isArray(sentSiteDoc?.services) ? sentSiteDoc.services : [],
2281
+ prior: readBackendState(siteDir, client.origin).services
2282
+ })
2283
+ })
2284
+ }
2285
+ // Re-base the page attribution: our emitted document and the backend's post-write
2286
+ // copy of it are the two sides' new agreed state.
2287
+ const patch = {}
2288
+ const ours = sentSiteDoc
2289
+ if (ours) patch.local = computeUnitHashes(ours)
2290
+ // `finalized[].document` is the backend's own representation, read back from
2291
+ // the stored row — the only remote-side base a push can produce.
2292
+ const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
2293
+ if (theirs) {
2294
+ patch.remote = computeUnitHashes(theirs)
2295
+ // A unit the backend kept for someone else is not agreed state: its remote base
2296
+ // stays where it was, so a refusal names it as changed upstream.
2297
+ const before = readUnitBases(siteDir, client.origin).remote
2298
+ for (const path of notHeld.kept) {
2299
+ if (before[path]) patch.remote[path] = before[path]
2300
+ else delete patch.remote[path]
2301
+ }
2302
+ }
2303
+ if (Object.keys(patch).length) writeUnitBases(siteDir, client.origin, patch)
2304
+ // The backend's post-write document carries `$uuid` per item at every nesting
2305
+ // level, so the push that just landed also re-arms identity for the next one.
2306
+ // Replaced wholesale: an item that no longer exists must not keep a uuid that
2307
+ // would re-target something else.
2308
+ if (theirs) writeItemUuids(siteDir, client.origin, collectUnitUuids(theirs))
2309
+ else {
2310
+ // ⛔ BANKING IS BEST-EFFORT AND ITS FAILURE USED TO BE SILENT — say it here,
2311
+ // because the cost lands two commands away and names something else.
2312
+ //
2313
+ // Without `finalized[0].document` (carrying `pages`) this push banks NO item
2314
+ // identity. The push still reports success. The next `push`/`publish` then
2315
+ // emits with no `$uuid` per item, and the backend refuses — correctly, since
2316
+ // silently re-identifying every stored row is far worse. But that refusal
2317
+ // reads as a stale-token or a producer bug, with nothing pointing back at the
2318
+ // push that failed to bank.
2319
+ //
2320
+ // ⚠️ We cannot tell WHY it is absent from here — a response shape that
2321
+ // changed, a lane that shipped nothing, a backend that does not echo the
2322
+ // document. Report the observable fact and let the operator carry it.
2323
+ note(
2324
+ 'identity not banked: this push returned no post-write document, so no per-item ' +
2325
+ '$uuid was stored. The next push or publish will be identity-blind and the backend ' +
2326
+ 'may refuse it. `uniweb pull` re-arms identity if that happens.'
2327
+ )
2328
+ }
2329
+ // The files of the units the backend now holds as ours are the synced version — the
2330
+ // ancestor the next `pull --merge` merges from.
2331
+ if (heldUnits.length) recordPushedUnits(siteDir, heldUnits)
2332
+ }
2333
+ if (siteContent) {
2334
+ if (siteContentUuid) {
2335
+ const finalized = await pushLane(
2336
+ 'site-content',
2337
+ () =>
2338
+ client.updateSiteContent(siteContentUuid, siteContent.buffer),
2339
+ () =>
2340
+ explainStaleSiteContent({
2341
+ client,
2342
+ siteDir,
2343
+ localBuffer: siteContent.buffer,
2344
+ uuid: siteContentUuid
2345
+ }),
2346
+ { boundUuid: siteContentUuid, reportTemplate: true }
2347
+ )
2348
+ if (!finalized) {
2349
+ mergeHarvested()
2350
+ return { exitCode: 1, finalizedTotal, wrote }
2351
+ }
2352
+ harvestSiteContent(finalized)
2353
+ siteFinalizedDoc = finalized[0]?.document || null
2354
+ finalizedTotal += finalized.length
2355
+ settleSiteContent()
2356
+ } else {
2357
+ const payload = await postLane(
2358
+ 'site-content',
2359
+ () => client.createSiteContent(siteContent.buffer),
2360
+ undefined,
2361
+ { reportTemplate: true }
2362
+ )
2363
+ if (payload === null) return { exitCode: 1, finalizedTotal, wrote }
2364
+ const minted = extractMintedSiteUuid(payload)
2365
+ if (!minted) {
2366
+ error(
2367
+ 'The create response carried no minted site-content uuid — cannot record the site identity or push its folder.'
2368
+ )
2369
+ note(JSON.stringify(payload).slice(0, 800))
2370
+ return { exitCode: 1, finalizedTotal, wrote }
2371
+ }
2372
+ writeSiteEntityUuid(siteDir, client.origin, minted)
2373
+ updateSyncCache(siteDir, client.origin, { siteUuid: minted })
2374
+ boundSiteUuid = minted
2375
+ wrote.push('recorded the site in sync.json')
2376
+ // The OTHER create path (a media-less push never reaches `ensureSiteExists`,
2377
+ // which is gated on the site having local media). Both mint a site, so both
2378
+ // owe the same record — recording it in only one place would make the recorded
2379
+ // owner present or absent depending on whether the site happens to have images.
2380
+ // The backend echoes `org`/`hosts_free` top-level here too, beside `report`
2381
+ // and `site` (NOT beside `finalized`, which lives at report.finalized).
2382
+ const createdOrg = await recordAndDescribeOwner({ client, siteDir, payload, note })
2383
+ if (createdOrg)
2384
+ wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
2385
+ const createdFinalized = extractFinalized(payload)
2386
+ harvestSiteContent(createdFinalized)
2387
+ siteFinalizedDoc = createdFinalized?.[0]?.document || null
2388
+ finalizedTotal += createdFinalized?.length ?? 1
2389
+ settleSiteContent()
2390
+ }
2391
+ }
2392
+
2393
+ // Lane 2 — collections (the @uniweb/folder + the records it references), keyed by the
2394
+ // site-content uuid. On a brand-new site the backend creates the folder on this first
2395
+ // push. Records round-trip their own $uuid (back-filled into source files); the folder
2396
+ // itself has no uuid (the backend owns it).
2397
+ if (records) {
2398
+ if (!boundSiteUuid) {
2399
+ error(
2400
+ 'Cannot push records — the site has no uuid yet. Push the site-content lane first.'
2401
+ )
2402
+ return { exitCode: 1, finalizedTotal, wrote }
2403
+ }
2404
+ const finalized = await pushLane(
2405
+ 'records',
2406
+ () => client.pushFolder(boundSiteUuid, records.buffer),
2407
+ undefined,
2408
+ { boundUuid: boundSiteUuid }
2409
+ )
2410
+ if (!finalized) {
2411
+ mergeHarvested()
2412
+ return { exitCode: 1, finalizedTotal, wrote }
2413
+ }
2414
+ harvest(finalized)
2415
+ const bf = backfillEntityUuids({ index: records.index, finalized })
2416
+ // ⭐ Record which uuid THIS backend minted for each record, keyed by the record's
2417
+ // own id. The file keeps that own id; the next push to this backend sends the
2418
+ // mapped uuid, and a push to another backend sends none so that one mints its own.
2419
+ if (bf.mapped && Object.keys(bf.mapped).length) {
2420
+ updateBackendMap(siteDir, client.origin, 'records', bf.mapped)
2421
+ }
2422
+ for (const w of bf.warnings) note(`! ${w}`)
2423
+ for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
2424
+ // ⭐ BANK THE RECORDS' LIST ITEMS: each item's `$uuid` in the document the backend returned,
2425
+ // paired by place with the lists as sent — so the next send of a record updates its items
2426
+ // rather than being refused for replacing them (`readRecordItemUuids`).
2427
+ bankRecordItems(
2428
+ siteDir,
2429
+ client.origin,
2430
+ finalized.map((f) => ({
2431
+ uuid: f?.uuid,
2432
+ lists: Number.isInteger(f?.index) ? records.index?.[f.index]?.lists : null,
2433
+ returned: f?.document
2434
+ }))
2435
+ )
2436
+ draftsNotKept = bf.notKeptAsDrafts || []
2437
+ if (bf.updated.length)
2438
+ wrote.push(`wrote ${bf.updated.length} record file(s)`)
2439
+ // ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records carry their own `$uuid`
2440
+ // in their source files and map it per backend (above); the folder's ITEMS have nowhere to
2441
+ // be written, so they are banked here from the document the backend just
2442
+ // returned.
2443
+ //
2444
+ // ⛔ Without this a `publish` after a `push` is refused: send-only-changed
2445
+ // skips the unchanged records and re-sends the folder ALONE, its `contents`
2446
+ // items carry no `$uuid`, and a `multi` item without one reads as new — so
2447
+ // the backend refuses rather than replacing every placement. Reproduced on a
2448
+ // live manor 2026-08-27; the identities were on the wire all along.
2449
+ const folderDoc = finalized.find((f) => f?.document?.contents)?.document
2450
+ if (folderDoc) {
2451
+ const placements = collectFolderItemUuids(folderDoc)
2452
+ if (Object.keys(placements).length)
2453
+ writeFolderItemUuids(siteDir, client.origin, placements)
2454
+ // ⭐ A LINK RECORD'S IDENTITY is its folder entry's `$uuid`, banked as a record's is: into
2455
+ // its file the first time, then mapped per backend (`link-records.js`, 2026-09-28).
2456
+ if (records.links?.length) {
2457
+ const lb = backfillLinkUuids({ links: records.links, folderDoc })
2458
+ if (Object.keys(lb.mapped).length) updateBackendMap(siteDir, client.origin, 'records', lb.mapped)
2459
+ for (const w of lb.warnings) note(`! ${w}`)
2460
+ if (lb.updated.length) wrote.push(`wrote ${lb.updated.length} link file(s)`)
2461
+ }
2462
+ // ⭐ …and a FILE RECORD's: a file cannot carry it, so it is banked per backend by the file's
2463
+ // path (`sync.json` `files`; `file-records.js`, 2026-09-28).
2464
+ if (records.files?.length) {
2465
+ const fb = bankedFileUuids({ files: records.files, folderDoc })
2466
+ if (Object.keys(fb).length) updateBackendMap(siteDir, client.origin, 'files', fb)
2467
+ }
2468
+ }
2469
+ finalizedTotal += finalized.length
2470
+ }
2471
+
2472
+ // Persist the full content-hash map so the next push skips unchanged entities,
2473
+ // then bank the post-write tokens so the NEXT push carries a current base.
2474
+ // Entities absent from finalized[] (skipped, or not editable) keep their cached
2475
+ // value — absence is not invalidation.
2476
+ writeSyncCache(siteDir, client.origin, hashes, applied)
2477
+ // A record that named a record this push created is banked as the next push will send
2478
+ // it — by that record's uuid, which the back-fill above just recorded.
2479
+ if (records && pkg.namesNew?.length) {
2480
+ try {
2481
+ await rebankRecordHashes(siteDir, client.origin, pkg.namesNew)
2482
+ } catch (err) {
2483
+ note(`! Could not re-bank ${pkg.namesNew.length} record(s) that name a new record: ${err.message}. The next push sends them again.`)
2484
+ }
2485
+ }
2486
+ mergeHarvested()
2487
+ // (The site-content lane's own state was settled when it landed — `settleSiteContent`.)
2488
+ // What the backend holds that this copy doesn't. None of it was overwritten, and no
2489
+ // later push will overwrite it — but the files are behind until a pull.
2490
+ if (notHeld.kept.length || notHeld.foreign.length) {
2491
+ // Named by the file it is in here, not by its id (`unitFilesOf`).
2492
+ const paths = [
2493
+ ...new Set([...notHeld.kept, ...notHeld.foreign].map((p) => (p === 'site.yml' ? p : unitFilesOf(siteDir, p)[0] || p)))
2494
+ ].sort()
2495
+ note(`Changed on the backend by someone else, not in your files yet: ${paths.join(', ')}`)
2496
+ note('Take them with `uniweb refresh` (or `uniweb pull --merge`). Until then, pushing leaves them as they are.')
2497
+ }
2498
+ // ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
2499
+ // piece of sync state above is banked: the push itself happened, and the next one must
2500
+ // see it. What must not happen is a publish that goes live on top of it, which is why
2501
+ // the exit code carries it (publish stops on anything but 0). The files keep their
2502
+ // `draft: true` (see backfillEntityUuids).
2503
+ if (draftsNotKept.length) {
2504
+ const one = draftsNotKept.length === 1
2505
+ const shown = draftsNotKept.map((p) => (isAbsolute(p) ? relative(siteDir, p) : p))
2506
+ error(
2507
+ `This backend did not keep ${one ? 'a record' : `${shown.length} records`} as a draft, ` +
2508
+ `so ${one ? 'it' : 'they'} would be delivered once the site is published:\n ` +
2509
+ shown.join('\n ') +
2510
+ '\n The backend does not support `draft: true` yet. Once it does, push again with --all.'
2511
+ )
2512
+ return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote }
2513
+ }
2514
+ return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
2515
+ }