@uniweb/build 0.29.1 → 0.30.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.
Files changed (41) hide show
  1. package/package.json +5 -5
  2. package/src/content/index.js +6 -6
  3. package/src/dev-backend.js +31 -31
  4. package/src/i18n/freeform.js +44 -24
  5. package/src/i18n/index.js +22 -22
  6. package/src/i18n/{collections.js → records.js} +114 -51
  7. package/src/i18n/sync.js +9 -8
  8. package/src/site/build-site-data.js +9 -12
  9. package/src/site/config.js +1 -1
  10. package/src/site/content-collector.js +35 -40
  11. package/src/site/data-fetcher.js +23 -10
  12. package/src/site/entity-pool.js +211 -0
  13. package/src/site/fetch-shapes.js +13 -12
  14. package/src/site/foundation-ref.js +1 -1
  15. package/src/site/index.js +4 -4
  16. package/src/site/plugin.js +58 -63
  17. package/src/site/queries-config.js +324 -0
  18. package/src/site/{collection-processor.js → query-processor.js} +180 -95
  19. package/src/site/records-config.js +299 -0
  20. package/src/site/schemaless-data.js +2 -2
  21. package/src/utils/numeric-prefix.js +63 -0
  22. package/src/uwx/backfill.js +5 -5
  23. package/src/uwx/data-schema.js +2 -2
  24. package/src/uwx/entity-source.js +122 -0
  25. package/src/uwx/folder.js +92 -77
  26. package/src/uwx/index.js +33 -13
  27. package/src/uwx/locale-sync.js +2 -2
  28. package/src/uwx/project-writer.js +36 -10
  29. package/src/uwx/queries-config.js +11 -0
  30. package/src/uwx/records-project.js +535 -0
  31. package/src/uwx/{collections.js → records.js} +152 -69
  32. package/src/uwx/site-diff.js +6 -6
  33. package/src/uwx/site-project.js +30 -5
  34. package/src/uwx/site.js +295 -27
  35. package/src/uwx/sync-package.js +32 -18
  36. package/src/validate-data.js +17 -19
  37. package/src/site/collections-config.js +0 -260
  38. package/src/uwx/collection-source.js +0 -180
  39. package/src/uwx/collections-config.js +0 -9
  40. package/src/uwx/collections-project.js +0 -335
  41. /package/src/search/{collections.js → records-index.js} +0 -0
package/src/uwx/site.js CHANGED
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // The document mirrors the @uniweb/site-content Model: `info` (brief) · `pages`
12
12
  // (self-nesting; each page carries its `page_sections` as an inline field) ·
13
- // `layout_sections` · `extensions` · `collections`. `info.foundation`
13
+ // `layout_sections` · `extensions` · `queries`. `info.foundation`
14
14
  // carries the verbatim `site.yml::foundation` string (the round-trip source of
15
15
  // truth).
16
16
  //
@@ -54,13 +54,13 @@ import {
54
54
  processMarkdownFile,
55
55
  } from '../site/content-collector.js'
56
56
  import { normalizeHideIn } from '../site/nav-visibility.js'
57
- import { resolveDefaultLocale, validateLanguageConfig, collectionDataUrl } from '@uniweb/core'
57
+ import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
58
58
  import { emitEntitySyncPackage } from './entity-document.js'
59
59
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
60
60
  import { unwrapLocalized } from './backfill.js'
61
61
  import { loadFreeformTranslation } from '../i18n/freeform.js'
62
62
  import { upsertYamlScalar } from './yaml-upsert.js'
63
- import { resolveCollectionsConfig } from './collections-config.js'
63
+ import { resolveQueriesConfig } from './queries-config.js'
64
64
 
65
65
  const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
66
66
 
