uniweb 0.84.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,15 +42,15 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/content-writer": "^0.3.4",
45
+ "@uniweb/runtime": "^0.29.2",
45
46
  "@uniweb/kit": "^0.19.18",
47
+ "@uniweb/schemas": "^0.12.0",
46
48
  "@uniweb/core": "^0.37.2",
47
- "@uniweb/semantic-parser": "^1.5.1",
48
- "@uniweb/runtime": "^0.29.2",
49
- "@uniweb/schemas": "^0.12.0"
49
+ "@uniweb/semantic-parser": "^1.5.1"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.77.0",
53
52
  "@uniweb/content-reader": "^1.2.8",
53
+ "@uniweb/build": "^0.78.0",
54
54
  "@uniweb/semantic-parser": "^1.5.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -1962,15 +1962,15 @@ When an external query is enough and when a transport is the answer: `developmen
1962
1962
 
1963
1963
  Full model: `reference/data-fetching.md`. Where-object format with examples: `authoring/predicates.md`.
1964
1964
 
1965
- ### Search (`search:`)
1965
+ ### Search (`services.search`)
1966
1966
 
1967
- Search follows the same arrangement as `fetcher:` — the **site** declares where results come from, and a search UI reads them the same way regardless. Never hardcode a search endpoint in a component; that couples the foundation to one host.
1967
+ Search follows the same arrangement as `fetcher:` — the **site** declares where results come from, and a search UI reads them the same way regardless. Never hardcode a search endpoint in a component; that couples the foundation to one host. Search is on by default; its entry under `services:` (see *Services*, below) turns it off, sets options, or — on a site you publish — asks the host for it:
1968
1968
 
1969
1969
  ```yaml
1970
1970
  # site.yml
1971
- search:
1972
- enabled: true
1973
- provider: index # default — download an index, match in the browser
1971
+ services:
1972
+ search:
1973
+ provider: index # default — download an index, match in the browser
1974
1974
  ```
1975
1975
 
1976
1976
  | Provider | Answers with | Trade-off |
@@ -1980,12 +1980,13 @@ search:
1980
1980
  | *any other name* | A foundation-supplied search transport | Fully open — Typesense, Meilisearch, Pagefind, a vendor API |
1981
1981
 
1982
1982
  ```yaml
1983
- search:
1984
- provider: endpoint
1985
- endpoint: _search # REQUIRED — there is no default
1983
+ services:
1984
+ search:
1985
+ provider: endpoint
1986
+ endpoint: _search # REQUIRED — there is no default
1986
1987
  ```
1987
1988
 
1988
- `endpoint:` is **required** with `provider: endpoint`; omit it and the provider refuses the query rather than guessing a path. It resolves against the site's base path — `/` → `/_search`, `base: /docs/` → `/docs/_search`, a subpath-served site follows its subpath. An absolute `https://…` URL points at another origin. A host that serves the site may offer search itself, supplying the address so the site declares none.
1989
+ `endpoint:` is **required** with `provider: endpoint`; omit it and the provider refuses the query rather than guessing a path. It resolves against the site's base path — `/` → `/_search`, `base: /docs/` → `/docs/_search`, a subpath-served site follows its subpath. An absolute `https://…` URL points at another origin. A host that serves the site may offer search itself: `search: true` asks for it, and the host supplies the address. An endpoint of your own asks the host to leave its search off, so yours answers.
1989
1990
 
1990
1991
  **Results have one shape, whatever the provider.** Always present: `id`, `type`, `route`, `href`, `title`, `pageTitle`, `excerpt`, `snippetHtml`. Provider-optional (`null` when absent): `sectionId`, `anchor`, `description`, `component`, `snippetText`, `matches`, `group`, `item`. `type` is `page`, `section` or `record`; on a record hit, `item` holds the record's fields and `group` names the set it came from. Whether an optional field arrives is a deployment fact, not a content fact — the same site yields `item` from a server provider and `null` from the local index — so guard them: `result.item?.image`.
1991
1992
 
@@ -2002,26 +2003,27 @@ const { results, isLoading, query } = useSearch(website) // `query` is a funct
2002
2003
 
2003
2004
  Full reference: `authoring/search.md`.
2004
2005
 
2005
- ### Forms (`submit:`)
2006
+ ### Forms (`services.submit`)
2006
2007
 
2007
2008
  Drawing a form is a foundation's job; delivering what a visitor typed needs a
2008
2009
  server. Where that server is comes from the **site or its host** — never from a
2009
- section type, same arrangement as `fetcher:` and `search:`.
2010
+ section type, same arrangement as `fetcher:` and search.
2010
2011
 
2011
2012
  A form gets its destination from the first of these that applies:
2012
2013
 
2013
- 1. **One the host supplies** — `services.submit` in the served payload. Where the
2014
- host handles submissions, that is the destination and nothing in `site.yml`
2015
- overrides it — so a site published to Uniweb Cloud needs **no `submit:`**: it
2016
- asks for form handling with `services:` instead (*Uniweb Cloud*, below).
2017
- 2. **`submit:` in `site.yml`** — an endpoint you name yourself, for a host that
2018
- does not handle submissions, or a static site.
2014
+ 1. **One the host supplies** — `config.services.submit` in the payload it serves. Where the
2015
+ host handles submissions, that is the destination. A site published to Uniweb
2016
+ Cloud asks for it with `submit: true` under `services:` and names no address.
2017
+ 2. **An address of the site's own** — `submit: <url>` under `services:`, for a
2018
+ static site or a host that does not handle submissions. On a host that does, it
2019
+ asks the host to leave its own off, so this one answers.
2019
2020
  3. **Neither** — there is no destination: render no form, or fall back to contact
2020
2021
  details the site already carries.
2021
2022
 
2022
2023
  That is the general arrangement, not a forms-only one. A host states everything
2023
- it offers in the served payload's `services`, keyed by name, and every service
2024
- resolves by the same rule — the host's offer, then your declaration, then neither.
2024
+ it offers in the served payload's `config.services`, keyed by name, and every service
2025
+ resolves by the same rule — the host's offer, then the site's own address, then
2026
+ neither.
2025
2027
 
2026
2028
  ⭐ **Before you render UI for a service, ask whether the site has it** — one predicate per service,
2027
2029
  no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isApiEnabled()`, `isAssistantEnabled()`,
@@ -2116,10 +2118,15 @@ wherever tracking is configured, with no help from your components. List
2116
2118
  leaving it out does not switch the baseline off.
2117
2119
 
2118
2120
  ```yaml
2119
- # site.yml — only when YOU are providing the endpoint. Publishing to Uniweb
2120
- # Cloud needs nothing here; `uniweb export` and most `deploy --host` targets do.
2121
- submit: /forms # base-relative, resolved like search.endpoint
2122
- submit: https://forms.example.com/intake # or another origin
2121
+ # site.yml
2122
+ services:
2123
+ submit: true # your host's form handling (Uniweb Cloud)
2124
+ ```
2125
+
2126
+ ```yaml
2127
+ # site.yml — when YOU are providing the endpoint: `uniweb export`, most `deploy --host` targets
2128
+ services:
2129
+ submit: https://forms.example.com/intake # or /forms, base-relative like search's endpoint
2123
2130
  ```
2124
2131
 
2125
2132
  ```jsx
