uniweb 0.33.1 → 0.34.1

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.33.1",
3
+ "version": "0.34.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.13.1",
45
- "@uniweb/kit": "^0.14.0",
46
- "@uniweb/semantic-parser": "^1.3.1",
47
- "@uniweb/runtime": "^0.13.1"
44
+ "@uniweb/runtime": "^0.13.3",
45
+ "@uniweb/kit": "^0.15.0",
46
+ "@uniweb/core": "^0.14.1",
47
+ "@uniweb/semantic-parser": "^1.3.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.29.1",
51
50
  "@uniweb/content-reader": "^1.2.4",
52
- "@uniweb/semantic-parser": "^1.3.1"
51
+ "@uniweb/semantic-parser": "^1.3.1",
52
+ "@uniweb/build": "^0.30.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -770,24 +770,36 @@ seo:
770
770
 
771
771
  That emits a `Content-Signal:` line in `robots.txt`. Declare only what you mean — an omitted signal says nothing, which is not the same as saying no.
772
772
 
773
- ### Collections and dynamic routes
773
+ ### Records, queries and dynamic routes
774
774
 
775
- Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **Collections are the other kind: repeating content managed as a set of files**, one item per file, that pages pull from. Blog posts, team members, products, case studies, bibliographies.
775
+ Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **The other kind is a set of records: repeating content managed as one file per item**, that pages pull from. Blog posts, team members, products, case studies, bibliographies.
776
776
 
777
- They're delivered through the **same data pipeline as remote APIs**, so from a component's point of view a locally-authored collection and a backend-served one look identical. Whether the records live in files or behind an endpoint is a transport concern (see *Fetching from other sources* in Part 4).
777
+ It is a small database, not a page tree. Three things, deliberately separate:
778
778
 
779
779
  ```
780
780
  site/
781
781
  ├── pages/
782
- ├── collections/
783
- │ ├── articles/
782
+ ├── entities/ # your stored things. The folder names their data schema
783
+ │ ├── article/
784
784
  │ │ ├── getting-started.md
785
785
  │ │ └── design-tips.md
786
- │ └── team/
786
+ │ └── person/
787
787
  │ └── alice.yml
788
+ ├── records.yml # what is PUBLISHED
789
+ ├── queries.yml # how content is REACHED
788
790
  └── site.yml
789
791
  ```
790
792
 
793
+ **`entities/{schema}/` — the pool.** The folder names the data schema and nothing else:
794
+
795
+ | on disk | schema |
796
+ |---|---|
797
+ | `entities/article/…` | `@/article` — your foundation's own |
798
+ | `entities/std/person/…` | `@std/person` — the shared standard set |
799
+ | `entities/acme/project/…` | `@acme/project` — an org's |
800
+
801
+ Keep those folders flat: `entities/article/design-tips.md` works; `entities/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records.yml` instead.
802
+
791
803
  **Four formats, one shape.** All of these produce the same records at runtime:
792
804
 
793
805
  | Format | Best for | Notes |
@@ -799,62 +811,85 @@ site/
799
811
 
800
812
  **One record per file, or many.** A single mapping at the top of a file makes one record and the filename stem becomes its `slug`. A top-level array (YAML/JSON) or a multi-entry `.bib` makes many, each carrying its own `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
801
813
 
802
- **Keep collection folders flat.** `collections/articles/design-tips.md` works; `collections/articles/2025/design-tips.md` does not.
803
-
804
814
  Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `published`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. `published: false` hides an item without deleting it; items with no `published` field are included.
805
815
 
806
- **Declare each collection in `site.yml`:**
816
+ **`records.yml` listing an entity is what publishes it.** A file in `entities/` exists; listing it here makes it a record. Anything you leave out is a draft — no flag to set. **The common case is three lines:**
807
817
 
808
818
  ```yaml
