@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.
- package/package.json +5 -5
- package/src/content/index.js +0 -1
- package/src/dev/api-mount.js +27 -19
- package/src/site/content-collector.js +20 -6
- package/src/site/data-fetcher.js +19 -0
- package/src/site/index.js +1 -2
- package/src/site/plugin.js +18 -5
- package/src/site/queries-config.js +2 -1
- package/src/site/query-processor.js +4 -40
- package/src/uwx/emit-surface.json +1 -13
- package/src/uwx/index.js +8 -4
- package/src/uwx/services-request.js +350 -198
- package/src/uwx/shown-records.js +52 -0
- package/src/uwx/site-diff.js +47 -0
- package/src/uwx/site-project.js +39 -53
- package/src/uwx/site.js +58 -161
- package/src/uwx/sync-package.js +30 -21
|
@@ -1,35 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A site's services
|
|
3
|
-
*
|
|
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:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
23
|
-
* the
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
266
|
+
* One row → the entry `site.yml::services` writes for it.
|
|
98
267
|
*
|
|
99
|
-
*
|
|
100
|
-
* when
|
|
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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
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
|
-
*
|
|
144
|
-
* setting the ask names equal?
|
|
301
|
+
* The site's rows → the `site.yml::services` map — what a pull writes.
|
|
145
302
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
*
|
|
167
|
-
*
|
|
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
|
|
173
|
-
* @param {object[]}
|
|
174
|
-
* @
|
|
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
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
for (const row of Array.isArray(
|
|
180
|
-
if (!isMap(row) || typeof row.name !== 'string' || !row.name ||
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
204
|
-
*
|
|
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
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* @param {
|
|
224
|
-
* @
|
|
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
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
379
|
+
return rows
|
|
256
380
|
}
|
|
257
381
|
|
|
258
382
|
/**
|
|
259
|
-
* The
|
|
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
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
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.
|
|
267
|
-
* @param {object[]} [p.
|
|
268
|
-
* @param {
|
|
269
|
-
* @returns {object
|
|
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
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
const out =
|
|
275
|
-
for (const
|
|
276
|
-
|
|
277
|
-
if (
|
|
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
|
+
}
|