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 +5 -5
- package/partials/agents.md +89 -59
- package/src/backend/service-request.js +87 -145
- package/src/backend/site-sync.js +154 -47
- package/src/commands/doctor.js +32 -36
- package/src/commands/publish.js +14 -23
- package/src/commands/pull.js +13 -2
- package/src/commands/push.js +6 -21
- package/src/framework-index.json +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "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": {
|
package/partials/agents.md
CHANGED
|
@@ -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
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
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
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
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,
|
|
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
|
|
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
|
|
2014
|
-
host handles submissions, that is the destination
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
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
|
|
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
|
|
2120
|
-
|
|
2121
|
-
submit:
|
|
2122
|
-
|
|
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
|
-
>
|
|
2142
|
-
>
|
|
2143
|
-
>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2221
|
+
services:
|
|
2222
|
+
tracking:
|
|
2223
|
+
endpoint: /collect
|
|
2224
|
+
consent: required
|
|
2214
2225
|
```
|
|
2215
2226
|
|
|
2216
|
-
A host may also supply one
|
|
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
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
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
|
-
|
|
2280
|
-
|
|
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
|
|
2287
|
-
|
|
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
|
-
|
|
2544
|
-
|
|
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`
|
|
2554
|
-
|
|
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`
|
|
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
|
|
2665
|
-
submit: false
|
|
2666
|
-
|
|
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
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
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]*.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
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;
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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.
|
|
159
|
-
* @param {
|
|
160
|
-
* @param {
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
: '
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
}
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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[] }} `
|
|
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
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
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 [
|
|
195
|
-
if (!
|
|
196
|
-
|
|
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 (
|
|
200
|
-
|
|
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: `{ <
|
|
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
|
|
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
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
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
|
-
|
|
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
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
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 =
|
|
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
|
|
413
|
+
* `services.tracking` — flag the keys and values that are carried and never acted on.
|
|
418
414
|
*
|
|
419
|
-
*
|
|
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
|
-
*
|
|
429
|
-
*
|
|
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
|
|
815
|
+
message: `site.yml: \`services.tracking\` should be true, false, an address, or a map of options`
|
|
820
816
|
})
|
|
821
|
-
warn(`[${id}] ${siteName}: \`tracking
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
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
|
|
939
|
-
*
|
|
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
|
-
`
|
|
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}`
|
package/src/commands/publish.js
CHANGED
|
@@ -57,7 +57,9 @@ import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
|
57
57
|
import {
|
|
58
58
|
bankLanguages,
|
|
59
59
|
reconcile,
|
|
60
|
-
|
|
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
|
|
808
|
-
//
|
|
809
|
-
// this
|
|
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
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
|
|
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.')
|
package/src/commands/pull.js
CHANGED
|
@@ -95,7 +95,8 @@ import {
|
|
|
95
95
|
rebankSyncHashes,
|
|
96
96
|
writeQueryUuids,
|
|
97
97
|
mergeBaseVersions,
|
|
98
|
-
|
|
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
|
-
|
|
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
|
package/src/commands/push.js
CHANGED
|
@@ -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 {
|
|
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
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
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(', ')}` : '')
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
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.
|
|
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.
|
|
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.
|
|
130
|
+
"version": "0.10.22",
|
|
131
131
|
"path": "framework/unipress",
|
|
132
132
|
"deps": [
|
|
133
133
|
"@uniweb/build",
|