@uniweb/build 0.77.0 → 0.78.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,35 +1,90 @@
1
1
  /**
2
- * A site's services request — `site.yml::services`, a map from service name to
3
- * what the owner asks their host for.
2
+ * A site's services — `site.yml::services`, ONE entry per service, saying
3
+ * everything the owner says about it.
4
4
  *
5
5
  * ```yaml
6
6
  * services:
7
- * search: true # turn it on
8
- * submit: false # turn it off
9
- * api: # turn it on, with the service's own settings
10
- * grade: pro
7
+ * search: # on: the host's, or the built-in index
8
+ * exclude: { routes: [/legal] } # an option the site's runtime reads
9
+ * submit: https://forms.example.com/f/abc # an address: a provider the site brings
10
+ * tracking: { consent: required }
11
+ * api: { grade: pro } # a setting for the host's service
11
12
  * ```
12
13
  *
13
- * ⭐ ONE REQUEST, WHICHEVER BACKEND A COMMAND TALKS TO. The rows a backend holds
14
- * for the site are kept apart from it, in that backend's entry in `sync.json` — the
15
- * CLI's record of what it last agreed with that site, which nobody edits.
14
+ * ⭐ ONE PLACE [Diego, 2026-10-06]. Until then the site's own provider and options
15
+ * lived in top-level `search:` / `submit:` / `assistant:` / `tracking:` / `api:`, and
16
+ * the request to the host in `services:` — two switches for one thing. Those keys are
17
+ * retired and refused (`refuseRetiredServiceKeys`).
16
18
  *
17
- * ⛔ NOT THE TOP-LEVEL `search:` / `submit:` / `assistant:` / `tracking:` KEYS.
18
- * Those say how the site uses a provider it brings itself; this asks the host. A
19
- * site's own `submit: https://forms.example.com/…` read as a request would turn on
20
- * the host's form handling, whose address then outranks the site's.
19
+ * Each entry goes ONE place per lane, decided here and nowhere else:
21
20
  *
22
- * Pure functions, shared by the producer (`site.js`), pull (`site-project.js`) and
23
- * the CLI's push and publish.
21
+ * - **a built site** reads it as its own config (`config.<name>` in the payload) —
22
+ * the switch, an address, the options. ⛔ For `api` only its switch and address:
23
+ * its settings are for the host that provisions it. Never a credential.
24
+ * - **a push** sends it as the request: ONE row per entry in the `services` Section,
25
+ * `{ name, enabled?: false, config? }`, whose `config` is the whole entry — an
26
+ * address, the options, the host's settings — never a credential. What reaches a
27
+ * hosted page from a row is the host's to project.
28
+ *
29
+ * ⭐ THE ROW CARRIES THE WHOLE ENTRY [Diego, 2026-10-06: "If it's on and has
30
+ * configuration, it can all be in the same place"]. ⛔ Until that evening a push sent
31
+ * the address and the options as `settings.<name>` beside the row — the two places
32
+ * this module exists to replace, moved from `site.yml` onto the wire.
33
+ *
34
+ * ⭐ AN ADDRESS MEANS THE SITE BRINGS ITS OWN PROVIDER [Diego, 2026-10-06], so the
35
+ * host is asked to leave its own off — a host's offer outranks the site's address
36
+ * (`core/src/services.js`), and would otherwise win.
37
+ *
38
+ * ⭐ ONE REQUEST, WHICHEVER BACKEND A COMMAND TALKS TO. What a push sends is what the
39
+ * file says, plus OFF for each service this copy holds that the file no longer lists
40
+ * (`statedServices`); the services it holds are named, `{ name: $uuid }`, in that
41
+ * backend's entry in `sync.json`, which nobody edits. The backend decides per service.
42
+ *
43
+ * Pure functions, shared by the static build (`content-collector.js`), the producer
44
+ * (`site.js`), pull (`site-project.js`) and the CLI's push, publish and doctor.
45
+ *
46
+ * Spec: kb/framework/reference/site-services-request.md
24
47
  *
25
48
  * @module
26
49
  */
27
50
 
