@uniweb/build 0.63.0 → 0.63.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.63.0",
3
+ "version": "0.63.2",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,11 +58,11 @@
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/content-reader": "^1.2.5",
61
+ "@uniweb/projections": "^0.7.3",
61
62
  "@uniweb/content-writer": "^0.3.4",
62
63
  "@uniweb/schemas": "^0.7.0",
63
- "@uniweb/projections": "^0.7.3",
64
- "@uniweb/semantic-parser": "^1.4.1",
65
- "@uniweb/theming": "^0.1.15"
64
+ "@uniweb/theming": "^0.1.15",
65
+ "@uniweb/semantic-parser": "^1.4.1"
66
66
  },
67
67
  "optionalDependencies": {
68
68
  "@uniweb/runtime": "^0.27.4"
@@ -361,7 +361,7 @@ async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
361
361
  )
362
362
  if (pending.length === 0) return
363
363
 
364
- const dataSchemas = loadFoundationDataSchemas(siteRoot, siteYml)
364
+ const dataSchemas = foundationDataSchemas(siteRoot, siteYml)
365
365
  if (!dataSchemas) return
366
366
 
367
367
  for (const decl of pending) {
@@ -447,11 +447,15 @@ function localFoundationSrcDir(siteRoot, siteYml) {
447
447
 
448
448
  /** The data schemas a site's foundation declares, or null when unresolvable. */
449
449
  export function foundationDataSchemas(siteRoot, siteYml) {
450
- return loadFoundationDataSchemas(siteRoot, siteYml)
450
+ return foundationSchemaJson(siteRoot, siteYml)?.dataSchemas || null
451
451
  }
452
452
 
453
- /** The foundation's built data-schema map, or null when there is nothing to read. */
454
- function loadFoundationDataSchemas(siteRoot, siteYml) {
453
+ /**
454
+ * A site's foundation's built `schema.json` whole — its section types (each with its `data:`)
455
+ * beside `_self` and `dataSchemas` — or null when the foundation is not in the project or not
456
+ * built.
457
+ */
458
+ export function foundationSchemaJson(siteRoot, siteYml) {
455
459
  if (!siteYml?.foundation) return null
456
460
  let info
457
461
  try {
@@ -463,7 +467,7 @@ function loadFoundationDataSchemas(siteRoot, siteYml) {
463
467
  const schemaPath = join(info.path, 'dist', 'meta', 'schema.json')
464
468
  if (!existsSync(schemaPath)) return null
465
469
  try {
466
- return JSON.parse(readFileSync(schemaPath, 'utf8'))?.dataSchemas || null
470
+ return JSON.parse(readFileSync(schemaPath, 'utf8')) || null
467
471
  } catch {
468
472
  return null
469
473
  }
@@ -0,0 +1,59 @@
1
+ // ⭐ A NAME THAT IS A DATA KEY, NOT A DATA SCHEMA — its records are of the key's type.
2
+ //
3
+ // [Diego, 2026-09-25] — *"if team is just a data key for the `@std/member` type and that type
4
+ // has a schema, it should not be understood as missing a schema and becoming static data."*
5
+ //
6
+ // A query that declares no `schema:` takes its own name (`team` → `@/team`), and so does its
7
+ // records folder (`records/team/`). When no data schema has that name, but the foundation's
8
+ // section types declare the data key of that name with a type — `data: { team: '@/member' }` in a
9
+ // `meta.js` — the records ARE of that type: a push sends them as its entities, and the query names
10
+ // it on the wire. ⛔ Until 2026-09-25 they were read as having no data schema at all and went out
11
+ // as static files, although the one section that reads them declared what they are.
12
+ //
13
+ // ⚠️ THIS DECIDES ONLY WHICH DATA SCHEMA THE RECORDS BELONG TO. Where they live on disk, how the
14
+ // site builds them and how their translations are keyed stay keyed by the name (`team`), as the
15
+ // author wrote it — the records are where the author put them, and the query still reads them.
16
+ //
17
+ // ⛔ A key two section types declare with different types names no type: nothing says which.
18
+
19
+ import { declaredKeys } from '@uniweb/core/data-keys'
20
+
21
+ /**
22
+ * The type each data key of a foundation names, where every declaration of the key agrees on
23
+ * one — from its built `schema.json`: each section type's `data:` (at the top level, beside
24
+ * `_self` and `dataSchemas`), each layout's (under `_layouts`), and the foundation's own, which
25
+ * reaches every section (`main.js` `data:`, under `_self`).
26
+ *
27
+ * @param {object|null} foundationSchema - a foundation's built `dist/meta/schema.json`
28
+ * @returns {Map<string, string>} data key → its schema ref (`team` → `@/member`)
29
+ */
30
+ export function dataKeyTypes(foundationSchema) {
31
+ const types = new Map()
32
+ const conflicted = new Set()
33
+ const declare = (data) => {
34
+ for (const [key, ref] of declaredKeys(data)) {
35
+ if (!ref) continue
36
+ if (types.has(key) && types.get(key) !== ref) conflicted.add(key)
37
+ else types.set(key, ref)
38
+ }
39
+ }
40
+ for (const [name, entry] of Object.entries(foundationSchema || {})) {
41
+ if (!entry || typeof entry !== 'object' || name === 'dataSchemas') continue
42
+ if (name === '_self') declare(entry.data)
43
+ else if (name === '_layouts') for (const layout of Object.values(entry)) declare(layout?.data)
44
+ else if (!name.startsWith('_')) declare(entry.data)
45
+ }
46
+ for (const key of conflicted) types.delete(key)
47
+ return types
48
+ }
49
+
50
+ /**
51
+ * The data key a name-defaulted ref stands for — `@/team` → `team` — or null for any other ref.
52
+ *
53
+ * @param {unknown} ref
54
+ * @returns {string|null}
55
+ */
56
+ export function keyOfDefaultRef(ref) {
57
+ const m = typeof ref === 'string' ? /^@\/([^/]+)$/.exec(ref) : null
58
+ return m ? m[1] : null
59
+ }
package/src/uwx/index.js CHANGED
@@ -57,6 +57,15 @@ export { buildFolderEntity,
57
57
  collectFolderItemUuids,
58
58
  stampFolderItemUuids
59
59
  } from './folder.js'
60
+ // The identity of a record's list items — banked per record from what a push sent and
61
+ // what came back, stamped on the next send.
62
+ export {
63
+ recordItemLists,
64
+ harvestRecordItems,
65
+ storedRecordItems,
66
+ stampRecordItems,
67
+ reprintRecordItems
68
+ } from './record-items.js'
60
69
  export {
61
70
  recordsToProject,
62
71
  declarationsToQueriesYml,
@@ -7,5 +7,6 @@ export {
7
7
  defaultSchema,
8
8
  deferredFromSchema,
9
9
  foundationDataSchemas,
10
+ foundationSchemaJson,
10
11
  QUERIES_YML_RELPATH,
11
12
  } from '../site/queries-config.js'
@@ -0,0 +1,312 @@
1
+ // The identity of a record's LIST ITEMS — what a push must remember so that the next send of a
2
+ // record updates its stored items instead of replacing them.
3
+ //
4
+ // ⛔ AN ITEM OF A LIST WITHOUT A `$uuid` IS A NEW ROW. A record's `many` sections are `multi`
5
+ // sections on the backend, so a record re-sent with its list items uuid-less would insert them all
6
+ // and delete every stored one. The backend refuses that, whole (`identity_required`, naming the
7
+ // section) — measured 2026-09-25 on an edit of a talk whose `sessions` it held. A single section
8
+ // needs nothing: its one item is matched by section. So every LIST of a record carries its items'
9
+ // `$uuid`s once the backend holds them: a `many` section, a list nested in a section or in an
10
+ // item, and a self-nesting list's `$children`, at every depth.
11
+ //
12
+ // ⭐ BANKED PER RECORD, FROM WHAT WAS SENT AND WHAT CAME BACK. An item has no file and no name, so
13
+ // nothing on disk can carry its identity — the fourth kind in the producer's ledger, beside
14
+ // site-content units, records and folder placements. The backend's response mirrors the package
15
+ // item for item ("the producer walks both trees in lockstep"), so a push pairs each item it sent
16
+ // with the `$uuid` returned at the same place, and banks both: that uuid, and a fingerprint of the
17
+ // item as it was sent (`harvestRecordItems`). A pull pairs the same way — the files it writes hold
18
+ // the stored items in stored order.
19
+ //
20
+ // ⭐ MATCHED BY CONTENT, THEN BY PLACE (`stampRecordItems`). The next send gives an item the uuid of
21
+ // the banked item with the same fingerprint — its own fields unchanged, wherever it moved — and
22
+ // otherwise the one banked at its own position: an item edited where it stands. An item's own lists
23
+ // are then matched the same way under the item it took. Anything else goes without one, as
24
+ // a new row, and a banked item nothing claims is left out of the send, which deletes it. ⛔ No uuid
25
+ // is stamped twice: two items claiming one row is the failure identity exists to prevent.
26
+ //
27
+ // THE BANK IS FLAT, one line per item — it is committed, in `sync.json`: each item under its
28
+ // place in the record, its value its `$uuid` and, when known, its fingerprint —
29
+ // `"sessions[0]": "<uuid> <fingerprint>"`. A list's place is its path within its container, the
30
+ // same on both sides: `sessions` for a `many` section, `details.notes` for a list inside a single
31
+ // section, `printings` inside an item, `$children` for the items nested under an item of a
32
+ // self-nesting list — so `parts[0].$children[1]` is the second part nested under the first.
33
+
34
+ import { entityContentHash } from './records.js'
35
+
36
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
37
+
38
+ // An item's OWN content: its fields, with the lists nested in it left out — they are matched
39
+ // on their own, under it. ⛔ Not the item whole: a parent that moved while one of its children
40
+ // changed then matches nothing, and it comes back new with its whole subtree (measured
41
+ // 2026-09-25 on a tree reordered while a nested part was renamed).
42
+ function ownContent(def, value) {
43
+ if (!isPlainObject(value)) return value
44
+ const out = {}
45
+ for (const [key, v] of Object.entries(value)) {
46
+ if (key === '$children' && def?.self_nesting === true) continue
47
+ const field = def?.fields?.[key]
48
+ if (field?.type === 'section') {
49
+ if (field.multiple === true) continue
50
+ out[key] = ownContent(field, v)
51
+ } else {
52
+ out[key] = v
53
+ }
54
+ }
55
+ return out
56
+ }
57
+
58
+ // An item's own content in 16 hex characters — `$uuid`s aside, like every content hash of ours.
59
+ const fingerprint = (def, item) => entityContentHash(ownContent(def, item)).slice(0, 16)
60
+
61
+ // Each list of a container — a single section's value, or one item of a list — by its path.
62
+ function eachList(def, value, prefix, visit) {
63
+ if (!isPlainObject(value)) return
64
+ for (const [key, field] of Object.entries(def?.fields || {})) {
65
+ if (field?.type !== 'section') continue
66
+ if (field.multiple === true) {
67
+ if (Array.isArray(value[key])) visit(`${prefix}${key}`, field, value[key])
68
+ } else {
69
+ eachList(field, value[key], `${prefix}${key}.`, visit)
70
+ }
71
+ }
72
+ if (def?.self_nesting === true && Array.isArray(value.$children)) {
73
+ visit(`${prefix}$children`, def, value.$children)
74
+ }
75
+ }
76
+
77
+ // Each list of a record document: its `many` sections, and the lists inside its single ones.
78
+ function eachRecordList(declaration, document, visit) {
79
+ for (const [name, def] of Object.entries(declaration?.sections || {})) {
80
+ if (!def || typeof def !== 'object') continue
81
+ const value = document?.[name]
82
+ if (def.multiple === true) {
83
+ if (Array.isArray(value)) visit(name, def, value)
84
+ } else {
85
+ eachList(def, value, `${name}.`, visit)
86
+ }
87
+ }
88
+ }
89
+
90
+ function itemsOf(def, items) {
91
+ return items.map((item) => {
92
+ const node = { h: fingerprint(def, item) }
93
+ const nested = {}
94
+ eachList(def, item, '', (path, d, list) => {
95
+ nested[path] = itemsOf(d, list)
96
+ })
97
+ if (Object.keys(nested).length) node.c = nested
98
+ return node
99
+ })
100
+ }
101
+
102
+ /**
103
+ * The lists of one record document, as sent: each list's items by path, each item its
104
+ * fingerprint (`h`) and its own lists (`c`). What `harvestRecordItems` pairs with the
105
+ * document the backend returns. Not stored — a bank is (`harvestRecordItems`).
106
+ *
107
+ * @param {object} document - the record's `$`-document
108
+ * @param {object} declaration - its data schema's declaration (`{ name, sections }`)
109
+ * @returns {Object<string, Array<{ h: string, c?: object }>>} empty when the record has no list
110
+ */
111
+ export function recordItemLists(document, declaration) {
112
+ const lists = {}
113
+ eachRecordList(declaration, document, (path, def, items) => {
114
+ lists[path] = itemsOf(def, items)
115
+ })
116
+ return lists
117
+ }
118
+
119
+ /** Whether a record's lists hold any item at all. */
120
+ export function hasListItems(lists) {
121
+ return Object.values(lists || {}).some((items) => items.length > 0)
122
+ }
123
+
124
+ const valueAt = (value, path) =>
125
+ path.split('.').reduce((v, key) => (isPlainObject(v) ? v[key] : undefined), value)
126
+
127
+ const uuidOf = (item) => (isPlainObject(item) && typeof item.$uuid === 'string' && item.$uuid ? item.$uuid : null)
128
+
129
+ function pairItems(ours, theirs, at, bank) {
130
+ // Paired by position: the backend returns each list as it was sent. A list that came back
131
+ // with another length is still paired, but without fingerprints, so the next send matches
132
+ // its items by place alone.
133
+ const exact = ours.length === theirs.length
134
+ theirs.forEach((item, k) => {
135
+ const uuid = uuidOf(item)
136
+ if (!uuid) return
137
+ const place = `${at}[${k}]`
138
+ bank[place] = exact ? `${uuid} ${ours[k].h}` : uuid
139
+ for (const [path, sub] of Object.entries(ours[k]?.c || {})) {
140
+ const list = valueAt(item, path)
141
+ if (Array.isArray(list)) pairItems(sub, list, `${place}.${path}`, bank)
142
+ }
143
+ })
144
+ }
145
+
146
+ /**
147
+ * The bank entry for one record: each list item's `$uuid` in the document the backend
148
+ * returned (a push's finalized document, or a pull's), with its fingerprint as sent, paired
149
+ * by place with the lists as sent (`recordItemLists`).
150
+ *
151
+ * @param {object} lists - `recordItemLists` of the document as sent
152
+ * @param {object} returned - the backend's document for the same record
153
+ * @returns {Object<string, string>} place → `"<uuid> <fingerprint>"` (or `"<uuid>"`)
154
+ */
155
+ export function harvestRecordItems(lists, returned) {
156
+ const bank = {}
157
+ for (const [path, ours] of Object.entries(lists || {})) {
158
+ const theirs = valueAt(returned, path)
159
+ if (Array.isArray(theirs)) pairItems(ours, theirs, path, bank)
160
+ }
161
+ return bank
162
+ }
163
+
164
+ // A list in the backend's document: an array of items that each carry a `$uuid`.
165
+ const isStoredList = (v) => Array.isArray(v) && v.length > 0 && v.every((item) => uuidOf(item))
166
+
167
+ function storedLists(container, prefix, bank) {
168
+ for (const [key, value] of Object.entries(container || {})) {
169
+ if (key.startsWith('$') && key !== '$children') continue
170
+ if (isStoredList(value)) {
171
+ value.forEach((item, k) => {
172
+ const place = `${prefix}${key}[${k}]`
173
+ bank[place] = item.$uuid
174
+ storedLists(item, `${place}.`, bank)
175
+ })
176
+ } else if (uuidOf(value)) {
177
+ // A section: its lists sit under its name.
178
+ storedLists(value, `${prefix}${key}.`, bank)
179
+ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The bank entry for one record read from the backend's document ALONE — for a record whose
185
+ * items were never banked, like one pushed by a CLI from before this. No fingerprints: what
186
+ * was sent is unknown, so the next send matches each item by place.
187
+ *
188
+ * Read by shape: a list is an array whose every item carries a `$uuid`, and a section is an
189
+ * object carrying one — a reference is a string or an `{ schema, entity }`, and a localized
190
+ * value a `{ lang: … }` map, neither of which does.
191
+ *
192
+ * @param {object} returned - the backend's document for one record
193
+ * @returns {Object<string, string>} place → `"<uuid>"`
194
+ */
195
+ export function storedRecordItems(returned) {
196
+ const bank = {}
197
+ storedLists(returned, '', bank)
198
+ return bank
199
+ }
200
+
201
+ // The bank's items grouped by list: list path → { index → { u, h, place } }.
202
+ function bankedLists(bank) {
203
+ const lists = new Map()
204
+ for (const [place, value] of Object.entries(bank || {})) {
205
+ const m = /^(.*)\[(\d+)\]$/.exec(place)
206
+ if (!m || typeof value !== 'string') continue
207
+ const [u, h] = value.split(' ')
208
+ if (!u) continue
209
+ if (!lists.has(m[1])) lists.set(m[1], [])
210
+ lists.get(m[1])[Number(m[2])] = { u, h: h || null, place }
211
+ }
212
+ return lists
213
+ }
214
+
215
+ // Which banked item each item takes: the same fingerprint first, then the same place.
216
+ function matchItems(prints, banked) {
217
+ const assigned = new Array(prints.length).fill(null)
218
+ const taken = new Set()
219
+ const byPrint = new Map()
220
+ banked.forEach((b, j) => {
221
+ if (!b?.h) return
222
+ if (!byPrint.has(b.h)) byPrint.set(b.h, [])
223
+ byPrint.get(b.h).push(j)
224
+ })
225
+ prints.forEach((h, i) => {
226
+ const queue = byPrint.get(h)
227
+ while (queue?.length) {
228
+ const j = queue.shift()
229
+ if (taken.has(j)) continue
230
+ assigned[i] = banked[j]
231
+ taken.add(j)
232
+ break
233
+ }
234
+ })
235
+ prints.forEach((_, i) => {
236
+ if (assigned[i] || taken.has(i) || !banked[i]) return
237
+ assigned[i] = banked[i]
238
+ taken.add(i)
239
+ })
240
+ return assigned
241
+ }
242
+
243
+ function stampList(def, items, at, lists, counts, claimed) {
244
+ const banked = (at !== null && lists.get(at)) || []
245
+ const assigned = matchItems(items.map((item) => fingerprint(def, item)), banked)
246
+ items.forEach((item, i) => {
247
+ const b = assigned[i]
248
+ if (b && !claimed.has(b.u)) {
249
+ item.$uuid = b.u
250
+ claimed.add(b.u)
251
+ counts.stamped++
252
+ } else {
253
+ counts.unknown++
254
+ }
255
+ // An item's own lists are banked under the place of the item it matched — a new item's
256
+ // nested items are new too.
257
+ const under = b && item.$uuid === b.u ? b.place : null
258
+ eachList(def, item, '', (path, d, list) =>
259
+ stampList(d, list, under === null ? null : `${under}.${path}`, lists, counts, claimed)
260
+ )
261
+ })
262
+ }
263
+
264
+ /**
265
+ * Stamp a record document about to be sent with its list items' banked `$uuid`s.
266
+ *
267
+ * @param {object} document - the record's `$`-document; stamped in place
268
+ * @param {object} declaration - its data schema's declaration
269
+ * @param {Object<string,string>} bank - this record's entry (`harvestRecordItems` /
270
+ * `storedRecordItems`)
271
+ * @returns {{ stamped: number, unknown: number }} items given a uuid, and items sent as new
272
+ */
273
+ export function stampRecordItems(document, declaration, bank) {
274
+ const counts = { stamped: 0, unknown: 0 }
275
+ const claimed = new Set()
276
+ const lists = bankedLists(bank)
277
+ eachRecordList(declaration, document, (path, def, items) => {
278
+ stampList(def, items, path, lists, counts, claimed)
279
+ })
280
+ return counts
281
+ }
282
+
283
+ function flatPrints(lists, at, out) {
284
+ for (const [path, items] of Object.entries(lists || {})) {
285
+ items.forEach((node, k) => {
286
+ const place = `${at}${path}[${k}]`
287
+ out[place] = node.h
288
+ flatPrints(node.c, `${place}.`, out)
289
+ })
290
+ }
291
+ return out
292
+ }
293
+
294
+ /**
295
+ * A bank entry with its fingerprints taken again from the record as the NEXT send will
296
+ * build it, each uuid kept at its place. For a record that named a new record by `$ref`:
297
+ * once that record is minted the next send names it by uuid, so an item holding such a
298
+ * reference is fingerprinted anew — the same item, another encoding.
299
+ *
300
+ * @param {Object<string,string>} bank - this record's entry
301
+ * @param {object} lists - `recordItemLists` of the record as the next send builds it
302
+ * @returns {Object<string,string>}
303
+ */
304
+ export function reprintRecordItems(bank, lists) {
305
+ const prints = flatPrints(lists, '', {})
306
+ const out = {}
307
+ for (const [place, value] of Object.entries(bank || {})) {
308
+ const [u] = String(value).split(' ')
309
+ out[place] = prints[place] ? `${u} ${prints[place]}` : value
310
+ }
311
+ return out
312
+ }
@@ -41,11 +41,12 @@ import yaml from 'js-yaml'
41
41
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
42
42
  import { parseFrontmatter } from './entity-source.js'
43
43
  import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
44
- import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
45
- import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
44
+ import { defaultSchema, deferredFromSchema, foundationDataSchemas, foundationSchemaJson } from './queries-config.js'
45
+ import { dataKeyTypes } from './data-key-types.js'
46
+ import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
46
47
  import { folderYmlPath } from '../site/records-config.js'
47
48
  import { contentBodyTarget } from './record-layout.js'
48
- import { unresolveSelfScope, refuseOrgOption } from './self-scope.js'
49
+ import { unresolveSelfScope, resolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
49
50
  import { parseCatalogRef } from '../site/foundation-ref.js'
50
51
  import { unwrapLocalized } from './backfill.js'
51
52
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
@@ -153,11 +154,62 @@ function indexFolder(folderDoc) {
153
154
  // `schemaForPoolDirs`, and deliberately not a second rule: if the two disagreed,
154
155
  // a pulled record would land somewhere the next build reads as a different
155
156
  // schema — silently, because both paths are well-formed.
156
- function recordDirFor(recordsRoot, model, scope) {
157
- const dirs = poolDirsForSchema(unresolveSelfScope(model, scope))
157
+ function recordDirFor(recordsRoot, model, scope, own) {
158
+ const dirs = poolDirsForSchema(unresolveSelfScope(model, scope, own))
158
159
  return dirs ? join(recordsRoot, ...dirs) : null
159
160
  }
160
161
 
162
+ // ⭐ A PULLED RECORD GOES BACK INTO THE FILE THAT HOLDS IT — wherever under the records
163
+ // directory that is, so long as its folder reads as the same Model. Two folders can:
164
+ // under a foundation in `@std`, `records/person/` (`@/person`, qualified into `@std`) and
165
+ // `records/std/person/` both read as `@std/person`. ⛔ Looked for in the one folder the
166
+ // pull derived, as until 2026-09-25, a record the author kept in the other was written
167
+ // again beside it — two files, one record — and the next push was refused.
168
+ //
169
+ // ⭐ And a folder named for a data key the foundation types, when no data schema has that name,
170
+ // reads as that type — `records/team/` as `@std/member` for `data: { team: '@/member' }`
171
+ // (`data-key-types.js`), the same rule a push reads it by.
172
+ function recordDirsReadingAs(recordsRoot, model, scope, { own = null, keyTypes = null } = {}) {
173
+ const dirs = []
174
+ if (!existsSync(recordsRoot)) return dirs
175
+ const visible = (dir) =>
176
+ readdirSync(dir, { withFileTypes: true })
177
+ .filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
178
+ .map((e) => e.name)
179
+ for (const first of visible(recordsRoot)) {
180
+ const at = [first]
181
+ const keyType = !own?.has(first) ? keyTypes?.get(first) : null
182
+ if (
183
+ resolveSelfScope(schemaForPoolDirs(at), scope) === model ||
184
+ (keyType && resolveSelfScope(keyType, scope) === model)
185
+ ) {
186
+ dirs.push(join(recordsRoot, first))
187
+ }
188
+ for (const second of visible(join(recordsRoot, first))) {
189
+ if (resolveSelfScope(schemaForPoolDirs([first, second]), scope) === model) {
190
+ dirs.push(join(recordsRoot, first, second))
191
+ }
192
+ }
193
+ }
194
+ return dirs
195
+ }
196
+
197
+ // What the site's foundation declares, when its declarations are in this project: its own
198
+ // data-schema names (`ownSchemaNames`) and the types of its data keys (`dataKeyTypes`). Both
199
+ // null otherwise — a clone's foundation is not in the project.
200
+ function siteFoundationDeclarations(siteRoot) {
201
+ let siteYml = null
202
+ try {
203
+ siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || null
204
+ } catch {
205
+ return { own: null, keyTypes: null }
206
+ }
207
+ const schema = siteYml ? foundationSchemaJson(siteRoot, siteYml) : null
208
+ return schema
209
+ ? { own: ownSchemaNames(schema.dataSchemas || {}), keyTypes: dataKeyTypes(schema) }
210
+ : { own: null, keyTypes: null }
211
+ }
212
+
161
213
  // ⚠️ Undoing the producer's self-scope resolution (`unresolveSelfScope`) lives in
162
214
  // `./self-scope.js`, beside the forward rule it inverts. It did not show before
163
215
  // records were placed by their model: every record went to
@@ -231,7 +283,7 @@ function isDerivedDeferred(d, dataSchemas) {
231
283
  return d.deferred.every((f) => a.has(f))
232
284
  }
233
285
 
234
- function declToFileShape(wire, dataSchemas = null, scope = null) {
286
+ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null) {
235
287
  // ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
236
288
  // `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
237
289
  // (`site.js::queriesNested`), and both checks below are keyed by the author's
@@ -239,7 +291,7 @@ function declToFileShape(wire, dataSchemas = null, scope = null) {
239
291
  // explicit schema the author never had — and the derived-`deferred` lookup would
240
292
  // miss, persisting a derivation into their file (the 2026-08-29 defect).
241
293
  const d = scope && typeof wire.schema === 'string'
242
- ? { ...wire, schema: unresolveSelfScope(wire.schema, scope) }
294
+ ? { ...wire, schema: unresolveSelfScope(wire.schema, scope, own) }
243
295
  : wire
244
296
  const name = d.name || d.$id
245
297
  const decl = {}
@@ -262,7 +314,11 @@ function declToFileShape(wire, dataSchemas = null, scope = null) {
262
314
  decl.source = source
263
315
  }
264
316
 
265
- if (d.schema && d.schema !== defaultSchema(name)) decl.schema = d.schema
317
+ // The default is the query's name — or, when no data schema has that name, the type the
318
+ // foundation declares for the data key of that name (`data-key-types.js`), which is what a
319
+ // push sent in its place. Either one stays unwritten, so the terse file stays terse.
320
+ const keyDefault = name && !own?.has(name) ? keyTypes?.get(name) : null
321
+ if (d.schema && d.schema !== defaultSchema(name) && d.schema !== keyDefault) decl.schema = d.schema
266
322
  setIf(decl, 'sort', d.sort)
267
323
  setIf(decl, 'where', d.where)
268
324
  setIf(decl, 'limit', d.limit)
@@ -348,10 +404,14 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
348
404
  const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
349
405
  const selfScope =
350
406
  scope !== undefined ? scope : (parseCatalogRef(document?.info?.foundation)?.scope ?? null)
407
+ // Which of the scope's schemas are the foundation's own — the rest keep their scope
408
+ // (`unresolveSelfScope`) — and the types of its data keys.
409
+ const own = ownSchemaNames(dataSchemas)
410
+ const keyTypes = siteYml ? dataKeyTypes(foundationSchemaJson(siteRoot, siteYml)) : null
351
411
 
352
412
  const queries = {}
353
413
  for (const d of decls) {
354
- const { name, decl } = declToFileShape(d, dataSchemas, selfScope)
414
+ const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes)
355
415
  if (!name) continue
356
416
  queries[name] = decl
357
417
  }
@@ -477,6 +537,10 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
477
537
  // `@scope/x` model is placed back where the author wrote it. ⛔ It was the site
478
538
  // owner's org, read from `sync.json`, until 2026-09-22 (`self-scope.js`).
479
539
  const selfScope = opts.scope ?? null
540
+ // Which of that scope's Models are the foundation's own — the rest keep their scope, so
541
+ // a standard `@std/person` under a foundation in `@std` is placed in `records/std/person/`
542
+ // (`unresolveSelfScope`).
543
+ const { own, keyTypes } = siteFoundationDeclarations(siteRoot)
480
544
  if (typeof resolveDeclaration !== 'function') {
481
545
  throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
482
546
  }
@@ -514,7 +578,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
514
578
  restoreAssetRefs(recordDocs, backendState.assets || {})
515
579
  // This backend's record map, inverted: its uuid → the record's own id.
516
580
  const recordMap = backendState.records || {}
517
- const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
581
+ const ownIdByTheirs = new Map(Object.entries(recordMap).map(([ownId, theirs]) => [theirs, ownId]))
518
582
  // own id → their uuid, for every record this pull wrote. Recorded at the end.
519
583
  const learned = {}
520
584
  // uuid → the path under `records/` the record landed at. Only this loop knows
@@ -522,6 +586,16 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
522
586
  // re-derived (a second rule could pick a different extension and the folder
523
587
  // would name a file that is not there).
524
588
  const poolPathByUuid = new Map()
589
+ // The file holding a record already, by its own id: the derived folder first, then any
590
+ // other that reads as the same Model (`recordDirsReadingAs`).
591
+ const readingAs = (model) => recordDirsReadingAs(recordsRoot, model, selfScope, { own, keyTypes })
592
+ const findRecordFile = (poolDir, model, ownId) =>
593
+ findRecordFileByUuid(poolDir, ownId) ||
594
+ readingAs(model)
595
+ .filter((dir) => dir !== poolDir)
596
+ .map((dir) => findRecordFileByUuid(dir, ownId))
597
+ .find(Boolean) ||
598
+ null
525
599
 
526
600
  for (const document of recordDocs) {
527
601
  const where = locate(document, folderIndex)
@@ -536,7 +610,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
536
610
  continue
537
611
  }
538
612
 
539
- const poolDir = recordDirFor(recordsRoot, schema, selfScope)
613
+ const poolDir = recordDirFor(recordsRoot, schema, selfScope, own)
540
614
  if (!poolDir) {
541
615
  skipped.push({
542
616
  uuid: document.$uuid,
@@ -550,8 +624,8 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
550
624
  // the first backend a record reaches minted the id it keeps — so the backend's
551
625
  // uuid is the own id, exactly as before identity was keyed by backend.
552
626
  const theirs = document.$uuid || null
553
- const own = theirs ? ownIdByTheirs.get(theirs) || theirs : null
554
- const existing = own ? findRecordFileByUuid(poolDir, own) : null
627
+ const ownId = theirs ? ownIdByTheirs.get(theirs) || theirs : null
628
+ const existing = ownId ? findRecordFile(poolDir, schema, ownId) : null
555
629
 
556
630
  let filePath
557
631
  let format
@@ -561,8 +635,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
561
635
  format = existing.format
562
636
  isNew = false
563
637
  } else {
564
- format = defaultFormat(poolDir, declaration)
565
- filePath = join(poolDir, where.slug + EXT_FOR_FORMAT[format])
638
+ // A new record goes where the project already keeps records of its Model — its derived
639
+ // folder, else another that reads as the Model (`records/team/` for `@std/member`).
640
+ const home = existsSync(poolDir) ? poolDir : readingAs(schema)[0] || poolDir
641
+ format = defaultFormat(home, declaration)
642
+ filePath = join(home, where.slug + EXT_FOR_FORMAT[format])
566
643
  isNew = true
567
644
  }
568
645
 
@@ -572,7 +649,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
572
649
 
573
650
  // ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
574
651
  // backend must not overwrite the identity the record already has.
575
- const toWrite = own && own !== theirs ? { ...document, $uuid: own } : document
652
+ const toWrite = ownId && ownId !== theirs ? { ...document, $uuid: ownId } : document
576
653
  let status
577
654
  try {
578
655
  status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName })
@@ -591,7 +668,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
591
668
  // terms, so that is what `folderToFolderYml` will look them up by.
592
669
  if (theirs) {
593
670
  poolPathByUuid.set(theirs, relative(recordsRoot, filePath).split(sep).join('/'))
594
- if (own) learned[own] = theirs
671
+ if (ownId) learned[ownId] = theirs
595
672
  }
596
673
  }
597
674
 
@@ -32,13 +32,14 @@
32
32
  // reference or held back, and the CLI pushed again to complete it.
33
33
  // ⚠️ item_ref and file fields are still sent as the file writes them.
34
34
  //
35
- // ⚠️ A record of a `many` section is sent without a `$uuid`, so a push that changes a
36
- // record whose section the backend already holds is REFUSED, whole (`identity_required`):
37
- // applied, it would re-identify every stored item. Measured 2026-09-25 — this said such a
38
- // push "replaces that section's records" until then. Its first push, which creates the
39
- // items, is not affected.
35
+ // ⭐ A record of a `many` section — an ITEM of a record's list — carries the `$uuid` the backend
36
+ // gave it once the backend holds it, from a bank the CLI keeps per record (`record-items.js`,
37
+ // stamped in `sync-package.js`). Sent without it, a record whose list the backend holds is
38
+ // REFUSED, whole (`identity_required`): applied, it would re-identify every stored item —
39
+ // measured 2026-09-25 on an edit of a talk, before the bank existed.
40
40
 
41
41
  import { readBackendState } from './sync-store.js'
42
+ import { dataKeyTypes, keyOfDefaultRef } from './data-key-types.js'
42
43
  import { readFileSync, existsSync } from 'node:fs'
43
44
  import yaml from 'js-yaml'
44
45
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
@@ -879,6 +880,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
879
880
  folder: { ...folder, nodes: [] },
880
881
  recordsDirExists: pool.exists,
881
882
  sendFolder: sendsFolder(pool, []),
883
+ declarations: new Map(),
884
+ keyTyped: new Map(),
882
885
  }
883
886
  }
884
887
 
@@ -976,6 +979,24 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
976
979
  )
977
980
  }
978
981
 
982
+ // ⭐ A NAME THAT IS A DATA KEY, NOT A DATA SCHEMA [Diego, 2026-09-25] (`data-key-types.js`):
983
+ // a records folder or a query whose name-defaulted schema (`@/team`) resolves to nothing,
984
+ // while the foundation's section types type the data key of that name (`team: '@/member'`),
985
+ // holds records of that type. `keyTyped` records each such name for the `queries` Section,
986
+ // which must name the same data schema (`sync-package.js` hands it on). ⛔ Never for a schema
987
+ // a query asked for explicitly: that one is the author's, and fails loudly.
988
+ const keyTypes = dataKeyTypes(localSchema)
989
+ const keyTyped = new Map()
990
+ const typedFor = async (schema) => {
991
+ const key = keyOfDefaultRef(schema)
992
+ const type = key ? keyTypes.get(key) : null
993
+ if (!type || explicitBy.has(schema)) return null
994
+ const declaration = await declarationFor(modelFor(type, `data key "${key}"`))
995
+ if (!declaration) return null
996
+ keyTyped.set(schema, type)
997
+ return declaration
998
+ }
999
+
979
1000
  const sourceLocale =
980
1001
  opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
981
1002
 
@@ -1016,10 +1037,17 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1016
1037
  // of another Model by its handle (`speaker: ada`), and the push sends that record's
1017
1038
  // uuid in its place — or its `$id`, while it is new (`refResolver`).
1018
1039
  const readSchemas = []
1040
+ // ⛔ ONE RECORD IN TWO FILES — the same own id, its `$uuid`, twice — is refused, naming
1041
+ // both. It is not two records, and sent, both would claim one entity. A pull could write
1042
+ // one until 2026-09-25: under a foundation in `@std`, a standard `@std/person` record was
1043
+ // written again into `records/person/`, beside the author's `records/std/person/`
1044
+ // (`self-scope.js::unresolveSelfScope`), and a push then reported only a name two
1045
+ // records share.
1046
+ const fileByOwnId = new Map()
1019
1047
  for (const [schema, poolEntities] of poolBySchema) {
1020
1048
  const label = poolEntities[0].dirs.join('/')
1021
1049
  const modelName = modelFor(schema, `${pool.dir}/${label}/`)
1022
- const declaration = await declarationFor(modelName)
1050
+ const declaration = (await declarationFor(modelName)) || (await typedFor(schema))
1023
1051
  if (!declaration) {
1024
1052
  // An explicit schema the author asked for is a hard error; one only the
1025
1053
  // folder's name supplied is a soft skip, reported below.
@@ -1055,6 +1083,16 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1055
1083
  // ⚠️ A multi-record file (array YAML, BibTeX) contributes several records
1056
1084
  // from one path, so the slug — not the file stem — completes the id.
1057
1085
  rec.$id = rec.$id || [...pooled.dirs, r.slug].join('/')
1086
+ const ownId = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
1087
+ const firstFile = ownId ? fileByOwnId.get(ownId) : undefined
1088
+ if (firstFile && firstFile !== pooled.relPath) {
1089
+ refusals.push(
1090
+ `${pooled.relPath}: holds the same record as ${firstFile} — both carry ` +
1091
+ `\`$uuid: ${ownId}\` — so a push would send one record twice. Keep one of them.`
1092
+ )
1093
+ } else if (ownId) {
1094
+ fileByOwnId.set(ownId, pooled.relPath)
1095
+ }
1058
1096
  flat.push(rec)
1059
1097
  sourceBySlug.set(r.slug, r)
1060
1098
  produced.push({ id: rec.$id, slug: r.slug })
@@ -1145,7 +1183,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1145
1183
  const hasRecords = poolBySchema.has(schema)
1146
1184
  const resolved = hasRecords
1147
1185
  ? !unresolved.has(schema)
1148
- : Boolean(await declarationFor(modelFor(schema, `query "${name}"`)))
1186
+ : Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema)))
1149
1187
  if (!resolved) {
1150
1188
  if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
1151
1189
  schemaless.push({ name, model: resolveSelfScope(schema, scope) })
@@ -1183,6 +1221,12 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1183
1221
  folder: { ...folder, nodes },
1184
1222
  recordsDirExists: pool.exists,
1185
1223
  sendFolder: sendsFolder(pool, entities),
1224
+ // Each entity's declaration, by its Model — what walks a record's lists
1225
+ // (`record-items.js`).
1226
+ declarations: new Map(readSchemas.map(({ declaration }) => [declaration.name, declaration])),
1227
+ // A name-defaulted schema that stands for the type of the data key of its name
1228
+ // (`@/team` → `@/member`) — see `typedFor` above.
1229
+ keyTyped,
1186
1230
  }
1187
1231
  }
1188
1232
 
@@ -33,6 +33,7 @@
33
33
  import { readFileSync } from 'node:fs'
34
34
  import { join, resolve } from 'node:path'
35
35
  import yaml from 'js-yaml'
36
+ import { isStandardSchema } from '@uniweb/schemas'
36
37
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
37
38
  import { detectFoundationType, parseCatalogRef } from '../site/foundation-ref.js'
38
39
  import { readFoundationName } from '../schema.js'
@@ -66,7 +67,8 @@ export function resolveSelfScope(ref, scope) {
66
67
  }
67
68
 
68
69
  /**
69
- * The inverse, for a pull: `@<scope>/x` → `@/x` for the site's foundation's scope.
70
+ * The inverse, for a pull: `@<scope>/x` → `@/x` — when `x` is one of the foundation's
71
+ * OWN data schemas.
70
72
  *
71
73
  * ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
72
74
  * on both ends. A record authored under `records/article/` comes back as
@@ -76,17 +78,51 @@ export function resolveSelfScope(ref, scope) {
76
78
  * relied on the query-name default comes back with an explicit schema it never
77
79
  * had.
78
80
  *
81
+ * ⛔ AND NOT EVERY `@<scope>/x` WAS AN `@/x`: the forward rule is not one-to-one. A
82
+ * foundation in `@std` — every template's — whose site also uses the STANDARD
83
+ * `@std/person` ships both its own `@/specimen` and that `@std/person` as `@std/…`.
84
+ * Inverted whole, as it was until 2026-09-25, the standard schema came back as the
85
+ * foundation's own: a clone put `@std/person` records in `records/person/` and
86
+ * rewrote the query to `@/person`, and a pull into a working copy wrote them BESIDE
87
+ * the author's `records/std/person/`, after which every reference to a person named
88
+ * two records and the next push was refused. So `x` is written back as `@/x` only
89
+ * when it is the foundation's own: one of `own`, the names its declarations define
90
+ * (`ownSchemaNames`) — or, when those cannot be read here (its foundation is not in
91
+ * this project, as after a clone), any name that is not a standard schema of `@std`.
92
+ *
79
93
  * A model scoped to ANY OTHER scope is left alone: it genuinely is that org's, and
80
94
  * `@/` would be a lie.
81
95
  *
82
96
  * @param {unknown} ref
83
97
  * @param {unknown} scope - the scope the pulled records were qualified with
98
+ * @param {Set<string>|null} [own] - the names of the data schemas the foundation
99
+ * defines, when known
84
100
  * @returns {unknown}
85
101
  */
86
- export function unresolveSelfScope(ref, scope) {
102
+ export function unresolveSelfScope(ref, scope, own = null) {
87
103
  const handle = bareOrg(scope)
88
- if (typeof ref !== 'string' || !handle) return ref
89
- return ref.startsWith(`@${handle}/`) ? `@/${ref.slice(handle.length + 2)}` : ref
104
+ if (typeof ref !== 'string' || !handle || !ref.startsWith(`@${handle}/`)) return ref
105
+ const name = ref.slice(handle.length + 2)
106
+ const isOwn = own ? own.has(name) : !(handle === 'std' && isStandardSchema(name))
107
+ return isOwn ? `@/${name}` : ref
108
+ }
109
+
110
+ /**
111
+ * The names of the data schemas a foundation DEFINES — the `@/x` keys of its built
112
+ * declarations (`dist/meta/schema.json` → `dataSchemas`), which is what `register`
113
+ * registers. A schema it only references keeps its scope there (`@std/person`) and is
114
+ * not one of them. Null when there are no declarations to read.
115
+ *
116
+ * @param {object|null} dataSchemas - a foundation's `dataSchemas` map
117
+ * @returns {Set<string>|null}
118
+ */
119
+ export function ownSchemaNames(dataSchemas) {
120
+ if (!dataSchemas || typeof dataSchemas !== 'object') return null
121
+ return new Set(
122
+ Object.keys(dataSchemas)
123
+ .filter((ref) => ref.startsWith('@/'))
124
+ .map((ref) => ref.slice(2))
125
+ )
90
126
  }
91
127
 
92
128
  /**
@@ -95,7 +131,8 @@ export function unresolveSelfScope(ref, scope) {
95
131
  *
96
132
  * - `foundation` given (a pull passes the pinned ref the pushed site carries) — a
97
133
  * catalog ref's scope, or a local foundation's, resolved from that value.
98
- * - else `foundationDir` — that local foundation's.
134
+ * - else `foundationDir` — that local foundation's; when its name has none, the scope of
135
+ * the release `site.yml::foundation` pins for it, if that names the same foundation.
99
136
  * - else `site.yml::foundation`, the same way.
100
137
  *
101
138
  * A local foundation's scope is the one in its name, read by the one rule
@@ -112,7 +149,10 @@ export function unresolveSelfScope(ref, scope) {
112
149
  * @returns {Promise<string|null>}
113
150
  */
114
151
  export async function siteSelfScope(siteRoot, { foundation, foundationDir } = {}) {
115
- if (foundation === undefined && foundationDir) return localFoundationScope(resolve(foundationDir))
152
+ if (foundation === undefined && foundationDir) {
153
+ const dir = resolve(foundationDir)
154
+ return (await localFoundationScope(dir)) ?? (await pinnedReleaseScope(siteRoot, dir))
155
+ }
116
156
  const declared = foundation !== undefined ? foundation : readDeclaredFoundation(siteRoot)
117
157
  const value = declared && typeof declared === 'object' ? declared.name : declared
118
158
  if (typeof value !== 'string' || !value) return null
@@ -136,6 +176,26 @@ async function localFoundationScope(dir) {
136
176
  }
137
177
  }
138
178
 
179
+ // ⭐ A LOCAL FOUNDATION WHOSE NAME CARRIES NO SCOPE, handed in as the source of the release
180
+ // the site pins (`site.yml::foundation: '@std/lab@0.1.0'`, the same name): that release's
181
+ // scope. The site's pin IS the foundation's name with its scope, and the local folder is its
182
+ // source. It arises when a registration was written to a file (`register --scope @std -o`),
183
+ // which writes nothing into the source — so a push given the source (`--foundation <dir>`)
184
+ // found a bare name and shipped every `@/x` unresolved (measured 2026-09-25: records of `@/team`
185
+ // shipped as static files). ⛔ Only for the same name: a pin of another foundation says
186
+ // nothing about this one's scope.
187
+ async function pinnedReleaseScope(siteRoot, dir) {
188
+ const declared = readDeclaredFoundation(siteRoot)
189
+ const ref = parseCatalogRef(declared && typeof declared === 'object' ? declared.name : declared)
190
+ if (!ref?.scope) return null
191
+ try {
192
+ const { name } = await readFoundationName(dir)
193
+ return splitFoundationName(name).bare === ref.name ? ref.scope : null
194
+ } catch {
195
+ return null
196
+ }
197
+ }
198
+
139
199
  function readDeclaredFoundation(siteRoot) {
140
200
  try {
141
201
  const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
package/src/uwx/site.js CHANGED
@@ -919,7 +919,7 @@ function externalSource(d) {
919
919
  return source
920
920
  }
921
921
 
922
- function queriesNested(declarations, uuids = null, scope = null) {
922
+ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null) {
923
923
  const out = []
924
924
  for (const [name, d] of Object.entries(declarations)) {
925
925
  refuseUnder(d.where, `queries.${name}`)
@@ -936,7 +936,10 @@ function queriesNested(declarations, uuids = null, scope = null) {
936
936
  // stored under, so a verbatim `@/member` beside records stored as `@acme/member`
937
937
  // names nothing: the query resolves no Model and the page that binds it renders
938
938
  // empty. The pull puts the author's `@/` back (`records-project.js::declarationsToQueriesYml`).
939
- setIf(data, 'schema', resolveSelfScope(d.schema, scope))
939
+ // A query whose name-defaulted schema stands for the type of the data key of its name
940
+ // names that type — the data schema its records are sent as (`data-key-types.js`).
941
+ const schema = (!d.schemaExplicit && keyTyped?.get(d.schema)) || d.schema
942
+ setIf(data, 'schema', resolveSelfScope(schema, scope))
940
943
  setIf(data, 'sort', d.sort)
941
944
  // Legacy `filter:` is not synced — it is translated to `where` upstream
942
945
  // (the canonical predicate). No legacy fields on the wire.
@@ -1545,7 +1548,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1545
1548
  // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1546
1549
  // consumer sees, leave the identifier alone.
1547
1550
  const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
1548
- doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope)
1551
+ doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope, opts.keyTyped)
1549
1552
  // Emitted ONLY when the file declares the key — see the header above
1550
1553
  // `serviceRecords`: on a replaced Section, absent and empty are different
1551
1554
  // requests and one of them is destructive.
@@ -20,6 +20,7 @@
20
20
  // lane (the idempotent no-op).
21
21
 
22
22
  import { buildRecordEntities, entityContentHash } from './records.js'
23
+ import { recordItemLists, hasListItems, stampRecordItems } from './record-items.js'
23
24
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
24
25
  import { buildFolderEntity } from './folder.js'
25
26
  import { siteProjectToDocument } from './site.js'
@@ -217,6 +218,10 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
217
218
  * stamped onto the site-content document so the backend matches our items
218
219
  * instead of re-minting them (which deletes and recreates every page and
219
220
  * section row). Sourced from a pull or a push response, cached by the caller.
221
+ * @param {Object<string,Object<string,string>>} [opts.recordItemUuids] - record `$uuid` → the
222
+ * identity of its list items, as a previous push or pull banked it
223
+ * (`record-items.js`); stamped onto each record the backend holds, so a re-sent
224
+ * record updates its items instead of replacing them.
220
225
  * @param {Object<string,string>} [opts.itemBaseVersions] - record `$uuid` → opaque
221
226
  * per-ITEM `version`. Narrowed at emit to the records the package carries and
222
227
  * sent as `entries[].item_base_versions`, so the backend can refuse only the
@@ -253,7 +258,11 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
253
258
  * records: { buffer, entityCount, index, models }|null,
254
259
  * hashes: Object<string,string>, warnings: string[], refusals: string[],
255
260
  * skipped: number, schemaless: Array<{name: string, model: string}>,
256
- * localAssets: string[], applied: object, namesNew: string[] }>}
261
+ * localAssets: string[], applied: object, namesNew: string[],
262
+ * recordItemIdentity: { stamped: number, unknown: number, unbanked: string[] } }>}
263
+ * Each record entry of `records.index` carries `lists`: the record's list items as
264
+ * sent (`record-items.js::recordItemLists`), for the caller to bank against the
265
+ * document the backend returns.
257
266
  * `namesNew` lists the records this package sends whose references name a record it
258
267
  * creates — by `$ref`, where the next push will send a uuid — as keys of `hashes`. A
259
268
  * caller that banks `hashes` re-banks these once the push is applied.
@@ -347,6 +356,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
347
356
  // must name the Model its records are stored under, and both are qualified
348
357
  // from one `@/x` by one rule (`./self-scope.js`) — so they take one scope.
349
358
  scope,
359
+ // …and the same data schema, where a name stands for the type of its data key
360
+ // (`@/team` → `@/member`, `data-key-types.js`): decided once, by the records.
361
+ keyTyped: col.keyTyped,
350
362
  // Withhold the `$services`/`$secrets` Sections when the caller has
351
363
  // determined the file is not asking for anything new by them. Passed
352
364
  // through rather than decided here: the last-agreed state is project
@@ -452,6 +464,32 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
452
464
  for (const e of col.entities) collectFrom(e.document)
453
465
  const localAssets = [...localAssetSet]
454
466
 
467
+ // ⭐ THE IDENTITY OF A RECORD'S LIST ITEMS (`record-items.js`), taken on each document as it
468
+ // will be sent — after every rewrite above. `opts.recordItemUuids` is the caller's bank: per
469
+ // record (its uuid on this backend), each list's items as a previous push or pull left them.
470
+ // A record this backend has not minted has only new items, and one it holds with no bank entry
471
+ // is named in `unbanked`, so the caller can recover its items' identity before sending it.
472
+ // Stamping does not move a content hash: `$uuid` is stripped from every one.
473
+ const recordItemBank =
474
+ opts.recordItemUuids && typeof opts.recordItemUuids === 'object' ? opts.recordItemUuids : null
475
+ const recordItemIdentity = { stamped: 0, unknown: 0, unbanked: [] }
476
+ const unbankedIds = new Set()
477
+ for (const e of col.entities) {
478
+ const declaration = col.declarations?.get(e.model)
479
+ if (!declaration) continue
480
+ e.lists = recordItemLists(e.document, declaration)
481
+ const uuid = e.document.$uuid
482
+ if (!uuid || !hasListItems(e.lists)) continue
483
+ const banked = recordItemBank?.[uuid]
484
+ if (!banked) {
485
+ unbankedIds.add(e.id)
486
+ continue
487
+ }
488
+ const n = stampRecordItems(e.document, declaration, banked)
489
+ recordItemIdentity.stamped += n.stamped
490
+ recordItemIdentity.unknown += n.unknown
491
+ }
492
+
455
493
  const siteEntity = siteDoc
456
494
  ? { id: siteDoc.$id, model: siteDoc.$schema, file: 'entities/site-content.json', document: siteDoc }
457
495
  : null
@@ -492,7 +530,12 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
492
530
  // leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
493
531
  // back-fill (backfillEntityUuids skips it — the folder has no uuid to write).
494
532
  const entities = [folder, ...changedRecords.map((r) => r.entity)].map(stamp)
495
- const index = [{ kind: 'folder' }, ...changedRecords.map((r) => r.index)]
533
+ // Each record's lists as sent ride on its index entry: the caller pairs them with the
534
+ // document the backend returns, to bank the items' identity (`record-items.js`).
535
+ const index = [{ kind: 'folder' }, ...changedRecords.map((r) => ({ ...r.index, lists: r.entity.lists || {} }))]
536
+ for (const r of changedRecords) {
537
+ if (unbankedIds.has(r.entity.id)) recordItemIdentity.unbanked.push(r.entity.document.$uuid)
538
+ }
496
539
  // The folder references every record's Model via `entry.model` — including records
497
540
  // filtered out here by send-only-changed. Declare them all (the backend rejects a
498
541
  // folder that references an undeclared Model).
@@ -545,5 +588,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
545
588
  // no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
546
589
  // before is the index-loss signature the backend refuses — the caller reports it.
547
590
  itemIdentity,
591
+ // The records' list items: how many took a banked uuid and how many go as new, and the
592
+ // records this package SENDS that the backend holds with list items and no bank entry
593
+ // (their uuids) — sent as they are, the backend refuses each one (`identity_required`).
594
+ recordItemIdentity,
548
595
  }
549
596
  }
@@ -43,8 +43,12 @@ export const SYNC_STORE_FILE = 'sync.json'
43
43
  /** Current on-disk shape. Bumped only for a breaking layout change. */
44
44
  const VERSION = 1
45
45
 
46
- /** Map sections — merged entry by entry. Anything else in a patch replaces. */
47
- const MAP_SECTIONS = new Set(['records', 'items', 'queries', 'folders', 'assets'])
46
+ /**
47
+ * Map sections — merged entry by entry. Anything else in a patch replaces.
48
+ * `recordItems` is keyed by a record's uuid here, each entry that record's list items
49
+ * (`record-items.js`) — replaced whole, one record at a time.
50
+ */
51
+ const MAP_SECTIONS = new Set(['records', 'items', 'queries', 'folders', 'assets', 'recordItems'])
48
52
 
49
53
  const storePath = (siteDir) => join(siteDir, SYNC_STORE_FILE)
50
54
 
@@ -207,7 +211,7 @@ export function updateBackendState(siteDir, origin, patch) {
207
211
  *
208
212
  * @param {string} siteDir
209
213
  * @param {string} origin
210
- * @param {string} section - one of `records` `items` `queries` `folders` `assets`
214
+ * @param {string} section - one of `records` `items` `queries` `folders` `assets` `recordItems`
211
215
  * @param {Record<string, *>} entries
212
216
  * @param {(next: *, prior: *) => *} [merge]
213
217
  * @returns {{ added: string[], changed: string[], written: boolean }}
@@ -41,6 +41,7 @@ import { validateAndNormalizeSchema, buildDataSchemaMap } from './resolve-data-s
41
41
  export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
42
42
  import { buildSchema } from './schema.js'
43
43
  import { resolveRecordsDir, readEntityPool, groupPoolBySchema } from './site/entity-pool.js'
44
+ import { dataKeyTypes, keyOfDefaultRef } from './uwx/data-key-types.js'
44
45
  import { readEntityFile } from './uwx/entity-source.js'
45
46
  import { toFetchList } from './site/data-fetcher.js'
46
47
  import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
@@ -86,6 +87,9 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
86
87
  const srcDir = resolveFoundationSrcPath(foundationPath)
87
88
  const foundation = await buildSchema(srcDir)
88
89
  const dataSchemas = foundation.dataSchemas || {}
90
+ // The type each data key names — a records folder named for a typed key holds records of
91
+ // that type when no data schema has its name (`uwx/data-key-types.js`).
92
+ const keyTypes = dataKeyTypes(foundation)
89
93
 
90
94
  const site = await collectSiteContent(siteRoot, { foundationPath })
91
95
  const config = site.config || {}
@@ -275,7 +279,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
275
279
  // its folder names. ⭐ The set a push sends: every file in the records directory is
276
280
  // a record and every record is pushed, whether or not a section reads it — and
277
281
  // until 2026-09-24 one that no section read was never checked here.
278
- const files = await validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths: config.paths, checked: checkedRecords })
282
+ const files = await validateRecordFiles(siteRoot, { srcDir, dataSchemas, keyTypes, paths: config.paths, checked: checkedRecords })
279
283
  violations.push(...files.violations)
280
284
  setupErrors.push(...files.setupErrors)
281
285
  for (const ref of files.schemas) schemasSeen.add(ref)
@@ -321,22 +325,34 @@ function listViolation(finding, label, base) {
321
325
  * that names no record of its schema is reported under the rule `ref`, since a push
322
326
  * cannot send it.
323
327
  *
324
- * A folder whose schema resolves to nothing is left out in silence: its records are
325
- * schema-less, delivered as files, and the push says so itself.
328
+ * ⭐ A folder named for a data key the foundation types, when no data schema has its name,
329
+ * holds records of that type and is checked against it — `records/team/` against `@/member`
330
+ * for `data: { team: '@/member' }` — the way a push sends them [Diego, 2026-09-25]
331
+ * (`uwx/data-key-types.js`). A folder whose schema resolves to nothing even so is left out in
332
+ * silence: its records are schema-less, delivered as files, and the push says so itself.
326
333
  *
327
334
  * @returns {Promise<{ violations: object[], setupErrors: object[], schemas: Set<string>, checked: number }>}
328
335
  */
329
- async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths, checked }) {
336
+ async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, keyTypes = new Map(), paths, checked }) {
330
337
  const out = { violations: [], setupErrors: [], schemas: new Set(), checked: 0 }
331
338
  const pool = await readEntityPool(siteRoot, { dir: resolveRecordsDir(siteRoot, paths).rel })
332
339
  // Every record is read first: a reference names a record of another schema by its
333
340
  // name — its file's, or its `slug:` — or by the id its file carries.
334
341
  const read = []
335
342
  const names = new Map() // schema (as its folder names it) → the names and ids of its records
336
- for (const [ref, entities] of groupPoolBySchema(pool.entities)) {
337
- const schema = await schemaForRecords(ref, { srcDir, dataSchemas })
343
+ for (const [folderRef, entities] of groupPoolBySchema(pool.entities)) {
344
+ let ref = folderRef
345
+ let schema = await schemaForRecords(folderRef, { srcDir, dataSchemas })
346
+ const typed = schema ? null : keyTypes.get(keyOfDefaultRef(folderRef))
347
+ if (typed) {
348
+ schema = await schemaForRecords(typed, { srcDir, dataSchemas })
349
+ if (schema) ref = typed
350
+ }
351
+ // Records are named under what they ARE — a reference to `@/member` finds the ones kept
352
+ // in `records/team/` — and under the folder's own ref as well.
338
353
  const known = names.get(ref) ?? new Set()
339
354
  names.set(ref, known)
355
+ if (ref !== folderRef) names.set(folderRef, known)
340
356
  for (const pooled of entities) {
341
357
  let records
342
358
  try {