@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.
@@ -1,48 +1,36 @@
1
- // `records.yml` — the site's FOLDER: what is in it, and where.
1
+ // `records.yml` — how the site's records folder is ORGANIZED. Optional.
2
2
  //
3
- // ⭐ LISTING AN ENTITY HERE IS WHAT MAKES IT A RECORD. An entity in `entities/`
4
- // exists; a record is a leaf entry in the site's folder. The backend's own gauge:
5
- // "An entity in no folder is not a record either: it exists, but cannot be
6
- // publicly fetched; placing a ref to it in a published folder is what makes one."
7
- // ⇒ An unreferenced entity is a draft, for free, with no flag to set.
3
+ // ⭐ IT DOES NOT SAY WHAT IS IN THE FOLDER (ruled 2026-09-21 [Diego]). Every file in
4
+ // `records/` is a record — placing it there is what makes one (`entity-pool.js`).
5
+ // This file only sorts records into sub-folders, so a site whose records are one
6
+ // flat set has no `records.yml` at all, which is the common case.
8
7
  //
9
- // ⛔ IT IS A DATABASE, NOT A PAGE SYSTEM. Records are data. They usually have no
10
- // structure at all — a flat pool, and THE COMMON CASE IS THREE LINES:
11
- //
12
- // - person/*.md
13
- // - publication/*.md
14
- // - project/*.md
8
+ // ⛔ UNTIL 2026-09-21 THIS FILE WAS THE MEMBERSHIP LIST: a bare string at the top
9
+ // level listed an entity and made it a record, an unlisted one was left out, and
10
+ // the file's absence or emptiness decided what a push did. All of that moved to
11
+ // the directory. A top-level path is refused rather than read — it would claim to
12
+ // select records while selecting nothing.
15
13
  //
16
14
  // ⛔ AND STRUCTURE IS QUERY SCOPE, NOT NAVIGATION. A `folder:` is an addressable
17
- // dimension a query slices on — `where: { path: { under: 'archive' } }` — never a
18
- // menu, a listing or a URL tree. The question is not "does this site want
19
- // sub-pages?" but "will a query ever ask for a SLICE rather than the whole pool?"
20
- // Most will not, which is why flat is the norm rather than a simplification.
21
- //
22
- // ⭐ THE SHAPE IS A LIST, and an entity gets no ceremony: a bare string IS an
23
- // entity path, one file or a pattern matching many. Only the exceptions announce
24
- // themselves — `url:`, `asset:`, `folder:`. (An earlier design keyed a map by
25
- // entry name; it charged every entry a key, a kind and often a label to buy
26
- // navigation ergonomics most sites never use. Measured on a realistic file: 7 of
27
- // 11 entries are just a path.)
15
+ // dimension a query slices on — `scope: archive` — never a menu, a listing or a URL
16
+ // tree. The question is not "does this site want sub-pages?" but "will a query ever
17
+ // ask for a SLICE rather than the whole set?" Most will not, which is why flat is
18
+ // the norm rather than a simplification.
28
19
  //
29
- // ⛔ NO QUERIES IN HERE. A computed subset is a named query, in `queries.yml`.
30
- // Curation enumerates; queries compute. Two constructs, two jobs.
20
+ // ⭐ THE SHAPE IS A LIST OF FOLDERS, each holding records by path — one file, or a
21
+ // pattern matching many — and further folders:
31
22
  //
32
- // ⭐ AND IT IS THE SYNC CONTROL. What syncs is exactly what this file references —
33
- // no flag, no inference. `missing` and `empty` differ deliberately:
23
+ // - folder: archive
24
+ // label: Publication Archive
25
+ // records:
26
+ // - publication/2025-*.md
34
27
  //
35
- // missing → inert. Do not sync; leave the server's folder untouched.
36
- // empty → destructive. Sync an empty folder, removing what is there.
28
+ // Every record no folder names sits at the top of the site's folder.
37
29
  //
38
- // The safe state is the ABSENCE of a file and the destructive act requires
39
- // affirmatively creating one, so a live folder cannot be wiped by deleting
40
- // something. ⛔ Do not "simplify" these into one behaviour — it would delete a
41
- // capability. The placeholder hazard (someone creates an empty file meaning to
42
- // fill it in) is guarded at the CLI with a count and a confirmation; the format
43
- // stays honest and the CLI does the asking.
30
+ // ⛔ NO QUERIES IN HERE. A computed subset is a named query, in `queries.yml`.
31
+ // Organizing places records; queries compute. Two constructs, two jobs.
44
32
  //