28
- /** Top-level `site.yml` keys that hold an address — named when one is misplaced here. */
29
- const ADDRESS_KEYS = new Set(['search', 'submit', 'assistant', 'tracking', 'api'])
30
-
31
51
  const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
32
52
 
53
+ /**
54
+ * The options each service's RUNTIME reads from the site's config — the framework's
55
+ * own vocabulary, which `uniweb doctor` checks an entry against. On the wire they ride
56
+ * in the row's `config` with everything else; this list decides nothing there.
57
+ *
58
+ * ⚠️ Read off the readers, and owed to them: `Website.getSearchConfig` and kit's search
59
+ * client, and the search index `@uniweb/projections` builds (search); `readEndpoint`
60
+ * (every `endpoint`); `wireTracker` (tracking). A key one of them starts reading
61
+ * belongs here, or `doctor` calls it unknown.
62
+ */
63
+ export const RUNTIME_KEYS = Object.freeze({
64
+ search: Object.freeze(['enabled', 'provider', 'endpoint', 'include', 'exclude', 'fields', 'weight']),
65
+ submit: Object.freeze(['enabled', 'endpoint']),
66
+ assistant: Object.freeze(['enabled', 'endpoint']),
67
+ tracking: Object.freeze(['enabled', 'endpoint', 'emit', 'consent', 'scripts', 'flushIntervalMs', 'debug']),
68
+ api: Object.freeze(['enabled', 'endpoint'])
69
+ })
70
+
71
+ /**
72
+ * Services only a host provides, which a site never answers itself: no address, no
73
+ * runtime part. A site does not declare its own record provider — a third-party
74
+ * source is a foundation transport.
75
+ */
76
+ const HOST_ONLY = new Set(['records'])
77
+
78
+ /**
79
+ * Credential-shaped keys, mirroring the set the delivery edge strips on the reading
80
+ * side — deliberately the SAME list, so the two guards are visibly twins. Never sent
81
+ * and never published: a key belongs in the host's secret store, set in the app.
82
+ */
83
+ const CREDENTIAL_KEYS = Object.freeze(['apiKey', 'api_key', 'key', 'token', 'secret'])
84
+
85
+ /** The top-level keys `services:` replaced, refused by name. */
86
+ const RETIRED_SERVICE_KEYS = Object.freeze(['search', 'submit', 'assistant', 'tracking', 'api'])
87
+
33
88
  /** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
34
89
  function stable(value) {
35
90
  return JSON.stringify(value ?? null, (_k, v) =>
@@ -37,28 +92,121 @@ function stable(value) {
37
92
  )
38
93
  }
39
94
 
95
+ /** The address an entry names, or null — a string, or a map's `endpoint`. */
96
+ function addressOf(value) {
97
+ if (typeof value === 'string') return value.trim() || null
98
+ if (isMap(value) && typeof value.endpoint === 'string' && value.endpoint.trim()) return value.endpoint.trim()
99
+ return null
100
+ }
101
+
102
+ /** The keys of a map the runtime reads for this service. */
103
+ function isRuntimeKey(name, key) {
104
+ const keys = RUNTIME_KEYS[name]
105
+ return keys ? keys.includes(key) : true
106
+ }
107
+
108
+ /** A map without its credential-shaped keys, and which ones it had. */
109
+ function withoutCredentials(value) {
110
+ const found = CREDENTIAL_KEYS.filter((k) => k in value)
111
+ if (!found.length) return { value, found }
112
+ const out = { ...value }
113
+ for (const k of found) delete out[k]
114
+ return { value: out, found }
115
+ }
116
+
117
+ function sayCredentials(name, found, warn) {
118
+ if (found.length) {
119
+ warn(
120
+ `\`services.${name}\` carries ${found.map((k) => `\`${k}\``).join(', ')} — a credential is never sent ` +
121
+ 'or published. Set it in the app, where secrets are kept.'
122
+ )
123
+ }
124
+ }
125
+
40
126
  /**
41
- * A row as it is stored and compared: no payload-local `$id`, `enabled` only when
42
- * it is `false` (absent means on), no empty `config`.
127
+ * Refuse the top-level keys `services:` replaced.
128
+ *
129
+ * @param {object} siteYml
130
+ * @param {string} [where]
131
+ * @throws {Error} naming each key and how to move it
43
132
  */
44
- function canonicalRow(row) {
45
- const { $id: _id, enabled, ...rest } = row
46
- const out = { ...rest }
47
- if (enabled === false) out.enabled = false
48
- if (isMap(out.config) && Object.keys(out.config).length === 0) delete out.config
49
- return out
133
+ export function refuseRetiredServiceKeys(siteYml, where = 'site.yml') {
134
+ if (!isMap(siteYml)) return
135
+ const found = RETIRED_SERVICE_KEYS.filter((k) => siteYml[k] !== undefined)
136
+ if (!found.length) return
137
+ const lines = found.map((k) => ` ${k}: …`).join('\n')
138
+ const api = found.includes('api')
139
+ ? '\n `api:` named the address a local mock answered on — in `uniweb dev`, `$devApi` now ' +
140
+ 'supplies it. Asking your host for an app backend is `api: true` under `services:`.'
141
+ : ''
142
+ throw new Error(
143
+ `[uniweb] ${where}: ${found.map((k) => `\`${k}:\``).join(', ')} ` +
144
+ `${found.length === 1 ? 'is' : 'are'} retired — a service lives under \`services:\`, ` +
145
+ `one entry per service:\n\nservices:\n${lines}${api}`
146
+ )
50
147
  }
