uniweb 0.83.0 → 0.85.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.
@@ -1,68 +1,45 @@
1
1
  /**
2
- * The services request — is the file ASKING for something, or just carrying an
3
- * old answer?
2
+ * The requests a push or publish carries — what the owner asks for, and whether it
3
+ * is new.
4
4
  *
5
- * ## The defect this exists to close
5
+ * Two of them, kept by two mechanisms:
6
6
  *
7
- * `$services` / `$secrets` are Sections of the site-content document, so they ride
8
- * inside **every** push — the block is emitted whenever the key exists in
9
- * `site.yml`. Editing one paragraph on one page therefore re-sends the whole
10
- * request block.
7
+ * - ⭐ **The services** — `site.yml::services`, a map by service name *[Diego,
8
+ * 2026-10-06]*. A push STATES them — the file's, and off for each held one it no
9
+ * longer lists — and the backend decides per service from the versions sent
10
+ * (`statedServices`, `@uniweb/build/uwx`; spec:
11
+ * kb/framework/reference/site-services-request.md). All this module does for them
12
+ * is say what the file asks that will not be sent (`announceServices`).
13
+ * - **The language selection** — `site.yml::publishLanguages` — told apart from the
14
+ * status quo by a fingerprint banked in `deploy.yml` (`reconcile`, `bankLanguages`).
11
15
  *
12
- * ⛔ And the backend REPLACES what it is sent: a row anchors by its natural key and
13
- * is updated in place (`SectionScope::DeclaredOnly`; backend's
14
- * `uuidless_records_anchor_by_natural_key_and_keep_the_stored_uuid`, written
15
- * against this emitter's shape). So a re-send is not a harmless echo — it
16
- * **overwrites the stored request**, including a decision the owner made in the
17
- * app, which is where the consent workflow's publish happens.
16
+ * ⭐ The model both follow — *"the services in `site.yml` are a request, never a
17
+ * tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file.
18
18
  *
19
- * ⭐ Under the model the file follows — *"the services in `site.yml` are a request,
20
- * never a tracking of what is running"* [Diego, 2026-09-05] — **re-sending an
21
- * unchanged block is making a request nobody made.** You ask by CHANGING the file.
22
- *
23
- * ⚠️ It was mostly inert until 2026-09-08: `enabled` was honoured for `api` alone,
24
- * so a stale re-send of the other names overwrote rows nothing read. That stopped
25
- * being true the same day, and `api` was never inert — an `enabled: false` on it
26
- * with a live plan schedules a paid service to end.
27
- *
28
- * ## What "changed" is measured against
29
- *
30
- * The last block we are known to have sent, recorded in **`deploy.yml`** — a
31
- * COMMITTED project file that travels with a clone.
32
- *
33
- * ⛔ Deliberately NOT `.uniweb/sync-cache.json`, which is the obvious place and the
34
- * wrong one: it is gitignored, per-clone and deletable, so a teammate's fresh clone
35
- * has no base at all — and this workflow is multi-machine by construction (consent
36
- * in a browser, publish from the app). A base that vanishes turns this gate into
37
- * either the original defect or a silently dropped request.
38
- *
39
- * ## ⛔ A HASH, never the block
40
- *
41
- * `$secrets` names every secret the site has (values are the `#ref` marker, never
42
- * secret material — the old wording here said otherwise), and `$services[].config` is opaque and
43
- * per-service — anything may be in it. `deploy.yml` is committed, so recording
44
- * either verbatim would write them into git. Equality is all this gate needs;
45
- * *what* differs is a question for the backend's own copy of the request, not for
46
- * a mirror of our own.
19
+ * ⛔ *From 2026-09-20 to 2026-10-06 the services lived only in `sync.json`, which
20
+ * nobody edits, so a CLI user could ask for nothing; this module then compared them by
21
+ * a fingerprint in `deploy.yml`, which could not tell who moved; and until 2026-10-07
22
+ * it read the site's services before every push and settled each against a record in
23
+ * `sync.json` (`settleServices`), because the backend replaced the list it was sent.*
47
24
  *
48
25
  * @module
49
26
  */