45
- // Model: entity · record · query · folder.
33
+ // Model: record · folder · query.
46
34
 
47
35
  import { existsSync } from 'node:fs'
48
36
  import { readFile } from 'node:fs/promises'
@@ -50,6 +38,7 @@ import { join } from 'node:path'
50
38
  import yaml from 'js-yaml'
51
39
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
52
40
  import { compareByNumericPrefix } from '../utils/numeric-prefix.js'
41
+ import { RECORDS_DIR } from './entity-pool.js'
53
42
 
54
43
  export const RECORDS_YML_RELPATH = 'records.yml'
55
44
 
@@ -58,13 +47,8 @@ export function recordsYmlPath(siteRoot) {
58
47
  return join(siteRoot, RECORDS_YML_RELPATH)
59
48
  }
60
49
 
61
- /** What `records.yml` says about syncing, before any entry is resolved. */
62
- export const FOLDER_MISSING = 'missing'
63
- export const FOLDER_EMPTY = 'empty'
64
- export const FOLDER_DECLARED = 'declared'
65
-
66
50
  /**
67
- * Match one entity path against a `records.yml` pattern.
51
+ * Match one record path against a `records.yml` pattern.
68
52
  *
69
53
  * ⛔ `*` DOES NOT CROSS A `/`, which is what a reader expects of a file pattern
70
54
  * and is NOT what `@uniweb/core`'s `globMatch` does. That one backs the `like`
@@ -101,62 +85,67 @@ export function slugForEntity(entity) {
101
85
  }
102
86
 
103
87
  /**
104
- * Read `records.yml`.
88
+ * Read `records.yml` — the folder's organization.
105
89
  *
106
- * ⛔ THE THREE STATES ARE NOT TWO. `missing` and `empty` mean different things
107
- * and the difference is load-bearing (see the header), so this reports which it
108
- * saw rather than collapsing them into "no entries".
90
+ * ⭐ MISSING AND EMPTY MEAN THE SAME: no sub-folders, every record at the top.
91
+ * ⛔ Until 2026-09-21 they differed — missing left a backend's folder alone, empty
92
+ * removed what was there — because this file decided what was in the folder. The
93
+ * `records/` directory decides that now, so no state of this file removes a record.
109
94
  *
110
- * @returns {Promise<{ state: string, entries: Array, error: string|null }>}
95
+ * ⛔ A MALFORMED FILE IS STILL AN ERROR: what it meant to organize cannot be guessed.
96
+ *
97
+ * @returns {Promise<{ exists: boolean, entries: Array, error: string|null }>}
111
98
  */
112
99
  export async function readRecordsConfig(siteRoot) {
113
100
  const file = join(siteRoot, RECORDS_YML_RELPATH)
114
- if (!existsSync(file)) return { state: FOLDER_MISSING, entries: [], error: null }
101
+ if (!existsSync(file)) return { exists: false, entries: [], error: null }
115
102
 
116
103
  let doc
117
104
  try {
118
105
  doc = yaml.load(await readFile(file, 'utf8'), YAML_OPTIONS)
119
106
  } catch (err) {
120
- return { state: FOLDER_MISSING, entries: [], error: `${RECORDS_YML_RELPATH}: ${err.message}` }
107
+ return { exists: true, entries: [], error: `${RECORDS_YML_RELPATH}: ${err.message}` }
121
108
  }
122
109
 
123
- if (doc === null || doc === undefined || (Array.isArray(doc) && doc.length === 0)) {
124
- return { state: FOLDER_EMPTY, entries: [], error: null }
125
- }
110
+ if (doc === null || doc === undefined) return { exists: true, entries: [], error: null }
126
111
  if (!Array.isArray(doc)) {
127
112
  return {
128
- state: FOLDER_MISSING,
113
+ exists: true,
129
114
  entries: [],
130
115
  error:
131
- `${RECORDS_YML_RELPATH} must be a LIST of what is in the folder, not ${typeof doc === 'object' ? 'a mapping' : 'a single value'}. ` +
132
- `The common case is three lines:\n - person/*.md\n - publication/*.md`,
116
+ `${RECORDS_YML_RELPATH} must be a LIST of folders, not ${typeof doc === 'object' ? 'a mapping' : 'a single value'}. ` +
117
+ `For example:\n - folder: archive\n records:\n - publication/2025-*.md`,
133
118
  }
134
119
  }
135
- return { state: FOLDER_DECLARED, entries: doc, error: null }
120
+ return { exists: true, entries: doc, error: null }
136
121
  }
