@uniweb/build 0.55.0 → 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.
package/src/uwx/site.js CHANGED
@@ -67,6 +67,7 @@ import { emitEntitySyncPackage } from './entity-document.js'
67
67
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
68
68
  import { unwrapLocalized } from './backfill.js'
69
69
  import { loadFreeformTranslation } from '../i18n/freeform.js'
70
+ import { updateBackendState, readBackendState } from './sync-store.js'
70
71
  import { upsertYamlScalar } from './yaml-upsert.js'
71
72
  import { resolveQueriesConfig } from './queries-config.js'
72
73
  import { resolveSelfScope } from './self-scope.js'
@@ -974,8 +975,14 @@ function queriesNested(declarations, uuids = null, org = null) {
974
975
 
975
976
  // ── `services` + `secrets` — a site's own service records ─────────────────────
976
977
  //
977
- // ⭐ THE FILE KEYS ARE `$services` / `$secrets`, NOT `services` / `secrets`, and the
978
- // `$` is load-bearing rather than decorative. `site.yml::services` is ALREADY TAKEN,
978
+ // ⚠️ THESE WERE `site.yml::$services` / `$secrets` UNTIL 2026-09-20. They now live in
979
+ // `sync.json::backends.<origin>.{services,secrets}`, because what a site is PROVISIONED
980
+ // with is a fact about one backend — a service is bought on the backend that sold it.
981
+ // The `$` prefix existed to keep them off `site.yml::services`, which means something
982
+ // else on the bundle lane (the HOST tier); out of `site.yml` entirely, that collision
983
+ // is gone too.
984
+ //
985
+ // The original note, kept because it explains why the names could not simply be reused: `site.yml::services` is ALREADY TAKEN,
979
986
  // on the other lane: the bundle lane spreads site.yml whole into the payload, so a
980
987
  // `services:` block there lands at `config.services` — the HOST tier — which is the
981
988
  // documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
@@ -1056,9 +1063,9 @@ function serviceRecords(declared, identify, label) {
1056
1063
  }
1057
1064
 
1058
1065
  /** One service per `name` — the same keyspace `config.services` uses at runtime. */
1059
- function servicesNested(siteYml) {
1066
+ function servicesNested(provisioned) {
1060
1067
  return serviceRecords(
1061
- siteYml.$services,
1068
+ provisioned?.services,
1062
1069
  (e) => (typeof e.name === 'string' && e.name ? e.name : null),
1063
1070
  '$services'
1064
1071
  )
@@ -1076,9 +1083,9 @@ function servicesNested(siteYml) {
1076
1083
  * judgement belongs; framework does not strip it, because silently dropping a value
1077
1084
  * an author typed would leave them believing a secret was set.
1078
1085
  */
1079
- function secretsNested(siteYml) {
1086
+ function secretsNested(provisioned) {
1080
1087
  return serviceRecords(
1081
- siteYml.$secrets,
1088
+ provisioned?.secrets,
1082
1089
  (e) => {
1083
1090
  if (typeof e.name !== 'string' || !e.name) return null
1084
1091
  return typeof e.service === 'string' && e.service
@@ -1212,6 +1219,7 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1212
1219
  *
1213
1220
  * @param {string} siteRoot - directory containing site.yml
1214
1221
  * @param {object} [opts]
1222
+ * @param {string} [opts.backend] - whose `$uuid` to put on the wire (sync.json)
1215
1223
  * @param {string} [opts.entityUuid] - override the entity `$uuid` (tests); default
1216
1224
  * is `site.yml::$uuid` (absent on first sync — `$id`-only document).
1217
1225
  * @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
@@ -1497,8 +1505,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1497
1505
  // `$uuid?` then `$id` `$model`, then sections in Model-declared order. The entity
1498
1506
  // `$uuid` lives in site.yml (back-filled after first sync); absent on first sync.
1499
1507
  const doc = {}
1508
+ // ⭐ From `sync.json`, keyed by the backend this document is being produced FOR.
1509
+ // It was `site.yml::$uuid` — one scalar with no way to say which backend minted it.
1510
+ // A producer with no backend emits no `$uuid`, which is exactly right: the wire
1511
+ // value is that backend's name for this site, and there is no such name yet.
1500
1512
  const entityUuid =
1501
- opts.entityUuid || (typeof siteYml.$uuid === 'string' ? siteYml.$uuid : undefined)
1513
+ opts.entityUuid ||
1514
+ (opts.backend ? readBackendState(siteRoot, opts.backend).site?.uuid : undefined)
1502
1515
  if (entityUuid) doc.$uuid = entityUuid
1503
1516
  doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
1504
1517
  doc.$model = SITE_MODEL_NAME
@@ -1537,9 +1550,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1537
1550
  // ⚖️ Default is to declare, so every existing caller is unchanged and the
1538
1551
  // withholding is opt-in.
1539
1552
  const declare = opts.declareServices !== false
1540
- const services = declare ? servicesNested(siteYml) : undefined
1553
+ // ⭐ Provisioned per backend — a service is bought on the backend that sold it,
1554
+ // so which rows exist is a question about ONE backend. They were `site.yml::$services`
1555
+ // / `$secrets`, a single copy that could only ever describe one.
1556
+ const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
1557
+ const services = declare ? servicesNested(provisioned) : undefined
1541
1558
  if (services) doc.services = services
1542
- const secrets = declare ? secretsNested(siteYml) : undefined
1559
+ const secrets = declare ? secretsNested(provisioned) : undefined
1543
1560
  if (secrets) doc.secrets = secrets
1544
1561
  return doc
1545
1562
  }
@@ -1589,8 +1606,8 @@ export async function emitSiteSyncPackage(siteRoot, opts = {}) {
1589
1606
  * @param {string} uuid - the entity uuid the backend minted/echoed
1590
1607
  * @returns {boolean} true if site.yml changed
1591
1608
  */
1592
- export function writeSiteEntityUuid(siteRoot, uuid) {
1593
- return upsertYamlScalar(join(siteRoot, 'site.yml'), '$uuid', uuid)
1609
+ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1610
+ return updateBackendState(siteRoot, backend, { site: { uuid } })
1594
1611
  }
1595
1612
 
1596
1613
  /**
@@ -1639,45 +1656,12 @@ export function writeSiteEntityUuid(siteRoot, uuid) {
1639
1656
  * @param {string} handle - the bare org handle (no leading `@`)
1640
1657
  * @returns {boolean} true if site.yml changed
1641
1658
  */
1642
- export function writeSiteOrg(siteRoot, handle) {
1643
- return upsertYamlScalar(join(siteRoot, 'site.yml'), '$org', handle)
1659
+ export function writeSiteOrg(siteRoot, backend, handle) {
1660
+ return updateBackendState(siteRoot, backend, { site: { org: handle } })
1644
1661
  }
1645
1662
 
1646
- /**
1647
- * Record the backend this project SYNCS WITH (`site.yml::$backend`), beside `$uuid`/`$org`.
1648
- *
1649
- * ⭐ It is not a tag on the site uuid — it is the project's **sync scope**. Four surfaces
1650
- * hold backend-minted identity, and this one fact is what makes all of them meaningful:
1651
- * `site.yml::$uuid`, every collection record's `$uuid` in its own source file,
1652
- * `assets.json`, and `.uniweb/sync-cache.json`. Change the backend and every one of them
1653
- * is foreign at once — which is why a mismatch is a stop, not a fallback
1654
- * (`assertSiteBackendScope` in the CLI).
1655
- *
1656
- * ⛔ **Written ONLY for a non-default backend.** The 98% case stays out of the file, and an
1657
- * absent value reads as the default — correct both for a project written before this key
1658
- * existed and for one synced against the default. Callers decide; this writer does not know
1659
- * the CLI's default. See `recordSiteBackend` (cli/src/backend/site-sync.js), which is where
1660
- * the "is it the default?" test lives.
1661
- *
1662
- * ⚠️ **The name is load-bearing. Five alternatives were considered and rejected (2026-08-24);
1663
- * do not re-propose one.** `origin` is wrong twice over — a site HAS an origin (its own
1664
- * served domain), and this lane is git-modeled, where `origin` names a remote rather than a
1665
- * URL. `remote` implies a multiplicity this design closes. `hosting` and `platform` name the
1666
- * hosting edge, which this package has no client for. `host` collides with
1667
- * `deploy.yml::targets.<n>.host` (the deploy adapter). A `uuid: <id>@<backend>` suffix was
1668
- * rejected too: it would make "this never reaches the backend" a discipline at every read
1669
- * site instead of a property, and `siteProjectToDocument` assigns `$uuid` straight onto the
1670
- * wire document.
1671
- *
1672
- * Safe as a plain YAML scalar: a URL's `:` is followed by `/` or a digit, never a space, so
1673
- * it needs no quoting (verified against js-yaml for apex, `localhost:8080` and host:port+path
1674
- * forms, 2026-08-24). This is the same class of hazard as `$org`'s leading `@`, which is
1675
- * stripped for exactly that reason — but it lands on the safe side.
1676
- *
1677
- * @param {string} siteRoot
1678
- * @param {string} origin - a bare origin, no trailing slash
1679
- * @returns {boolean} true if site.yml changed
1680
- */
1681
- export function writeSiteBackend(siteRoot, origin) {
1682
- return upsertYamlScalar(join(siteRoot, 'site.yml'), '$backend', origin)
1683
- }
1663
+ // ⛔ `writeSiteBackend` and `site.yml::$backend` are GONE (2026-09-20). The scope is
1664
+ // no longer a value recorded beside the uuid — it is the KEY the identity is stored
1665
+ // under (`sync.json::backends.<origin>`), so there is nothing to record and nothing
1666
+ // that can disagree with it. `kb/framework/reference/sync-json.md`.
1667
+
@@ -273,6 +273,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
273
273
  const exportedAt = opts.exportedAt
274
274
 
275
275
  const col = await buildRecordEntities(siteRoot, {
276
+ // Which backend's record uuids go on the wire (sync.json) — see records.js.
277
+ ...(opts.backend ? { backend: opts.backend } : {}),
276
278
  ...(opts.foundationDir ? { foundationDir: opts.foundationDir } : {}),
277
279
  ...(opts.resolveModel ? { resolveModel: opts.resolveModel } : {}),
278
280
  ...(sourceLocale ? { sourceLocale } : {}),
@@ -316,6 +318,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
316
318
  const siteDoc = includeSite
317
319
  ? await siteProjectToDocument(siteRoot, {
318
320
  sourceLocale,
321
+ // Whose $uuid belongs on this wire document — see siteProjectToDocument.
322
+ ...(opts.backend ? { backend: opts.backend } : {}),
319
323
  ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
320
324
  // ⛔ THE SAME ORG `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
321
325
  // must name the Model its records are stored under, and both are qualified
@@ -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
+ }