50
27
 
51
28
  import { createHash } from 'node:crypto'
29
+ import { existsSync, readFileSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+ import { readServicesRequest } from '@uniweb/build/uwx'
52
32
 
53
33
  /**
54
- * A stable fingerprint of one declared block, or `null` when the key is absent.
34
+ * A stable fingerprint of one declared value, or `null` when the key is absent.
55
35
  *
56
- * ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: absent
57
- * means *"I am not telling you about this"* and `[]` is an explicit clear. This
58
- * function only has to preserve that distinction — the destructive difference
59
- * between them is the backend's.
36
+ * ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: for the
37
+ * language selection, no key means "every declared language" and `[]` means none.
60
38
  *
61
- * Rows are ordered by their serialized form and object keys sorted, so reordering
62
- * rows or keys in the file is not a change. It is not a request to move a line.
39
+ * List entries are ordered by their serialized form and object keys sorted, so
40
+ * reordering them in the file is not a change.
63
41
  *
64
- * @param {*} declared - the raw value: provisioned `services` / `secrets` rows (from
65
- * `sync.json`), what the site has stored for them, or `site.yml::publishLanguages`
42
+ * @param {*} declared
66
43
  * @returns {string|null} 16 hex chars, or null when undeclared
67
44
  */
68
45
  export function fingerprintDeclaration(declared) {
@@ -70,13 +47,10 @@ export function fingerprintDeclaration(declared) {
70
47
  const canonical = Array.isArray(declared)
71
48
  ? declared.map(stableString).sort()
72
49
  : [stableString(declared)]
73
- return createHash('sha256')
74
- .update(JSON.stringify(canonical))
75
- .digest('hex')
76
- .slice(0, 16)
50
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 16)
77
51
  }
78
52
 
79
- /** Absent, or an empty list — a stored request that asks for nothing. */
53
+ /** Absent, or an empty list — a stored value that asks for nothing. */
80
54
  function isNothing(value) {
81
55
  return value === undefined || value === null || (Array.isArray(value) && value.length === 0)
82
56
  }
@@ -91,125 +65,22 @@ function stableString(value) {
91
65
  }
92
66
 
93
67
  /**
94
- * Both blocks' fingerprints, in the shape `deploy.yml::lastDeploy.<target>` keeps
95
- * them. Keys are omitted rather than set to null, so an undeclared block leaves no
96
- * trace in the file.
68
+ * A two-way request field, reconciled against what the site has stored.
97
69
  *
98
- * @param {object} siteYml
99
- * @returns {{servicesRequest?: string, secretsRequest?: string}}
100
- */
101
- export function fingerprintRequest(siteYml, provisioned = {}) {
102
- const out = {}
103
- // ⭐ Two sources, on purpose. The PROVISIONED rows come from `sync.json` for the
104
- // backend being published to (they were `site.yml::$services` / `$secrets` until
105
- // 2026-09-20). The language selection below is still authored, so still site.yml.
106
- const services = fingerprintDeclaration(provisioned?.services)
107
- if (services) out.servicesRequest = services
108
- const secrets = fingerprintDeclaration(provisioned?.secrets)
109
- if (secrets) out.secretsRequest = secrets
110
- // ⭐ The language selection is a request too, and it is the one that moves a
111
- // PRICE — the line is billed on how many languages go out. Banking it is what
112
- // lets a later edit read as "the owner asked for another language" rather than
113
- // as a value that was always there.
114
- const langs = fingerprintDeclaration(siteYml?.publishLanguages)
115
- if (langs) out.publishLanguagesRequest = langs
116
- return out
117
- }
118
-
119
- /**
120
- * Should this push DECLARE the request blocks?
121
- *
122
- * ⛔ **Absence of a record means YES.** Three reasons, and the third is the one
123
- * that decides it:
124
- *
125
- * 1. It is the behaviour every CLI has had, so nothing regresses.
126
- * 2. A project may legitimately have no `deploy.yml` (never published, or
127
- * `autoSave: off`), and that is not evidence about the request.
128
- * 3. ⭐ Failing the other way DROPS A REAL REQUEST IN SILENCE. Between the two
129
- * failure directions, sending an unchanged block writes back what is usually
130
- * already there, while withholding a changed one leaves an owner's edit with
131
- * no effect and nothing said. The loud failure is the better one.
132
- *
133
- * @param {object} siteYml - the parsed site.yml
134
- * @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
135
- * @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
136
- */
137
- export function decideDeclaration(siteYml, lastDeploy, provisioned = {}) {
138
- const now = fingerprintRequest(siteYml, provisioned)
139
- if (!now.servicesRequest && !now.secretsRequest) {
140
- // Nothing in the file to send. The gate is moot; say so rather than
141
- // reporting "unchanged", which would imply a comparison happened.
142
- return { declare: true, reason: 'undeclared' }
143
- }
144
- if (!lastDeploy || typeof lastDeploy !== 'object') {
145
- return { declare: true, reason: 'no-record' }
146
- }
147
- const same =
148
- now.servicesRequest === (lastDeploy.servicesRequest || undefined) &&
149
- now.secretsRequest === (lastDeploy.secretsRequest || undefined)
150
- return same
151
- ? { declare: false, reason: 'unchanged' }
152
- : { declare: true, reason: 'changed' }
153
- }
154
-
155
- /**
156
- * The four-way reconcile, once the backend's own copy of the request is in hand.
157
- *
158
- * ⭐ THIS SUPERSEDES `decideDeclaration` WHERE THE STATUS READ SUCCEEDS. That one
159
- * compares the file to our MEMORY of what we last sent, which leaves a window: a
160
- * request changed in the app between a `uniweb pull` and the next publish reads as
161
- * unchanged-from-nothing. Comparing to the backend's own rows closes it, because
162
- * the base stops being something we have to remember correctly.
163
- *
164
- * ⛔ It still needs the banked fingerprint. Local and remote differing says the two
165
- * disagree; it does not say WHO MOVED. Only the last agreed state does, and that is
166
- * the difference between "the app decided, adopt it" and "you edited, send it".
167
- *
168
- * ## The four outcomes
169
- *
170
- * | local | remote | |
171
- * |---|---|---|
172
- * | unchanged | unchanged | `none` — nobody asked anything |
173
- * | unchanged | **moved** | `adopt` — the app decided; the file is merely behind |
174
- * | **edited** | unchanged | `send` — a real request |
175
- * | **edited** | **moved** | `conflict` — ⛔ two intents, and only the owner ranks them |
70
+ * `site.yml::publishLanguages` is the owner's ASK, pushed up, projected back on pull,
71
+ * and stored on the other side where something else may move it.
176
72
  *
177
- * ⛔ `conflict` never guesses and never sends. A last-write-wins on a field that
178
- * schedules a paid service to end is not a tie-break, it is a coin toss with the
179
- * owner's money.
180
- *
181
- * ⚖️ No base ⇒ we cannot tell `adopt` from `conflict`, so we fall back to the
182
- * conservative reading of a difference: if the two differ, `conflict`; if they
183
- * agree, `none`. That withholds rather than sends, which is safe HERE — unlike
184
- * `decideDeclaration`, nothing is silently dropped, because a conflict is reported.
185
- *
186
- * @param {object} siteYml
187
- * @param {*} remoteServices - the status read's `services` rows, or undefined
188
- * @param {object|null} lastDeploy - the banked base
189
- * @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
190
- */
191
- export function reconcileRequest(siteYml, remoteServices, lastDeploy, provisioned = {}) {
192
- return reconcile(provisioned?.services, remoteServices, lastDeploy?.servicesRequest)
193
- }
194
-
195
- /**
196
- * The same reconcile over any two-way request field.
197
- *
198
- * ⭐ `$services` was the first, not the only one. `site.yml::publishLanguages` is
199
- * the same shape — the owner's ASK, pushed up, projected back on pull, and stored
200
- * on the other side where something else may move it.
201
- *
202
- * ⛔ AND A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. That was the
203
- * reasoning that nearly left languages out: "nobody overwrites it, so there is no
204
- * hazard." Overwriting is not the only thing a base is for — without one, a value
205
- * that has always been in the file is indistinguishable from one the owner just
206
- * typed, so the CLI cannot tell an intentional change from the status quo. For
207
- * languages that difference is money: the count is priced, so a new language is a
208
- * charge, and saying so before sending requires knowing it is new.
73
+ * ⛔ A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. Overwriting is not the
74
+ * only thing a base is for — without one, a value that has always been in the file
75
+ * is indistinguishable from one the owner just typed, so the CLI cannot tell an
76
+ * intentional change from the status quo. For languages that difference is money:
77
+ * the count is priced, so a new language is a charge, and saying so before sending
78
+ * requires knowing it is new.
209
79
  *
210
80
  * @param {*} localValue - the file's declaration
211
81
  * @param {*} remoteValue - what the site has stored
212
82
  * @param {string|null} baseFingerprint - what we last agreed on
83
+ * @returns {{action: 'none'|'adopt'|'send'|'conflict', local: string|null, remote: string|null}}
213
84
  */
214
85
  export function reconcile(localValue, remoteValue, baseFingerprint) {
215
86
  const local = fingerprintDeclaration(localValue)
@@ -217,11 +88,9 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
217
88
  const base = baseFingerprint || null
218
89
 
219
90
  if (local === remote) return { action: 'none', local, remote }
220
- // ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict. With
221
- // nothing stored either — an empty list is the store's nothing — there is nothing to do; with
222
- // something stored, the file is merely behind. Until 2026-09-26 this was a `conflict`, and the
223
- // first publish of a site whose file is silent warned that its services "were changed
224
- // elsewhere, and site.yml changed too", listing both as nothing.
91
+ // ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict.
92
+ // With nothing stored either there is nothing to do; with something stored, the file
93
+ // is merely behind.
225
94
  if (local === null) return { action: isNothing(remoteValue) ? 'none' : 'adopt', local, remote }
226
95
  if (!base) return { action: 'conflict', local, remote }
227
96
 
@@ -231,3 +100,107 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
231
100
  if (localMoved && !remoteMoved) return { action: 'send', local, remote }
232
101
  return { action: 'conflict', local, remote }
233
102
  }
103
+
104
+ /**
105
+ * What a publish records in `deploy.yml` about the language selection: its
106
+ * fingerprint, on EVERY publish — it rides the content push, so what went live is
107
+ * what was agreed.
108
+ *
109
+ * ⛔ A FINGERPRINT, never the list: `deploy.yml` records what a publish did, and the
110
+ * selection itself is in `site.yml`.
111
+ *
112
+ * @param {object} siteYml
113
+ * @returns {{publishLanguagesRequest?: string}}
114
+ */
115
+ export function bankLanguages(siteYml) {
116
+ const langs = fingerprintDeclaration(siteYml?.publishLanguages)
117
+ return langs ? { publishLanguagesRequest: langs } : {}
118
+ }
119
+
120
+ /**
121
+ * The services the site's foundation says it renders — the build's `_self.supports`, else
122
+ * `package.json::uniweb.supports` — or null when that is unknown: no local foundation, or
123
+ * one that declares nothing. ⛔ Absent is UNKNOWN, never "none".
124
+ *
125
+ * @param {string} siteDir
126
+ * @param {object} siteYml - parsed
127
+ * @returns {Promise<string[]|null>}
128
+ */
129
+ export async function foundationSupports(siteDir, siteYml) {
130
+ try {
131
+ if (!siteYml?.foundation) return null
132
+ const { detectFoundationType } = await import('@uniweb/build')
133
+ const found = detectFoundationType(siteYml.foundation, siteDir)
134
+ if (found?.type !== 'local' || !found.path) return null
135
+ const built = join(found.path, 'dist', 'meta', 'schema.json')
136
+ if (existsSync(built)) {
137
+ const derived = JSON.parse(readFileSync(built, 'utf8'))?._self?.supports
138
+ if (Array.isArray(derived)) return derived
139
+ }
140
+ const declared = JSON.parse(readFileSync(join(found.path, 'package.json'), 'utf8'))?.uniweb?.supports
141
+ return Array.isArray(declared) ? declared : null
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+
147
+ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
148
+
149
+ /**
150
+ * Say what `site.yml::services` asks that will not be sent as written — a credential,
151
+ * an entry that is not one, an `api` address — and where it and the foundation disagree,
152
+ * before a push or publish sends the rest. What a push sends is the producer's
153
+ * (`statedServices`), from the same file.
154
+ *
155
+ * ⭐ The foundation INFORMS and never decides [Diego, 2026-10-07]: a service the file turns
156
+ * on that the foundation does not say it renders is said; so is one it renders that the
157
+ * file does not mention, since every service is off unless the site asks for it. An
158
+ * explicit `false` is a decision, and quiets the second. `tracking` is in neither — a
159
+ * foundation may not claim it, since it renders nothing — and `records` is left to
160
+ * publish, which knows whether the pages show any (`recordsNotAsked`).
161
+ *
162
+ * @param {object} p
163
+ * @param {object} p.siteYml - parsed
164
+ * @param {{ warn: Function }} p.say
165
+ * @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
166
+ */
167
+ export function announceServices({ siteYml, say, supports = null }) {
168
+ const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) }) || []
169
+ if (!Array.isArray(supports)) return
170
+ const ignored = new Set(['tracking', 'records'])
171
+ const has = (ask) => ask.enabled !== false || typeof ask.config?.endpoint === 'string'
172
+ const unrendered = asks.filter((a) => has(a) && !ignored.has(a.name) && !supports.includes(a.name)).map((a) => a.name)
173
+ if (unrendered.length) {
174
+ say.warn(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
175
+ }
176
+ const mentioned = new Set(asks.map((a) => a.name))
177
+ const unasked = supports.filter((n) => !ignored.has(n) && !mentioned.has(n))
178
+ if (unasked.length) {
179
+ say.warn(
180
+ `Your foundation renders ${names(unasked)}, which site.yml does not ask for — a service is off ` +
181
+ `unless the site asks for it. Add ${unasked.length === 1 ? 'it' : 'them'} under \`services:\`, or set ` +
182
+ `${unasked.length === 1 ? 'it' : 'each'} to \`false\` if that is what you mean.`
183
+ )
184
+ }
185
+ }
186
+
187
+ /**
188
+ * The publish warning for `records`: the site's pages show records live, and `site.yml`
189
+ * does not ask for the service that delivers them on a published site [Diego, 2026-10-07:
190
+ * "When records is off and the site is published with us, there is no meant to be a fall
191
+ * back at all"]. Syncing records does not depend on it, so push and pull say nothing.
192
+ *
193
+ * @param {object} p
194
+ * @param {object} p.siteYml - parsed
195
+ * @param {string[]} p.shown - from the package (`recordsShown`)
196
+ * @returns {string|null} the warning, or null
197
+ */
198
+ export function recordsNotAsked({ siteYml, shown }) {
199
+ if (!Array.isArray(shown) || !shown.length) return null
200
+ const records = (readServicesRequest(siteYml?.services) || []).find((a) => a.name === 'records')
201
+ if (records && records.enabled !== false) return null
202
+ return (
203
+ `Your pages show records from ${names(shown)}, but site.yml does not ask for \`records\` — ` +
204
+ 'a published site delivers them only with it on. Add `records: true` under `services:`.'
205
+ )
206
+ }