137
122
 
138
123
  /**
139
- * Resolve the folder's entries against the entity pool.
124
+ * Place the site's records in its folder: the sub-folders `records.yml` declares,
125
+ * and the top of the folder for every record no sub-folder names.
140
126
  *
141
127
  * Every rule here is a guard, and each one exists because its failure was
142
128
  * SILENT. `entries: [artcles]` used to produce a real, reachable, empty path
143
129
  * with no warning at all.
144
130
  *
145
- * ⛔ ONE PLACEMENT PER ENTITY. Two entries matching one file is a hard error, not
131
+ * ⛔ ONE PLACEMENT PER RECORD. Two entries matching one file is a hard error, not
146
132
  * a second placement. The wire could carry many-to-many — `folder.js` nests, and
147
- * placements are keyed by their `name` chain — but `core/src/where.js`'s
148
- * `matchUnder` is STRING-ONLY, so a record with two paths would match nothing
149
- * under `where: { path: { under: … } }`, silently. Widening `under` is a
150
- * predicate the backend also evaluates natively, so it is a cross-lane change to
151
- * agree first, not to infer. Until then the file lane is the floor: one folder.
133
+ * a placement is banked by the record it references — but a record's `path` is one string:
134
+ * `@uniweb/core`'s `withinScope` matches nothing that is not a string, so a record
135
+ * with two paths would fall outside every `scope:`, silently. The records service
136
+ * evaluates `scope` natively, so widening it is a cross-lane change to agree first,
137
+ * not to infer. Until then the file lane is the floor: one folder.
152
138
  *
153
139
  * @param {Array} entries - the parsed `records.yml` list
154
- * @param {Array} pool - entities from `readEntityPool`
140
+ * @param {Array} pool - records from `readEntityPool`
141
+ * @param {object} [opts]
142
+ * @param {string} [opts.dir] - the records directory as written, for messages
155
143
  * @returns {{ nodes: Array, placements: Map, errors: string[], warnings: string[] }}
156
- * `nodes` is the folder tree (branches + entity placements, in file order);
157
- * `placements` maps an entity id to its `{ entity, path, slug }`.
144
+ * `nodes` is the folder tree — the declared sub-folders in file order, then every
145
+ * record no sub-folder names; `placements` maps EVERY record's id to its
146
+ * `{ entity, path, slug }`, `path` being `''` at the top of the folder.
158
147
  */