809
- collections:
810
- articles: collections/articles # simple form — just point at the folder
819
+ - article/*.md
820
+ - person/*.yml
821
+ ```
822
+
823
+ A bare string is a path under `entities/`, naming one file or matching many.
824
+
825
+ ⚠️ **An empty `records.yml` is not the same as having none.** No file means "leave the published set alone". An empty file means "the folder holds nothing", which REMOVES what is published. The CLI asks before it does that.
826
+
827
+ **Structure is for querying, not for navigation.** Add a `folder:` only when a query needs to ask for a *slice* of the pool rather than all of it — most sites never do:
811
828
 
812
- team: # extended form
813
- path: collections/team # or `url:` for a remote source
814
- sort: order asc # `date desc`, `title asc`, …
815
- where: { published: { ne: false } } # a predicate — see authoring/predicates.md
816
- limit: 100
829
+ ```yaml
830
+ - article/2026-*.md
831
+ - folder: archive
832
+ label: The Archive
833
+ records:
834
+ - article/2025-*.md
835
+ ```
836
+
837
+ A query then slices it with `where: { path: { under: 'archive' } }`. An entity belongs to one folder; if you want a computed subset, that is a query, not a second placement.
838
+
839
+ **`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the published records of that schema are its rows:
840
+
841
+ ```yaml
842
+ recent:
843
+ schema: '@/article'
844
+ sort: date desc # `date desc`, `title asc`, …
845
+ limit: 10
846
+
847
+ team:
848
+ schema: '@std/person'
849
+ where: { published: { ne: false } } # a predicate — see authoring/predicates.md
817
850
  ```
818
851
 
819
- **Show a collection on a page** with `data:` in `page.yml` (the whole collection), or `fetch:` in a section's frontmatter (a subset):
852
+ You can keep the same declarations under `queries:` in `site.yml` instead, if you would rather have one file.
853
+
854
+ **Show a query on a page** with `data:` in `page.yml` (the whole result), or `fetch:` in a section's frontmatter (a subset):
820
855
 
821
856
  ```yaml
822
857
  # pages/blog/page.yml | # a section on the homepage
823
858
  title: Blog | ---
824
- data: articles | type: ArticleTeaser
825
- | fetch: { collection: articles, limit: 3, sort: date desc }
859
+ data: recent | type: ArticleTeaser
860
+ | fetch: { query: recent, limit: 3 }
826
861
  | ---
827
862
  ```
828
863
 
829
- **Give each item its own page with a `[slug]/` folder** under the list page:
864
+ **Give each record its own page with a `[slug]/` folder** under the list page:
830
865
 
831
866
  ```
832
867
  pages/blog/
833
- ├── page.yml # title: Blog / data: articles
868
+ ├── page.yml # title: Blog / data: recent
834
869
  ├── list.md
835
870
  └── [slug]/
836
871
  ├── page.yml
837
- └── article.md # just `type: Article` — the item arrives automatically
872
+ └── article.md # just `type: Article` — the record arrives automatically
838
873
  ```
839
874
 
840
- `collections/articles/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
875
+ `entities/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
841
876
 
842
- > **The record arrives as a single-element array under the collection key** — `content.data.articles[0]`, not `content.data.article`. The runtime never coerces it to an object and never synthesizes a singular key. See *Data* in Part 4.
877
+ > **The record arrives as a single-element array under the query key** — `content.data.recent[0]`, not `content.data.article`. The runtime never coerces it to an object and never synthesizes a singular key. See *Data* in Part 4.
843
878
 
844
- **Two options for bigger collections:**
879
+ **Two options for bigger sets:**
845
880
 
846
- `deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch on demand via `useEntityDetail`. For a remote collection, add `detailUrl: /api/articles/{slug}` so the framework knows how to fetch one full record; file-based collections emit per-record files at `/data/<name>/<slug>.json` and need no configuration.
881
+ `deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch on demand via `useEntityDetail`. For a remote source, add `detailUrl: /api/articles/{slug}` so the framework knows how to fetch one full record; file-based records emit per-record files at `/data/<name>/<slug>.json` and need no configuration.
847
882
 
848
883
  `queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
849
884
 
850
885
  ```yaml
851
- collections:
852
- members:
853
- path: collections/members
854
- queryable:
855
- department: { type: enum, label: Department, options: [biology, physics, chemistry] }
856
- tenured: { type: boolean, label: Tenured }
857
- start_year: { type: range, label: Start year, min: 1800, max: 2025 }
886
+ # queries.yml
887
+ members:
888
+ schema: '@std/person'
889
+ queryable:
890
+ department: { type: enum, label: Department, options: [biology, physics, chemistry] }
891
+ tenured: { type: boolean, label: Tenured }
892
+ start_year: { type: range, label: Start year, min: 1800, max: 2025 }
858
893
  ```
859
894
 
860
895
  The site declares the *surface*; the foundation reads the metadata, renders matching controls (dropdown, toggle, slider), and composes the predicate when the reader picks values.
@@ -1745,13 +1780,13 @@ A foundation can route a scope to a plain folder of schema files instead of a pa
1745
1780
  ```yaml
1746
1781
  # pages/blog/page.yml
1747
1782
  fetch:
1748
- collection: articles
1783
+ query: recent
1749
1784
  where: { published: true, tags: featured }
1750
1785
  sort: date desc
1751
1786
  limit: 3
1752
1787
  ```
1753
1788
 
1754
- **Lean lists with `deferred:`.** Collections with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]` in `site.yml`. The cascade payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json` (file-based collections) or fetched from an author-declared `detailUrl:` (API-backed). On dynamic-route pages the focused record's full data is delivered automatically; elsewhere components fetch on demand via `useEntityDetail`. The hook is safe to call on any collection: when there is no separate detail source it returns the record you passed in, because nothing was stripped from it.
1789
+ **Lean lists with `deferred:`.** A query over records with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]`. The cascade payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json` (file-based records) or fetched from an author-declared `detailUrl:` (API-backed). On dynamic-route pages the focused record's full data is delivered automatically; elsewhere components fetch on demand via `useEntityDetail`. The hook is safe to call on any query: when there is no separate detail source it returns the record you passed in, because nothing was stripped from it.
1755
1790
 
1756
1791
  **Component-side fetching.** When a component genuinely needs to fetch on its own (a search box, "load more", a lazy popover), use the kit hooks — `useFetched`, `useCacheEntry`, `useEntityDetail`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
1757
1792
 
@@ -1766,7 +1801,7 @@ A site isn't limited to file-based collections. The `fetcher:` block in `site.ym
1766
1801
  fetcher:
1767
1802
  baseUrl: https://api.example.com
1768
1803
  headers: { X-Tenant: acme }
1769
- envelope: { collection: data.items, item: data.article, error: errors.0.message }
1804
+ envelope: { list: data.items, item: data.article, error: errors.0.message }
1770
1805
 
1771
1806
  supports: [where, limit, sort] # which operators the source evaluates natively
1772
1807
 
@@ -2383,12 +2418,12 @@ Running `extract` before a build is the usual first mistake — it reads the com
2383
2418
 
2384
2419
  ```
2385
2420
  locales/freeform/es/pages/about/hero.md # by page route
2386
- locales/freeform/es/collections/articles/x.md # collections work too
2421
+ locales/freeform/es/entities/article/x.md # records work too
2387
2422
  ```
2388
2423
 
2389
2424
  These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
2390
2425
 
2391
- **Collections translate in the same `extract` run**, into their own manifest at `locales/collections/manifest.json`.
2426
+ **Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it.
2392
2427
 
2393
2428
  **Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
2394
2429
 
@@ -105,34 +105,39 @@ export function resolveBackendOrigin(flag, { siteScope, siteBackend } = {}) {
105
105
  }
106
106
 
107
107
  /**
108
- * The fallback capability doc when `GET /dev/config` is absent or unreachable
109
- * (an older backend, or no backend at all). Keeps the client non-breaking: the
110
- * bases mirror a self-serve dev backend.
108
+ * The fallback capability doc for when `GET /dev/config` was not asked for (no
109
+ * credential in hand) or did not answer. Keeps the client non-breaking.
110
+ *
111
+ * ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads exactly one leaf of
112
+ * that document — `delivery.siteSubscriptionRequired` — and its absence is meaningful:
113
+ * unknown reads falsy, and the caller stays silent rather than claiming a deployment
114
+ * does or does not charge. Every other key that used to sit here had no reader.
115
+ *
116
+ * ⛔ **Do not restore a key "for completeness".** A default for a field nothing reads is
117
+ * a reader waiting to happen, and it is how this file came to describe a client that
118
+ * discovered its backend's gateway base, asset base and login path — none of which was
119
+ * ever true. The removals, and why each was not merely unused but wrong:
120
+ *
121
+ * `gatewayBase` sat here UNREAD until 2026-07-29. A serve location is read from the
122
+ * response that carries it (an upload plan's `serve_base`, an asset
123
+ * entry's `serve_url`, a payload's `config.base`) — never from a
124
+ * handshake, which cannot know a per-response answer.
125
+ * `assetBase` until 2026-08-17: one production host, hardcoded, applied to every
126
+ * deployment the CLI can be pointed at. Read only to compose an asset
127
+ * URL the plan already returns verbatim. Reader and composer both gone.
128
+ * `runtime` until 2026-08-22. A backend does not hold runtimes — a version comes
129
+ * from a CDN — so there is no installed set to report. What a site gets
130
+ * follows from its foundation's floor (`info.runtime`, at register).
131
+ * `auth` `loginPath` was never read: the login path is a constant in
132
+ * `utils/registry-auth.js`, and the ORIGIN comes from the resolution
133
+ * ladder, so the CLI is never told where to log in — it is born knowing.
134
+ * `delivery` `deploy` and `broker` had no reader. `publish` had one, but it could
135
+ * never refuse: the backend sent a literal true for every deployment,
136
+ * so the gate read a constant. Removed on both sides 2026-08-30.
137
+ * `assets` `supported` had no reader; the asset lane reports its own capability
138
+ * through the upload plan it returns.
111
139
  */
