uniweb 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
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/runtime": "^0.13.1",
45
- "@uniweb/semantic-parser": "^1.3.1",
46
- "@uniweb/core": "^0.13.1",
47
- "@uniweb/kit": "^0.14.0"
44
+ "@uniweb/kit": "^0.15.0",
45
+ "@uniweb/runtime": "^0.13.2",
46
+ "@uniweb/core": "^0.14.0",
47
+ "@uniweb/semantic-parser": "^1.3.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.29.0",
51
- "@uniweb/content-reader": "^1.2.4",
52
- "@uniweb/semantic-parser": "^1.3.1"
50
+ "@uniweb/build": "^0.30.0",
51
+ "@uniweb/semantic-parser": "^1.3.1",
52
+ "@uniweb/content-reader": "^1.2.4"
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
 
@@ -27,6 +27,7 @@ import {
27
27
  computeUnitHashes,
28
28
  collectUnitUuids,
29
29
  collectFolderItemUuids,
30
+ collectQueryUuids,
30
31
  readAssetMap
31
32
  } from '@uniweb/build/uwx'
32
33
 
@@ -241,6 +242,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
241
242
  const prior = readSyncCacheFile(siteDir)
242
243
  const dropped = [
243
244
  'itemUuids',
245
+ 'queryUuids',
244
246
  'hashes',
245
247
  'baseVersions',
246
248
  'unitBases',
@@ -248,6 +250,9 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
248
250
  ].filter((k) => prior[k] && Object.keys(prior[k]).length)