@@ -2138,12 +2145,12 @@ if (!canSubmit) return null // nowhere to send — render no form, or fall ba
2138
2145
  ```
2139
2146
 
2140
2147
  > **The framework never invents an endpoint — but a host may supply one.** Don't
2141
- > reach for `submit:` reflexively: on Uniweb Cloud the host's form handling wins
2142
- > wherever it is on, so a `submit:` there answers only when it is off — ask for it
2143
- > with `services:` instead. Reach for `submit:` when you are the one hosting.
2148
+ > write an address reflexively: on Uniweb Cloud an address asks the host to turn its
2149
+ > own form handling off — `submit: true` is the whole declaration there. Reach for an
2150
+ > address when you are the one hosting.
2144
2151
  >
2145
- > `canSubmit` is false only when neither a declaration nor a host supplies a
2146
- > destination. **Check it when you render, not only on the button press** — a
2152
+ > `canSubmit` is false only when neither the host nor an address of the site's own
2153
+ > supplies a destination. **Check it when you render, not only on the button press** — a
2147
2154
  > form nobody can send should not be on the page at all. A read that 404s
2148
2155
  > degrades to `[]` and the page still renders; a write that 404s loses what a
2149
2156
  > person typed, so it gets no silent fallback.
@@ -2197,7 +2204,7 @@ from. `values` keeps the `File` so your input can show its selection.
2197
2204
 
2198
2205
  Full reference: `development/receiving-form-submissions.md`.
2199
2206
 
2200
- ### Tracking (`tracking:`)
2207
+ ### Tracking (`services.tracking`)
2201
2208
 
2202
2209
  A site may declare one **tracking destination**, and everything worth counting
2203
2210
  goes there as an event on a single stream. A page visit is just the event the
@@ -2205,23 +2212,28 @@ runtime emits by itself.
2205
2212
 
2206
2213
  ```yaml
2207
2214
  # site.yml — your own collector, on any host
2208
- tracking: https://collector.example.com/events
2215
+ services:
2216
+ tracking: https://collector.example.com/events
2217
+ ```
2209
2218
 
2219
+ ```yaml
2210
2220
  # or, when it needs more than an address
2211
- tracking:
2212
- endpoint: /collect
2213
- consent: required
2221
+ services:
2222
+ tracking:
2223
+ endpoint: /collect
2224
+ consent: required
2214
2225
  ```
2215
2226
 
2216
- A host may also supply one under `services.tracking`, and the usual precedence
2217
- applies: the host's, then yours, then neither.
2227
+ A host may also supply one — `tracking: true` asks for it on a site you publish —
2228
+ and the usual precedence applies: the host's, then yours, then neither. An
2229
+ endpoint of your own asks the host to leave its collector off, so yours answers.
2218
2230
 
2219
2231
  ⚠️ **The endpoint has to accept the framework's own format** — a batched
2220
2232
  `{ "events": [ … ] }` POST, documented in `reference/site-configuration.md`. It
2221
2233
  is not a third-party analytics product's public API, which expects that
2222
2234
  product's own shape.
2223
2235
 
2224
- A site may also name a vendor's own script under `tracking.scripts`, which the
2236
+ A site may also name a vendor's own script under `services.tracking.scripts`, which the
2225
2237
  runtime loads once, after consent when a gate is declared, and never in a frame
2226
2238
  or during prerender. That is a **separate path with no connection to the stream
2227
2239
  below** — the vendor measures its own way, and nothing you `track()` reaches it.
@@ -2271,21 +2283,24 @@ Narrow or widen that with `emit`:
2271
2283
 
2272
2284
  ```yaml
2273
2285
  # site.yml — your own collector
2274
- tracking:
2275
- endpoint: https://collector.example.com/events
2276
- emit: standard # minimal | standard | all — or a list of event names
2286
+ services:
2287
+ tracking:
2288
+ endpoint: https://collector.example.com/events
2289
+ emit: standard # minimal | standard | all — or a list of event names
2290
+ ```
2277
2291
 
2292
+ ```yaml
2278
2293
  # site.yml — a host that supplies the collector: say what to send, not where
2279
- tracking:
2280
- emit: minimal
2294
+ services:
2295
+ tracking:
2296
+ emit: minimal
2281
2297
  ```
2282
2298
 
2283
2299
  ⭐ **`emit` needs no endpoint of its own.** Where a host provides one, the site
2284
2300
  declares only what it wants sent and the address comes from the host. The two
2285
2301
  are read key by key, so naming `emit` alone overrides nothing else the host
2286
- declared. An `endpoint:` of your own is used wherever the host supplies no
2287
- collector — on a host without one, and on none — and where the host supplies
2288
- one, the host's is used.
2302
+ declared. An `endpoint:` of your own means you collect yourself, and asks the host
2303
+ to leave its collector off.
2289
2304
 
2290
2305
  `minimal` is `page_view` alone. `standard` is the default when the collector is
2291
2306
  your own. `all` is a standing yes, so an event added in a later framework
@@ -2540,8 +2555,9 @@ permission model and a CSS one.
2540
2555
  You do not need a live backend to build against one. In `site.yml`:
2541
2556
 
2542
2557
  ```yaml
2543
- api: /_api # where it answers — the same value in production
2544
- $devApi: ./mock/api.js # what answers it locally; `$` keys are never published
2558
+ services:
2559
+ api: true # ask your host for an app backend when you publish
2560
+ $devApi: ./mock/api.js # what answers it in `uniweb dev`; `$` keys are never published
2545
2561
  ```
2546
2562
 
2547
2563
  ```js
@@ -2550,8 +2566,10 @@ import { createMockBackend } from '@uniweb/api/mock'
2550
2566
  export default createMockBackend({ seed }).fetch
2551
2567
  ```
2552
2568
 
2553
- `uniweb dev` mounts it at your `api:` address, same-origin, so cookies and your
2554
- site's configuration behave exactly as they will in production. It **enforces** who
2569
+ `uniweb dev` answers your site's `api` service with it, at an address the dev server
2570
+ supplies on its own origin, so cookies behave exactly as they will in production. Write
2571
+ no address of your own for it: under `services:` an address means a backend you run, and
2572
+ asks your host to leave its own off. It **enforces** who
2555
2573
  may edit an entry and `append_only`, so a permission you are relying on fails on your
2556
2574
  machine rather than in front of a user. State is in memory; restart to reset.
2557
2575
 
@@ -2657,23 +2675,35 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2657
2675
 
2658
2676
  **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --backend <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --backend <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--backend` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2659
2677
 
2660
- **`services:` in `site.yml` asks your host for services** — site search, form handling, accounts:
2678
+ **`services:` in `site.yml` is where a site says which services it uses** — site search, form
2679
+ handling, analytics, an assistant, accounts — one entry per service:
2661
2680
 
2662
2681
  ```yaml
2663
2682
  services:
2664
- search: true # turn it on
2665
- submit: false # turn it off
2666
- api: # turn it on, with the service's own settings
2683
+ search: true # on — your host's, or the built-in index
2684
+ submit: false # off
2685
+ tracking: https://collector.example.com/events # a provider you bring
2686
+ api: # on, with the service's own settings
2667
2687
  grade: pro
2668
2688
  ```
2669
2689
 
2670
- A service you leave out keeps whatever the site has, and settings you leave out keep theirs — to turn
2671
- one off, say `false`. `uniweb push` and `uniweb publish` send what you changed since your last sync;
2672
- if the site's services changed elsewhere in the meantime — an author in the app — they offer to update
2673
- `site.yml` rather than send your older choice over it, and if both changed the same service they ask
2674
- which to keep. `uniweb pull` writes what the site has into `services:`. A change that needs payment is
2675
- settled in the app: `publish` opens it. ⛔ **Not the same as `search:` / `submit:`**, which configure a
2676
- provider the site brings itself — and `services:` never reaches the built site.
2690
+ An entry is `true`, `false`, an address of your own (a string, or `endpoint:` in a map), or a map
2691
+ of options. On a site you push or publish it is also **what you ask your host for**: `true` asks for
2692
+ its service, `false` asks it to turn its service off, and an address asks it to leave its own off so
2693
+ yours answers. **On a site your host publishes, a service is off unless `services:` asks for it** —
2694
+ list the ones the site uses (the templates list theirs). `records: true` asks for your records to be
2695
+ delivered live to the published pages: a query whose records have a data schema shows nothing there
2696
+ without it, and `publish` says so; a static build reads records from files and ignores it. An entry
2697
+ says everything about its service: a setting you remove from it is removed on the next push, and
2698
+ removing the entry switches the service off. `uniweb push` and `uniweb publish` send each service
2699
+ `site.yml` lists; a service the site has that this copy has never seen (turned on in the app since
2700
+ your last pull) is left as it is, and one changed both in the file and on the site since your last
2701
+ pull stops the push, naming it — `uniweb pull --merge`, then push. `uniweb pull` writes what the site
2702
+ has into `services:` (an off service with no settings only where the file already names it) — pull
2703
+ before you edit. A change that needs payment is settled in the app: `publish` opens it. **Everything in an entry but a
2704
+ credential is public** — it is built into the site, except `api`'s settings, which only your host
2705
+ reads; a key or token is set in the app. ⛔ The top-level `search:` / `submit:` / `assistant:` /
2706
+ `tracking:` / `api:` keys are retired: the build stops on them and says where each one moves.
2677
2707
 