@@ -223,36 +223,43 @@ function buildPageData(config, ctx) {
223
223
  let fetch =
224
224
  config.fetch ??
225
225
  (config.data
226
- ? { collection: Array.isArray(config.data) ? config.data[0] : config.data }
226
+ ? { query: Array.isArray(config.data) ? config.data[0] : config.data }
227
227
  : undefined)
228
- // Resolve the build-time `collection:` shorthand to the runtime-fetchable
228
+ // Resolve the authored `query:` shorthand to the runtime-fetchable
229
229
  // `path: /data/<name>.json` (the static convention the default-fetcher uses).
230
230
  // A shell/backend-hosted site renders client-side with NO prerender, so the
231
- // runtime fetches this decl directly — and `collection:` is build-time-only, so
231
+ // runtime fetches this decl directly — and `query:` is build-time-only, so
232
232
  // it would never resolve at render (the static build resolves it the same way
233
233
  // in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
234
234
  // at `<base>/data/<name>.json`.
235
- if (fetch && typeof fetch.collection === 'string') {
236
- const { collection, ...rest } = fetch
235
+ if (fetch && typeof fetch.query === 'string') {
236
+ const { query, ...rest } = fetch
237
237
  // ⭐ BOTH, deliberately, and they are not redundant.
238
238
  //
239
- // `collection` — the author's query, unresolved. A consumer that can ask
240
- // a host where collections live (`config.records`) resolves it there,
241
- // which is the only way a live lane is reachable at all: a resolved
242
- // path names one place and closes the question.
239
+ // `query` — the author's named query, unresolved. A consumer that can ask
240
+ // a host where records live (`config.records`) resolves it there, which
241
+ // is the only way a live lane is reachable at all: a resolved path names
242
+ // one place and closes the question.
243
243
  //
244
244
  // `path` — the compiled artifact, the answer when nobody declares a lane.
245
245
  // Also what a consumer still reading `fetch.path` gets, so teaching the
246
246
  // wire a new field does not break one that has not learned it.
247
247
  //
248
- // `@uniweb/core`'s resolveFetchConfigs gives `collection` precedence and
249
- // drops `path` once it has resolved an address — matching parseFetchConfig,
250
- // which has always returned early on `collection`.
248
+ // `@uniweb/core`'s resolveFetchConfigs gives the query precedence and drops
249
+ // `path` once it has resolved an address — matching parseFetchConfig, which
250
+ // has always returned early on the shorthand.
251
251
  //
252
- // `schema` (the collection name) is BOTH the content.data key and part of the
252
+ // `schema` (the query name) is BOTH the content.data key and part of the
253
253
  // dataStore cache key (deriveCacheKey hashes {path,url,endpoint,schema,…};
254
- // `collection` is ignored). Mirrors the static build's parseFetchConfig.
255
- fetch = { collection, path: collectionDataUrl(collection), schema: collection, ...rest }
254
+ // the shorthand is ignored). Mirrors the static build's parseFetchConfig.
255
+ // `query`, END TO END no crossing. An earlier version emitted `collection`
256
+ // here on the belief that this field was the backend's to name. MEASURED
257
+ // otherwise: framework already ships `transform`, `detailPage`, `merge` and
258
+ // `prerender` inside this same `fetch` object, which no backend could be
259
+ // validating — so `fetch` is a blob they carry and framework owns its
260
+ // vocabulary. ⇒ There was nothing to coordinate, and inventing a coordination
261
+ // is how a name stays wrong.
262
+ fetch = { query, path: queryDataUrl(query), schema: query, ...rest }
256
263
  }
257
264
  setIf(data, 'fetch', fetch)
258
265
  if (isDynamic) {
@@ -667,11 +674,91 @@ export function isSiteRelativeExtensionUrl(decl) {
667
674
  * rename every collection at once and the section goes all-blank and is refused.
668
675
  * That is the semantics, not a defect.
669
676
  *
677
+ * ⛔ DO NOT TRIM THE FIELDS BELOW, even the ones the backend never reads.
678
+ *
679
+ * The backend destructures exactly two — `name` and `schema` — and projects none of
680
+ * this Section into a published payload, so the rest read as dead weight. ⛔ THEY ARE
681
+ * OURS, AND THAT IS REASON ENOUGH: `excerpt`, `deferred`, `detailUrl` and `queryable`
682
+ * are read across FRAMEWORK's own runtime, build and kit — `useQueryable`
683
+ * is a public hook a foundation calls to render a filter UI. They drive the file
684
+ * lane, where they work. "The backend does not read it" was never an argument that
685
+ * nothing reads it.
686
+ *
687
+ * ⚠️ There may be a second reason, and it is NOT ours to assert. Backend states that
688
+ * their decl type reaches the app lane verbatim and that their reconcile
689
+ * replaces an item's `data` wholesale with no field-grain merge — from which an
690
+ * omitted field the EDITOR set would be destroyed on the next push. The mechanism is
691
+ * their code and theirs to state. **Whether the editor reads or writes this decl at
692
+ * all is FRONTEND's, and neither framework nor backend has established it.** Treat it
693
+ * as an open hypothesis, not a fact — see the doc below.
694
+ *
695
+ * ⚠️ Separately measured: framework does not emit `label` and has no such collection
696
+ * field — ours belongs to a `folders:` BRANCH. Backend's fixture asserted we mirror a
697
+ * `site.yml collections.<name>.label`; no such field has ever existed, and they have
698
+ * corrected it.
699
+ *
700
+ * ⇒ Full record, including what is established vs merely claimed:
701
+ * `kb/framework/build/collections-decl-open-questions.md`.
702
+ *
670
703
  * @param {object} declarations resolved collection declarations, keyed by name
671
704
  * @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
672
705
  * response or a pull. Absent on a first sync, where minting is correct.
673
706
  */
674
- function collectionsNested(declarations, uuids = null) {
707
+ // KEYS THAT MUST NOT REACH THE WIRE. Everything else on an authored declaration
708
+ // is emitted, including fields this build does not model — see the note in
709
+ // `queriesNested`. Enumerated here rather than inverted into an allowlist
710
+ // because framework OWNS this vocabulary and can therefore enumerate it
711
+ // truthfully; it does not own the Model's, and cannot.
712
+ //
713
+ // Sources, both framework's own: `site/query-processor.js::parseQueryConfig`
714
+ // (the decl parser) and `site/queries-config.js` (normalization). Pinned by
715
+ // `tests/uwx-decl-unmodelled-fields.test.js`, which fails if either gains a field
716
+ // that is neither emitted nor listed here.
717
+ // Authored keys the explicit block in `queriesNested` already consumes. Kept
718
+ // separate from the framework-local set below because these DO reach the wire —
719
+ // just under a wire spelling. ⚠️ `detailUrl` is the one that matters: it is emitted
720
+ // as `detail_url`, so a pass-through keyed on "is it already in `data`?" does not
721
+ // see it and the field rides TWICE. Measured 2026-08-29, in the first draft of this
722
+ // very change — and the push test missed it because both its controls (`limit`,
723
+ // `schema`) keep their names.
724
+ const DECL_EMITTED_ABOVE = new Set([
725
+ 'source',
726
+ 'schema',
727
+ 'sort',
728
+ 'where',
729
+ 'limit',
730
+ 'excerpt',
731
+ 'deferred',
732
+ 'detailUrl',
733
+ 'queryable'
734
+ ])
735
+
736
+ const DECL_NOT_ON_WIRE = new Set([
737
+ // Identity — rides as the record's own `name`, not inside `data`.
738
+ 'name',
739
+ // Folded into `source` above.
740
+ 'path',
741
+ 'url',
742
+ // Folded into `schema` above (the migration synonym).
743
+ 'model',
744
+ // Build state: whether the AUTHOR asked for the schema or the subfolder-name
745
+ // convention supplied it. Decides hard-error vs soft-skip during sync;
746
+ // `collections-config.js::toConfigQueries` strips it downstream too.
747
+ 'schemaExplicit',
748
+ // ⭐ FRAMEWORK-LOCAL, and the one that proves the rule. `route:` is a real
749
+ // authored field — `parseQueryConfig` reads it, and `collectItems` composes
750
+ // each item's link as `<route>/<slug>` — but the backend's Model has no slot for
751
+ // it, so emitting it would be sending build-time config to a store that validates
752
+ // against a declared schema. Measured 2026-08-29: a first version of this change
753
+ // passed unknown keys through blindly and would have started sending `route` from
754
+ // every site that declares one.
755
+ 'route',
756
+ // Legacy predicate, translated to the canonical `where` upstream. No legacy
757
+ // fields on the wire.
758
+ 'filter'
759
+ ])
760
+
761
+ function queriesNested(declarations, uuids = null) {
675
762
  const out = []
676
763
  for (const [name, d] of Object.entries(declarations)) {
677
764
  const data = {}
@@ -687,6 +774,33 @@ function collectionsNested(declarations, uuids = null) {
687
774
  setIf(data, 'deferred', d.deferred)
688
775
  setIf(data, 'detail_url', d.detailUrl)
689
776
  setIf(data, 'queryable', d.queryable)
777
+ // ⛔ EMIT WHAT WE DO NOT MODEL. The decl's field set is the BACKEND's Model
778
+ // (this document mirrors `@uniweb/site-content` — see the lane header), and
779
+ // their reconcile replaces `data` WHOLESALE with no field-grain merge. So an
780
+ // allowlist here does not merely fail to send an unmodelled field: it DESTROYS
781
+ // whatever was stored under it, silently, on every push.
782
+ //
783
+ // ⚠️ Measured 2026-08-29: the Model declares ELEVEN decl fields and this emitter
784
+ // knew ten. The eleventh is `label`, which framework has no authoring concept
785
+ // for — `label` in framework is a `folders:` BRANCH field (`{segment, label,
786
+ // entries}`), not a property of a collection.
787
+ //
788
+ // ⭐ `label` is the instance, not the defect. Any field the Model gains that we
789
+ // have not taught this function repeats it, and nothing reports the loss. Hence
790
+ // a DENY-list: framework can enumerate its own vocabulary truthfully and cannot
791
+ // enumerate the Model's, so the safe inversion is "withhold what is ours".
792
+ //
793
+ // ⚖️ We do NOT warn on an unrecognized key. Framework cannot tell a valid Model
794
+ // field from a typo — only the server can, and it validates every write against
795
+ // the declared schema. Its rejection is the honest signal; a guess from here
796
+ // would cry wolf on every legitimate new field. Same rule and same reasoning as
797
+ // `site/fetch-shapes.js`: drop only what is DERIVABLE, never what is merely
798
+ // unrecognized.
799
+ for (const [key, value] of Object.entries(d)) {
800
+ if (value === undefined) continue
801
+ if (DECL_EMITTED_ABOVE.has(key) || DECL_NOT_ON_WIRE.has(key)) continue
802
+ data[key] = value
803
+ }
690
804
  const rec = withIdentity(name, { name, ...data })
691
805
  const uuid = uuids?.[name]
692
806
  if (typeof uuid === 'string' && uuid) rec.$uuid = uuid
@@ -695,6 +809,121 @@ function collectionsNested(declarations, uuids = null) {
695
809
  return out
696
810
  }
697
811
 
812
+ // ── `services` + `secrets` — a site's own service records ─────────────────────
813
+ //
814
+ // ⭐ THE FILE KEYS ARE `$services` / `$secrets`, NOT `services` / `secrets`, and the
815
+ // `$` is load-bearing rather than decorative. `site.yml::services` is ALREADY TAKEN,
816
+ // on the other lane: the bundle lane spreads site.yml whole into the payload, so a
817
+ // `services:` block there lands at `config.services` — the HOST tier — which is the
818
+ // documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
819
+ // Reusing the name would give one key two meanings that differ per lane, which is
820
+ // the shape of bug nobody finds.
821
+ //
822
+ // `$` already means "backend-scoped, round-tripped, not hand-authored" in this file
823
+ // (`$uuid`, `$org`, `$backend`), and that is exactly what these are: a service's
824
+ // config is bound where the service is provisioned, and arrives here by `pull`.
825
+ //
826
+ // ⚖️ WHAT THESE ARE NOT. A site's OWN service declarations — `search:`, `submit:`,
827
+ // `assistant:`, `tracking:` — stay top-level `info.*` keys and are untouched. Those
828
+ // are authored, they resolve at the SITE tier (`config.<name>`, first choice in
829
+ // `@uniweb/core`'s `resolveService`), and moving them here would flip them to the
830
+ // host tier, where a block's mere presence declines every service it does not name.
831
+ // These Sections carry the services a site is PROVISIONED with — `api` above all,
832
+ // which has no file-authored form because it is bought, not declared.
833
+ //
834
+ // ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is
835
+ // REPLACED by what we send, so `[]` means "drop every stored config row" while a
836
+ // missing key means "I am not telling you about this". A project that has never
837
+ // pulled has no `$services`, and its ordinary push must not read as a request to
838
+ // wipe a service the operator configured in the app. So: emit the Section only when
839
+ // the file declares the key. Clearing is available and explicit — `$services: []`.
840
+ //
841
+ // ⚠️ The push gate is NOT what makes this safe, though it usually catches it: its
842
+ // tokens live in a gitignored per-clone cache, so a fresh clone pushes
843
+ // unconditionally. Correctness has to sit here.
844
+
845
+ /**
846
+ * `$services` / `$secrets` → Section records, or undefined when the key is absent.
847
+ *
848
+ * ⭐ PASSTHROUGH, NOT AN ALLOWLIST — the same rule and the same reason as
849
+ * `queriesNested` above. The field set belongs to the backend's Model, a service's
850
+ * `config` is opaque and per-service, and reconcile replaces `data` wholesale — so
851
+ * enumerating keys here would not merely fail to send a field we do not know, it
852
+ * would DESTROY whatever is stored under it on every push. Framework can enumerate
853
+ * its own vocabulary and cannot enumerate theirs; withhold ours, forward the rest.
854
+ *
855
+ * @param {*} declared - the raw `$services` / `$secrets` value from site.yml
856
+ * @param {(entry: object) => string|null} identify - the record's stable `$id`
857
+ * @param {string} label - the key name, for the one warning below
858
+ * @returns {object[]|undefined}
859
+ */
860
+ function serviceRecords(declared, identify, label) {
861
+ if (declared === undefined || declared === null) return undefined
862
+ if (!Array.isArray(declared)) {
863
+ console.warn(
864
+ `uwx/site: \`${label}:\` must be a list of entries — ignoring a ${typeof declared}.`
865
+ )
866
+ return undefined
867
+ }
868
+ const out = []
869
+ for (const entry of declared) {
870
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
871
+ const id = identify(entry)
872
+ // ⚠️ `name` is OUR OWN declared-required field, so a warning here is honest —
873
+ // unlike an unrecognized key, which only the server can judge. And the loss is
874
+ // otherwise invisible: on a replaced Section a dropped entry reads as a
875
+ // deliberate removal of its config row.
876
+ if (!id) {
877
+ console.warn(
878
+ `uwx/site: \`${label}:\` has an entry with no \`name\` — skipping it. On a replaced ` +
879
+ 'section a dropped entry reads as a deliberate removal of its config.'
880
+ )
881
+ continue
882
+ }
883
+ const data = {}
884
+ for (const [key, value] of Object.entries(entry)) {
885
+ if (value === undefined) continue
886
+ data[key] = value
887
+ }
888
+ out.push(withIdentity(id, data))
889
+ }
890
+ return out
891
+ }
892
+
893
+ /** One service per `name` — the same keyspace `config.services` uses at runtime. */
894
+ function servicesNested(siteYml) {
895
+ return serviceRecords(
896
+ siteYml.$services,
897
+ (e) => (typeof e.name === 'string' && e.name ? e.name : null),
898
+ '$services'
899
+ )
900
+ }
901
+
902
+ /**
903
+ * One secret per `(service, name)` — the pair the backend merges on. A site-level
904
+ * secret belongs to no service, so `service` is optional and the handle degrades to
905
+ * the bare name.
906
+ *
907
+ * ⛔ `value` IS FORWARDED VERBATIM, INCLUDING A LITERAL. A pulled secret carries the
908
+ * marker `#ref` meaning "a secret is set", never the value, and pushing the marker
909
+ * back means "leave it alone" — so the ordinary round trip sends nothing sensitive.
910
+ * A literal typed into the file is refused by the server, which is where that
911
+ * judgement belongs; framework does not strip it, because silently dropping a value
912
+ * an author typed would leave them believing a secret was set.
913
+ */
914
+ function secretsNested(siteYml) {
915
+ return serviceRecords(
916
+ siteYml.$secrets,
917
+ (e) => {
918
+ if (typeof e.name !== 'string' || !e.name) return null
919
+ return typeof e.service === 'string' && e.service
920
+ ? `${e.service}:${e.name}`
921
+ : e.name
922
+ },
923
+ '$secrets'
924
+ )
925
+ }
926
+
698
927
  /**
699
928
  * Map a file site project to the nested `@uniweb/site-content` `$`-document
700
929
  * (see the lane header above). PURE — reads the project, never mints, never writes.
@@ -709,7 +938,7 @@ function collectionsNested(declarations, uuids = null) {
709
938
  * the site's effective default locale (`defaultLanguage || languages[0] ||
710
939
  * 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
711
940
  * @returns {Promise<object>} the section-keyed `$`-document:
712
- * `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, collections }`
941
+ * `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, queries }`
713
942
  */
714
943
  export async function siteProjectToDocument(siteRoot, opts = {}) {
715
944
  const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
@@ -843,13 +1072,38 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
843
1072
  // secret store is the only right home either way.
844
1073
 
845
1074
  setIf(info, 'tracking', stripCredentials(siteYml.tracking, 'tracking'))
1075
+ // ⛔ `api` IS DELIBERATELY NOT HERE, and this note exists because every comment
1076
+ // above it argues the opposite — three services are on this allowlist precisely so
1077
+ // an authored block cannot work on a static host and vanish on the synced one.
1078
+ // Without this paragraph the next reader adds the missing fourth line and calls it
1079
+ // a bug fix.
1080
+ //
1081
+ // ⭐ `api` is the one service a site does not AUTHOR. It is a real backend that is
1082
+ // provisioned and paid for, so its address is the host's to supply — it arrives as
1083
+ // `config.services.api` and `@uniweb/api` reads it there (`resolveBase`). An
1084
+ // authored `api:` is the SITE tier, which outranks the host permanently.
1085
+ //
1086
+ // ⇒ Carrying it would turn a local-dev override into a production one the moment
1087
+ // someone pushed: the host would store `info.api` and serve it back as `config.api`,
1088
+ // which wins over the address of the backend the site actually has. The vanish on
1089
+ // this lane is the correct behaviour, not the bug the comments above describe —
1090
+ // there, a dropped block leaves a site with NO endpoint; here it leaves the site
1091
+ // with the RIGHT one.
1092
+ //
1093
+ // The provisioned record rides the `$services` section instead (see servicesNested).
846
1094
  setIf(info, 'paths', siteYml.paths)
847
1095
  setIf(info, 'data', siteYml.data ?? siteYml.fetch)
848
- // `app` — the deployment's `@uniweb/app-spec` reference (a bare uuid string),
849
- // bound when the site is hosted as a composite. Authored config that round-trips
850
- // verbatim/opaque (like `foundation`), so a pull→edit→push preserves it; it is
851
- // deployment-LOCAL (not portable across deployments). (uwx-format.md → info.app.)
852
- setIf(info, 'app', siteYml.app)
1096
+ // `app` IS RETIRED do not reintroduce it, in either direction. It carried an
1097
+ // opaque uuid naming a separate entity a host bound to the site; that entity is
1098
+ // gone, a site's services belong to the site itself, and NOTHING replaces the key.
1099
+ //
1100
+ // ⚠️ Removing the emit is the FIRST of two steps and the order is forced: a host
1101
+ // refuses a key it does not declare, so the producer stops sending before the
1102
+ // declaration is dropped. The reverse order fails every push in between.
1103
+ //
1104
+ // ✅ Unobservable, because nothing ever originated the key — no template writes
1105
+ // `site.yml::app`. The line round-tripped a value that was never set.
1106
+ // (uwx-format.md → info.app.)
853
1107
  // `template: true` designates this site as a clonable SITE-TEMPLATE: on push the
854
1108
  // backend applies a clonability designation to this site-content entity (it is
855
1109
  // NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
@@ -881,7 +1135,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
881
1135
 
882
1136
  // Collection DECLARATIONS — the merged collections.yml + site.yml::collections
883
1137
  // config (the records themselves are separate entities; this is just the config).
884
- const colConfig = await resolveCollectionsConfig(siteRoot, { siteYml })
1138
+ const colConfig = await resolveQueriesConfig(siteRoot, { siteYml })
885
1139
 
886
1140
  // `$uuid?` then `$id` `$model`, then sections in Model-declared order. The entity
887
1141
  // `$uuid` lives in site.yml (back-filled after first sync); absent on first sync.
@@ -895,7 +1149,21 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
895
1149
  doc.pages = pages
896
1150
  doc.layout_sections = layoutSections
897
1151
  doc.extensions = extensionsNested(siteYml)
898
- doc.collections = collectionsNested(colConfig.declarations, opts.collectionUuids)
1152
+ // THE SECTION IS `queries`. Backend renamed it on `@uniweb/site-content`
1153
+ // (2026-08-29) and explains it as named queries — content that is RESOLVED AT
1154
+ // RUNTIME rather than rendered, which is framework's own model of it
1155
+ // (`records-model.md` §1: a query is second-order site content).
1156
+ //
1157
+ // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1158
+ // consumer sees, leave the identifier alone.
1159
+ doc.queries = queriesNested(colConfig.declarations, opts.queryUuids)
1160
+ // Emitted ONLY when the file declares the key — see the header above
1161
+ // `serviceRecords`: on a replaced Section, absent and empty are different
1162
+ // requests and one of them is destructive.
1163
+ const services = servicesNested(siteYml)
1164
+ if (services) doc.services = services
1165
+ const secrets = secretsNested(siteYml)
1166
+ if (secrets) doc.secrets = secrets
899
1167
  return doc
900
1168
  }
901
1169
 
@@ -2,7 +2,7 @@
2
2
  // lanes, each its own `.uwx`:
3
3
  //
4
4
  // - site-content lane → one `@uniweb/site-content` entity (the static half).
5
- // - collections lane → one `@uniweb/folder` entity + the collection records it
5
+ // - records lane → one `@uniweb/folder` entity + the record entities it
6
6
  // references (the dynamic half; the `$ref` closure rides
7
7
  // together so brand-new records resolve in one call).
8
8
  //
@@ -14,12 +14,12 @@
14
14
  //
15
15
  // "Send only changed" spans both lanes via one content-hash map (the sync-cache):
16
16
  // - site-content lane fires iff the site entity changed.
17
- // - collections lane fires iff the folder changed OR any record changed — and when
17
+ // - records lane fires iff the folder changed OR any record changed — and when
18
18
  // it fires it carries the FULL folder (for the `$ref` closure + binding) plus the
19
- // changed records. An untouched site with collections pushes nothing on either
19
+ // changed records. An untouched site with records pushes nothing on either
20
20
  // lane (the idempotent no-op).
21
21
 
22
- import { buildCollectionEntities, entityContentHash } from './collections.js'
22
+ import { buildRecordEntities, entityContentHash } from './records.js'
23
23
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
24
24
  import { buildFolderEntity } from './folder.js'
25
25
  import { siteProjectToDocument } from './site.js'
@@ -235,11 +235,11 @@ function rewriteEntityAssets(node, map, ids) {
235
235
  * @param {object} [opts.exporter] @param {string} [opts.exportedAt]
236
236
  * @returns {Promise<{
237
237
  * siteContent: { buffer, entityCount, index, models }|null,
238
- * collections: { buffer, entityCount, index, models }|null,
238
+ * records: { buffer, entityCount, index, models }|null,
239
239
  * hashes: Object<string,string>, warnings: string[], skipped: number,
240
240
  * schemaless: Array<{name: string, model: string}>, localAssets: string[],
241
241
  * applied: object }>}
242
- * `schemaless` lists collections that resolved no data schema (soft-skipped from
242
+ * `schemaless` lists queries that resolved no data schema (soft-skipped from
243
243
  * the sync) — the composite deploy delivers these statically via the data ball.
244
244
  * `localAssets` lists the site-root local media refs (`/images/x.png`) the deploy
245
245
  * must upload + rewrite to serve URLs; co-located refs are warned and skipped.
@@ -247,7 +247,7 @@ function rewriteEntityAssets(node, map, ids) {
247
247
  * (`assetRewrite` / `assetIds` / `injectInfo` / `injectExtensions`), ready to be
248
248
  * passed straight back as opts. A caller that banks the `hashes` must bank this
249
249
  * beside them, or an offline re-emit cannot reproduce the document they describe.
250
- * Each lane is null when it has nothing to push. The collections `index` keeps a
250
+ * Each lane is null when it has nothing to push. The records `index` keeps a
251
251
  * leading `{ kind: 'folder' }` placeholder (submission position 0 → the folder
252
252
  * entity) so record back-fill stays positionally aligned; the folder itself has no
253
253
  * uuid to back-fill.
@@ -261,22 +261,31 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
261
261
  const exporter = opts.exporter
262
262
  const exportedAt = opts.exportedAt
263
263
 
264
- const col = await buildCollectionEntities(siteRoot, {
264
+ const col = await buildRecordEntities(siteRoot, {
265
265
  ...(opts.foundationDir ? { foundationDir: opts.foundationDir } : {}),
266
266
  ...(opts.resolveModel ? { resolveModel: opts.resolveModel } : {}),
267
267
  ...(sourceLocale ? { sourceLocale } : {}),
268
268
  // The publish org — resolves a foundation-relative `@/x` model ref into
269
269
  // `@org/x` before it ships. Absent on an offline probe, which is why
270
- // buildCollectionEntities warns rather than throws.
270
+ // buildRecordEntities warns rather than throws.
271
271
  ...(opts.org ? { org: opts.org } : {}),
272
272
  })
273
- const warnings = [...col.warnings]
273
+ const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
274
274
 
275
275
  // The folder rides over the FULL record set (before filtering) so its references
276
276
  // are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
277
277
  const folder = buildFolderEntity({
278
278
  recordEntities: col.entities,
279
- folders: col.colConfig?.folders ?? null,
279
+ // ⭐ AUTHORED, from `records.yml`. It used to be derived — one branch per
280
+ // collection — which made the folder a shadow of a directory layout rather
281
+ // than something the author states.
282
+ folderNodes: col.folder?.nodes ?? [],
283
+ // ⛔ Whether `records.yml` EXISTS — not whether it holds anything. Missing is
284
+ // inert; empty is a folder that REMOVES. Compared against the affirmative
285
+ // value, never `!== 'missing'`: an absent state would read as declared, and
286
+ // that is precisely how a site with no records.yml once emitted a folder that
287
+ // would have emptied the live one.
288
+ declared: col.recordsState === 'empty' || col.recordsState === 'declared',
280
289
  // Placement identity from the folder document a previous push returned.
281
290
  // Absent on a first push — every item is genuinely new then. Its ABSENCE on a
282
291
  // later push is what made `publish` after `push` fail: send-only-changed skips
@@ -284,13 +293,18 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
284
293
  // the payload whose item identity has to survive.
285
294
  ...(opts.folderItemUuids ? { itemUuids: opts.folderItemUuids } : {}),
286
295
  })
296
+ // ⚠️ A placement that produced no entity is dropped by the builder rather than
297
+ // sent pointing at nothing — but it must still be SAID. It means an entity was
298
+ // placed in records.yml and then skipped upstream (a schema that did not
299
+ // resolve, most often), and the record is simply absent from the site.
300
+ if (folder?.warnings?.length) warnings.push(...folder.warnings)
287
301
 
288
- // `collectionUuids` — identity for the `collections` section, keyed by collection
289
- // NAME because a declaration has no file of its own (see collectionsNested).
302
+ // `queryUuids` — identity for the `queries` section, keyed by query
303
+ // NAME because a declaration has no file of its own (see queriesNested).
290
304
  const siteDoc = includeSite
291
305
  ? await siteProjectToDocument(siteRoot, {
292
306
  sourceLocale,
293
- ...(opts.collectionUuids ? { collectionUuids: opts.collectionUuids } : {})
307
+ ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {})
294
308
  })
295
309
  : null
296
310
  // Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are
@@ -398,14 +412,14 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
398
412
  return isChanged
399
413
  }
400
414
 
401
- // --- collections lane --------------------------------------------------------
415
+ // --- records lane ------------------------------------------------------------
402
416
  // changed() has side effects (hashes/skipped), so evaluate every entity exactly
403
417
  // once, in a stable order: folder, then each record.
404
418
  const folderChanged = folder ? changed(folder) : false
405
419
  const recordChanged = col.entities.map((e, i) => ({ entity: e, index: col.index[i], changed: changed(e) }))
406
420
  const changedRecords = recordChanged.filter((r) => r.changed)
407
421
 
408
- let collections = null
422
+ let records = null
409
423
  if (folder && (folderChanged || changedRecords.length > 0)) {
410
424
  // Folder first (always, for the `$ref` closure), then changed records. The
411
425
  // leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
@@ -416,7 +430,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
416
430
  // filtered out here by send-only-changed. Declare them all (the backend rejects a
417
431
  // folder that references an undeclared Model).
418
432
  const referencedModels = [...collectReferencedModels(folder.document, new Set())]
419
- collections = { ...emitLane(entities, exporter, exportedAt, referencedModels), index }
433
+ records = { ...emitLane(entities, exporter, exportedAt, referencedModels), index }
420
434
  }
421
435
 
422
436
  // --- site-content lane -------------------------------------------------------
@@ -455,7 +469,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
455
469
  }
456
470
 
457
471
  return {
458
- siteContent, collections, siteContentUuid, hashes, warnings, skipped,
472
+ siteContent, records, siteContentUuid, hashes, warnings, skipped,
459
473
  schemaless: col.schemaless, localAssets, applied,
460
474
  // { stamped, unknown } when identity was applied; null when the caller passed
461
475
  // no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises'
27
27
  import { existsSync } from 'node:fs'
28
28
  import { join, resolve, basename } from 'node:path'
29
29
  import yaml from 'js-yaml'
30
- import { collectionNameFromUrl } from '@uniweb/core'
30
+ import { queryNameFromUrl } from '@uniweb/core'
31
31
 
32
32
  import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
33
33
  import { validateAndNormalizeSchema } from './resolve-data-schema.js'
@@ -39,7 +39,7 @@ export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
39
39
  import { buildSchema } from './schema.js'
40
40
  import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
41
41
  import { collectSiteContent } from './site/content-collector.js'
42
- import { processCollections } from './site/collection-processor.js'
42
+ import { processQueries } from './site/query-processor.js'
43
43
 
44
44
  // --- the join: sections ↔ schemas -------------------------------------------
45
45
 
@@ -58,7 +58,7 @@ import { processCollections } from './site/collection-processor.js'
58
58
  * this command and the build disagree about what feeds what.
59
59
  *
60
60
  * Data is acquired without a full build: the foundation schema via schema
61
- * discovery, the site sections via the content collector, the collections via
61
+ * discovery, the site sections via the content collector, the byQuery via
62
62
  * the collection processor (in-memory, full records — so `deferred:`
63
63
  * field-stripping never causes a false "missing required").
64
64
  *
@@ -85,15 +85,13 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
85
85
  const config = site.config || {}
86
86
  const basePath = typeof config.base === 'string' ? config.base : '/'
87
87
 
88
- // Compile file-based collections in-memory (the same step the data-only
89
- // pipeline runs). Full records — `writeCollectionFiles` is the stage that
88
+ // Compile file-based byQuery in-memory (the same step the data-only
89
+ // pipeline runs). Full records — `writeQueryFiles` is the stage that
90
90
  // strips `deferred:` fields, and we skip it.
91
- let collections = {}
92
- if (config.collections && typeof config.collections === 'object') {
93
- const collectionsBase = config.paths?.collections
94
- ? resolve(siteRoot, config.paths.collections)
95
- : null
96
- collections = await processCollections(siteRoot, config.collections, collectionsBase, basePath)
91
+ let byQuery = {}
92
+ if (config.queries && typeof config.queries === 'object') {
93
+
94
+ byQuery = await processQueries(siteRoot, config.queries, config.paths?.entities, basePath)
97
95
  }
98
96
 
99
97
  // Pass 1 — discover unique (file, schema-ref) pairs and who uses each.
@@ -144,7 +142,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
144
142
  let recordCount = 0
145
143
 
146
144
  for (const entry of work.values()) {
147
- const { records, error } = await resolveRecords(entry.path, { collections, siteRoot })
145
+ const { records, error } = await resolveRecords(entry.path, { byQuery, siteRoot })
148
146
  if (error) {
149
147
  setupErrors.push({ file: entry.path, message: error, users: entry.users })
150
148
  continue
@@ -252,7 +250,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
252
250
  * works on a link-mode site whose foundation is a registry ref with nothing
253
251
  * local), and an unresolved tag must be silent rather than an error. So the
254
252
  * package is resolved from this build's own graph, where it is an
255
- * optionalDependency, exactly as `i18n/collections.js` resolves it.
253
+ * optionalDependency, exactly as `i18n/records.js` resolves it.
256
254
  *
257
255
  * @param {Object} site - collected site content (`{ pages }`)
258
256
  * @returns {Promise<{ violations: Array, schemas: Set<string>, checked: number }>}
@@ -337,7 +335,7 @@ function nodesOfType(doc, type) {
337
335
  * This is the pass that closes an odd hole: a component declares
338
336
  * `data: { form: '@std/form' }`, an author writes a ```` ```yaml:form ```` block,
339
337
  * and until now **nothing checked one against the other**. The join walked
340
- * `section.fetch` — collections and fetches — so a schema bound to a key that a
338
+ * `section.fetch` — byQuery and fetches — so a schema bound to a key that a
341
339
  * tagged block fills was never applied to anything. `@std/form` existed for
342
340
  * exactly this and had never run outside its own contract test.
343
341
  *
@@ -462,17 +460,17 @@ function walkSections(sections, visit) {
462
460
  }
463
461
 
464
462
  /**
465
- * Resolve a fetch `path` to its records. Declared collections come from the
463
+ * Resolve a fetch `path` to its records. Declared byQuery come from the
466
464
  * in-memory compile (full records, current); a bare file under `public/`
467
465
  * (hand-authored data) is read from disk. Either way no prior build is needed.
468
466
  */
469
- async function resolveRecords(path, { collections, siteRoot }) {
467
+ async function resolveRecords(path, { byQuery, siteRoot }) {
470
468
  // A compiled-collection URL → a declared collection? Use the compiled
471
469
  // records. Anything else falls through to the file read below.
472
- const name = collectionNameFromUrl(path)
470
+ const name = queryNameFromUrl(path)
473
471
  let records
474
- if (Object.prototype.hasOwnProperty.call(collections, name)) {
475
- records = collections[name]
472
+ if (Object.prototype.hasOwnProperty.call(byQuery, name)) {
473
+ records = byQuery[name]
476
474
  } else {
477
475
  // Otherwise read the file from public/ (the data-fetcher's resolution root).
478
476
  const filePath = join(siteRoot, 'public', path)