51
148
 
52
- const rowNamed = (rows, name) =>
53
- Array.isArray(rows) ? rows.find((r) => isMap(r) && r.name === name) : undefined
149
+ /**
150
+ * Services whose settings are never published: only the switch and the address reach
151
+ * the site's config. `api`'s settings are for the host that provisions it (a grade,
152
+ * sign-in providers, billing) and no page reads them.
153
+ */
154
+ const UNPUBLISHED_SETTINGS = new Set(['api'])
54
155
 
55
156
  /**
56
- * `site.yml::services` → the asks, as rows in the `services` Section's shape:
57
- * `{ name, enabled?: false, config? }`.
157
+ * One entry → what the site's config carries: `false`, an address, or the entry's
158
+ * options — or undefined when there is nothing for it (`true` is the default).
159
+ *
160
+ * @param {string} name
161
+ * @param {*} value - the entry
162
+ * @returns {false|string|object|undefined}
163
+ */
164
+ export function runtimeServiceConfig(name, value) {
165
+ if (HOST_ONLY.has(name)) return undefined
166
+ if (value === false) return false
167
+ if (typeof value === 'string') return value.trim() || undefined
168
+ if (!isMap(value)) return undefined
169
+ const out = {}
170
+ for (const [key, v] of Object.entries(withoutCredentials(value).value)) {
171
+ if (!UNPUBLISHED_SETTINGS.has(name) || isRuntimeKey(name, key)) out[key] = v
172
+ }
173
+ if (out.enabled === true) delete out.enabled
174
+ return Object.keys(out).length ? out : undefined
175
+ }
176
+
177
+ /**
178
+ * `site.yml::services` → `{ <name>: <runtime value> }` for every entry the runtime
179
+ * has something for — what a static payload's `config.<name>` carries.
180
+ *
181
+ * @param {*} declared
182
+ * @param {{ warn?: (message: string) => void }} [opts]
183
+ * @returns {object}
184
+ */
185
+ export function runtimeServicesConfig(declared, { warn = () => {} } = {}) {
186
+ const out = {}
187
+ if (!isMap(declared)) return out
188
+ for (const [name, value] of Object.entries(declared)) {
189
+ if (isMap(value)) sayCredentials(name, withoutCredentials(value).found, warn)
190
+ const runtime = runtimeServiceConfig(name, value)
191
+ if (runtime !== undefined) out[name] = runtime
192
+ }
193
+ return out
194
+ }
195
+
196
+ /**
197
+ * `site.yml::services` → the asks to the host, ONE row per entry, in the `services`
198
+ * Section's shape: `{ name, enabled?: false, config? }`.
199
+ *
200
+ * `true` asks on; `false` asks off; a map asks on — or off, with `enabled: false` — and
201
+ * its keys are the row's `config`. An address — a string, or `endpoint:` in a map — is
202
+ * the site's own provider: it rides as `config.endpoint`, and the host is asked to
203
+ * leave its own off, since a host's offer outranks the site's address
204
+ * (`core/src/services.js`) and would otherwise win. `enabled` is the row's switch and
205
+ * never in `config`; a credential never leaves at all.
58
206
  *
59
207
  * @param {*} declared - the raw `services:` value
60
208
  * @param {{ warn?: (message: string) => void }} [opts] - told about every value it
61
- * ignores, with the fix
209
+ * ignores or trims, with the fix
62
210
  * @returns {object[]|null} null when the file asks nothing — no key, or no usable entry
63
211
  */