2678
2708
  **The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
2679
2709
 
@@ -5,36 +5,30 @@
5
5
  * Two of them, kept by two mechanisms:
6
6
  *
7
7
  * - ⭐ **The services** — `site.yml::services`, a map by service name *[Diego,
8
- * 2026-10-06]*. Sent as the site's own list with the owner's CHANGED asks applied,
9
- * decided per service by comparing the file, the site now and the record of the
10
- * last agreement in `sync.json` (`settleServices`; spec:
11
- * kb/framework/reference/site-services-request.md).
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`).
12
13
  * - **The language selection** — `site.yml::publishLanguages` — told apart from the
13
14
  * status quo by a fingerprint banked in `deploy.yml` (`reconcile`, `bankLanguages`).
14
15
  *
15
16
  * ⭐ The model both follow — *"the services in `site.yml` are a request, never a
16
- * tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file. An
17
- * unchanged ask is not re-sent: the backend REPLACES the services it is sent, so a
18
- * re-send would overwrite a decision the owner made in the app since.
17
+ * tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file.
19
18
  *
20
19
  * ⛔ *From 2026-09-20 to 2026-10-06 the services lived only in `sync.json`, which
21
- * nobody edits, so a CLI user could ask for nothing; and this module compared them by
22
- * a fingerprint in `deploy.yml`, which could not tell who moved.*
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.*
23
24
  *
24
25
  * @module
25
26
  */
26
27
 
27
28
  import { createHash } from 'node:crypto'
28
- import {
29
- readServicesRequest,
30
- takeServices,
31
- mergeServiceRows,
32
- reconcileServices,
33
- recordAfter,
34
- readBackendState,
35
- updateBackendState,
36
- writeSiteConfig
37
- } from '@uniweb/build/uwx'
29
+ import { existsSync, readFileSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+ import { readServicesRequest } from '@uniweb/build/uwx'
38
32
 
39
33
  /**
40
34
  * A stable fingerprint of one declared value, or `null` when the key is absent.
@@ -123,142 +117,90 @@ export function bankLanguages(siteYml) {
123
117
  return langs ? { publishLanguagesRequest: langs } : {}
124
118
  }
125
119
 
126
- /** One service, as an owner reads it: `on`, `off`, `on (grade: pro)`. */
127
- function describeService(row) {
128
- if (!row || typeof row !== 'object') return 'nothing set'
129
- const state = row.enabled === false ? 'off' : 'on'
130
- const settings =
131
- row.config && typeof row.config === 'object' ? Object.entries(row.config) : []
132
- if (!settings.length) return state
133
- const shown = settings
134
- .map(([k, v]) => `${k}: ${v !== null && typeof v === 'object' ? '…' : String(v)}`)
135
- .join(', ')
136
- return `${state} (${shown})`
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
+ }
137
145
  }
138
146
 
139
- const rowNamed = (rows, name) =>
140
- Array.isArray(rows) ? rows.find((r) => r && typeof r === 'object' && r.name === name) : undefined
147
+ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
141
148
 
142
149
  /**
143
- * Decide what a push or publish sends of `site.yml::services` — and, once it has
144
- * succeeded, what the project records.
145
- *
146
- * Per service the file names, three states are compared: the file, the site now
147
- * (`status.services`), and the record of the last agreement (this backend's entry in
148
- * `sync.json`). What the owner changed is sent; what the site changed is kept, and
149
- * offered into `site.yml`; where both changed, the owner is asked. The list sent is the
150
- * site's own, with only the changed asks applied (`mergeServiceRows`) — so every other
151
- * service and setting the site holds is sent as it is stored.
152
- *
153
- * ⛔ An open decision — an offer declined, a conflict not resolved, or no terminal to
154
- * ask at — is never sent, and the record keeps the earlier agreement for it, so the
155
- * next run still sees the site's change rather than reading it as the file's.
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`).
156
161
  *
157
162
  * @param {object} p
158
- * @param {object} p.client - the backend client (`origin`, `siteStatus`)
159
- * @param {string} p.siteDir
160
- * @param {object} p.siteYml - parsed; updated in place when the owner takes the site's services
161
- * @param {object|null} [p.status] - the site's status, when the caller has just read it
162
- * @param {boolean} [p.offline] - read nothing from the backend (`--dry-run`, `-o`)
163
- * @param {boolean} [p.interactive] - whether the owner can be asked
164
- * @param {(message: string, initial?: boolean) => Promise<boolean>} p.confirm
165
- * @param {{ info: Function, warn: Function, dim: Function, ok: Function }} p.say
166
- * @returns {Promise<{ emit: object, after: () => void }>} `emit` — options for the
167
- * producer; `after` — call once the push succeeded (or had nothing to send)
163
+ * @param {object} p.siteYml - parsed
164
+ * @param {{ warn: Function }} p.say
165
+ * @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
168
166
  */
169
- export async function settleServices({
170
- client,
171
- siteDir,
172
- siteYml,
173
- status,
174
- offline = false,
175
- interactive = false,
176
- confirm,
177
- say
178
- }) {
179
- const nothing = { emit: {}, after: () => {} }
180
- const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) })
181
- if (!asks) return nothing
182
-
183
- const state = readBackendState(siteDir, client.origin)
184
- const record = Array.isArray(state.services) ? state.services : undefined
185
- const siteUuid = state.site?.uuid || null
186
- let read = status
187
- if (read === undefined && !offline && siteUuid) read = await client.siteStatus(siteUuid)
188
- const stored = Array.isArray(read?.services) ? read.services : undefined
189
-
190
- const decision = reconcileServices({ asks, record, stored, siteKnown: Boolean(siteUuid) })
191
- if (decision.unreadable) {
192
- say.warn("site.yml asks for services, but this project has no record of your site's and could not read them, so none were sent.")
193
- say.dim(' Run `uniweb pull` to take them, then push again.')
194
- return nothing
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\`).`)
195
175
  }