112
- export const DISCOVERY_DEFAULTS = {
113
- // ⛔ No serve-root default, and no `assetBase`. Serve locations are read from
114
- // discovery or from a per-response field (an upload plan's `serve_base`, an
115
- // asset entry's `serve_url`); nothing here reconstructs one, so a default is a
116
- // route name with no consumer. (A `gatewayBase` entry lived here unread until
117
- // 2026-07-29.)
118
- //
119
- // `assetBase: 'https://assets.uniweb.app/'` sat here until 2026-08-17 — one
120
- // production host, hardcoded, applied to EVERY deployment the CLI can be
121
- // pointed at. It was only ever read to compose an asset URL, which the plan
122
- // already returns as `serve_url`; both the reader and the composer are gone.
123
- //
124
- // No `runtime` entry, and there must not be one. A backend does not hold
125
- // runtimes: a version is acquired from a CDN — the official mirror, the
126
- // distribution channel, or a local server — so there is no installed set for
127
- // it to report and nothing here to default. `runtime.installed` lived here
128
- // until 2026-08-22, alongside a `uniweb runtime register` verb that pushed
129
- // builds to a backend; both are gone. The runtime a site gets follows from
130
- // its foundation's floor (`info.runtime`, stated at register), not from
131
- // anything the CLI asks a backend about.
132
- auth: { loginPath: '/dev/auth/login', required: true },
133
- delivery: { deploy: true, publish: true, broker: 'self-serve' },
134
- assets: { supported: false }
135
- }
140
+ export const DISCOVERY_DEFAULTS = {}
136
141
 