249
251
  updateSyncCache(siteDir, {
250
252
  itemUuids: {},
253
+ // Remote-derived exactly like itemUuids — it holds the OLD site's collection
254
+ // ids, and surviving the drop it would offer them for the new site's sections.
255
+ queryUuids: {},
251
256
  hashes: {},
252
257
  baseVersions: {},
253
258
  unitBases: {},
@@ -468,6 +473,32 @@ export function readItemUuids(siteDir) {
468
473
  export function readFolderItemUuids(siteDir) {
469
474
  return readMap(siteDir, 'folderItemUuids')
470
475
  }
476
+
477
+ /**
478
+ * Collection-declaration identity: `{ <collection name>: <backend $uuid> }`.
479
+ *
480
+ * ⛔ THE THIRD MAP, AND EACH ONE EXISTS FOR THE SAME REASON. `itemUuids` is keyed by
481
+ * the file an item projects to. Two kinds of item have no file of their own — a
482
+ * folder's placements, and a collection DECLARATION (they all live in one
483
+ * `collections/collections.yml`) — so a path-keyed map has no shape either could
484
+ * occupy, and a push re-sends that whole section uuid-less.
485
+ *
486
+ * The backend refuses an all-blank section over stored items rather than applying it,
487
+ * because applying it would insert every record fresh and delete every stored row —
488
+ * content survives, identity does not. So `push` worked once and every push after was
489
+ * refused. Measured 2026-08-29; collab framework-backend-812b.
490
+ *
491
+ * ⭐ Keyed by NAME, which the backend enforces unique within the section and uses as
492
+ * its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
493
+ * handle the backend skips on parse and never stores.
494
+ */
495
+ export function readQueryUuids(siteDir) {
496
+ return readMap(siteDir, 'queryUuids')
497
+ }
498
+ export function writeQueryUuids(siteDir, map) {
499
+ if (!map || !Object.keys(map).length) return
500
+ updateSyncCache(siteDir, { queryUuids: map })
501
+ }
471
502
  export function writeFolderItemUuids(siteDir, map) {
472
503
  if (!map || !Object.keys(map).length) return
473
504
  updateSyncCache(siteDir, { folderItemUuids: map })
@@ -937,31 +968,83 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
937
968
  //
938
969
  // ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
939
970
  // a foundation-relative `@/member` into the `@org/member` the push shipped and
940
- // keyed its hashes by. Without it the emit does not fail — `buildCollectionEntities`
971
+ // keyed its hashes by. Without it the emit does not fail — `buildRecordEntities`
941
972
  // WARNS and ships the model unresolved, deliberately, so an org-less export still
942
973
  // works — so every record of a `@/`-scoped collection is emitted under a key that
943
974
  // can never match its banked one, and reads as changed forever.
944
975
  //
945
- // ⚠️ It hides in plain sight because `@std/…` collections are unaffected: their
976
+ // ⚠️ It hides in plain sight because `@std/…` queries are unaffected: their
946
977
  // scope is already absolute, so they match. A site mixing both — the marketing
947
978
  // fixture has `@std/person` AND `@proximify/member` — shows some records settling
948
979
  // and others never settling, which reads like a content problem rather than a
949
980
  // resolution one. Measured on matinee 2026-08-29: `status` reported 4 changed
950
981
  // immediately after a successful push; passing the org took it to 1.
982
+ const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
983
+ const changed =
984
+ (pkg.siteContent?.entityCount || 0) + (pkg.records?.entityCount || 0)
985
+ return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
986
+ }
987
+
988
+ /**
989
+ * The emit whose hashes are comparable with the banked ones — ONE definition.
990
+ *
991
+ * ⛔ EVERY DEFECT IN THIS AREA HAS BEEN A WRITER AND A READER DISAGREEING ABOUT
992
+ * WHICH DOCUMENT A HASH DESCRIBES, so the option set that makes them agree must
993
+ * have a single home. Assembled from three sources, each for a stated reason:
994
+ *
995
+ * · BANKED the injections the push applied before hashing — serve URLs and
996
+ * the pinned foundation ref, which only a round trip produces.
997
+ * · RE-DERIVED asset identity, from the COMMITTED `assets.json`, so a moved map
998
+ * reads as changed rather than matching a copy of itself.
999
+ * · RECORDED the site's org, which resolves a foundation-relative `@/x` into
1000
+ * the `@org/x` the push keyed its hashes by, and the collection
1001
+ * identity a push stamps — so `status` hashes the same document a
1002
+ * push would send rather than one missing a section's `$uuid`s.
1003
+ *
1004
+ * Offline by design — measured at zero HTTP requests, a property the cross-client
1005
+ * flows rely on.
1006
+ */
1007
+ async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {}) {
951
1008
  const applied = readAppliedInjections(siteDir)
952
1009
  const assetIds = readAssetMap(siteDir)
953
1010
  const org = readSiteOrg(siteDir)
954
- const pkg = await emitSyncPackages(siteDir, {
1011
+ const queryUuids = readQueryUuids(siteDir)
1012
+ return emitSyncPackages(siteDir, {
955
1013
  resolveModel: makeModelResolver({ client: null, offline: true }),
956
1014
  priorHashes,
957
1015
  sendAll,
958
1016
  ...applied,
1017
+ ...(Object.keys(queryUuids).length ? { queryUuids } : {}),
959
1018
  ...(Object.keys(assetIds).length ? { assetIds } : {}),
960
1019
  ...(org ? { org } : {})
961
1020
  })
962
- const changed =
963
- (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
964
- return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
1021
+ }
1022
+
1023
+ /**
1024
+ * Re-bank the send-only-changed hashes over the files as they NOW stand.
1025
+ *
1026
+ * ⛔ FOR A WRITER THAT REWRITES THE WORKING TREE — today, `uniweb pull`.
1027
+ *
1028
+ * A pull projects the backend's document into source files, and that projection is
1029
+ * canonical rather than byte-identical to what was there: it moves section ordering
1030
+ * out of filename prefixes (`1-hero.md` → `hero.md` plus an explicit `sections:`
1031
+ * list) and stamps each section's `id`. Lossless, and a different document.
1032
+ *
1033
+ * ⚠️ `pull` already knew this for the OTHER map — it clears the `local` unit base
1034
+ * because "what we would emit from them is not byte-identical to it, so the old
1035
+ * local base no longer describes anything". That reasoning was never carried to the
1036
+ * hashes, which were left STALE rather than unknown: the next `uniweb status`
1037
+ * reported the site as having unpushed content immediately after a pull, forever.
1038
+ * Measured on matinee 2026-08-29 — `push → pull` reported 1 changed of 8.
1039
+ *
1040
+ * ⭐ Re-banking is the correct answer rather than clearing, because after a pull the
1041
+ * on-disk state IS the agreed state: it came from the backend. A push with no edits
1042
+ * in between should send nothing, and clearing would make it send everything.
1043
+ */
1044
+ export async function rebankSyncHashes(siteDir) {
1045
+ const pkg = await comparisonEmit(siteDir, { sendAll: true })
1046
+ writeSyncCache(siteDir, pkg.hashes || {}, pkg.applied || {})
1047
+ return Object.keys(pkg.hashes || {}).length
965
1048
  }
966
1049
 
967
1050
  /**
@@ -986,7 +1069,7 @@ export async function pushSyncPackages({
986
1069
  asOrg,
987
1070
  report
988
1071
  }) {
989
- const { siteContent, collections, siteContentUuid, hashes, applied } = pkg
1072
+ const { siteContent, records, siteContentUuid, hashes, applied } = pkg
990
1073
  const { info, note, error } = report
991
1074
  const dim = report.dim || ((s) => s)
992
1075
 
@@ -1063,18 +1146,47 @@ export async function pushSyncPackages({
1063
1146
  return null
1064
1147
  }
1065
1148
  if (problem?.reason === 'identity_required') {
1149
+ // ⛔ THE REFUSAL IS PER-SECTION, NOT PER-PUSH, AND WE USED TO SAY OTHERWISE.
1150
+ //
1151
+ // The backend asks, of each section independently: is this section's ENTIRE
1152
+ // incoming record set uuid-less while the stored entity already has items in
1153
+ // it? So a push can carry correct identity for most of the document and still
1154
+ // be refused over one section it stamps none of — and the old wording ("this
1155
+ // copy has no record of the site's item identity", "the recovery … could not
1156
+ // reach the backend", "`uniweb pull` also restores it") was wrong on all
1157
+ // three counts in exactly that case: the copy has identity, no recovery was
1158
+ // attempted, and a pull changes nothing.
1159
+ //
1160
+ // ⭐ THE BACKEND ALREADY SENDS WHAT LOCATES IT — `section_id`,
1161
+ // `records_without_uuid`, `stored_items` — and this branch discarded every
1162
+ // one of them, so every refusal anyone collected was missing the only fields
1163
+ // that say WHERE. (Named by backend in collab `framework-backend-812b`,
1164
+ // 2026-08-28: "the offending section and both counts have been in the body of
1165
+ // every refusal you have collected".)
1166
+ const n = problem.records_without_uuid
1167
+ const stored = problem.stored_items
1168
+ // `section_name` landed backend-side 2026-08-28 (`90e7cd7e`), replacing an
1169
+ // i64 row id we could not resolve. The fallback stays for an older backend,
1170
+ // not because the swap is pending — it is done.
1171
+ const where = problem.section_name ?? problem.section_id
1066
1172
  error(
1067
- `${label} push refused — this copy has no record of the site's item identity.`
1068
- )
1069
- note(
1070
- 'Nothing was written. Pushing without it would have replaced the identity of every stored item.'
1071
- )
1072
- note(
1073
- 'The recovery normally runs automatically, so it likely could not reach the backend.'
1173
+ `${label} push refused — one section carries no item identity` +
1174
+ (where === undefined ? '.' : ` (section ${where}).`)
1074
1175
  )
1176
+ if (Number.isInteger(n) && Number.isInteger(stored)) {
1177
+ note(
1178
+ `That section sent ${n} record(s), none carrying a \`$uuid\`, while ${stored} item(s) are already stored there.`
1179
+ )
1180
+ }
1075
1181
  note(
1076
- 'Check your connection and re-run; `uniweb pull` also restores it.'
1182
+ 'Nothing was written. Applying it would have replaced the identity of every stored item in that section.'
1077
1183
  )
1184
+ // ⚠️ Deliberately NOT "run `uniweb pull`". A pull re-harvests identity from
1185
+ // the backend's own document, so it fixes a LOST cache — and does nothing at
1186
+ // all when the cache is intact and one section simply has no entry in it,
1187
+ // which is the case this message now names. Suggesting it there sends the
1188
+ // user to re-fetch a map they already have.
1189
+ if (problem.detail) note(String(problem.detail))
1078
1190
  return null
1079
1191
  }
1080
1192
  if (problem?.reason === 'stale_base') {
@@ -1160,8 +1272,8 @@ export async function pushSyncPackages({
1160
1272
  // The site's @uniweb/folder is genesis-owned: its structure is fixed on first
1161
1273
  // deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
1162
1274
  note(
1163
- "This site's collection structure is already established on the backend and can't be changed " +
1164
- 'in place — e.g. adding or removing a schema-backed collection, or switching one between ' +
1275
+ "This site's record structure is already established on the backend and can't be changed " +
1276
+ 'in place — e.g. adding or removing a schema-backed query, or switching one between ' +
1165
1277
  'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
1166
1278
  'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
1167
1279
  )
@@ -1264,6 +1376,16 @@ export async function pushSyncPackages({
1264
1376
  }
1265
1377
  harvest(finalized)
1266
1378
  siteFinalizedDoc = finalized[0]?.document || null
1379
+ // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
1380
+ // placements below. These items have no file to back-fill into — they all
1381
+ // come from one `collections/collections.yml` — so the only place their
1382
+ // `$uuid` can live is the cache, keyed by the name the backend enforces
1383
+ // unique. Without it every push after the first re-sends the whole
1384
+ // `collections` section uuid-less and is refused.
1385
+ if (siteFinalizedDoc) {
1386
+ const recordIds = collectQueryUuids(siteFinalizedDoc)
1387
+ if (Object.keys(recordIds).length) writeQueryUuids(siteDir, recordIds)
1388
+ }
1267
1389
  finalizedTotal += finalized.length
1268
1390
  } else {
1269
1391
  const payload = await postLane('site-content', () =>
@@ -1307,16 +1429,16 @@ export async function pushSyncPackages({
1307
1429
  // site-content uuid. On a brand-new site the backend creates the folder on this first
1308
1430
  // push. Records round-trip their own $uuid (back-filled into source files); the folder
1309
1431
  // itself has no uuid (the backend owns it).
1310
- if (collections) {
1432
+ if (records) {
1311
1433
  if (!boundSiteUuid) {
1312
1434
  error(
1313
- 'Cannot push collections — the site has no uuid yet. Push the site-content lane first.'
1435
+ 'Cannot push records — the site has no uuid yet. Push the site-content lane first.'
1314
1436
  )
1315
1437
  return { exitCode: 1, finalizedTotal, wrote }
1316
1438
  }
1317
1439
  const finalized = await pushLane(
1318
- 'collections',
1319
- () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg }),
1440
+ 'records',
1441
+ () => client.pushFolder(boundSiteUuid, records.buffer, { asOrg }),
1320
1442
  undefined,
1321
1443
  { boundUuid: boundSiteUuid }
1322
1444
  )
@@ -1325,7 +1447,7 @@ export async function pushSyncPackages({
1325
1447
  return { exitCode: 1, finalizedTotal, wrote }
1326
1448
  }
1327
1449
  harvest(finalized)
1328
- const bf = backfillEntityUuids({ index: collections.index, finalized })
1450
+ const bf = backfillEntityUuids({ index: records.index, finalized })
1329
1451
  for (const w of bf.warnings) note(`! ${w}`)
1330
1452
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
1331
1453
  if (bf.updated.length)
@@ -592,7 +592,7 @@ function resolveFoundationDir(projectDir, siteConfig) {
592
592
  *
593
593
  * The `buildLocalizedContent` step is the same call bundle mode makes
594
594
  * post-vite, so multi-locale sites get identical per-locale outputs in
595
- * either mode. Collection translation (`buildLocalizedCollections`)
595
+ * either mode. Collection translation (`buildLocalizedRecords`)
596
596
  * also runs here so deploy ships translated collection JSONs.
597
597
  *
598
598
  * Bug surfaced + fixed by routing deploy through this path: the bundle
@@ -652,28 +652,28 @@ async function buildSiteLink(projectDir, options = {}) {
652
652
  // Collection translations — optional; don't fail the build if
653
653
  // missing. Bundle mode does the same.
654
654
  try {
655
- const { buildLocalizedCollections } = await import('@uniweb/build/i18n')
656
- const collectionOutputs = await buildLocalizedCollections(projectDir, {
655
+ const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
656
+ const recordOutputs = await buildLocalizedRecords(projectDir, {
657
657
  locales: i18nConfig.locales,
658
658
  outputDir: distDir,
659
- collectionsLocalesDir: join(
659
+ recordLocalesDir: join(
660
660
  projectDir,
661
661
  i18nConfig.localesDir,
662
- 'collections'
662
+ 'records'
663
663
  )
664
664
  })
665
- const collectionCount = Object.values(collectionOutputs).reduce(
665
+ const recordCount = Object.values(recordOutputs).reduce(
666
666
  (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
667
667
  0
668
668
  )
669
- if (collectionCount > 0) {
669
+ if (recordCount > 0) {
670
670
  success(
671
- `Translated collections for ${Object.keys(collectionOutputs).length} locale(s)`
671
+ `Translated records for ${Object.keys(recordOutputs).length} locale(s)`
672
672
  )
673
673
  }
674
674
  } catch (err) {
675
675
  if (process.env.UNIWEB_DEBUG)
676
- console.error('Collection translation:', err.message)
676
+ console.error('Record translation:', err.message)
677
677
  }
678
678
  } catch (err) {
679
679
  error(`i18n build failed: ${err.message}`)
@@ -772,33 +772,33 @@ async function buildSite(projectDir, options = {}) {
772
772
 
773
773
  // Translate collections if they exist
774
774
  try {
775
- const { buildLocalizedCollections } = await import('@uniweb/build/i18n')
775
+ const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
776
776
 
777
- const collectionOutputs = await buildLocalizedCollections(projectDir, {
777
+ const recordOutputs = await buildLocalizedRecords(projectDir, {
778
778
  locales: i18nConfig.locales,
779
779
  outputDir: join(projectDir, 'dist'),
780
- collectionsLocalesDir: join(
780
+ recordLocalesDir: join(
781
781
  projectDir,
782
782
  i18nConfig.localesDir,
783
- 'collections'
783
+ 'records'
784
784
  )
785
785
  })
786
786
 
787
787
  // Count collections translated
788
- const collectionCount = Object.values(collectionOutputs).reduce(
788
+ const recordCount = Object.values(recordOutputs).reduce(
789
789
  (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
790
790
  0
791
791
  )
792
792
 
793
- if (collectionCount > 0) {
793
+ if (recordCount > 0) {
794
794
  success(
795
- `Translated collections for ${Object.keys(collectionOutputs).length} locale(s)`
795
+ `Translated records for ${Object.keys(recordOutputs).length} locale(s)`
796
796
  )
797
797
  }
798
798
  } catch (err) {
799
799
  // Collection translation is optional, don't fail build
800
800
  if (process.env.UNIWEB_DEBUG) {
801
- console.error('Collection translation:', err.message)
801
+ console.error('Record translation:', err.message)
802
802
  }
803
803
  }
804
804
  } catch (err) {
@@ -24,7 +24,7 @@
24
24
  * seed;
25
25
  * 4. install, then delegate the projection to the project-local `uniweb pull` (which
26
26
  * resolves the now-installed project-local `@uniweb/build`; clone forwards
27
- * `--no-collections` to it when set).
27
+ * `--no-records` to it when set).
28
28
  *
29
29
  * Sites are private — authenticate with `uniweb login` first; the session carries
30
30
  * identity + the backend origin. There is no `--foundation` flag: the site carries
@@ -37,7 +37,7 @@
37
37
  * the current workspace when run inside one)
38
38
  * uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
39
39
  * uniweb clone <uuid> --project docs Co-located docs/site
40
- * uniweb clone <uuid> --no-collections Pull pages only; skip collection records
40
+ * uniweb clone <uuid> --no-records Pull pages only; skip records
41
41
  *
42
42
  * Backend: via BackendClient (the site-content pull lane). Origin from
43
43
  * --registry > UNIWEB_REGISTER_URL > the local default (internal dev overrides;
@@ -176,7 +176,7 @@ export async function clone(args = [], deps = {}) {
176
176
  if (!siteUuid) {
177
177
  error('Missing site uuid.')
178
178
  log(
179
- `\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-collections]`
179
+ `\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-records]`
180
180
  )
181
181
  log(
182
182
  `${colors.dim}Sites are private — run \`uniweb login\` first.${colors.reset}`
@@ -184,8 +184,8 @@ export async function clone(args = [], deps = {}) {
184
184
  return { exitCode: 2 }
185
185
  }
186
186
 
187
- const noCollections =
188
- args.includes('--no-collections') || args.includes('--content-only')
187
+ const noRecords =
188
+ args.includes('--no-records') || args.includes('--content-only')
189
189
  const pathFlag = flagValue(args, '--path')
190
190
  const projectFlag = flagValue(args, '--project')
191
191
  const tokenFlag = flagValue(args, '--token')
@@ -386,7 +386,7 @@ export async function clone(args = [], deps = {}) {
386
386
  // bytes on disk, not of what the scaffolder first wrote.
387
387
  recordWritten(
388
388
  siteDir,
389
- ['site.yml', 'theme.yml', 'head.html', 'collections.yml']
389
+ ['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml']
390
390
  .map((f) => join(siteDir, f))
391
391
  .filter((p) => existsSync(p))
392
392
  )
@@ -394,7 +394,7 @@ export async function clone(args = [], deps = {}) {
394
394
  const pullExtra = []
395
395
  if (explicitBackend) pullExtra.push('--backend', explicitBackend)
396
396
  if (tokenFlag) pullExtra.push('--token', tokenFlag)
397
- if (noCollections) pullExtra.push('--no-collections')
397
+ if (noRecords) pullExtra.push('--no-records')
398
398
 
399
399
  if (deps.skipPull) {
400
400
  note('Skipping pull (test mode).')