196
-
197
- const askFor = (name) => asks.find((a) => a.name === name)
198
- const send = [...decision.send]
199
- let offered = [...decision.adopt]
200
- const open = []
201
-
202
- // ⛔ BOTH MOVED: only the owner can rank two of their own decisions. Not a stop — the
203
- // content they asked to push is a separate thing — and never a guess.
204
- if (decision.conflict.length) {
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) {
205
179
  say.warn(
206
- record
207
- ? "Your site's services and site.yml both changed since your last sync:"
208
- : 'site.yml asks for services your site has set differently:'
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.`
209
183
  )
210
- for (const name of decision.conflict) {
211
- say.dim(` ${name}: site.yml asks ${describeService(askFor(name))} — your site has ${describeService(rowNamed(stored, name))}`)
212
- }
213
- if (!interactive) {
214
- say.dim(" Left as your site has them — run without --non-interactive to choose.")
215
- open.push(...decision.conflict)
216
- } else if (await confirm('Use the services in site.yml?', false)) {
217
- send.push(...decision.conflict)
218
- } else {
219
- // Declining to send is not yet a decision to take the site's: offered below.
220
- offered = [...offered, ...decision.conflict]
221
- }
222
- }
223
-
224
- // The site moved and the file did not: the file is behind. Offered, never done — a
225
- // push changes `site.yml` only when the owner says so.
226
- if (offered.length) {
227
- say.info("Your site's services changed since your last sync:")
228
- for (const name of offered) {
229
- say.dim(` ${name}: your site has ${describeService(rowNamed(stored, name))} — site.yml says ${describeService(askFor(name))}`)
230
- }
231
- if (interactive && (await confirm('Update site.yml to match?', false))) {
232
- const services = takeServices(siteYml.services, stored, offered)
233
- writeSiteConfig(siteDir, { services })
234
- if (services) siteYml.services = services
235
- else delete siteYml.services
236
- say.ok('site.yml updated.')
237
- } else {
238
- open.push(...offered)
239
- }
240
- }
241
-
242
- if (send.length) {
243
- say.info(`Asking for: ${send.map((n) => `${n} ${describeService(askFor(n))}`).join(', ')}`)
244
184
  }
185
+ }
245
186
 
246
- // The list to send: the site's rows with the changed asks applied. With the site
247
- // unreadable and nothing changed, nothing is sent — the record may be stale, and
248
- // sending it would be asking for what the site may have moved away from.
249
- const base = stored ?? record ?? (siteUuid ? undefined : [])
250
- if (!stored && !send.length) {
251
- return {
252
- emit: { declareServices: false },
253
- after: () => {}
254
- }
255
- }
256
- const rows = mergeServiceRows(base, asks.filter((a) => send.includes(a.name)))
257
- return {
258
- emit: { serviceRows: rows },
259
- after: () => {
260
- const next = recordAfter({ record, agreed: rows, open })
261
- if (next) updateBackendState(siteDir, client.origin, { services: next })
262
- }
263
- }
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
+ )
264
206
  }
@@ -34,10 +34,12 @@ import {
34
34
  diffSiteUnits,
35
35
  describeSiteDiff,
36
36
  computeUnitHashes,
37
- collectSiteUnits,
38
37
  collectUnitUuids,
39
38
  collectFolderItemUuids,
40
39
  collectQueryUuids,
40
+ siteItemsByKey,
41
+ sameServiceRow,
42
+ heldServices,
41
43
  readBackendState,
42
44
  updateBackendMap,
43
45
  clearBackendSections,
@@ -118,6 +120,35 @@ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
118
120
  }
119
121
  }
120
122
 
123
+ /**
124
+ * The items a `stale_base` refusal names by key — `stale_keys`, one
125
+ * `{ section, key }` per clashing item of a Section matched by a field of its own
126
+ * (`services` and `queries` by `name`) — as one line per Section.
127
+ *
128
+ * @param {*} staleKeys - the refusal's `stale_keys`
129
+ * @returns {string[]}
130
+ */
131
+ export function describeStaleKeys(staleKeys) {
132
+ const bySection = new Map()
133
+ for (const entry of Array.isArray(staleKeys) ? staleKeys : []) {
134
+ const section = typeof entry?.section === 'string' && entry.section ? entry.section : null
135
+ if (!section) continue
136
+ const key = entry?.key
137
+ // A single-item Section (`settings`) is named by the Section alone, `key: {}`.
138
+ const single = key && typeof key === 'object' && !Object.keys(key).length
139
+ const name =
140
+ key && typeof key === 'object'
141
+ ? typeof key.name === 'string' ? key.name : Object.values(key).filter((v) => typeof v === 'string').join(' ')
142
+ : typeof key === 'string' ? key : null
143
+ if (!single && !name) continue
144
+ if (!bySection.has(section)) bySection.set(section, [])
145
+ if (name) bySection.get(section).push(name)
146
+ }
147
+ return [...bySection].map(([section, names]) =>
148
+ `Changed on your site since your last pull — ${section}${names.length ? `: ${names.join(', ')}` : ''}`
149
+ )
150
+ }
151
+
121
152
  // A unit in the form it is compared in across the two representations: keys sorted,
122
153
  // `$`-keys dropped (`$uuid`, `$id` — identity and payload handles, not content).
123
154
  const canonicalUnit = (v) =>
@@ -172,42 +203,65 @@ function writtenAsSent(sent, written) {
172
203
  * changes it is `pkg != base, host != base` and refused. And while the backend holds
173
204
  * units we don't, the entity token stays where it was, so the next push keeps them.
174
205
  *
175
- * With either document missing there is nothing to compare, and the tokens are banked
176
- * as the backend sent them — the contract is that `finalized[].document` is always
177
- * there.
178
- *
206
+ * ⭐ SINCE 2026-10-07 A HELD VERSION IS ALSO WHAT LETS A PUSH DELETE, so the rule covers
207
+ * every item, not only units. The site-content entity sends every version this copy
208
+ * holds, for items present and items the files dropped (`withBaseVersion`), and the
209
+ * backend deletes only the items a push holds a version for, while the site left them
210
+ * unchanged [Diego: "yes, apply it to pages too"; kb/framework/plans/services-exchange.md].
211
+ * A version banked for an item this copy never saw would therefore delete it. The map
212
+ * returned is the WHOLE set this copy now holds, in every Section but `secrets`
213
+ * (`siteItemsByKey`), and it replaces the one held before:
214
+ *
215
+ * - an item the backend holds as we sent it → its new version;
216
+ * - an item we sent that it kept for someone else → the version we held, for OUR content;
217
+ * - an item it holds that we did not send → the version we held, if we had seen it, and
218
+ * none if we had not (a unit of it is `foreign`);
219
+ * - an item we held that it no longer holds → none: the push deleted it, or the site did.
220
+ *
221
+ * ⛔ Until then only units were compared, and every other item's version was banked as
222
+ * the backend returned it — harmless only while none of them was ever sent.
223
+ *
224
+ * A service is compared by its switch and its whole `config` (`sameServiceRow`), every
225
+ * other item on the keys we sent (`writtenAsSent`).
226
+ *
227
+ * With either document missing there is nothing to compare, and with no versions
228
+ * returned (an older backend) nothing to bank: `itemVersions` is null, and the versions
229
+ * held stay as they were. The contract is that `finalized[].document` is always there.
230
+ *
231
+ * @param {object} p
232
+ * @param {object|null} p.sent - the site-content document this push sent
233
+ * @param {object|null} p.written - the backend's post-write copy of it
234
+ * @param {string|null} [p.version] - the entity's post-write version
235
+ * @param {Object<string,string>|null} [p.itemVersions] - the post-write version of every item
236
+ * @param {Object<string,string>} [p.held] - the versions this copy held before the push
179
237
  * @returns {{ version: string|null, itemVersions: Object<string,string>|null,
180
- * held: string[], kept: string[], foreign: string[] }} `held`: units the backend
238
+ * held: string[], kept: string[], foreign: string[] }} `itemVersions`: the versions
239
+ * this copy now holds, or null to leave them as they were; `held`: units the backend
181
240
  * holds as we sent them; `kept`: units it kept someone else's content for;
182
241
  * `foreign`: units it holds that we did not send. All paths.
183
242
  */
184
- export function heldTokens({ sent, written, version = null, itemVersions = null }) {
185
- if (!sent || !written) return { version, itemVersions, held: [], kept: [], foreign: [] }
186
- const ours = collectSiteUnits(sent)
187
- const theirs = collectSiteUnits(written)
188
- const uuidAt = collectUnitUuids(written)
189
- const units = new Set(Object.values(uuidAt))
243
+ export function heldTokens({ sent, written, version = null, itemVersions = null, held: before = {} }) {
244
+ if (!sent || !written) return { version, itemVersions: null, held: [], kept: [], foreign: [] }
245
+ const prior = before && typeof before === 'object' ? before : {}
246
+ const ours = siteItemsByKey(sent)
247
+ const theirs = siteItemsByKey(written)
248
+ const same = (key, a, b) => (key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b))
190
249
  const held = []
191
250
  const kept = []
192
251
  const foreign = []
193
252
  const items = {}
194
- for (const [path, uuid] of Object.entries(uuidAt)) {
195
- if (!ours.has(path)) {
196
- foreign.push(path)
253
+ for (const [key, item] of theirs) {
254
+ if (!item.uuid) continue
255
+ const unit = key.startsWith('unit:') ? key.slice('unit:'.length) : null
256
+ const mine = ours.get(key)
257
+ if (mine && same(key, mine.record, item.record)) {
258
+ if (unit) held.push(unit)
259
+ const token = itemVersions?.[item.uuid] ?? prior[item.uuid]
260
+ if (token) items[item.uuid] = token
197
261
  continue
198
262
  }
199
- if (!writtenAsSent(ours.get(path), theirs.get(path))) {
200
- kept.push(path)
201
- continue
202
- }
203
- held.push(path)
204
- if (itemVersions?.[uuid]) items[uuid] = itemVersions[uuid]
205
- }
206
- // A token for an item that is not a unit (a `queries` declaration) is never sent
207
- // back — the emit narrows preconditions to units (`withBaseVersion`) — so it is
208
- // banked as before: it can neither arm nor disarm anything.
209
- for (const [uuid, token] of Object.entries(itemVersions || {})) {
210
- if (!units.has(uuid)) items[uuid] = token
263
+ if (unit) (mine ? kept : foreign).push(unit)
264
+ if (prior[item.uuid]) items[item.uuid] = prior[item.uuid]
211
265
  }
212
266
  return {
213
267
  version: foreign.length ? null : version,
@@ -218,6 +272,26 @@ export function heldTokens({ sent, written, version = null, itemVersions = null
218
272
  }
219
273
  }
220
274
 
275
+ /**
276
+ * The versions a pull leaves this copy holding: one for every site-content item the
277
+ * pulled document carries, in every Section but `secrets` (`siteItemsByKey`) — the
278
+ * whole set, replacing what was held. A pull is how a copy sees the site, so an item
279
+ * the site no longer has is forgotten, and the next push does not send its version.
280
+ *
281
+ * @param {object|null} doc - the pulled site-content document
282
+ * @param {Object<string,string>|null} itemVersions - the pull manifest's `item_versions`
283
+ * @returns {Object<string,string>|null} null when there is nothing to bank (no document, or
284
+ * an older backend that sends no item versions)
285
+ */
286
+ export function pulledItemVersions(doc, itemVersions) {
287
+ if (!doc || !itemVersions || typeof itemVersions !== 'object') return null
288
+ const out = {}
289
+ for (const { uuid } of siteItemsByKey(doc).values()) {
290
+ if (uuid && typeof itemVersions[uuid] === 'string') out[uuid] = itemVersions[uuid]
291
+ }
292
+ return out
293
+ }
294
+
221
295
  // The files a unit projects to, under the site's own roots — `site.yml::paths` can
222
296
  // relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
223
297
  // to three files.
@@ -378,7 +452,9 @@ export function keepModels(siteDir, backend, models) {
378
452
  // ⭐ **Everything here is regenerable and losing it never produces a WRONG result** —
379
453
  // only a slower transfer or a round trip. That is the membership test; anything
380
454
  // failing it belongs in `sync.json` (which is where the uuid maps went on
381
- // 2026-09-20 — see `readItemUuids` below).
455
+ // 2026-09-20 — see `readItemUuids` below). ⚠️ One exception since 2026-10-07, and it
456
+ // is said where it lives: the item versions are what lets a push delete, so without
457
+ // them a dropped page is kept until the next pull (`readItemBaseVersions`).
382
458
  //
383
459
  // ⚠️ It was `sync-cache.json` and its header called it "a pure wire-efficiency
384
460
  // cache — NOT identity, the minted `$uuid` lives in the source files". That was
@@ -771,20 +847,35 @@ export function readBaseVersions(siteDir, backend) {
771
847
  }
772
848
 
773
849
  /**
774
- * Per-ITEM staleness tokens: `{ <record $uuid>: <opaque version> }`.
850
+ * Per-ITEM staleness tokens: `{ <item $uuid>: <opaque version> }` — one for every item
851
+ * of the site-content entity this copy has seen.
775
852
  *
776
853
  * The entity token gates the whole document, so a stale base on any one record
777
854
  * refuses the entire push — which fires on the common case of two people editing
778
- * different sections and teaches them to reach for `--force`. These gate per record
855
+ * different sections and teaches them to reach for `--force`. These gate per item
779
856
  * instead. Same contract as the entity token: opaque, cached, echoed, never parsed.
780
857
  *
781
- * Merged rather than replaced: a push carries only CHANGED entities, so a response
782
- * reports tokens for a subset of the site. Replacing would drop the tokens of every
783
- * record that wasn't in this package and silently degrade those to ungated.
858
+ * ⭐ AND THEY ARE WHAT LETS A PUSH DELETE (2026-10-07): the backend deletes only the
859
+ * items a push holds a version for, so every one held is sent, present or dropped
860
+ * (`withBaseVersion`). Hence the set is REPLACED, never merged — by a pull
861
+ * (`pulledItemVersions`) and by a push of the site-content lane (`heldTokens`) — and
862
+ * holds only items this copy has seen. ⚠️ The one thing losing this cache now costs that
863
+ * is not a round trip: until the next pull, a page or section the files dropped is kept
864
+ * on the site rather than deleted, since there is no version to send for it.
865
+ * ⛔ *It was merged until then, across both lanes, for a push that sent only units.*
784
866
  */
785
867
  export function readItemBaseVersions(siteDir, backend) {
786
868
  return readMap(siteDir, backend, 'itemBaseVersions')
787
869
  }
870
+ /**
871
+ * Replace the item versions this copy holds — a push's (`heldTokens`) or a pull's
872
+ * (`pulledItemVersions`) whole set. ⛔ Not a merge: an item that left the set must
873
+ * leave the map, or its version is sent again and asks the backend to delete it.
874
+ */
875
+ export function writeItemBaseVersions(siteDir, backend, versions) {
876
+ if (!versions || typeof versions !== 'object') return
877
+ updateSyncCache(siteDir, backend, { itemBaseVersions: { ...versions } })
878
+ }
788
879
  export function mergeItemBaseVersions(siteDir, backend, versions) {
789
880
  if (!versions || !Object.keys(versions).length) return
790
881
  // ⛔ The read names the backend. Without it the read keyed nothing, came back `{}`,
@@ -1933,7 +2024,11 @@ export async function pushSyncPackages({
1933
2024
  // for choosing between pulling and forcing.
1934
2025
  const detail = await explainStale()
1935
2026
  for (const line of detail) note(line)
1936
- if (!detail.length) {
2027
+ // ⭐ Items no file path names — a service, a query — come named by their Section's
2028
+ // key (`stale_keys`, `{ section, key: { name } }`), so say them by name.
2029
+ const keyed = describeStaleKeys(problem.stale_keys)
2030
+ for (const line of keyed) note(line)
2031
+ if (!detail.length && !keyed.length) {
1937
2032
  const stale = Array.isArray(problem.stale_entities)
1938
2033
  ? problem.stale_entities
1939
2034
  : []
@@ -2079,9 +2174,11 @@ export async function pushSyncPackages({
2079
2174
  // otherwise the next attempt would re-send lane 1 with a base the backend has
2080
2175
  // already moved past, and refuse a push the user just made.
2081
2176
  const newVersions = {}
2082
- // Per-item tokens from the SAME response. Keyed by record `$uuid` and flat
2083
- // across entities (the cache is a single map), unlike `newVersions`, which is
2084
- // keyed by entity uuid.
2177
+ // Per-item tokens from the SAME response — the site-content entity's, keyed by item
2178
+ // `$uuid`: the whole set this copy now holds (`heldTokens`), which replaces the one
2179
+ // it held. Null until that lane lands, which leaves the held set as it was.
2180
+ // ⛔ The records lane's are not kept: that lane is gated by its entity version, and
2181
+ // until 2026-10-07 they were merged into the same map, which was sent for nothing.
2085
2182
  //
2086
2183
  // ⛔ Both grains must be re-armed from the push, for one reason: a push writes,
2087
2184
  // so every token this clone holds for a record it just changed is now stale. Read
@@ -2094,22 +2191,19 @@ export async function pushSyncPackages({
2094
2191
  // exact shape one grain up (see delivery-lane.md "Both feed directions are
2095
2192
  // load-bearing"); the item grain had the same hole until backend `d7e46335`
2096
2193
  // started echoing `item_versions` here.
2097
- const newItemVersions = {}
2194
+ let heldItemVersions = null
2098
2195
  const harvest = (finalized) => {
2099
2196
  for (const f of finalized || []) {
2197
+ // NOT gated on `changed`: the backend pins "zero-write ⇒ version unmoved", so a
2198
+ // no-op resubmit hands back the value we already hold. (The site-content lane
2199
+ // filters first, `harvestSiteContent`.)
2100
2200
  if (f.uuid && f.version) newVersions[f.uuid] = f.version
2101
- // NOT gated on `changed` — same rule as the entity token: the backend pins
2102
- // "zero-write ⇒ version unmoved", so a no-op resubmit hands back the value we
2103
- // already hold. (The site-content lane filters first, `harvestSiteContent`.)
2104
- // An older backend omits the field entirely, which leaves the cached tokens
2105
- // alone and degrades to the entity grain, exactly as before.
2106
- if (f.itemVersions) Object.assign(newItemVersions, f.itemVersions)
2107
2201
  }
2108
2202
  }
2109
2203
  // Both grains land together, at every point the old code banked the entity one.
2110
2204
  const mergeHarvested = () => {
2111
2205
  mergeBaseVersions(siteDir, client.origin, newVersions)
2112
- mergeItemBaseVersions(siteDir, client.origin, newItemVersions)
2206
+ if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
2113
2207
  }
2114
2208
  // ⛔ The site-content lane banks only the tokens for content this copy holds — the
2115
2209
  // document we sent against the one the backend stored (`heldTokens`, and the two
@@ -2124,9 +2218,11 @@ export async function pushSyncPackages({
2124
2218
  sent: sentSiteDoc,
2125
2219
  written: f.document,
2126
2220
  version: f.version,
2127
- itemVersions: f.itemVersions
2221
+ itemVersions: f.itemVersions,
2222
+ held: heldItemVersions ?? readItemBaseVersions(siteDir, client.origin)
2128
2223
  })
2129
- harvest([{ ...f, version: held.version, itemVersions: held.itemVersions }])
2224
+ harvest([{ ...f, version: held.version }])
2225
+ if (held.itemVersions) heldItemVersions = held.itemVersions
2130
2226
  heldUnits.push(...held.held)
2131
2227
  notHeld.kept.push(...held.kept)
2132
2228
  notHeld.foreign.push(...held.foreign)
@@ -2153,6 +2249,17 @@ export async function pushSyncPackages({
2153
2249
  if (siteFinalizedDoc) {
2154
2250
  const recordIds = collectQueryUuids(siteFinalizedDoc)
2155
2251
  if (Object.keys(recordIds).length) writeQueryUuids(siteDir, client.origin, recordIds)
2252
+ // ⭐ THE SERVICES THIS COPY NOW HOLDS, `{ name: $uuid }`: those the push stated and
2253
+ // those held before, as the site returned them (`heldServices`). A service added on
2254
+ // the site since this copy's last pull is left out — held, the next push would state
2255
+ // it off, switching off a service nobody here has seen. Replaced, not merged.
2256
+ updateBackendState(siteDir, client.origin, {
2257
+ services: heldServices({
2258
+ written: siteFinalizedDoc.services,
2259
+ sent: Array.isArray(sentSiteDoc?.services) ? sentSiteDoc.services : [],
2260
+ prior: readBackendState(siteDir, client.origin).services
2261
+ })
2262
+ })
2156
2263
  }
2157
2264
  // Re-base the page attribution: our emitted document and the backend's post-write
2158
2265
  // copy of it are the two sides' new agreed state.
@@ -19,6 +19,7 @@ import {
19
19
  } from '@uniweb/build'
20
20
  import { loadDeployYml, AGENTS_KEYS } from '@uniweb/build/site'
21
21
  import { listAdapters } from '@uniweb/build/hosts'
22
+ import { RUNTIME_KEYS } from '@uniweb/build/uwx'
22
23
  import { getCliVersion } from '../versions.js'
23
24
  import { readAgentsVersion } from '../utils/agents-stamp.js'
24
25
  import { writeJsonPreservingStyle } from '../utils/json-file.js'
@@ -388,17 +389,12 @@ function nearestKnownKey(input, known) {
388
389
  }
389
390
 
390
391
  /**
391
- * The options `tracking:` actually has a reader.
392
- *
393
- * ⚠️ **Kept here rather than imported, because nothing at runtime enumerates
394
- * them** — `wireTracker` reads named properties off the resolved declaration, it
395
- * does not iterate a list. So there is no existing array to import and this
396
- * duplicates nothing. It does mean the list can drift: the readers are
397
- * `runtime/src/wire-foundation.js::wireTracker` (`consent`, `scripts`, `debug`)
398
- * and `core/src/services.js::readEndpoint` (`endpoint`). Add a key there, add it
399
- * here.
392
+ * The options `services.tracking` has a reader for — the build's list, which also
393
+ * decides what a push sends the host as its settings (`RUNTIME_KEYS`, `@uniweb/build`).
394
+ * ⛔ *Until 2026-10-06 doctor kept a copy of its own, which had drifted: it lacked
395
+ * `emit` and `flushIntervalMs`, which `wireTracker` reads.*
400
396
  */
401
- const TRACKING_KEYS = ['endpoint', 'consent', 'scripts', 'debug']
397
+ const TRACKING_KEYS = RUNTIME_KEYS.tracking
402
398
 
403
399
  /**
404
400
  * Spellings that read as an ATTEMPT to require consent but do not require it.
@@ -414,10 +410,10 @@ const TRACKING_KEYS = ['endpoint', 'consent', 'scripts', 'debug']
414
410
  const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true', 'yes', 'on', '1'])
415
411
 
416
412
  /**
417
- * `tracking:` — flag the keys and values that are carried and never acted on.
413
+ * `services.tracking` — flag the keys and values that are carried and never acted on.
418
414
  *
419
- * The block is forwarded to the host as opaque data and resolved at render, so
420
- * nothing downstream rejects a mistake in it. Every error here therefore fails
415
+ * A key the site's tracking does not read goes to the host as a setting, and the
416
+ * rest is resolved at render, so nothing downstream rejects a mistake in it. Every error here therefore fails
421
417
  * the same way: **silently, at a visitor's browser, as an absence** — which is
422
418
  * also exactly what a site that configured nothing looks like. There is no
423
419
  * symptom to notice and nothing to grep for.
@@ -425,8 +421,8 @@ const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true',
425
421
  * ⭐ That is the whole argument for checking it at `doctor` time: it is the only
426
422
  * moment in the chain where a person who can fix it is looking at it.
427
423
  *
428
- * A bare `tracking: <url>` string is the documented shorthand and carries no
429
- * options — there is nothing in it to be wrong.
424
+ * An address, `true` or `false` carries no options — there is nothing in it to be
425
+ * wrong.
430
426
  */
431
427
  /**
432
428
  * `uniweb doctor` — is `package.json` behind what the build derived?
@@ -806,8 +802,8 @@ export function checkUngatedServiceControls({ foundationName, folderName, srcDir
806
802
  }
807
803
 
808
804
  export function checkTrackingBlock({ siteName, siteYml, issues }) {
809
- const tracking = siteYml?.tracking
810
- if (tracking === undefined || tracking === null) return
805
+ const tracking = siteYml?.services?.tracking
806
+ if (tracking === undefined || tracking === null || typeof tracking === 'boolean') return
811
807
  if (typeof tracking === 'string') return
812
808
 
813
809
  if (typeof tracking !== 'object' || Array.isArray(tracking)) {
@@ -816,9 +812,9 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
816
812
  id,
817
813
  type: 'warning',
818
814
  site: siteName,
819
- message: `site.yml: \`tracking:\` should be an endpoint string, or a map of options`
815
+ message: `site.yml: \`services.tracking\` should be true, false, an address, or a map of options`
820
816
  })
