@uniweb/build 0.55.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * `sync.json` — what each backend minted, keyed by origin.
3
+ *
4
+ * ## What it is
5
+ *
6
+ * Every id in this file was assigned by a backend and is meaningless against any
7
+ * other one: a site uuid, a record's uuid, an asset's content-addressed id. The
8
+ * file is **committed**, because a teammate who clones the project otherwise holds
9
+ * content with no way to say which site on which backend it is.
10
+ *
11
+ * ⭐ **If it changed, something got synced.** Nothing else writes it — which is the
12
+ * whole reason it is separate from `deploy.yml`, whose diff means "a target changed".
13
+ *
14
+ * Spec: `kb/framework/reference/sync-json.md`. Why it exists at all:
15
+ * `kb/framework/plans/backend-scoped-project-state.md`.
16
+ *
17
+ * ## Why it is keyed by ORIGIN, all the way down
18
+ *
19
+ * A project may sync with any number of backends. Nothing can be shared between
20
+ * them, so nothing is: each origin gets its own section and they never merge.
21
+ *
22
+ * ⭐ **The payoff is a guard that stops existing.** Identity used to sit in
23
+ * `site.yml` as a single `$uuid`, with `$backend` recording which backend it came
24
+ * from and `assertSiteBackendScope` refusing a command whose origin disagreed.
25
+ * Here there is no configuration in which backend A's ids can reach B — you read
26
+ * A's section for A — so there is no mismatch to detect.
27
+ *
28
+ * ## ⛔ No dependencies, by requirement
29
+ *
30
+ * `clone` writes a site's identity BEFORE `pnpm install` has run, so the writer has
31
+ * to work with nothing installed. `site.yml`'s equivalent needed a hand-rolled
32
+ * dependency-free fallback for exactly this (`cli/utils/site-identity.js`); JSON
33
+ * needs no parser, so this module uses `node:fs` and nothing else.
34
+ *
35
+ * @module
36
+ */
37
+
38
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
39
+ import { dirname, join } from 'node:path'
40
+
41
+ export const SYNC_STORE_FILE = 'sync.json'
42
+
43
+ /** Current on-disk shape. Bumped only for a breaking layout change. */
44
+ const VERSION = 1
45
+
46
+ /** Map sections — merged entry by entry. Anything else in a patch replaces. */
47
+ const MAP_SECTIONS = new Set(['records', 'items', 'queries', 'folders', 'assets'])
48
+
49
+ const storePath = (siteDir) => join(siteDir, SYNC_STORE_FILE)
50
+
51
+ /**
52
+ * A bare origin with no trailing slash, or null when unparseable.
53
+ *
54
+ * Callers may pass a whole endpoint URL — `http://localhost:8080/dev/site/…` and
55
+ * `http://localhost:8080` address the same backend. `https://x` and `http://x` do
56
+ * not: a scheme change is a different backend, not the same one reached differently.
57
+ */
58
+ export function normalizeOrigin(value) {
59
+ if (typeof value !== 'string' || !value.trim()) return null
60
+ try {
61
+ // ⛔ http(s) only: `new URL('localhost:8080')` PARSES — as the scheme `localhost:` —
62
+ // and its origin is the string "null". Keyed by that, a mistyped backend would get a
63
+ // section of its own.
64
+ const u = new URL(value)
65
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The whole file, normalized. A missing, unreadable or malformed file reads as
73
+ * empty rather than throwing: this is a record, and a corrupt one must never be the
74
+ * reason a push fails. The next write repairs it.
75
+ *
76
+ * @param {string} siteDir
77
+ * @returns {{ version: number, backends: Record<string, object> }}
78
+ */
79
+ export function readSyncStore(siteDir) {
80
+ const empty = { version: VERSION, backends: {} }
81
+ const p = storePath(siteDir)
82
+ if (!existsSync(p)) return empty
83
+ try {
84
+ const parsed = JSON.parse(readFileSync(p, 'utf8'))
85
+ const backends = parsed?.backends
86
+ if (!backends || typeof backends !== 'object' || Array.isArray(backends)) return empty
87
+ const out = {}
88
+ for (const [origin, state] of Object.entries(backends)) {
89
+ const key = normalizeOrigin(origin)
90
+ if (key && state && typeof state === 'object' && !Array.isArray(state)) out[key] = state
91
+ }
92
+ return { version: VERSION, backends: out }
93
+ } catch {
94
+ return empty
95
+ }
96
+ }
97
+
98
+ /**
99
+ * One backend's section, or `{}` when this project has never synced with it.
100
+ *
101
+ * ⛔ Returns a COPY at the top level, so a caller mutating it cannot write through
102
+ * to the next read. The maps inside are shared — treat the whole thing as read-only
103
+ * and go through `updateBackendMap` to change anything.
104
+ *
105
+ * @param {string} siteDir
106
+ * @param {string} origin
107
+ * @returns {object}
108
+ */
109
+ export function readBackendState(siteDir, origin) {
110
+ const key = normalizeOrigin(origin)
111
+ if (!key) return {}
112
+ const found = readSyncStore(siteDir).backends[key]
113
+ return found ? { ...found } : {}
114
+ }
115
+
116
+ /** Every origin this project has synced with, sorted. */
117
+ export function listSyncedBackends(siteDir) {
118
+ return Object.keys(readSyncStore(siteDir).backends).sort()
119
+ }
120
+
121
+ /**
122
+ * Write the store, with every level sorted.
123
+ *
124
+ * ⛔ **Sorting is not tidiness.** A committed file that reorders itself produces a
125
+ * diff on every push and teaches people to stop reading it — at which point a real
126
+ * change to what a site is bound to passes unnoticed. Same rule, and the same
127
+ * reason, as `assets.json`, which this file absorbs.
128
+ */
129
+ function writeStore(siteDir, backends) {
130
+ const sortedBackends = {}
131
+ for (const origin of Object.keys(backends).sort()) {
132
+ const state = backends[origin]
133
+ const sortedState = {}
134
+ for (const section of Object.keys(state).sort()) {
135
+ const value = state[section]
136
+ if (MAP_SECTIONS.has(section) && value && typeof value === 'object' && !Array.isArray(value)) {
137
+ const sortedMap = {}
138
+ for (const k of Object.keys(value).sort()) sortedMap[k] = value[k]
139
+ sortedState[section] = sortedMap
140
+ } else {
141
+ sortedState[section] = value
142
+ }
143
+ }
144
+ sortedBackends[origin] = sortedState
145
+ }
146
+ const text = JSON.stringify({ version: VERSION, backends: sortedBackends }, null, 2) + '\n'
147
+ const p = storePath(siteDir)
148
+ // A no-op write is worse than useless: it dirties `git status` on a push that
149
+ // changed nothing, which is the other half of the diff-stability rule above.
150
+ if (existsSync(p)) {
151
+ try {
152
+ if (readFileSync(p, 'utf8') === text) return false
153
+ } catch {
154
+ /* unreadable — fall through and replace it */
155
+ }
156
+ }
157
+ mkdirSync(dirname(p), { recursive: true })
158
+ writeFileSync(p, text)
159
+ return true
160
+ }
161
+
162
+ /**
163
+ * Merge `patch` into one backend's section.
164
+ *
165
+ * **Map sections merge entry by entry; everything else replaces.** A push carries
166
+ * only what that run touched, so replacing a map would drop every entry it happened
167
+ * not to mention — which is most of them on a partial push. `services` and
168
+ * `secrets` are arrays and a whole projection from the backend, so replacing is
169
+ * right for those.
170
+ *
171
+ * @param {string} siteDir
172
+ * @param {string} origin
173
+ * @param {object} patch - e.g. `{ site: { uuid }, records: { 'a.md': 'uuid' } }`
174
+ * @returns {boolean} whether the file changed
175
+ */
176
+ export function updateBackendState(siteDir, origin, patch) {
177
+ const key = normalizeOrigin(origin)
178
+ if (!key || !patch || typeof patch !== 'object') return false
179
+ const { backends } = readSyncStore(siteDir)
180
+ const prior = backends[key] || {}
181
+ const next = { ...prior }
182
+
183
+ for (const [section, value] of Object.entries(patch)) {
184
+ if (value === undefined) continue
185
+ if (MAP_SECTIONS.has(section) && value && typeof value === 'object' && !Array.isArray(value)) {
186
+ next[section] = { ...(prior[section] || {}), ...value }
187
+ } else if (section === 'site' && value && typeof value === 'object') {
188
+ // The identity object merges too: a create knows the uuid, the org prompt
189
+ // that follows knows the handle, and neither should erase the other.
190
+ next[section] = { ...(prior[section] || {}), ...value }
191
+ } else {
192
+ next[section] = value
193
+ }
194
+ }
195
+
196
+ backends[key] = next
197
+ return writeStore(siteDir, backends)
198
+ }
199
+
200
+ /**
201
+ * Merge entries into one map section, with an optional per-entry rule.
202
+ *
203
+ * `merge(next, prior)` decides what an entry becomes when one already exists, and
204
+ * returns `undefined` to leave the stored one alone. Without it, the incoming entry
205
+ * wins. Assets use it to carry a `served` fingerprint forward across a push that
206
+ * learned an id without learning an address.
207
+ *
208
+ * @param {string} siteDir
209
+ * @param {string} origin
210
+ * @param {string} section - one of `records` `items` `queries` `folders` `assets`
211
+ * @param {Record<string, *>} entries
212
+ * @param {(next: *, prior: *) => *} [merge]
213
+ * @returns {{ added: string[], changed: string[], written: boolean }}
214
+ */
215
+ export function updateBackendMap(siteDir, origin, section, entries, merge) {
216
+ const key = normalizeOrigin(origin)
217
+ const added = []
218
+ const changed = []
219
+ if (!key || !MAP_SECTIONS.has(section) || !entries || typeof entries !== 'object') {
220
+ return { added, changed, written: false }
221
+ }
222
+
223
+ const { backends } = readSyncStore(siteDir)
224
+ const prior = backends[key] || {}
225
+ const map = { ...(prior[section] || {}) }
226
+
227
+ for (const [k, incoming] of Object.entries(entries)) {
228
+ if (incoming === undefined || incoming === null) continue
229
+ const was = map[k]
230
+ const value = was !== undefined && merge ? merge(incoming, was) : incoming
231
+ if (value === undefined) continue
232
+ if (was === undefined) added.push(k)
233
+ else if (JSON.stringify(was) !== JSON.stringify(value)) changed.push(k)
234
+ else continue
235
+ map[k] = value
236
+ }
237
+
238
+ if (!added.length && !changed.length) return { added, changed, written: false }
239
+ backends[key] = { ...prior, [section]: map }
240
+ return { added, changed, written: writeStore(siteDir, backends) }
241
+ }
242
+
243
+ /**
244
+ * The merge rule for the `assets` section — pass it to `updateBackendMap`.
245
+ *
246
+ * ⭐ **One definition, because three callers need it.** A push learns an id; only an
247
+ * UPLOAD learns the address the host serves it at. So a push that re-records the same
248
+ * bytes must not erase the `served` fingerprint an earlier one recorded, and a push
249
+ * whose bytes changed must drop it — the old address no longer describes them.
250
+ * Hand-rolled at each call site this is three chances to forget the second half.
251
+ *
252
+ * @param {{id: string, ext?: string, served?: string}} next
253
+ * @param {{id: string, ext?: string, served?: string}} prior
254
+ */
255
+ export function carryServed(next, prior) {
256
+ if (next?.served || !prior || prior.id !== next?.id) return next
257
+ return prior.served ? { ...next, served: prior.served } : next
258
+ }
259
+
260
+ /**
261
+ * Empty named sections of one backend's state.
262
+ *
263
+ * ⛔ **`updateBackendState` cannot express this**: map sections MERGE, so passing
264
+ * `{ items: {} }` is a no-op. Clearing needs its own verb, and the absence of one is
265
+ * how a caller ends up reaching into the file by hand.
266
+ *
267
+ * @param {string} siteDir
268
+ * @param {string} origin
269
+ * @param {string[]} sections
270
+ * @returns {string[]} the sections that actually held something
271
+ */
272
+ export function clearBackendSections(siteDir, origin, sections) {
273
+ const key = normalizeOrigin(origin)
274
+ if (!key || !Array.isArray(sections)) return []
275
+ const { backends } = readSyncStore(siteDir)
276
+ const prior = backends[key]
277
+ if (!prior) return []
278
+ const cleared = []
279
+ const next = { ...prior }
280
+ for (const section of sections) {
281
+ const v = prior[section]
282
+ if (v && typeof v === 'object' && Object.keys(v).length) {
283
+ cleared.push(section)
284
+ next[section] = {}
285
+ }
286
+ }
287
+ if (!cleared.length) return []
288
+ backends[key] = next
289
+ writeStore(siteDir, backends)
290
+ return cleared
291
+ }
292
+
293
+ /**
294
+ * Forget one backend entirely. Every other backend is untouched — which is the
295
+ * point of the keying, and the reason this is safe in a way clearing the old
296
+ * single-valued identity never was.
297
+ *
298
+ * @returns {boolean} whether anything was removed
299
+ */
300
+ export function clearBackend(siteDir, origin) {
301
+ const key = normalizeOrigin(origin)
302
+ if (!key) return false
303
+ const { backends } = readSyncStore(siteDir)
304
+ if (!backends[key]) return false
305
+ delete backends[key]
306
+ // ⭐ The last backend gone ⇒ the FILE goes. An empty ledger reads exactly like no
307
+ // ledger, and leaving one behind is an untracked file in `git status` — a trace
308
+ // of the very sync the caller asked to forget (the throwaway dev-server lane).
309
+ if (!Object.keys(backends).length) {
310
+ unlinkSync(storePath(siteDir))
311
+ return true
312
+ }
313
+ writeStore(siteDir, backends)
314
+ return true
315
+ }
316
+
317
+ /**
318
+ * Delete `sync.json` outright — `uniweb forget --all`, which turns a COPY of a
319
+ * project into a new one.
320
+ *
321
+ * Every id in this file belongs to the project it was copied from, and keeping any
322
+ * of them is how a copy's push updates the original's site. There is no partial
323
+ * version of that: a copy owns none of these sites.
324
+ *
325
+ * @param {string} siteDir
326
+ * @returns {string[]|null} the origins it held, or null when there was no file. A
327
+ * file that does not parse is removed all the same — it is still the original's —
328
+ * and reports `[]`.
329
+ */
330
+ export function forgetSyncStore(siteDir) {
331
+ const p = storePath(siteDir)
332
+ if (!existsSync(p)) return null
333
+ const origins = Object.keys(readSyncStore(siteDir).backends).sort()
334
+ unlinkSync(p)
335
+ return origins
336
+ }
337
+
338
+ /**
339
+ * The local ref a given asset id is known at for one backend, or null.
340
+ *
341
+ * Exists for `pull`, which meets stored content carrying an id and has to put the
342
+ * file back at the path its author wrote rather than inventing one.
343
+ */
344
+ export function refForAssetId(siteDir, origin, id) {
345
+ if (!id) return null
346
+ const assets = readBackendState(siteDir, origin).assets || {}
347
+ for (const [ref, v] of Object.entries(assets)) {
348
+ if (v && v.id === id) return ref
349
+ }
350
+ return null
351
+ }