64
212
  export function readServicesRequest(declared, { warn = () => {} } = {}) {
@@ -69,212 +217,216 @@ export function readServicesRequest(declared, { warn = () => {} } = {}) {
69
217
  }
70
218
  const asks = []
71
219
  for (const [name, value] of Object.entries(declared)) {
72
- if (value === true) asks.push({ name })
73
- else if (value === false) asks.push({ name, enabled: false })
74
- else if (isMap(value)) {
75
- const { enabled, ...settings } = value
76
- if (enabled !== undefined && typeof enabled !== 'boolean') {
77
- warn(`\`services.${name}.enabled\` is true or false. Ignoring \`${name}\`.`)
78
- continue
220
+ if (value === true) {
221
+ asks.push({ name })
222
+ continue
223
+ }
224
+ if (value === false) {
225
+ asks.push({ name, enabled: false })
226
+ continue
227
+ }
228
+ if (typeof value !== 'string' && !isMap(value)) {
229
+ warn(`\`services.${name}\` is true, false, an address, or a map. Ignoring it.`)
230
+ continue
231
+ }
232
+ if (isMap(value) && value.enabled !== undefined && typeof value.enabled !== 'boolean') {
233
+ warn(`\`services.${name}.enabled\` is true or false. Ignoring \`${name}\`.`)
234
+ continue
235
+ }
236
+ const address = addressOf(value)
237
+ if (typeof value === 'string' && !address) continue // an empty string asks nothing
238
+ if (address && HOST_ONLY.has(name)) {
239
+ warn(`\`services.${name}\` has no address of its own — your host provides it. Ignoring \`${name}\`.`)
240
+ continue
241
+ }
242
+ const { enabled, ...entry } = isMap(value) ? value : { endpoint: address }
243
+ const { value: config, found } = withoutCredentials(entry)
244
+ sayCredentials(name, found, warn)
245
+ if (address) {
246
+ config.endpoint = address
247
+ // ⚠️ Said for `api` because its old spelling invites it: `api: /_api` was where a
248
+ // local mock answered, and under `services:` an address turns the host's off.
249
+ if (name === 'api') {
250
+ warn(
251
+ "`services.api` is an address: it asks your host to leave its own `api` off, so the site uses yours. " +
252
+ "For your host's, write `api: true`; in `uniweb dev`, `$devApi` answers it."
253
+ )
79
254
  }
80
- asks.push({
81
- name,
82
- ...(enabled === false ? { enabled: false } : {}),
83
- ...(Object.keys(settings).length ? { config: settings } : {})
84
- })
85
- } else {
86
- const address =
87
- typeof value === 'string' && ADDRESS_KEYS.has(name)
88
- ? ` An address for the site's own ${name} goes in the top-level \`${name}:\` key.`
89
- : ''
90
- warn(`\`services.${name}\` is true, false, or a map of its settings. Ignoring it.${address}`)
91
255
  }
256
+ asks.push({
257
+ name,
258
+ ...(address || enabled === false ? { enabled: false } : {}),
259
+ ...(Object.keys(config).length ? { config } : {})
260
+ })
92
261
  }
93
262
  return asks.length ? asks : null
94
263
  }
95
264
 
96
265
  /**
97
- * The site's stored rows → the `site.yml::services` map — what pull writes.
266
+ * One row → the entry `site.yml::services` writes for it.
98
267
  *
99
- * A row becomes `true` or `false`, or a map of its settings with `enabled: false`
100
- * when it is off.
101
- *
102
- * @param {object[]} rows
103
- * @returns {object|null} null when there is nothing to write
268
+ * An address with the host's own off is the site's own provider, written as the entry
269
+ * was — a bare address when that is all it says. With the host's own on, the address
270
+ * goes: the host's offer is what answers now, and it was turned on elsewhere.
104
271
  */
105
- export function servicesRequestFromRows(rows) {
106
- if (!Array.isArray(rows)) return null
107
- const out = {}
108
- for (const row of rows) {
109
- if (!isMap(row) || typeof row.name !== 'string' || !row.name) continue
110
- out[row.name] = requestValue(row)
111
- }
112
- return Object.keys(out).length ? out : null
113
- }
114
-
115
- /** One stored row → its value in the `site.yml::services` map. */
116
- function requestValue(row) {
272
+ function entryFromRow(row) {
117
273
  const off = row.enabled === false
118
- const settings = isMap(row.config) && Object.keys(row.config).length ? row.config : null
119
- if (!settings) return !off
120
- return off ? { enabled: false, ...settings } : { ...settings }
274
+ const config = isMap(row.config) ? { ...row.config } : {}
275
+ const address = addressOf(config)
276
+ if (address && off) return Object.keys(config).length === 1 ? address : config
277
+ if (address) delete config.endpoint
278
+ if (!Object.keys(config).length) return !off
279
+ return off ? { enabled: false, ...config } : config
121
280
  }
122
281
 
123
282
  /**
124
- * The `site.yml::services` map with these services taken from the site's rows — a
125
- * service the site holds no row for leaves the map.
126
- *
127
- * @param {*} declared - the current `services:` value
128
- * @param {object[]} stored - the site's rows now
129
- * @param {string[]} names
130
- * @returns {object|null} the new map, or null when nothing is left in it
283
+ * The credentials an author typed into an entry, kept on the one a pull writes over it.
284
+ * A push never sends one — it is stripped, with a warning naming the app — so the site's
285
+ * row cannot hold it, and writing the row whole would delete what the author typed.
286
+ * ⛔ *Until 2026-10-06 they sat in top-level keys the writer merged one level deep,
287
+ * which kept them; `services` is written whole.*
131
288
  */
132
- export function takeServices(declared, stored, names) {
133
- const out = isMap(declared) ? { ...declared } : {}
134
- for (const name of names) {
135
- const row = rowNamed(stored, name)
136
- if (row) out[name] = requestValue(row)
137
- else delete out[name]
138
- }
139
- return Object.keys(out).length ? out : null
289
+ function keepCredentials(entry, local) {
290
+ if (!isMap(local)) return entry
291
+ const found = CREDENTIAL_KEYS.filter((k) => k in local)
292
+ if (!found.length) return entry
293
+ const kept = Object.fromEntries(found.map((k) => [k, local[k]]))
294
+ if (entry === true) return kept
295
+ if (entry === false) return { enabled: false, ...kept }
296
+ if (typeof entry === 'string') return { endpoint: entry, ...kept }
297
+ return isMap(entry) ? { ...entry, ...kept } : entry
140
298
  }
141
299
 
142
300
  /**
143
- * Does this stored row already give what the ask asks — the same switch, and every
144
- * setting the ask names equal?
301
+ * The site's rows → the `site.yml::services` map — what a pull writes.
145
302
  *
146
- * @param {object|undefined} row
147
- * @param {object} ask
148
- * @returns {boolean}
149
- */
150
- export function satisfies(row, ask) {
151
- if (!isMap(row)) return false
152
- if ((row.enabled === false) !== (ask.enabled === false)) return false
153
- if (!ask.config) return true
154
- const stored = isMap(row.config) ? row.config : {}
155
- return Object.entries(ask.config).every(([key, value]) => stable(stored[key]) === stable(value))
156
- }
157
-
158
- /** Two rows that are the same as stored — both absent included. */
159
- function sameRow(a, b) {
160
- return stable(isMap(a) ? canonicalRow(a) : null) === stable(isMap(b) ? canonicalRow(b) : null)
161
- }
162
-
163
- /**
164
- * The full list to send: the stored rows, with these asks applied by name.
303
+ * ⭐ AN OFF SERVICE WITH NO SETTINGS IS WRITTEN ONLY WHERE `site.yml` ALREADY NAMES IT
304
+ * [Diego, 2026-10-07]. Every service is on or off, and off by default, so a `false` line
305
+ * adds nothing — and writing one for every service switched off in the app, or for each
306
+ * line the file dropped, would fill the file with them. So a pull writes a service that is
307
+ * on, or that has settings (an own provider's address); and one that is off, as `false`,
308
+ * where the file names it — a file listing a service the site has since switched off must
309
+ * say so, or its next push switches it back on. The rows it leaves out are still held
310
+ * (`sync.json`), and a push states them off, as the site has them (`statedServices`).
311
+ * ⛔ *Until then every row was written, an off one as `false`.*
165
312
  *
166
- * ⭐ A FULL LIST, because the backend replaces the `services` Section with what it is
167
- * sent: a row left out would lose its stored settings. A row no ask names is sent as
168
- * stored. A row an ask names takes the ask's switch, and its settings over the
169
- * stored ones, key by key. Every field is kept — a row is opaque past `name`,
170
- * `enabled` and `config`.
313
+ * ⚖️ What it gives up: a `false` an author wrote does not reach a fresh clone, whose
314
+ * `site.yml` names nothing yet. It still means off there.
171
315
  *
172
- * @param {object[]|null|undefined} base - the site's rows (or the record of them)
173
- * @param {object[]} asks - from `readServicesRequest`
174
- * @returns {object[]}
316
+ * @param {object} p
317
+ * @param {object[]} [p.rows] - the `services` Section: the site's rows
318
+ * @param {object} [p.local] - the `services:` the pull writes over — which services it
319
+ * names, and the credentials a push never sends (`keepCredentials`)
320
+ * @returns {object|null} null when there is nothing to write
175
321
  */
176
- export function mergeServiceRows(base, asks) {
177
- const out = []
178
- const at = new Map()
179
- for (const row of Array.isArray(base) ? base : []) {
180
- if (!isMap(row) || typeof row.name !== 'string' || !row.name || at.has(row.name)) continue
181
- at.set(row.name, out.length)
182
- out.push(canonicalRow(row))
183
- }
184
- for (const ask of asks || []) {
185
- const index = at.get(ask.name)
186
- const next = index === undefined ? { name: ask.name } : { ...out[index] }
187
- if (ask.enabled === false) next.enabled = false
188
- else delete next.enabled
189
- if (ask.config) next.config = { ...(isMap(next.config) ? next.config : {}), ...ask.config }
190
- if (index === undefined) {
191
- at.set(ask.name, out.length)
192
- out.push(next)
193
- } else {
194
- out[index] = next
195
- }
322
+ export function servicesFromDocument({ rows, local } = {}) {
323
+ const before = isMap(local) ? local : {}
324
+ const out = {}
325
+ for (const row of Array.isArray(rows) ? rows : []) {
326
+ if (!isMap(row) || typeof row.name !== 'string' || !row.name || row.name in out) continue
327
+ const entry = entryFromRow(row)
328
+ if (entry === false && !(row.name in before)) continue
329
+ out[row.name] = keepCredentials(entry, before[row.name])
196
330
  }
197
- return out
331
+ return Object.keys(out).length ? out : null
198
332
  }
199
333
 
200
334
  /**
201
- * Per service the file names: who moved since the last agreement, and so what to do.
335
+ * The `services` Section a push sends: every service `site.yml` lists, as it says it, and
336
+ * OFF for every service this copy holds that the file does not list.
202
337
  *
203
- * Three states — the file's asks, the site's rows now (`stored`), and the record of
204
- * the last agreement (`record`, from `sync.json`):
338
+ * ⭐ A PUSH STATES, AND THE BACKEND DECIDES PER SERVICE [Diego, 2026-10-07 —
339
+ * kb/framework/plans/services-exchange.md]. Every service is on or off, off by default.
205
340
  *
206
- * | the file vs the record | the site vs the record | outcome |
207
- * |---|---|---|
208
- * | unchanged | unchanged | nothing |
209
- * | changed | unchanged | `send` |
210
- * | unchanged | changed | `adopt` — the site's is kept, and may be taken into the file |
211
- * | changed | changed, differently | `conflict` — only the owner can rank the two |
341
+ * - A service the file lists goes WHOLE — its switch, and the entry as its `config` — so
342
+ * a setting removed from the file is removed.
343
+ * - A service this copy holds — a pull returned it, or a push sent it — and the file
344
+ * does not list is stated `{ name, enabled: false }`, with no settings. Deleting the
345
+ * line turns it off and removes its settings, which would otherwise stay live on the
346
+ * page, since a row's `config` reaches it whether the row is on or off, and come back
347
+ * with the next pull.
348
+ * - A service this copy never saw is not sent, and the site keeps it.
212
349
  *
213
- * A service the site already gives as asked is nothing, whoever moved. With no record
214
- * the two cannot be told apart: a service the site holds nothing for is sent, one it
215
- * holds differently is a conflict. With the site unreadable, what changed against the
216
- * record is sent over the record; with neither, nothing can be merged onto for a site
217
- * that exists — `unreadable` — while a site not yet created has nothing stored.
350
+ * A held service carries its `$uuid`, and the push sends the version of every item it
351
+ * holds (`withBaseVersion`), so a service changed on the site since is kept when the file
352
+ * left it as it was, and refused when the file changed it too.
218
353
  *
219
- * @param {object} p
220
- * @param {object[]} p.asks - from `readServicesRequest`
221
- * @param {object[]} [p.record] - the rows last agreed, when the project has them
222
- * @param {object[]} [p.stored] - the site's rows now, when they could be read
223
- * @param {boolean} [p.siteKnown=true] - whether the site exists on this backend
224
- * @returns {{ send: string[], adopt: string[], conflict: string[], unreadable: boolean }}
354
+ * ⛔ *Until then a push sent the site's whole list, read just before it, with the file's
355
+ * changed asks applied — a service left out was deleted — and the CLI kept its own copy
356
+ * of the list in `sync.json` to tell who had changed what.*
357
+ *
358
+ * @param {object[]|null} asks - from `readServicesRequest`
359
+ * @param {object} [held] - `{ <name>: <$uuid> }`, the services this copy holds (`sync.json`)
360
+ * @returns {object[]} the rows; `[]` when there is nothing to state
225
361
  */
226
- export function reconcileServices({ asks, record, stored, siteKnown = true }) {
227
- const send = []
228
- const adopt = []
229
- const conflict = []
230
- const haveRecord = Array.isArray(record)
231
- const haveStored = Array.isArray(stored)
232
- if (!haveRecord && !haveStored) {
233
- return siteKnown
234
- ? { send, adopt, conflict, unreadable: true }
235
- : { send: asks.map((a) => a.name), adopt, conflict, unreadable: false }
362
+ export function statedServices(asks, held = {}) {
363
+ const ids = isMap(held) ? held : {}
364
+ const uuidOf = (name) => (typeof ids[name] === 'string' && ids[name] ? ids[name] : null)
365
+ const rows = []
366
+ const listed = new Set()
367
+ for (const ask of asks || []) {
368
+ listed.add(ask.name)
369
+ const row = { name: ask.name }
370
+ if (ask.enabled === false) row.enabled = false
371
+ if (ask.config) row.config = { ...ask.config }
372
+ if (uuidOf(ask.name)) row.$uuid = uuidOf(ask.name)
373
+ rows.push(row)
236
374
  }
237
- for (const ask of asks) {
238
- const now = rowNamed(stored, ask.name)
239
- const then = rowNamed(record, ask.name)
240
- if (!haveStored) {
241
- if (!satisfies(then, ask)) send.push(ask.name)
242
- continue
243
- }
244
- if (satisfies(now, ask)) continue
245
- if (!haveRecord) {
246
- ;(now === undefined ? send : conflict).push(ask.name)
247
- continue
248
- }
249
- const fileMoved = !satisfies(then, ask)
250
- const siteMoved = !sameRow(now, then)
251
- if (fileMoved && !siteMoved) send.push(ask.name)
252
- else if (!fileMoved && siteMoved) adopt.push(ask.name)
253
- else conflict.push(ask.name)
375
+ for (const name of Object.keys(ids).sort()) {
376
+ if (listed.has(name) || !uuidOf(name)) continue
377
+ rows.push({ name, enabled: false, $uuid: uuidOf(name) })
254
378
  }
255
- return { send, adopt, conflict, unreadable: false }
379
+ return rows
256
380
  }
257
381
 
258
382
  /**
259
- * The record after a push: what this project now agrees with the site on.
383
+ * The services this copy holds after a push or a pull — `{ <name>: <$uuid> }`, read from
384
+ * the document the backend returned.
260
385
  *
261
- * The rows sent, or — when nothing was — the site's rows now. ⛔ For a service left
262
- * OPEN (an adopt not taken, a conflict not resolved) the earlier agreement stands,
263
- * or the next run would read the site's change as the file's and send the stale ask.
386
+ * A pull returns every service the site has, all seen (`sent` absent). A push returns the
387
+ * site's rows after the write, which may include services added since this copy's last
388
+ * pull: those were neither sent nor held before, so they are left out — held, a later
389
+ * push would state them off, switching off a service nobody here has seen.
264
390
  *
265
391
  * @param {object} p
266
- * @param {object[]} [p.record] - the rows last agreed
267
- * @param {object[]} [p.agreed] - the rows sent, or the site's rows now
268
- * @param {string[]} [p.open] - services whose decision is still open
269
- * @returns {object[]|undefined} undefined when there is nothing to record
392
+ * @param {object[]} [p.written] - the `services` rows the backend returned
393
+ * @param {object[]} [p.sent] - the rows the push sent; absent for a pull
394
+ * @param {object} [p.prior] - the services held before
395
+ * @returns {object} the services now held
270
396
  */
271
- export function recordAfter({ record, agreed, open = [] }) {
272
- if (!Array.isArray(agreed)) return undefined
273
- const keep = new Set(open)
274
- const out = agreed.filter((r) => isMap(r) && !keep.has(r.name)).map(canonicalRow)
275
- for (const name of open) {
276
- const then = rowNamed(record, name)
277
- if (then) out.push(canonicalRow(then))
397
+ export function heldServices({ written, sent, prior } = {}) {
398
+ const before = isMap(prior) ? prior : {}
399
+ const stated = Array.isArray(sent) ? new Set(sent.filter(isMap).map((r) => r.name)) : null
400
+ const out = {}
401
+ for (const row of Array.isArray(written) ? written : []) {
402
+ if (!isMap(row) || typeof row.name !== 'string' || !row.name) continue
403
+ if (typeof row.$uuid !== 'string' || !row.$uuid) continue
404
+ if (stated && !stated.has(row.name) && !(row.name in before)) continue
405
+ out[row.name] = row.$uuid
278
406
  }
407
+ // Sorted: it is written to `sync.json`, a committed file that must not reorder itself.
408
+ return Object.fromEntries(Object.keys(out).sort().map((k) => [k, out[k]]))
409
+ }
410
+
411
+ /** What a row SAYS — its switch and its `config` — not the fields a backend adds to it. */
412
+ function said(row) {
413
+ if (!isMap(row)) return null
414
+ const out = {}
415
+ if (row.enabled === false) out.enabled = false
416
+ if (isMap(row.config) && Object.keys(row.config).length) out.config = row.config
279
417
  return out
280
418
  }
419
+
420
+ /**
421
+ * Whether the site holds a service as a push sent it: the switch and the WHOLE
422
+ * `config`. ⛔ Not "equal on the keys we sent", which units use: an `on` row sends no
423
+ * `enabled` and an entry with no settings no `config`, so a row the app switched off,
424
+ * or gave settings, would read as ours.
425
+ *
426
+ * @param {object} sent - the row a push sent
427
+ * @param {object} written - the site's row after it
428
+ * @returns {boolean}
429
+ */
430
+ export function sameServiceRow(sent, written) {
431
+ return isMap(sent) && isMap(written) && stable(said(sent)) === stable(said(written))
432
+ }