821
- warn(`[${id}] ${siteName}: \`tracking:\` is neither an endpoint string nor a map of options.`)
817
+ warn(`[${id}] ${siteName}: \`services.tracking\` is neither true, false, an address nor a map of options.`)
822
818
  return
823
819
  }
824
820
 
@@ -830,10 +826,10 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
830
826
  id,
831
827
  type: 'warning',
832
828
  site: siteName,
833
- message: `site.yml: \`tracking:\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
829
+ message: `site.yml: \`services.tracking\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
834
830
  })
835
831
  warn(
836
- `[${id}] ${siteName}: \`tracking:\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
832
+ `[${id}] ${siteName}: \`services.tracking\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
837
833
  .map((k) => `'${k}'`)
838
834
  .join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
839
835
  )
@@ -842,7 +838,7 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
842
838
  if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
843
839
  }
844
840
  log(
845
- ` ${colors.dim}Carried to the host as opaque data and never read. Known: ${TRACKING_KEYS.join(', ')}.${colors.reset}`
841
+ ` ${colors.dim}Not an option the site's tracking reads — sent to your host as a setting. Known: ${TRACKING_KEYS.join(', ')}.${colors.reset}`
846
842
  )
847
843
  }
848
844
 
@@ -869,9 +865,9 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
869
865
  id,
