@uniweb/build 0.55.1 → 0.57.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.
@@ -5,14 +5,21 @@
5
5
  * by name. Read-only — never writes. See deploy-config-writer.js for
6
6
  * the write path.
7
7
  *
8
- * On-disk shape:
8
+ * On-disk shape (spec: kb/framework/reference/deploy-yml.md):
9
9
  * default: production
10
10
  * targets:
11
- * production: { host, ...host-specific config }
11
+ * production: { host, backend?, ...host-specific config }
12
12
  * preview: { host, ...host-specific config }
13
- * autoSave: lastDeploy | off | full
14
- * lastDeploy:
15
- * production: { at, url, foundation, runtime, artifactSha, ... }
13
+ * deploys:
14
+ * production: { at, host, url?, git?, servicesRequest? }
15
+ * saveDeploys: true
16
+ *
17
+ * ⚠️ `deploys` was `lastDeploy` and `saveDeploys` was `autoSave: off|lastDeploy|full`
18
+ * until 2026-09-20. `full` was "reserved; behaves as lastDeploy" — so the tri-state
19
+ * was already a boolean in practice, and the rename says so. `autoSave` also
20
+ * SOUNDED like it covered the whole file, which it never did.
21
+ *
22
+ * ⚠️ Nothing a backend minted lives here any more. Identity is `sync.json`'s.
16
23
  *
17
24
  * The CLI and the build pipeline both call resolveTarget() to turn a
18
25
  * loaded document + a (possibly null) --target flag into a concrete
@@ -26,9 +33,9 @@ import { existsSync } from 'node:fs'
26
33
  import { join } from 'node:path'
27
34
  import yaml from 'js-yaml'
28
35
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
36
+ import { normalizeOrigin } from '../uwx/sync-store.js'
29
37
 
30
38
  const DEFAULT_TARGET_NAME = 'production'
31
- const VALID_AUTOSAVE = new Set(['off', 'lastDeploy', 'full'])
32
39
 
33
40
  /**
34
41
  * Load and validate deploy.yml from `siteDir`. Returns null when the
@@ -57,7 +64,7 @@ export async function loadDeployYml(siteDir) {
57
64
  * targetName: string,
58
65
  * host: string,
59
66
  * config: object,
60
- * autoSave: 'off'|'lastDeploy'|'full',
67
+ * saveDeploys: boolean,
61
68
  * fromFile: boolean,
62
69
  * }}
63
70
  */
@@ -73,7 +80,7 @@ export function resolveTarget(deployYml, requestedTarget) {
73
80
  targetName: DEFAULT_TARGET_NAME,
74
81
  host: 'uniweb',
75
82
  config: {},
76
- autoSave: 'lastDeploy',
83
+ saveDeploys: true,
77
84
  fromFile: false,
78
85
  }
79
86
  }
@@ -99,11 +106,73 @@ export function resolveTarget(deployYml, requestedTarget) {
99
106
  targetName: name,
100
107
  host,
101
108
  config,
102
- autoSave: deployYml.autoSave || 'lastDeploy',
109
+ saveDeploys: deployYml.saveDeploys !== false,
103
110
  fromFile: true,
104
111
  }
105
112
  }
106
113
 