137
142
  export class BackendClient {
138
143
  /**
@@ -250,18 +255,78 @@ export class BackendClient {
250
255
  // ── Discovery ─────────────────────────────────────────────────────────────────
251
256
 
252
257
  /**
253
- * GET /dev/config the anonymous capability/handshake document. The one route
254
- * that answers before login (`auth: false`). Lazy + cached for the client's
255
- * lifetime; a missing route or any transport/parse error falls back to
256
- * DISCOVERY_DEFAULTS (non-breaking an older backend still works). Lets the
257
- * CLI hardcode nothing about a backend but its origin and discover the rest:
258
- * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?).
258
+ * The session bearer IF one can be had without asking — an explicit `--token`, an
259
+ * env var, or a stored unexpired session. Never prompts, never logs in, returns null
260
+ * instead. `token()` is the one that may block; this is for calls that want to be
261
+ * authenticated when possible but must not *cause* an authentication.
262
+ * @returns {Promise<string|null>}
263
+ */
264
+ async _tokenIfAvailable() {
265
+ if (this._token) return this._token
266
+ // ⛔ The injected resolver must be honoured here too, not just in `token()`.
267
+ // `pull` and `clone` pass one (`deps.getToken`), so skipping it would treat a caller
268
+ // that supplies its own auth as unauthenticated — and, worse, fall through to the
269
+ // machine's stored session, quietly using a DIFFERENT credential than the caller
270
+ // asked for. A throwing resolver is "no token", never a failed command.
271
+ if (this._getToken) {
272
+ try {
273
+ return (await this._getToken()) || null
274
+ } catch {
275
+ return null
276
+ }
277
+ }
278
+ try {
279
+ const stored = await readRegistryAuth()
280
+ if (stored?.token && !isExpired(stored)) return stored.token
281
+ } catch {
282
+ /* advisory — a missing or unreadable session is simply "no token" */
283
+ }
284
+ return null
285
+ }
286
+
287
+ /**
288
+ * GET /dev/config — the capability/handshake document. Lazy + cached for the client's
289
+ * lifetime; a missing route, a 401, or any transport/parse error falls back to
290
+ * DISCOVERY_DEFAULTS, so this can never be the reason a command fails.
291
+ *
292
+ * ⭐ **Authenticated, or not sent at all.** `/dev/*` is the CLI's lane and the CLI is
293
+ * an authenticated client, so this attaches the bearer when it has one and **makes no
294
+ * request when it does not** — which keeps that true by construction rather than by
295
+ * ordering luck, and makes the route moving behind auth a no-op here.
296
+ *
297
+ * ⚠️ It uses `_tokenIfAvailable()` and never `token()`, deliberately: **discovery must
298
+ * never be the thing that triggers a login.** A capability probe that opens a password
299
+ * prompt would be a worse defect than the anonymous call it replaced.
300
+ *
301
+ * ⛔ **Most of this document is deliberately not read.** `gatewayBase` and `assetBase`
302
+ * were dropped because a serve location is read from the response that carries it
303
+ * (`serve_base`, `serve_url`, `config.base`), never from a handshake; `auth.loginPath`
304
+ * is not read either — the login path is a hardcoded constant
305
+ * (`utils/registry-auth.js`). What is actually consumed is ONE leaf —
306
+ * `delivery.siteSubscriptionRequired` — and nothing else. Do not add a reader for the
307
+ * rest: each one would be a second place a backend's layout is pinned.
308
+ *
259
309
  * @returns {Promise<object>}
260
310
  */
261
311
  async discover() {
262
312
  if (this._discovery) return this._discovery
313
+ const bearer = await this._tokenIfAvailable()
314
+
315
+ // ⛔ NO CREDENTIAL ⇒ NO REQUEST. This is the rule made structural rather than
316
+ // incidental: apart from the login routes themselves, the CLI does not touch
317
+ // `/dev/*` without a bearer. The defaults are the honest answer here — we do not
318
+ // know this backend's capabilities and are not entitled to ask yet — and every
319
+ // caller already treats them as non-breaking, so nothing downstream changes.
320
+ if (!bearer) {
321
+ this._discovery = { ...DISCOVERY_DEFAULTS }
322
+ return this._discovery
323
+ }
324
+
263
325
  try {
264
- const res = await this.request('/dev/config', { auth: false })
326
+ const res = await this.request('/dev/config', {
327
+ auth: false,
328
+ headers: { Authorization: `Bearer ${bearer}` }
329
+ })
265
330
  this._discovery = res.ok ? await res.json() : { ...DISCOVERY_DEFAULTS }
266
331
  } catch {
267
332
  this._discovery = { ...DISCOVERY_DEFAULTS }
@@ -519,7 +584,29 @@ export class BackendClient {
519
584
  * shipped backend-side — collab backend-framework-b220):
520
585
  * { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
521
586
  * `draft_dirty` = never-published, or the synced draft changed since the last
522
- * publish ("pushed but not published"). The path is VERB-FIRST (`status/{uuid}`)
587
+ * publish ("pushed but not published").
588
+ *
589
+ * ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
590
+ * it yet** (shipped 2026-08-29; documented here so it is not lost twice):
591
+ *
592
+ * last_published_url · last_published_foundation · last_published_extensions
593
+ * last_published_runtime · last_published_runtime_floor · last_published_runtime_resolution
594
+ *
595
+ * Three things about it that a reader will otherwise get wrong:
596
+ *
597
+ * ⛔ `runtime_resolution` is `resolved` or `pinned:<reason>` (`operator` / `unknown_floor` /
598
+ * `no_foundations`). **A pin is a first-class answer, not a failure** — most sites are pinned
599
+ * at any moment, and an "old" runtime still satisfies the site's floor. Never surface
600
+ * `pinned:*` as an error state.
601
+ * ⛔ `extensions` is there because a site's code surface is the primary foundation **plus N
602
+ * extensions**; reading the primary alone describes a site nobody has.
603
+ * ⛔ `last_*` is deliberate on every one. `unpublish` LEAVES the URL populated (the static site
604
+ * may still be reachable), so beside `published: bool` a bare `published_url` would read as a
605
+ * liveness claim and be wrong exactly when it matters. And **nothing back-fills** — a site
606
+ * published before this reports them absent, which means "published before we recorded it",
607
+ * never "has no foundation".
608
+ *
609
+ * The path is VERB-FIRST (`status/{uuid}`)
523
610
  * to match the lane (`publish/{uuid}`, `content/push/{uuid}`, `folder/pull/{uuid}`),
524
611
  * not the `{uuid}/status` the shipping-verbs §8 sketch assumed. null on
525
612
  * 404 (unknown/not-yours) / 401 / any failure — `status --remote` degrades to local.
@@ -27,7 +27,7 @@ import {
27
27
  computeUnitHashes,
28
28
  collectUnitUuids,
29
29
  collectFolderItemUuids,
30
- collectCollectionUuids,
30
+ collectQueryUuids,
31
31
  readAssetMap
32
32
  } from '@uniweb/build/uwx'
33
33
 
@@ -242,7 +242,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
242
242
  const prior = readSyncCacheFile(siteDir)
243
243
  const dropped = [
244
244
  'itemUuids',
245
- 'collectionUuids',
245
+ 'queryUuids',
246
246
  'hashes',
247
247
  'baseVersions',
248
248
  'unitBases',
@@ -252,7 +252,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
252
252
  itemUuids: {},
253
253
  // Remote-derived exactly like itemUuids — it holds the OLD site's collection
254
254
  // ids, and surviving the drop it would offer them for the new site's sections.
255
- collectionUuids: {},
255
+ queryUuids: {},
256
256
  hashes: {},
257
257
  baseVersions: {},
258
258
  unitBases: {},
@@ -492,12 +492,12 @@ export function readFolderItemUuids(siteDir) {
492
492
  * its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
493
493
  * handle the backend skips on parse and never stores.
494
494
  */
495
- export function readCollectionUuids(siteDir) {
496
- return readMap(siteDir, 'collectionUuids')
495
+ export function readQueryUuids(siteDir) {
496
+ return readMap(siteDir, 'queryUuids')
497
497
  }
498
- export function writeCollectionUuids(siteDir, map) {
498
+ export function writeQueryUuids(siteDir, map) {
499
499
  if (!map || !Object.keys(map).length) return
500
- updateSyncCache(siteDir, { collectionUuids: map })
500
+ updateSyncCache(siteDir, { queryUuids: map })
501
501
  }
502
502
  export function writeFolderItemUuids(siteDir, map) {
503
503
  if (!map || !Object.keys(map).length) return
@@ -859,28 +859,27 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
859
859
  : `Created the site on the backend, owned personally (recorded $uuid in site.yml).`
860
860
  )
861
861
 
862
- // The billing line needs the JOIN of two independent facts, and either alone
863
- // gives a wrong answer:
864
- // hosts_free a property of the SCOPE (is this owner exempt?)
865
- // siteSubscriptionRequired a property of the DEPLOYMENT (does it charge at all?)
866
- // Keyed on the scope alone, this fires on every local publish where nothing
867
- // enforces until the warning is trained away. Keyed on the deployment alone it
868
- // fires at exempt owners. An older backend supplies neither, so both read falsy
869
- // and nothing is said: silence beats a claim we cannot justify.
870
- const hostsFree = payload?.hosts_free === true
871
- let enforces = false
872
- try {
873
- const cfg = await client.discover()
874
- enforces = cfg?.delivery?.siteSubscriptionRequired === true
875
- } catch {
876
- /* discovery is advisory here never fail a create over a message */
877
- }
878
- if (hostsFree) {
862
+ // What the create echoed about this site's OWNER, and nothing beyond it.
863
+ //
864
+ // `hosts_free` is a property of the SCOPE is this owner exempt? — and it is the only
865
+ // billing fact the CLI holds. This used to JOIN it with `siteSubscriptionRequired`
866
+ // from the discovery document, a property of the DEPLOYMENT. That leaf left the wire:
867
+ // every deployment charges, so it read true everywhere and the join was testing a
868
+ // constant.
869
+ //
870
+ // AND THE WARNING WENT WITH IT — this is NOT a fallback to keying on the scope
871
+ // alone, which is the exact thing the join existed to prevent. Whether a given publish
872
+ // is charged is derived per-site at publish time, on a side the CLI cannot see, so any
873
+ // prediction made here can only be approximately right and would go stale silently.
874
+ // The backend's typed 402 (`reason: "no_subscription"`) is exact, per-site, and
875
+ // arrives when it matters; `backend/payment-handoff.js` already turns it into a
876
+ // checkout. Deciding whether payment is due is not the CLI's job.
877
+ //
878
+ // What survives is the reassuring direction only, and only when it was stated:
879
+ // `false` is an answer we deliberately do not speak to, and missing is not an answer
880
+ // at all. Do not reintroduce a "you will be charged" line here.
881
+ if (payload?.hosts_free === true) {
879
882
  note?.('This owner is hosted free — publishing will not require a subscription.')
880
- } else if (enforces) {
881
- note?.(
882
- 'Publishing this site live will require a hosting subscription on this backend.'
883
- )
884
883
  }
885
884
  return org
886
885
  }
@@ -968,12 +967,12 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
968
967
  //
969
968
  // ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
970
969
  // a foundation-relative `@/member` into the `@org/member` the push shipped and
971
- // keyed its hashes by. Without it the emit does not fail — `buildCollectionEntities`
970
+ // keyed its hashes by. Without it the emit does not fail — `buildRecordEntities`
972
971
  // WARNS and ships the model unresolved, deliberately, so an org-less export still
973
972
  // works — so every record of a `@/`-scoped collection is emitted under a key that
974
973
  // can never match its banked one, and reads as changed forever.
975
974
  //
976
- // ⚠️ It hides in plain sight because `@std/…` collections are unaffected: their
975
+ // ⚠️ It hides in plain sight because `@std/…` queries are unaffected: their
977
976
  // scope is already absolute, so they match. A site mixing both — the marketing
978
977
  // fixture has `@std/person` AND `@proximify/member` — shows some records settling
979
978
  // and others never settling, which reads like a content problem rather than a
@@ -981,7 +980,7 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
981
980
  // immediately after a successful push; passing the org took it to 1.
982
981
  const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
983
982
  const changed =
984
- (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
983
+ (pkg.siteContent?.entityCount || 0) + (pkg.records?.entityCount || 0)
985
984
  return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
986
985
  }
987
986
 
@@ -1008,13 +1007,13 @@ async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {
1008
1007
  const applied = readAppliedInjections(siteDir)
1009
1008
  const assetIds = readAssetMap(siteDir)
1010
1009
  const org = readSiteOrg(siteDir)
1011
- const collectionUuids = readCollectionUuids(siteDir)
1010
+ const queryUuids = readQueryUuids(siteDir)
1012
1011
  return emitSyncPackages(siteDir, {
1013
1012
  resolveModel: makeModelResolver({ client: null, offline: true }),
1014
1013
  priorHashes,
1015
1014
  sendAll,
1016
1015
  ...applied,
1017
- ...(Object.keys(collectionUuids).length ? { collectionUuids } : {}),
1016
+ ...(Object.keys(queryUuids).length ? { queryUuids } : {}),
1018
1017
  ...(Object.keys(assetIds).length ? { assetIds } : {}),
1019
1018
  ...(org ? { org } : {})
1020
1019
  })
@@ -1069,7 +1068,7 @@ export async function pushSyncPackages({
1069
1068
  asOrg,
1070
1069
  report
1071
1070
  }) {
1072
- const { siteContent, collections, siteContentUuid, hashes, applied } = pkg
1071
+ const { siteContent, records, siteContentUuid, hashes, applied } = pkg
1073
1072
  const { info, note, error } = report
1074
1073
  const dim = report.dim || ((s) => s)
1075
1074
 
@@ -1272,8 +1271,8 @@ export async function pushSyncPackages({
1272
1271
  // The site's @uniweb/folder is genesis-owned: its structure is fixed on first
1273
1272
  // deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
1274
1273
  note(
1275
- "This site's collection structure is already established on the backend and can't be changed " +
1276
- 'in place — e.g. adding or removing a schema-backed collection, or switching one between ' +
1274
+ "This site's record structure is already established on the backend and can't be changed " +
1275
+ 'in place — e.g. adding or removing a schema-backed query, or switching one between ' +
1277
1276
  'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
1278
1277
  'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
1279
1278
  )
@@ -1383,8 +1382,8 @@ export async function pushSyncPackages({
1383
1382
  // unique. Without it every push after the first re-sends the whole
1384
1383
  // `collections` section uuid-less and is refused.
1385
1384
  if (siteFinalizedDoc) {
1386
- const collectionIds = collectCollectionUuids(siteFinalizedDoc)
1387
- if (Object.keys(collectionIds).length) writeCollectionUuids(siteDir, collectionIds)
1385
+ const recordIds = collectQueryUuids(siteFinalizedDoc)
1386
+ if (Object.keys(recordIds).length) writeQueryUuids(siteDir, recordIds)
1388
1387
  }
1389
1388
  finalizedTotal += finalized.length
1390
1389
  } else {
@@ -1429,16 +1428,16 @@ export async function pushSyncPackages({
1429
1428
  // site-content uuid. On a brand-new site the backend creates the folder on this first
1430
1429
  // push. Records round-trip their own $uuid (back-filled into source files); the folder
1431
1430
  // itself has no uuid (the backend owns it).
1432
- if (collections) {
1431
+ if (records) {
1433
1432
  if (!boundSiteUuid) {
1434
1433
  error(
1435
- 'Cannot push collections — the site has no uuid yet. Push the site-content lane first.'
1434
+ 'Cannot push records — the site has no uuid yet. Push the site-content lane first.'
1436
1435
  )
1437
1436
  return { exitCode: 1, finalizedTotal, wrote }
1438
1437
  }
1439
1438
  const finalized = await pushLane(
1440
- 'collections',
1441
- () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg }),
1439
+ 'records',
1440
+ () => client.pushFolder(boundSiteUuid, records.buffer, { asOrg }),
1442
1441
  undefined,
1443
1442
  { boundUuid: boundSiteUuid }
1444
1443
  )
@@ -1447,7 +1446,7 @@ export async function pushSyncPackages({
1447
1446
  return { exitCode: 1, finalizedTotal, wrote }
1448
1447
  }
1449
1448
  harvest(finalized)
1450
- const bf = backfillEntityUuids({ index: collections.index, finalized })
1449
+ const bf = backfillEntityUuids({ index: records.index, finalized })
1451
1450
  for (const w of bf.warnings) note(`! ${w}`)
1452
1451
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
1453
1452
  if (bf.updated.length)