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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- 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
|
+
}
|