114
+ /**
115
+ * The target a publish to `origin` records its deploy under.
116
+ *
117
+ * ⭐ **The target follows the backend, never the reverse.** A publish goes to ONE backend,
118
+ * decided before this runs — `--backend`, or the backend the user is logged in to
119
+ * *[Diego, 2026-09-21: "publish should publish to the backend the user logged in to"]*.
120
+ * The default target is only a tie-break, winning when it names that backend too.
121
+ *
122
+ * 1. no deploy.yml → the scaffold: `production`, naming `origin`
123
+ * 2. `host: uniweb` targets naming `origin` → the default one if it is among them,
124
+ * else the first by name. A target with no `backend:` names `defaultBackend` —
125
+ * its documented meaning
126
+ * 3. none → a NEW target, named after the backend's host (`uniweb.app`,
127
+ * `localhost:8080`), `-2`, `-3`… if taken. The caller adds it when it persists.
128
+ *
129
+ * ⛔ Until 2026-09-21 publish always resolved the DEFAULT target, whatever backend it
130
+ * published to: a publish to a second backend recorded over the default target's deploy,
131
+ * and compared its service request against that target's fingerprint.
132
+ *
133
+ * @param {object|null} deployYml - from loadDeployYml
134
+ * @param {string} origin - the backend being published to
135
+ * @param {{ defaultBackend?: string }} [opts]
136
+ * @returns {{ targetName: string, host: 'uniweb', config: object, saveDeploys: boolean,
137
+ * fromFile: boolean }} `fromFile: false` means the file has no such target yet
138
+ */
139
+ export function resolvePublishTarget(deployYml, origin, { defaultBackend } = {}) {
140
+ const key = normalizeOrigin(origin)
141
+ if (!deployYml) {
142
+ return {
143
+ targetName: DEFAULT_TARGET_NAME,
144
+ host: 'uniweb',
145
+ config: { backend: key },
146
+ saveDeploys: true,
147
+ fromFile: false,
148
+ }
149
+ }
150
+ const saveDeploys = deployYml.saveDeploys !== false
151
+ const targets = deployYml.targets || {}
152
+ const naming = Object.keys(targets)
153
+ .filter((name) => {
154
+ const t = targets[name]
155
+ return t && t.host === 'uniweb' && normalizeOrigin(t.backend || defaultBackend) === key
156
+ })
157
+ .sort()
158
+ if (naming.length) {
159
+ const targetName = naming.includes(deployYml.default) ? deployYml.default : naming[0]
160
+ const { host, ...config } = targets[targetName]
161
+ return { targetName, host, config, saveDeploys, fromFile: true }
162
+ }
163
+ let base = 'uniweb'
164
+ try {
165
+ base = new URL(key).host || base
166
+ } catch {
167
+ /* keep the fallback */
168
+ }
169
+ let targetName = base
170
+ for (let i = 2; Object.prototype.hasOwnProperty.call(targets, targetName); i++) {
171
+ targetName = `${base}-${i}`
172
+ }
173
+ return { targetName, host: 'uniweb', config: { backend: key }, saveDeploys, fromFile: false }
174
+ }
175
+
107
176
  function validate(doc, path) {
108
177
  if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
109
178
  throw new Error(`${path}: top level must be a map.`)
@@ -111,39 +180,19 @@ function validate(doc, path) {
111
180
  if (doc.targets !== undefined && (typeof doc.targets !== 'object' || Array.isArray(doc.targets))) {
112
181
  throw new Error(`${path}: \`targets\` must be a map.`)
113
182
  }
114
- // A site has exactly ONE Uniweb identity — `site.yml::$uuid`, a single scalar,
115
- // set by the create and never moved. So two targets naming `host: uniweb` cannot
116
- // both be coherent: whichever one `default:` selects is the site, and the other
117
- // silently describes a site that does not exist. The per-target `backend` key
118
- // makes that look expressible, which is exactly why it is worth rejecting rather
119
- // than leaving to be discovered.
120
- //
121
- // *(Diego, 2026-08-12: multiple deploys are not expected in general, and are not
122
- // allowed for Uniweb Cloud specifically — the only host that reads `$uuid` at
123
- // all. Every other host is stateless from the CLI's side, so any number of those
124
- // targets is fine.)*
125
- if (doc.targets && typeof doc.targets === 'object' && !Array.isArray(doc.targets)) {
126
- const uniwebTargets = Object.entries(doc.targets)
127
- .filter(([, t]) => t && typeof t === 'object' && t.host === 'uniweb')
128
- .map(([name]) => name)
129
- if (uniwebTargets.length > 1) {
130
- throw new Error(
131
- `${path}: only one target may use \`host: uniweb\` — found ${uniwebTargets.length} (${uniwebTargets.join(', ')}). ` +
132
- 'A site has a single Uniweb identity (site.yml::$uuid), so a second Uniweb target cannot describe a different site. ' +
133
- 'Keep one, or point the others at a third-party host.'
134
- )
135
- }
136
- }
137
- if (doc.autoSave !== undefined && !VALID_AUTOSAVE.has(doc.autoSave)) {
138
- throw new Error(
139
- `${path}: \`autoSave\` must be one of: ${[...VALID_AUTOSAVE].join(', ')}.`
140
- )
183
+ // ⭐ SEVERAL `host: uniweb` TARGETS ARE LEGAL (2026-09-20). This rejected a second one
184
+ // because a site had a single identity, `site.yml::$uuid`, so two Uniweb targets
185
+ // could not both be coherent. Identity is keyed by backend origin in `sync.json`
186
+ // now: two targets naming DIFFERENT backends are two sites, and two naming the SAME
187
+ // backend are one site shipped twice. The reason is gone, so the rejection is too.
188
+ if (doc.saveDeploys !== undefined && typeof doc.saveDeploys !== 'boolean') {
189
+ throw new Error(`${path}: \`saveDeploys\` must be true or false.`)
141
190
  }
142
191
  if (doc.default !== undefined && typeof doc.default !== 'string') {
143
192
  throw new Error(`${path}: \`default\` must be a string.`)
144
193
  }
145
- if (doc.lastDeploy !== undefined && (typeof doc.lastDeploy !== 'object' || Array.isArray(doc.lastDeploy))) {
146
- throw new Error(`${path}: \`lastDeploy\` must be a map.`)
194
+ if (doc.deploys !== undefined && (typeof doc.deploys !== 'object' || Array.isArray(doc.deploys))) {
195
+ throw new Error(`${path}: \`deploys\` must be a map.`)
147
196
  }
148
197
  return doc
149
198
  }
@@ -1,49 +1,130 @@
1
- // A site's ENTITY POOL — every stored thing on disk, and the model each one has.
1
+ // A site's RECORDS on disk — every file in `records/`, and the model each one has.
2
2
  //
3
- // ⛔ THE PATH DECLARES THE MODEL, AND NOTHING ELSE. That is the whole point of
4
- // this directory, and it is the de-conflation the records model is built on:
5
- // `collections/<name>/` used to mean three things at once — these files are
6
- // entities, their schema is `@/<name>`, and they are grouped as `<name>` for
7
- // placement. `entities/{schema}/` declares only the first two. Grouping moved to
8
- // `records.yml`, which is what makes an entity a RECORD.
3
+ // ⭐ PLACING A FILE IN `records/` IS WHAT MAKES IT A RECORD (ruled 2026-09-21
4
+ // [Diego]). The directory is the file side of the site's records folder: the
5
+ // backend's folder holds references to entities, and putting a ref in it is what
6
+ // makes one a record — putting a file in this directory is the same act. Nothing
7
+ // else is needed, and nothing lists it. (`records.yml` only organizes records into
8
+ // sub-folders; see `records-config.js`.) A file whose name starts with `_` or `.`
9
+ // is not read, so it is not a record.
9
10
  //
10
- // ⇒ So nothing here reads a query, a folder, or any config. A pool is a fact
11
- // about the filesystem.
11
+ // ⛔ A RECORD IS NOT "PUBLISHED" BY BEING HERE. `push` sends the site's records to a
12
+ // backend, and they are served once the SITE is published — publishing the site
13
+ // publishes its folder and what is in it. Nothing about a single record decides that.
14
+ //
15
+ // ⛔ THE PATH DECLARES THE MODEL, AND NOTHING ELSE. `collections/<name>/` used to
16
+ // mean three things at once — these files are entities, their schema is
17
+ // `@/<name>`, and they are grouped as `<name>` for placement. A schema folder here
18
+ // declares only the model; grouping is `records.yml`'s.
19
+ //
20
+ // ⇒ So nothing here reads a query or a folder. A site's records are a fact about
21
+ // the filesystem — plus `site.yml::paths.records`, which says where they live.
12
22
  //
13
23
  // ⭐ DEPTH NAMES THE SCOPE — the schema-ref grammar, spelled as directories:
14
24
  //
15
- // entities/person/ada.md → @/person (the foundation's own)
16
- // entities/std/person/ada.md → @std/person (the shared standard set)
17
- // entities/acme/project/x.md → @acme/project (an org's)
25
+ // records/person/ada.md → @/person (the foundation's own)
26
+ // records/std/person/ada.md → @std/person (the shared standard set)
27
+ // records/acme/project/x.md → @acme/project (an org's)
18
28
  //
19
29
  // matching `build/src/resolve-data-schema.js`, which is the only grammar there
20
30
  // is: a bare directory name can mean `@/<name>` and nothing else.
21
31
  //
22
- // ⛔ BARE, NOT `entities/@std/`. Measured: `@` is a reserved indicator in YAML
32
+ // ⛔ BARE, NOT `records/@std/`. Measured: `@` is a reserved indicator in YAML
23
33
  // 1.2, so a bare `@std/person/*.md` scalar throws in js-yaml — and the message is
24
34
  // `bad indentation of a sequence entry`, which names neither the cause nor the
25
- // fix. The `@` form would force quotes on the most common line in `records.yml`
26
- // and put two spellings in one list.
35
+ // fix. The `@` form would force quotes on every pattern in `records.yml` that
36
+ // names a scoped schema.
27
37
  //
28
38
  // ⛔ AND NO NESTING BELOW THE SCHEMA DIR, which is what makes the depth rule
29
39
  // total: it is the FILE's depth that decides, so one path answers the question
30
- // with nothing to classify and no ambiguous case to resolve.
40
+ // with nothing to classify and no ambiguous case to resolve. Organization is a
41
+ // `folder:` in `records.yml`, never a directory.
31
42
  //
32
- // ⭐ That costs nothing — it CLOSES a divergence that was live and silent.
33
- // `uwx/collection-source.js::reportNestedRecords` warns today that records below
34
- // a collection's top level "build and render locally but are absent from the
35
- // synced set": the delivery lane recursed and the sync lane was one level deep.
36
- // `records.yml` replaces on-disk nesting outright, so the two lanes stop
37
- // disagreeing rather than being taught to agree.
43
+ // ⛔ THE DIRECTORY WAS `entities/` UNTIL 2026-09-21, and `site.yml::paths.entities`
44
+ // the key that moved it. Both are refused by name rather than read (no alias —
45
+ // there is no population to carry): a leftover `entities/` beside `records/` would
46
+ // otherwise hold records nothing reads, silently.
38
47
 
39
48
  import { readdir } from 'node:fs/promises'
40
- import { existsSync } from 'node:fs'
41
- import { join, extname, basename } from 'node:path'
49
+ import { existsSync, readFileSync, statSync } from 'node:fs'
50
+ import { join, extname, basename, resolve } from 'node:path'
51
+ import yaml from 'js-yaml'
52
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
53
+
54
+ /** Where a site's records live, relative to its root, unless `site.yml::paths.records` moves them. */
55
+ export const RECORDS_DIR = 'records'
56
+
57
+ /** The directory's name until 2026-09-21. Read only to refuse it. */
58
+ const RETIRED_DIR = 'entities'
59
+
60
+ function isDirectory(path) {
61
+ try {
62
+ return statSync(path).isDirectory()
63
+ } catch {
64
+ return false
65
+ }
66
+ }
42
67
 
43
- /** Where a site's entities live, relative to its root. */
44
- export const ENTITIES_DIR = 'entities'
68
+ function readSitePaths(siteRoot) {
69
+ const file = join(siteRoot, 'site.yml')
70
+ if (!existsSync(file)) return {}
71
+ try {
72
+ const doc = yaml.load(readFileSync(file, 'utf8'), YAML_OPTIONS)
73
+ return doc && typeof doc.paths === 'object' && doc.paths !== null ? doc.paths : {}
74
+ } catch {
75
+ // An unreadable site.yml is reported by whoever reads the site; this only
76
+ // needs the one key, and the default is the right answer without it.
77
+ return {}
78
+ }
79
+ }
45
80
 
46
- /** Source extensions an entity file may have. Mirrors the sync-lane reader. */
81
+ /**
82
+ * Refuse the retired `entities/` directory when it is not the one the site reads.
83
+ *
84
+ * ⛔ A RENAMED DIRECTORY FAILS SILENTLY — its files are simply not read, and a site
85
+ * with no records looks exactly like a site whose records moved. So its presence
86
+ * stops the build, the push and every other reader, naming the new place.
87
+ */
88
+ export function refuseRetiredRecordsDir(siteRoot, rel) {
89
+ const retired = resolve(siteRoot, RETIRED_DIR)
90
+ if (resolve(siteRoot, rel) === retired) return
91
+ if (!isDirectory(retired)) return
92
+ throw new Error(
93
+ `[uniweb] ${RETIRED_DIR}/ is not read — a site's records live in \`${rel}/\` now. ` +
94
+ `Rename the directory (\`git mv ${RETIRED_DIR} ${rel}\`) and every file in it is a record, ` +
95
+ `as before; records.yml no longer lists them.`
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Where a site's records live — `site.yml::paths.records`, else `records/`.
101
+ *
102
+ * ⭐ THE ONE RESOLVER, and every lane asks it: the build, the push, the pull and
103
+ * the CLI. ⛔ Until 2026-09-21 only the build honoured the key — then called
104
+ * `paths.entities` — while push read the default, so a site that moved its records
105
+ * built fine and then failed to push, with an error naming a directory it did not
106
+ * use (measured).
107
+ *
108
+ * @param {string} siteRoot
109
+ * @param {object} [paths] - `site.yml::paths`, when the caller has already read it;
110
+ * otherwise `site.yml` is read here.
111
+ * @returns {{ rel: string, abs: string }} `rel` as written (for messages), `abs`
112
+ * resolved against the site root — an absolute `paths.records` is honoured.
113
+ */
114
+ export function resolveRecordsDir(siteRoot, paths) {
115
+ const p = paths === undefined ? readSitePaths(siteRoot) : paths || {}
116
+ if (p.entities !== undefined) {
117
+ throw new Error(
118
+ `[uniweb] site.yml: \`paths.entities\` is now \`paths.records\` — the directory a site's ` +
119
+ `records live in. Rename the key.`
120
+ )
121
+ }
122
+ const rel = typeof p.records === 'string' && p.records.trim() ? p.records.trim().replace(/\/+$/, '') : RECORDS_DIR
123
+ refuseRetiredRecordsDir(siteRoot, rel)
124
+ return { rel, abs: resolve(siteRoot, rel) }
125
+ }
126
+
127
+ /** Source extensions a record file may have. Mirrors the sync-lane reader. */
47
128
  export const ENTITY_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
48
129
 
49
130
  const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
@@ -51,7 +132,7 @@ const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
51
132
  /**
52
133
  * The schema ref a pool path names.
53
134
  *
54
- * @param {string[]} dirs - the directory segments below `entities/`
135
+ * @param {string[]} dirs - the directory segments below `records/`
55
136
  * @returns {string|null} the ref, or null when the depth names no schema
56
137
  */
57
138
  export function schemaForPoolDirs(dirs) {
@@ -61,7 +142,7 @@ export function schemaForPoolDirs(dirs) {
61
142
  }
62
143
 
63
144
  /**
64
- * Where a schema's entities live — the inverse of `schemaForPoolDirs`.
145
+ * Where a schema's records live — the inverse of `schemaForPoolDirs`.
65
146
  *
66
147
  * ⛔ ONE IMPLEMENTATION AND ITS INVERSE, IN ONE PLACE, for the reason this file
67
148
  * exists at all: the reader derives a model from a path and the pull side derives
@@ -70,7 +151,7 @@ export function schemaForPoolDirs(dirs) {
70
151
  * `deferredFromSchema` — a deriver and its recognizer must not be two copies.
71
152
  *
72
153
  * @param {string} schema - a ref: `@/name` or `@org/name`
73
- * @returns {string[]|null} the directory segments below `entities/`, or null for
154
+ * @returns {string[]|null} the directory segments below `records/`, or null for
74
155
  * a ref this layout cannot express
75
156
  */
76
157
  export function poolDirsForSchema(schema) {
@@ -85,7 +166,7 @@ export function poolDirsForSchema(schema) {
85
166
  /**
86
167
  * Both readings of a 2-segment pool path, for an error that has to name them.
87
168
  *
88
- * ⚠️ A reader who mistakes `entities/person/2024/ada.md` for "the `person`
169
+ * ⚠️ A reader who mistakes `records/person/2024/ada.md` for "the `person`
89
170
  * schema, organised by year" needs to be told what the build actually did with
90
171
  * it, not merely that something did not resolve. The wrong reading is the
91
172
  * plausible one, so the message carries both.
@@ -98,33 +179,46 @@ export function poolPathReadings(dirs) {
98
179
  }
99
180
 
100
181
  /**
101
- * Read a site's entity pool.
182
+ * Read a site's records.
102
183
  *
103
- * Returns entities in a stable, path-sorted order — the wire's package digest
184
+ * Returns them in a stable, path-sorted order — the wire's package digest
104
185
  * depends on it — each carrying the model its path declares.
105
186
  *
106
187
  * ⚠️ NOTHING HERE RESOLVES A SCHEMA. Whether `@std/person` is a schema this site
107
188
  * can actually see is a question for whoever holds the foundation's built schema
108
189
  * map, and only that caller can raise the error §4 of the model asks for. This
109
- * reports the pool's SHAPE — a file with no schema above it, a file nested too
110
- * deep — because those are answerable from the filesystem alone.
190
+ * reports the directory's SHAPE — a file with no schema above it, a file nested
191
+ * too deep — because those are answerable from the filesystem alone.
111
192
  *
112
193
  * @param {string} siteRoot
113
194
  * @param {object} [opts]
114
- * @param {string} [opts.dir] - override the pool directory (site-root-relative)
195
+ * @param {string} [opts.dir] - the records directory, site-root-relative or
196
+ * absolute, when the caller already resolved it; otherwise `resolveRecordsDir`
197
+ * reads `site.yml::paths.records`.
115
198
  * @returns {Promise<{
116
199
  * entities: Array<{ id, schema, slug, dirs, relPath, absPath, ext }>,
117
200
  * errors: string[],
118
201
  * exists: boolean,
202
+ * dir: string,
119
203
  * }>}
120
- * `id` is the entity's path under `entities/` without its extension — unique
204
+ * `id` is the record's path under `records/` without its extension — unique
121
205
  * by construction, stable across pushes, and derivable identically on both
122
- * sides without either lane holding the other's ids.
206
+ * sides without either lane holding the other's ids. `exists` is whether the
207
+ * directory is there at all, which is not the same as holding nothing: a push
208
+ * leaves the backend's folder alone for a site with no records directory.
123
209
  */
124
210
  export async function readEntityPool(siteRoot, opts = {}) {
125
- const rel = opts.dir || ENTITIES_DIR
126
- const base = join(siteRoot, rel)
127
- if (!existsSync(base)) return { entities: [], errors: [], exists: false }
211
+ let rel
212
+ if (opts.dir) {
213
+ rel = String(opts.dir).replace(/\/+$/, '')
214
+ refuseRetiredRecordsDir(siteRoot, rel)
215
+ } else {
216
+ rel = resolveRecordsDir(siteRoot).rel
217
+ }
218
+ // ⛔ `resolve`, not `join`: an absolute `paths.records` is supported, and
219
+ // `join(siteRoot, '/abs')` is a path under the site root that does not exist.
220
+ const base = resolve(siteRoot, rel)
221
+ if (!isDirectory(base)) return { entities: [], errors: [], exists: false, dir: rel }
128
222
 
129
223
  const entities = []
130
224
  const errors = []
@@ -188,7 +282,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
188
282
  }
189
283
 
190
284
  await walk(base, [])
191
- return { entities, errors, exists: true }
285
+ return { entities, errors, exists: true, dir: rel }
192
286
  }
193
287
 
194
288
  /**
package/src/site/index.js CHANGED
@@ -50,5 +50,5 @@ export {
50
50
  applyPostProcessing,
51
51
  mergeDataIntoContent
52
52
  } from './data-fetcher.js'
53
- export { loadDeployYml, resolveTarget } from './deploy-config.js'
54
- export { recordLastDeploy, recordTarget } from './deploy-config-writer.js'
53
+ export { loadDeployYml, resolveTarget, resolvePublishTarget } from './deploy-config.js'
54
+ export { recordLastDeploy, recordTarget, forgetDeploys, forgetDeployYml } from './deploy-config-writer.js'
@@ -50,7 +50,7 @@ import { collectSiteContent, mountEntriesOf } from './content-collector.js'
50
50
  import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
51
51
  import { processAdvancedAssets } from './advanced-processors.js'
52
52
  import { processQueries, writeQueryFiles } from './query-processor.js'
53
- import { ENTITIES_DIR } from './entity-pool.js'
53
+ import { RECORDS_DIR, resolveRecordsDir } from './entity-pool.js'
54
54
  import { executeFetch, mergeDataIntoContent, toFetchList } from './data-fetcher.js'
55
55
  import { shouldSplitContent } from './split-content.js'
56
56
  import { FONT_LINKS_MARKER } from './head-markers.js'
@@ -133,7 +133,7 @@ export function shouldPrefetchInDev(cfg) {
133
133
  * query list just collected, reload. `queries.yml` declares queries, and so does
134
134
  * `site.yml` (`queries:`), beside the rest of the site's configuration.
135
135
  * - `'records'` — re-materialize the query files, reload: `records.yml` decides
136
- * which entities are records, as an entity change decides what one says.
136
+ * which folder each record sits in, which a query's `scope:` reads.
137
137
  * - `'content'` — re-collect the site, reload.
138
138
  *
139
139
  * ⛔ Until 2026-09-14 nothing watched `records.yml` or `queries.yml`, a `site.yml`
@@ -665,7 +665,7 @@ export function siteContentPlugin(options = {}) {
665
665
  let resolvedPagesPath = null // Resolved from site.yml pagesDir or default
666
666
  let resolvedMountPaths = [] // Absolute dirs mounted under pages/ via site.yml paths:
667
667
  let resolvedLayoutPath = null // Resolved from site.yml layoutDir or default
668
- let resolvedEntitiesDir = null // site.yml `paths.entities`, site-root-relative
668
+ let resolvedRecordsDir = null // site.yml `paths.records` as written, via `resolveRecordsDir`
669
669
  let headHtml = '' // Contents of site/head.html for injection
670
670
  let basePath = '/' // Vite's config.base, always has trailing slash
671
671
 
@@ -725,8 +725,11 @@ export function siteContentPlugin(options = {}) {
725
725
  */
726
726
  async function materializeQueries(queries, config) {
727
727
  if (!queries) return
728
- const byQuery = await processQueries(resolvedSitePath, queries, resolvedEntitiesDir, basePath, {
728
+ const byQuery = await processQueries(resolvedSitePath, queries, resolvedRecordsDir, basePath, {
729
729
  locale: resolveDefaultLocale(config ?? {}) ?? null,
730
+ // ⭐ A draft is previewable here, as a hidden page is — `pnpm dev` is where the
731
+ // author looks at it. A production build leaves it out (`buildStart`).
732
+ includeDrafts: !isProduction,
730
733
  })
731
734
  await writeQueryFiles(resolvedSitePath, byQuery, queries)
732
735
  }
@@ -911,7 +914,7 @@ export function siteContentPlugin(options = {}) {
911
914
  resolvedLayoutPath = paths.layout
912
915
  ? resolve(resolvedSitePath, paths.layout)
913
916
  : resolve(resolvedSitePath, 'layout')
914
- resolvedEntitiesDir = paths.entities || null
917
+ resolvedRecordsDir = resolveRecordsDir(resolvedSitePath, paths).rel
915
918
  resolvedMountPaths = mountEntriesOf(paths).map(([, rel]) => resolve(resolvedSitePath, rel))
916
919
 
917
920
  if (queriesConfig) {
@@ -934,7 +937,7 @@ export function siteContentPlugin(options = {}) {
934
937
  resolvedLayoutPath = paths.layout
935
938
  ? resolve(resolvedSitePath, paths.layout)
936
939
  : resolve(resolvedSitePath, 'layout')
937
- resolvedEntitiesDir = paths.entities || null
940
+ resolvedRecordsDir = resolveRecordsDir(resolvedSitePath, paths).rel
938
941
  }
939
942
  },
940
943
 
@@ -954,7 +957,7 @@ export function siteContentPlugin(options = {}) {
954
957
  // In production, do it here
955
958
  if (isProduction && siteContent.config?.queries) {
956
959
  console.log('[site-content] Materializing queries...')
957
- const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries, resolvedEntitiesDir, basePath)
960
+ const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries, resolvedRecordsDir, basePath)
958
961
  await writeQueryFiles(resolvedSitePath, byQuery, siteContent.config.queries)
959
962
  }
960
963
 
@@ -1113,11 +1116,11 @@ export function siteContentPlugin(options = {}) {
1113
1116
  // ⭐ WATCH THE POOL, NOT A DIRECTORY PER QUERY. This used to resolve
1114
1117
  // `config.path` for each declaration and watch each one, so a schema
1115
1118
  // folder no query had mentioned yet — a new one, mid-session — was
1116
- // watched by nothing and its records never rebuilt. `entities/` is one
1119
+ // watched by nothing and its records never rebuilt. `records/` is one
1117
1120
  // recursive root and covers every schema, present and future.
1118
1121
  {
1119
1122
  const contentPaths = new Set([
1120
- resolve(resolvedSitePath, resolvedEntitiesDir || ENTITIES_DIR)
1123
+ resolve(resolvedSitePath, resolvedRecordsDir || RECORDS_DIR)
1121
1124
  ])
1122
1125
 
1123
1126
  for (const contentPath of contentPaths) {
@@ -16,9 +16,9 @@
16
16
  // rather keep one file. Precedence (per-query, per-key): queries.yml > site.yml.
17
17
  //
18
18
  // ⛔ THE THREE JOBS `collections/<name>/` USED TO FUSE ARE NOW THREE THINGS.
19
- // `entities/{schema}/` is the pool, `records.yml` is the folder (what makes an
20
- // entity a record), and a query asks the folder for a set. This file resolves the
21
- // LAST of those only.
19
+ // `records/{schema}/` holds the site's records (placing a file there is what makes
20
+ // it one), `records.yml` sorts them into folders, and a query asks for a set. This
21
+ // file resolves the LAST of those only.
22
22
  //
23
23
  // ⚠️ `collections.yml` and `site.yml::collections` are GONE, with no alias and no
24
24
  // deprecation path — the model's §5 ruling, and there is nothing outside this
@@ -85,7 +85,7 @@ export function defaultSchema(name) {
85
85
  // address nothing local can derive.
86
86
  function normalizeQueryDecl(name, decl) {
87
87
  // ⭐ THE STRING SHORTHAND NAMES THE SCHEMA, because that is what a file-based
88
- // query actually needs — `entities/{schema}/` supplies the records, so there is
88
+ // query actually needs — `records/{schema}/` supplies the records, so there is
89
89
  // no directory left to name. (It named a PATH while the pool was
90
90
  // `collections/<name>/` and the same directory answered both questions.)
91
91
  if (typeof decl === 'string') return { name, schema: decl }
@@ -165,7 +165,7 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
165
165
  if (decl.path && decl.url === undefined) {
166
166
  console.warn(
167
167
  `[uniweb] query "${decl.name}": \`path: ${decl.path}\` is ignored. A query names a ` +
168
- `\`schema:\` and \`entities/{schema}/\` supplies its records — there is no directory ` +
168
+ `\`schema:\` and \`records/{schema}/\` supplies its records — there is no directory ` +
169
169
  `for it to name. Move the files under the schema folder instead.`
170
170
  )
171
171
  }
@@ -234,6 +234,16 @@ export function refuseQueryDeclaration(decl) {
234
234
  }
235
235
  refuseQueryRoute(decl, where)
236
236
  refuseLimit(decl.limit, where)
237
+ // ⛔ `sync:` IS RETIRED (2026-09-21). `sync: false` kept a query's records off a
238
+ // backend while a query owned its records; every record in `records/` is pushed
239
+ // now, whatever reads it, so an ignored `sync: false` would push exactly what the
240
+ // author meant to hold back. Refused, naming what does that now.
241
+ if (decl.sync !== undefined) {
242
+ throw new Error(
243
+ `[uniweb] ${where}: \`sync:\` is retired — a query does not own its records. Every file in ` +
244
+ `\`records/\` is a record and is pushed; a file whose name starts with \`_\` is not a record.`
245
+ )
246
+ }
237
247
  const external = decl.url !== undefined
238
248
  if (!external) {
239
249
  for (const key of ['method', 'body', 'transform', 'record']) {