870
866
  type: 'warning',
871
867
  site: siteName,
872
- message: `site.yml: \`tracking.consent:\` is ${shown} — only the exact value \`required\` turns the gate on`
868
+ message: `site.yml: \`services.tracking.consent\` is ${shown} — only the exact value \`required\` turns the gate on`
873
869
  })
874
- warn(`[${id}] ${siteName}: \`tracking.consent:\` is ${shown}; the gate is OFF.`)
870
+ warn(`[${id}] ${siteName}: \`services.tracking.consent\` is ${shown}; the gate is OFF.`)
875
871
  log(
876
872
  ` ${colors.dim}Only \`consent: required\` holds events until a visitor answers. Anything else${colors.reset}`
877
873
  )
@@ -894,10 +890,10 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
894
890
  id,
895
891
  type: 'warning',
896
892
  site: siteName,
897
- message: `site.yml: \`tracking.scripts:\` has ${bad.length} ${bad.length === 1 ? 'entry' : 'entries'} with no URL`
893
+ message: `site.yml: \`services.tracking.scripts\` has ${bad.length} ${bad.length === 1 ? 'entry' : 'entries'} with no URL`
898
894
  })
899
895
  warn(
900
- `[${id}] ${siteName}: ${bad.length} \`tracking.scripts:\` ${bad.length === 1 ? 'entry has' : 'entries have'} no URL and will not load.`
896
+ `[${id}] ${siteName}: ${bad.length} \`services.tracking.scripts\` ${bad.length === 1 ? 'entry has' : 'entries have'} no URL and will not load.`
901
897
  )
