@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/package.json +8 -8
- package/src/site/deploy-config-writer.js +137 -29
- package/src/site/deploy-config.js +87 -38
- package/src/site/index.js +2 -2
- package/src/site/queries-config.js +1 -1
- package/src/uwx/asset-map.js +26 -148
- package/src/uwx/backfill.js +29 -2
- package/src/uwx/index.js +20 -8
- package/src/uwx/records-project.js +36 -14
- package/src/uwx/records.js +26 -1
- package/src/uwx/site-project.js +31 -12
- package/src/uwx/site.js +35 -51
- package/src/uwx/sync-package.js +4 -0
- package/src/uwx/sync-store.js +351 -0
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
|
-
//
|
|
978
|
-
//
|
|
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(
|
|
1066
|
+
function servicesNested(provisioned) {
|
|
1060
1067
|
return serviceRecords(
|
|
1061
|
-
|
|
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(
|
|
1086
|
+
function secretsNested(provisioned) {
|
|
1080
1087
|
return serviceRecords(
|
|
1081
|
-
|
|
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 ||
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
1659
|
+
export function writeSiteOrg(siteRoot, backend, handle) {
|
|
1660
|
+
return updateBackendState(siteRoot, backend, { site: { org: handle } })
|
|
1644
1661
|
}
|
|
1645
1662
|
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
+
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -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
|
+
}
|