159
- export function resolveFolder(entries, pool) {
148
+ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
160
149
  const errors = []
161
150
  const warnings = []
162
151
  const placements = new Map()
@@ -167,7 +156,7 @@ export function resolveFolder(entries, pool) {
167
156
  for (const e of pool || []) {
168
157
  // ⚠️ KEYED BY THE FILE AS IT EXISTS, prefix and all — an author writing
169
158
  // `post/01-lab-opens.md` is naming a file they can see, not the slug it
170
- // produces. `poolPath` is that path, relative to `entities/`.
159
+ // produces. `poolPath` is that path, relative to `records/`.
171
160
  byRelPath.set(e.poolPath, e)
172
161
  }
173
162
 
@@ -177,7 +166,7 @@ export function resolveFolder(entries, pool) {
177
166
  if (prior) {
178
167
  errors.push(
179
168
  `${RECORDS_YML_RELPATH}: "${entity.relPath}" is placed twice — by ${prior} and by ${where}. ` +
180
- `An entity occupies one folder; a computed subset is a named query, not a second placement.`
169
+ `A record sits in one folder; a computed subset is a named query, not a second placement.`
181
170
  )
182
171
  return null
183
172
  }
@@ -193,6 +182,17 @@ export function resolveFolder(entries, pool) {
193
182
 
194
183
  if (typeof entry === 'string') {
195
184
  const pattern = entry.trim()
185
+ // ⛔ A PATH AT THE TOP LEVEL SELECTED RECORDS until 2026-09-21 — it was how a
186
+ // file became one. Every file in the directory is a record now, so the line
187
+ // would claim to choose while choosing nothing: refused, with the reason.
188
+ if (pathSegs.length === 0) {
189
+ errors.push(
190
+ `${RECORDS_YML_RELPATH}: ${where} ("${pattern}") lists records at the top level. Every file in ` +
191
+ `${dir}/ is a record already — ${RECORDS_YML_RELPATH} only sorts records into folders. ` +
192
+ `Remove the entry, or put it under a \`folder:\`.`
193
+ )
194
+ return []
195
+ }
196
196
  if (!pattern) {
197
197
  errors.push(`${RECORDS_YML_RELPATH}: ${where} is an empty string.`)
198
198
  return []
@@ -201,23 +201,23 @@ export function resolveFolder(entries, pool) {
201
201
  const hit = byRelPath.get(pattern)
202
202
  if (!hit) {
203
203
  errors.push(
204
- `${RECORDS_YML_RELPATH}: ${where} names "${pattern}", which is not in entities/. ` +
205
- `A bare string is a path under entities/, extension included.`
204
+ `${RECORDS_YML_RELPATH}: ${where} names "${pattern}", which is not in ${dir}/. ` +
205
+ `A path is relative to ${dir}/, extension included.`
206
206
  )
207
207
  return []
208
208
  }
209
209
  const leaf = place(hit, pathSegs, where)
210
210
  return leaf ? [leaf] : []
211
211
  }
212
- // ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `entities/artcle/*.md` is the
213
- // old empty-branch defect respelled, and it produced a real, reachable,
214
- // empty path with no warning.
212
+ // ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `artcle/*.md` is the old
213
+ // empty-branch defect respelled, and it produced a real, reachable, empty
214
+ // path with no warning.
215
215
  const matches = [...byRelPath.entries()]
216
216
  .filter(([rel]) => matchEntityPattern(pattern, rel))
217
217
  .map(([, e]) => e)
218
218
  if (matches.length === 0) {
219
219
  errors.push(
220
- `${RECORDS_YML_RELPATH}: ${where} pattern "${pattern}" matches no entity. ` +
220
+ `${RECORDS_YML_RELPATH}: ${where} pattern "${pattern}" matches no record in ${dir}/. ` +
221
221
  `Check the schema folder name and the extension.`
222
222
  )
223
223
  return []
@@ -230,7 +230,9 @@ export function resolveFolder(entries, pool) {
230
230
  }
231
231
 
232
232
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
233
- errors.push(`${RECORDS_YML_RELPATH}: ${where} is neither a path nor a typed entry.`)
233
+ errors.push(
234
+ `${RECORDS_YML_RELPATH}: ${where} is neither ${pathSegs.length ? 'a path nor ' : ''}a \`folder:\` entry.`
235
+ )
234
236
  return []
235
237
  }
236
238
 
@@ -247,11 +249,12 @@ export function resolveFolder(entries, pool) {
247
249
  errors.push(`${RECORDS_YML_RELPATH}: ${where} declares a folder with no name.`)
248
250
  return []
249
251
  }
250
- // ⭐ `name` is the handle (the URL segment, sibling-unique); `label` is the
252
+ // ⭐ A folder's `name` is the segment a query's `scope:` names; `label` is its
251
253
  // display text. The store renamed the pair on 2026-09-04 — `path_segment` →
252
- // `name`, and the old `name` (display) → `label` — so one word means one
253
- // thing from records.yml (`folder:` / `label:`) to the wire to the service's
254
- // `$name`.
254
+ // `name`, and the old `name` (display) → `label`. ⛔ Not a URL segment: a folder
255
+ // is the curator's organization and maps to no route unless a query binds
256
+ // `scope: :dir` [Diego]. *("the URL segment, sibling-unique" stood here until
257
+ // 2026-09-21.)*
255
258
  const branch = { kind: 'branch', name: segment }
256
259
  if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
257
260
  const kids = Array.isArray(entry.records) ? entry.records : []
@@ -283,23 +286,28 @@ export function resolveFolder(entries, pool) {
283
286
 
284
287
  errors.push(
285
288
  `${RECORDS_YML_RELPATH}: ${where} has no recognized kind. ` +
286
- `A bare string is an entity path; anything else says \`folder:\`, \`url:\` or \`asset:\`.`
289
+ (pathSegs.length
290
+ ? `Inside a folder, a path names records; anything else says \`folder:\`.`
291
+ : `An entry is a \`folder:\` — its \`records:\` are paths under ${dir}/.`)
287
292
  )
288
293
  return []
289
294
  }
290
295
 
291
- const nodes = (entries || []).flatMap((entry, i) => resolveEntry(entry, [], i, ''))
296
+ const branches = (entries || []).flatMap((entry, i) => resolveEntry(entry, [], i, ''))
292
297
 
293
- // ⚠️ REPORT WHAT NOTHING REFERENCES. It is a legitimate draft state, so it is
294
- // not an error — but silence is how every defect in this area survived.
295
- for (const e of pool || []) {
296
- if (!placements.has(e.id)) {
297
- warnings.push(
298
- `${e.relPath} is in entities/ but referenced by nothing in ${RECORDS_YML_RELPATH} — ` +
299
- `it exists, but it is not a record and no query can reach it.`
300
- )
301
- }
302
- }
298
+ // ⭐ EVERY RECORD NO FOLDER NAMES SITS AT THE TOP — placement in the directory is
299
+ // what made it a record, so there is nothing for it to be left out of. In the
300
+ // directory's order, numeric-aware within a schema folder, the same order a
301
+ // pattern's matches take.
302
+ const unplaced = (pool || []).filter((e) => !placements.has(e.id))
303
+ const dirKey = (e) => (e.dirs || []).join('/')
304
+ unplaced.sort((a, b) => {
305
+ const da = dirKey(a)
306
+ const db = dirKey(b)
307
+ if (da !== db) return da < db ? -1 : 1
308
+ return compareByNumericPrefix(a.slug, b.slug)
309
+ })
310
+ const top = unplaced.map((e) => place(e, [], 'the directory')).filter(Boolean)
303
311
 
304
- return { nodes, placements, errors, warnings }
312
+ return { nodes: [...branches, ...top], placements, errors, warnings }
305
313
  }
@@ -1,159 +1,38 @@
1
1
  /**
2
- * `assets.json` — the site's committed map from a local asset path to the
3
- * backend's content-addressed id.
4
- *
5
- * ## Why it is COMMITTED, and why that is the whole point
6
- *
7
- * The same map used to be built on every push and thrown away
8
- * (`assetsByLocalUrl`, in memory, consumed by the content rewrite and dropped).
9
- * Rebuilt per push it is a private detail; committed it is a **log every machine
10
- * collaborating on this site can read**:
11
- *
12
- * - a teammate who clones knows which bytes the project expects to exist and
13
- * can fetch the ones they lack, instead of discovering a missing image at
14
- * render;
15
- * - `pull` can put an asset back at **the path its author wrote**, rather than
16
- * inventing one — without this, a push turns `/images/hero.png` into an
17
- * opaque id and nothing remembers it was ever called that;
18
- * - a re-push of unchanged media is visibly a no-op rather than a silent one.
19
- *
20
- * ⇒ It is project state, not cache. `.uniweb/` is gitignored in both scaffolded
21
- * templates, which is why this does not live there.
22
- *
23
- * ## ⛔ Diff stability is a hard requirement, not tidiness
24
- *
25
- * A committed file that reorders itself produces a spurious diff on every push
26
- * and trains people to stop reading it — at which point a real change to what a
27
- * site ships passes unnoticed. Keys are therefore **sorted**, the shape is flat,
28
- * and the writer is a no-op when nothing changed (it does not rewrite an
29
- * identical file, so `git status` stays clean on a push that moved no assets).
30
- *
31
- * ## What it deliberately does NOT hold
32
- *
33
- * ⛔ **No serve URL.** A URL is a host's route layout, and storing one here would
34
- * re-create — in a committed file, on every machine — exactly the coupling that
35
- * deleting `buildAssetUrl` removed from this CLI. The id plus the host's
36
- * `config.assets.url` pattern is the whole address, and only the host owns the
37
- * second half.
38
- *
39
- * ⚖️ **What it holds instead is a FINGERPRINT of the served URL** (`served`), and the
40
- * difference is the point: a hash can recognize an address and cannot compose one.
41
- * It exists for references that are a BARE STRING — `info.preview`, `info.favicon`,
42
- * `seo.image`, a section param — where there is no object to stamp `assetId` beside,
43
- * so the stored value is the serve URL alone. `pull` hashes such a string and, when
44
- * it matches, puts back the path the author wrote. Should the host ever serve an
45
- * asset at a new address, the fingerprint simply stops matching and the pull leaves
46
- * the URL — the honest projection — until the next push records the new one.
47
- *
48
- * ⛔ **No mime or size.** The store validates those and they are its to change;
49
- * a second copy here is a second thing to disagree.
2
+ * Asset reference helpers — two pure functions, no file.
3
+ *
4
+ * ⚠️ **The MAP moved to `sync.json` on 2026-09-20** (`sync-store.js`,
5
+ * `backends.<origin>.assets`). It lived here as `assets.json`, a single flat
6
+ * `local path → id` file that assumed ONE backend — which is exactly what it
7
+ * could not represent once a project syncs with several: an id is minted by a
8
+ * backend and means nothing to any other one. `readAssetMap` / `updateAssetMap` /
9
+ * `refForAssetId` / `ASSET_MAP_FILE` went with it.
10
+ *
11
+ * What stays here is what never touched the filesystem:
12
+ *
13
+ * - **`servedFingerprint`** — hashes a serve URL. It exists for references that are
14
+ * a BARE STRING (`info.preview`, `seo.image`, a section param), where there is no
15
+ * object to stamp an id beside, so the stored value is the address alone. ⛔ A
16
+ * fingerprint rather than the URL because a hash can RECOGNIZE an address and
17
+ * cannot COMPOSE one — route layout is the host's and never ours to rebuild.
18
+ * - **`restoreAssetRefs`** — puts an author's own paths back into a document pulled
19
+ * from a backend. Without it a push/pull cycle rewrites every image in a
20
+ * developer's source to a backend route: a mangling of files they own, by a round
21
+ * trip that changed nothing.
22
+ *
23
+ * ⭐ **`restoreAssetRefs` no-ops on an empty map**, which is what makes the store's
24
+ * `backend` parameter honest: a projection not tied to a backend has no known
25
+ * assets, so it restores nothing rather than guessing.
26
+ *
27
+ * @module
50
28
  */
51
29
 
52
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
53
30
  import { createHash } from 'node:crypto'
54
31
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
55
- import { join } from 'node:path'
56
-
57
- export const ASSET_MAP_FILE = 'assets.json'
58
-
59
- /** Current on-disk shape. Bumped only for a breaking layout change. */
60
- const VERSION = 1
61
-
62
- const mapPath = (siteDir) => join(siteDir, ASSET_MAP_FILE)
63
-
64
- /**
65
- * Read the site's asset map. A missing, unreadable or malformed file reads as
66
- * empty rather than throwing: the map is an accelerator and a record, and a
67
- * corrupt one must never be the reason a push fails. The next write repairs it.
68
- *
69
- * @param {string} siteDir
70
- * @returns {Record<string, { id: string, ext: string }>} local ref → identity
71
- */
72
- export function readAssetMap(siteDir) {
73
- const p = mapPath(siteDir)
74
- if (!existsSync(p)) return {}
75
- try {
76
- const parsed = JSON.parse(readFileSync(p, 'utf8'))
77
- const assets = parsed?.assets
78
- if (!assets || typeof assets !== 'object') return {}
79
- const out = {}
80
- for (const [ref, v] of Object.entries(assets)) {
81
- if (v && typeof v.id === 'string' && v.id) {
82
- out[ref] = { id: v.id, ext: typeof v.ext === 'string' ? v.ext : '' }
83
- if (typeof v.served === 'string' && v.served) out[ref].served = v.served
84
- }
85
- }
86
- return out
87
- } catch {
88
- return {}
89
- }
90
- }
91
-
92
- /**
93
- * Merge `entries` into the site's map and write it if anything changed.
94
- *
95
- * MERGE, not replace: a push carries only the refs that page's content touched,
96
- * so replacing would drop every asset this run happened not to mention — which
97
- * is most of them on a partial push. An entry whose id changed is overwritten
98
- * (the bytes at that path changed); an identical entry is left alone.
99
- *
100
- * @param {string} siteDir
101
- * @param {Record<string, { id: string, ext: string }>} entries
102
- * @returns {{ added: string[], changed: string[], written: boolean }}
103
- */
104
- export function updateAssetMap(siteDir, entries) {
105
- const prior = readAssetMap(siteDir)
106
- const added = []
107
- const changed = []
108
-
109
- for (const [ref, v] of Object.entries(entries || {})) {
110
- if (!v?.id) continue
111
- const was = prior[ref]
112
- // A new `served` fingerprint is a change too: the host serves these bytes at
113
- // another address now, and the old fingerprint would stop recognizing it. An
114
- // entry that carries none — a download learns identity, not an upload's URL —
115
- // keeps the one already recorded for the same bytes.
116
- const served = v.served || (was && was.id === v.id ? was.served : undefined)
117
- if (!was) added.push(ref)
118
- else if (was.id !== v.id || (served || '') !== (was.served || '')) changed.push(ref)
119
- else continue
120
- prior[ref] = { id: v.id, ext: v.ext || '', ...(served ? { served } : {}) }
121
- }
122
-
123
- if (!added.length && !changed.length) return { added, changed, written: false }
124
-
125
- // Sorted keys + a trailing newline: a committed file that reorders itself
126
- // produces a diff on every push and teaches people to skip reading it.
127
- const assets = {}
128
- for (const ref of Object.keys(prior).sort()) assets[ref] = prior[ref]
129
- writeFileSync(
130
- mapPath(siteDir),
131
- JSON.stringify({ version: VERSION, assets }, null, 2) + '\n'
132
- )
133
- return { added, changed, written: true }
134
- }
135
-
136
- /**
137
- * The local ref a given asset id is known at, or null.
138
- *
139
- * This is the direction `pull` needs and the reason the map is worth
140
- * committing: stored content carries an id, and only this can say the author
141
- * called it `/images/hero.png`. Without it a pull must invent a path.
142
- *
143
- * @param {Record<string, { id: string, ext: string }>} map
144
- * @param {string} id
145
- * @returns {string|null}
146
- */
147
- export function refForAssetId(map, id) {
148
- if (!id) return null
149
- for (const [ref, v] of Object.entries(map || {})) {
150
- if (v.id === id) return ref
151
- }
152
- return null
153
- }
154
32
 
155
33
  /**
156
- * The fingerprint `assets.json` keeps of the URL a host serves an asset at.
34
+ * The fingerprint the asset map in `sync.json` keeps of the URL a host serves an
35
+ * asset at.
157
36
  *
158
37
  * A hash, never the URL: it can recognize an address a pull brings back and it
159
38
  * cannot be used to compose one — see the header. Prefixed so a reader of the
@@ -318,13 +318,21 @@ function writeIfChanged(filePath, text) {
318
318
  * order: `{ id, model, slug, sourceFile, format?, multiRecord?, declaration? }`.
319
319
  * @param {object[]} params.finalized - response entries `{ index, uuid, changed?, document? }`.
320
320
  * @param {string} [params.sourceLocale]
321
- * @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[] }}
321
+ * @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[], mapped: Object<string,string> }}
322
+ * `mapped` is own id → the uuid this backend minted, for the caller to record per backend.
322
323
  */
323
324
  export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
324
325
  const updated = []
325
326
  const unchanged = []
326
327
  const deferred = []
327
328
  const warnings = []
329
+ // ⭐ own id → the uuid THIS backend minted. The caller files it under the backend in
330
+ // `sync.json::backends.<origin>.records`; this function knows no backend.
331
+ const mapped = {}
332
+ // Files holding only records that already carry their own id. Counted ONCE per file
333
+ // at the end: an array-form file holds several records, and "examined, nothing
334
+ // written" is a statement about the file, not about each record in it.
335
+ const ownIdFiles = new Set()
328
336
  // Multi-record files are written ONCE per file, applying every (slug → uuid).
329
337
  const arrayFiles = new Map() // array-form YAML/JSON: sourceFile -> Map(slug -> uuid)
330
338
  const bibFiles = new Map() // BibTeX: sourceFile -> Map(cite key -> uuid)
@@ -338,8 +346,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
338
346
  warnings.push(`finalized index ${i} has no matching submitted entity`)
339
347
  continue
340
348
  }
341
- // Skip the non-record entities: the site-content entity (its uuid is back-filled
342
- // into site.yml by the caller, via writeSiteEntityUuid) and the folder entity
349
+ // Skip the non-record entities: the site-content entity (its uuid is recorded in
350
+ // sync.json by the caller, via writeSiteEntityUuid) and the folder entity
343
351
  // (no uuid to back-fill — the backend owns the site's folder, keyed by the
344
352
  // site-content uuid). Both are positional placeholders with no record source file.
345
353
  if (entry.kind === 'site' || entry.kind === 'folder') continue
@@ -347,6 +355,22 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
347
355
  deferred.push({ index: i, id: entry.id, reason: 'no source file on disk' })
348
356
  continue
349
357
  }
358
+
359
+ // ⭐ Every record the backend returned is mapped: own id → the uuid it minted.
360
+ // A record new to EVERY backend has no own id yet, so the minted uuid becomes it
361
+ // (written below) and the mapping is identity — which is exactly the behaviour
362
+ // before identity was keyed by backend, so a single-backend project is unchanged.
363
+ mapped[entry.ownId || uuid] = uuid
364
+
365
+ // ⛔ A record that already carries its OWN id is NOT rewritten. Its id is stable
366
+ // and travels with the file; this backend's uuid belongs in the map, not in the
367
+ // author's file. And it must not be: variant A below re-renders the WHOLE file from
368
+ // the backend's document, which carries THIS backend's `$uuid` — so a second
369
+ // backend would silently overwrite the record's identity with its own.
370
+ if (entry.ownId) {
371
+ ownIdFiles.add(entry.sourceFile)
372
+ continue
373
+ }
350
374
  if (entry.multiRecord) {
351
375
  // Many records in one file → group by file, written once. BibTeX and
352
376
  // array-form YAML/JSON both re-render canonically (sync owns the output);
@@ -405,5 +429,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
405
429
  else warnings.push(`${file}: ${res.message}`)
406
430
  }
407
431
 
408
- return { updated, unchanged, deferred, warnings }
432
+ for (const file of ownIdFiles) {
433
+ if (!updated.includes(file) && !unchanged.includes(file)) unchanged.push(file)
434
+ }
435
+ return { updated, unchanged, deferred, warnings, mapped }
409
436
  }
@@ -10,9 +10,9 @@
10
10
  // ⭐ THE FLAT-VS-RECURSIVE DIVERGENCE THIS FILE USED TO WARN ABOUT IS CLOSED.
11
11
  // It reported records below a collection's top level as building and rendering
12
12
  // locally while being absent from the synced set — the delivery lane recursed,
13
- // this one was one level deep. `entities/{schema}/` has no nesting to disagree
13
+ // this one was one level deep. `records/{schema}/` has no nesting to disagree
14
14
  // about (`site/entity-pool.js` refuses it), and placement moved to `records.yml`,
15
- // so both lanes now read the same flat pool. The warning went with the ambiguity.
15
+ // so both lanes now read the same flat directory. The warning went with the ambiguity.
16
16
  //
17
17
  // A collection `.md` is NOT a page-section `.md`: its frontmatter is structured
18
18
  // DATA whose schema is the collection type's data schema (the `model:` Model), and
@@ -55,7 +55,7 @@ function formatFor(ext) {
55
55
  /**
56
56
  * Read ONE entity file into its raw source record(s).
57
57
  *
58
- * ⭐ THE FILE IS THE UNIT NOW. `entities/{schema}/` supplies the model, so
58
+ * ⭐ THE FILE IS THE UNIT NOW. `records/{schema}/` supplies the model, so
59
59
  * nothing above this needs a directory scan to know what a file is — the pool
60
60
  * reader (`site/entity-pool.js`) already walked the tree and paired each file
61
61
  * with its schema. A single-record file yields one record; array-form YAML/JSON