902
898
  log(
903
899
  ` ${colors.dim}An entry is a URL, or an object with a \`src\`. Anything else is dropped silently.${colors.reset}`
@@ -927,19 +923,19 @@ function editDistance(a, b) {
927
923
  /**
928
924
  * A site whose content declares a form needs somewhere to send it.
929
925
  *
930
- * Two things can supply that: `submit:` in site.yml, or the host at serve time.
931
- * Doctor can only see the first — so it warns only when *nothing* could
932
- * plausibly supply one: no declaration, and no deploy target that would put a
933
- * host in the picture. On a site bound to a host, having no `submit:` is the
934
- * correct configuration, and warning there would nag exactly the people who got
935
- * it right.
926
+ * Two things can supply that: `services.submit` in site.yml — asking the host for
927
+ * form handling, or naming the site's own — or the host at serve time. Doctor can
928
+ * only see the first — so it warns only when *nothing* could plausibly supply one:
929
+ * no entry, and no deploy target that would put a host in the picture. On a site
930
+ * bound to a host, an absent entry can be the correct configuration, and warning
931
+ * there would nag exactly the people who got it right.
936
932
  *
937
933
  * The consequence of being wrong in the other direction is what justifies the
938
- * check at all: a form with no destination renders disabled, which is visible
939
- * on the page but easy to ship without noticing.
934
+ * check at all: a form with no destination is not drawn, which is easy to ship
935
+ * without noticing.
940
936
  */
941
937
  export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issues }) {
942
- if (siteYml?.submit) return
938
+ if (siteYml?.services?.submit) return
943
939
 
944
940
  const forms = findFormContent(sitePath, siteYml)
945
941
  if (forms.length === 0) return
@@ -966,7 +962,7 @@ export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issue
966
962
  for (const f of forms.slice(0, 5)) log(` • ${f}`)
967
963
  if (n > 5) log(` ${colors.dim}…and ${n - 5} more${colors.reset}`)
968
964
  log(
969
- ` Set ${colors.green}submit${colors.reset} in site.yml if you are providing the endpoint.`
965
+ ` Ask your host for form handling — ${colors.green}services: { submit: true }${colors.reset} — or name your own: ${colors.green}services: { submit: https://… }${colors.reset}.`
970
966
  )
971
967
  log(
972
968
  ` ${colors.dim}A host that handles submissions supplies one itself — this check is skipped once a deploy target is configured.${colors.reset}`
@@ -57,7 +57,9 @@ import { emitSyncPackages } from '@uniweb/build/uwx'
57
57
  import {
58
58
  bankLanguages,
59
59
  reconcile,
60
- settleServices
60
+ announceServices,
61
+ foundationSupports,
62
+ recordsNotAsked
61
63
  } from '../backend/service-request.js'
62
64
  import { isSiteRelativeExtensionUrl } from '@uniweb/build'
63
65
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
@@ -804,26 +806,16 @@ export async function publish(args = []) {
804
806
  const injectInfo = {
805
807
  ...(fnd.ref ? { foundation: fnd.ref } : {})
806
808
  }
807
- // ⭐ WHAT THE SITE HAS — read before the push, for the two requests a publish
808
- // carries: the services (`site.yml::services`) and the language selection. Only
809
- // this read sees a decision the owner made in the app since this clone last synced.
810
- // It reads this backend's site, from sync.json; a never-synced site has none.
809
+ // ⭐ WHAT THE SITE HAS — read before the push, for the language selection. Only this
810
+ // read sees a decision the owner made in the app since this clone last synced. It
811
+ // reads this backend's site, from sync.json; a never-synced site has none.
811
812
  const boundUuid = readBackendState(siteDir, client.origin).site?.uuid || null
812
813
  const status = boundUuid ? await client.siteStatus(boundUuid) : null
813
814
 
814
- // ⭐ THE SERVICES: what the owner changed in `site.yml::services` is sent, applied
815
- // over the site's own list; what the site changed is kept and offered into the file;
816
- // where both changed, the owner is asked (`settleServices`). An ask whose decision is
817
- // still open is never sent over the decision the site holds.
818
- const services = await settleServices({
819
- client,
820
- siteDir,
821
- siteYml,
822
- status,
823
- interactive: !isNonInteractive(args),
824
- confirm,
825
- say
826
- })
815
+ // ⭐ THE SERVICES are stated by the push itself — the file's, and off for each held
816
+ // one it no longer lists (`statedServices`) — and the backend decides per service.
817
+ // Said here: what the file asks that will not be sent as written.
818
+ announceServices({ siteYml, say, supports: await foundationSupports(siteDir, siteYml) })
827
819
 
828
820
  // ⭐ THE LANGUAGE SELECTION IS A REQUEST TOO, and it is the one that costs.
829
821
  //
@@ -873,8 +865,6 @@ export async function publish(args = []) {
873
865
  backend: client.origin,
874
866
  // The keys this deployment's Sections take — see deploymentFields.
875
867
  ...fields,
876
- // The `services` Section as settled above — or withheld.
877
- ...services.emit,
878
868
  // Placement identity for the folder — see writeFolderItemUuids.
879
869
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
880
870
  // Identity for the records' list items — see readRecordItemUuids.
@@ -908,6 +898,10 @@ export async function publish(args = []) {
908
898
  if (refuseUnsendableRecords(pkg.refusals, { error: say.err, note: say.dim })) {
909
899
  return { exitCode: 1 }
910
900
  }
901
+ // ⭐ `records` is what delivers the records the pages show on the published site — said
902
+ // here, at publish, and never at push: syncing records does not depend on it.
903
+ const notAsked = recordsNotAsked({ siteYml, shown: pkg.recordsShown })
904
+ if (notAsked) say.warn(notAsked)
911
905
  const report = {
912
906
  info: (m) => say.info(m),
913
907
  note: (m) => say.dim(m),
@@ -921,9 +915,6 @@ export async function publish(args = []) {
921
915
  report
922
916
  })
923
917
  if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
924
- // The push stored what it sent: the services agreed on are recorded now, whether or
925
- // not the site then goes live.
926
- services.after()
927
918
  const siteUuid = pushResult.boundSiteUuid
928
919
  if (!siteUuid) {
929
920
  say.err('Push did not yield a site uuid — cannot go live.')
@@ -95,7 +95,8 @@ import {
95
95
  rebankSyncHashes,
96
96
  writeQueryUuids,
97
97
  mergeBaseVersions,
98
- mergeItemBaseVersions,
98
+ writeItemBaseVersions,
99
+ pulledItemVersions,
99
100
  writeUnitBases,
100
101
  writeItemUuids,
101
102
  writeFolderItemUuids
@@ -887,6 +888,9 @@ export async function pull(args = [], deps = {}) {
887
888
  // The Models the pulled site's queries name, as the push qualified them — the re-bank
888
889
  // below resolves with them too (see there).
889
890
  let pulledQueryModels = []
891
+ // The versions this copy holds after the pull: every site-content item the pulled
892
+ // document carries (`pulledItemVersions`), banked below once the files have taken it.
893
+ let pulledVersions = null
890
894
  if (content && !content.notModified) {
891
895
  const siteDoc =
892
896
  content.docs &&
@@ -905,6 +909,7 @@ export async function pull(args = [], deps = {}) {
905
909
  // The next push re-establishes it; until then our side reports as unknown,
906
910
  // which is honest rather than wrong.
907
911
  writeUnitBases(siteDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
912
+ pulledVersions = pulledItemVersions(siteDoc, content.itemVersions)
908
913
  // Per-item identity for the next push. Without it the backend reads our
909
914
  // records as new and re-mints every page and section row.
910
915
  writeItemUuids(siteDir, client.origin, collectUnitUuids(siteDoc))
@@ -1216,7 +1221,13 @@ export async function pull(args = [], deps = {}) {
1216
1221
  // Records this pull did not place were not taken, so neither is their lane.
1217
1222
  if (lane === folderLane && recordsNotPlaced) continue
1218
1223
  mergeBaseVersions(siteDir, client.origin, lane.versions)
1219
- mergeItemBaseVersions(siteDir, client.origin, lane.itemVersions)
1224
+ }
1225
+ // ⭐ The site-content items' versions REPLACE what this copy held: a pull is how it
1226
+ // sees the site, and a version kept for an item the site no longer has would be sent
1227
+ // again, asking the backend to delete it. The folder lane's are not kept — that lane
1228
+ // is gated by its entity version. (Both were merged into one map until 2026-10-07.)
1229
+ if (pulledVersions && content && !content.notModified && !content.refused) {
1230
+ writeItemBaseVersions(siteDir, client.origin, pulledVersions)
1220
1231
  }
1221
1232
  // Persist the ETags so the next pull is conditional (304 when unchanged). The
1222
1233
  // folder's is DROPPED when records were not placed, so the next pull fetches
@@ -75,7 +75,7 @@ import {
75
75
  describeSyncedElsewhere
76
76
  } from '../utils/site-identity.js'
77
77
  import { confirm, isNonInteractive } from '../utils/interactive.js'
78
- import { settleServices } from '../backend/service-request.js'
78
+ import { announceServices, foundationSupports } from '../backend/service-request.js'
79
79
  import { guardEmptyRecords } from '../utils/records-guard.js'
80
80
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
81
81
  import {
@@ -521,20 +521,10 @@ export async function push(args = [], deps = {}) {
521
521
  ref: siteYml?.foundation
522
522
  })
523
523
  }
524
- // ⭐ THE SERVICES `site.yml` ASKS FOR — what the owner changed is sent over the site's
525
- // own list; what the site changed is kept and offered into the file; where both
526
- // changed, the owner is asked. Shared with `uniweb publish` (`settleServices`).
527
- // Offline for `-o` and `--dry-run`: nothing is read, and nothing is recorded.
528
- const offline = Boolean(output) || dryRun
529
- const services = await settleServices({
530
- client,
531
- siteDir,
532
- siteYml,
533
- offline,
534
- interactive: !offline && !isNonInteractive(args),
535
- confirm,
536
- say: { info, warn, dim: note, ok: success }
537
- })
524
+ // ⭐ THE SERVICES `site.yml` ASKS FOR are stated by the emit — the file's, and off for
525
+ // each held one it no longer lists (`statedServices`) — and the backend decides per
526
+ // service. Said here: what the file asks that will not be sent as written.
527
+ announceServices({ siteYml, say: { warn }, supports: await foundationSupports(siteDir, siteYml) })
538
528
  const emitOptions = {
539
529
  backend: client.origin,
540
530
  // Placement identity for the folder — see writeFolderItemUuids.
@@ -570,9 +560,7 @@ export async function push(args = [], deps = {}) {
570
560
  itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
571
561
  }),
572
562
  ...(assetRewrite ? { assetRewrite } : {}),
573
- ...(assetIds ? { assetIds } : {}),
574
- // The `services` Section as settled above — or withheld.
575
- ...services.emit
563
+ ...(assetIds ? { assetIds } : {})
576
564
  }
577
565
  let pkg
578
566
  try {
@@ -601,8 +589,6 @@ export async function push(args = [], deps = {}) {
601
589
 
602
590
  // Nothing changed since the last push — the backend is already up to date.
603
591
  if (totalEntities === 0) {
604
- // The site already holds what this copy has, so what was settled is agreed.
605
- if (!offline) services.after()
606
592
  success(
607
593
  `Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`
608
594
  )
@@ -663,7 +649,6 @@ export async function push(args = [], deps = {}) {
663
649
  }
664
650
  })
665
651
  if (result.exitCode !== 0) return { exitCode: result.exitCode }
666
- services.after()
667
652
  success(
668
653
  `Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
669
654
  (result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-06T19:21:14.306Z",
3
+ "generatedAt": "2026-10-07T11:39:54.164Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.10",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.77.0",
13
+ "version": "0.78.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -117,7 +117,7 @@
117
117
  "deps": []
118
118
  },
119
119
  "@uniweb/templates": {
120
- "version": "0.17.14",
120
+ "version": "0.18.0",
121
121
  "path": "framework/templates",
122
122
  "deps": []
123
123
  },
@@ -127,7 +127,7 @@
127
127
  "deps": []
128
128
  },
129
129
  "@uniweb/unipress": {
130
- "version": "0.10.21",
130
+ "version": "0.10.22",
131
131
  "path": "framework/unipress",
132
132
  "deps": [
133
133
  "@uniweb/build",