@uniweb/build 0.62.0 → 0.63.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -25,16 +25,21 @@
25
25
  //
26
26
  // ⭐ A REFERENCE (`entity_ref`) NAMES ITS RECORD BY HANDLE in a file — `speaker: ada`,
27
27
  // the target's file name or its `slug:` — and is sent as the uuid THIS backend minted for
28
- // that record (`refResolver`). A target this backend has not minted yet (new in this
29
- // push) cannot be named on the wire, so its referrer WAITS: sent without that reference
30
- // and completed by the next pass, or held back when it cannot exist without it — see
31
- // `sync-package.js`, and the CLI's `pushInPasses`, which repeats the push.
28
+ // that record (`refResolver`). A target this backend has not minted yet is new, so this
29
+ // push creates it, and the reference names it by its `$id` in the package —
30
+ // `speaker: { $ref: "speaker/ada" }` — which the backend resolves to the uuid it mints.
31
+ // One push creates both. ⛔ Until 2026-09-25 such a referrer WAITED, sent without the
32
+ // reference or held back, and the CLI pushed again to complete it.
32
33
  // ⚠️ item_ref and file fields are still sent as the file writes them.
33
34
  //
34
- // ⚠️ A record of a `many` section is sent without a `$uuid`, so a push that changes a
35
- // record replaces that section's records rather than updating them in place.
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.
36
40
 
37
41
  import { readBackendState } from './sync-store.js'
42
+ import { dataKeyTypes, keyOfDefaultRef } from './data-key-types.js'
38
43
  import { readFileSync, existsSync } from 'node:fs'
39
44
  import yaml from 'js-yaml'
40
45
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
@@ -85,7 +90,7 @@ const RECORD_KEYS = new Set([
85
90
 
86
91
  // Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$schema`/… — never
87
92
  // field data; the sigil-exclusivity invariant guarantees this) at every level,
88
- // so a back-filled `$uuid` doesn't change the hash. Two are exceptions, because
93
+ // so a back-filled `$uuid` doesn't change the hash. Three are exceptions, because
89
94
  // they are content rather than identity:
90
95
  // - `$children` is STRUCTURAL content (a self-nesting record's subtree, e.g.
91
96
  // site-content's nested pages/sections), so it is KEPT and recursed into —
@@ -94,15 +99,21 @@ const RECORD_KEYS = new Set([
94
99
  // - `$disabled` is a record's delivery STATE (`draft: true` on the file side). It
95
100
  // is KEPT so that drafting or un-drafting a record with nothing else changed is
96
101
  // still sent; stripped, "send only changed" would never send the toggle.
102
+ // - `$ref` is a REFERENCE to a record the same push creates (`speaker: { $ref:
103
+ // "speaker/ada" }`), and which record it names is content. Once the backend has
104
+ // minted that record the next push names it by uuid instead — so the CLI re-banks a
105
+ // record that sent a `$ref` after its push (`namesNew`, `sync-package.js`). The
106
+ // folder's ref leaf is not this case: see below.
97
107
  function stripSigils(value) {
98
108
  if (Array.isArray(value)) return value.map(stripSigils)
99
109
  if (value && typeof value === 'object') {
100
110
  // ⛔ A `@uniweb/folder` REF LEAF ENCODES ONE REFERENCE TWO WAYS, and hashing the
101
111
  // encoding rather than the reference made the folder's hash unreproducible.
102
112
  //
103
- // `refLeaf` (uwx/folder.js) emits `$ref: <the record's $id>` — the pool position
104
- // `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema, entity:
105
- // <uuid> }` once it has been minted.
113
+ // `refLeaf` (uwx/folder.js) emits `entry: { $ref: <the record's $id> }` — the pool
114
+ // position `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema,
115
+ // entity: <uuid> }` once it has been minted. (`$ref` was a key of the leaf itself
116
+ // until 2026-09-25, when the backend made it a reference's value.)
106
117
  // Both denote the same record. A push hashes the folder BEFORE submitting, then
107
118
  // back-fills the minted `$uuid` into every record's source file — so the very
108
119
  // next emit builds the OTHER encoding, and the hash the push just banked can
@@ -116,19 +127,20 @@ function stripSigils(value) {
116
127
  // `name` (the record's handle) at its position inside the branch that holds it.
117
128
  // Two siblings may share a name — records of different schemas can — and position
118
129
  // still tells them apart; a leaf that comes to reference another record arrives
119
- // with that record changed, which re-sends the folder anyway. `$ref` adds a
120
- // payload-local handle and `entry` adds identity, and both are exactly what
121
- // `$uuid` is stripped for.
130
+ // with that record changed, which re-sends the folder anyway. `entry` carries a
131
+ // payload-local handle or an identity, and both are exactly what `$uuid` is
132
+ // stripped for.
122
133
  //
123
134
  // ⚖️ The previous rule kept `$ref` "so a reference change is visible". It still
124
135
  // is: point a leaf at a different record and its `name` moves with it.
125
136
  const isFolderRefLeaf = value.kind === 'ref'
126
137
  const out = {}
127
138
  for (const [k, v] of Object.entries(value)) {
128
- if (isFolderRefLeaf && (k === '$ref' || k === 'entry')) continue
129
- // `$children` (a self-nesting subtree) and `$disabled` (a delivery state) are
130
- // CONTENT, not identity sigils — kept, so a change to either stays visible.
131
- if (k === '$children' || k === '$disabled') {
139
+ if (isFolderRefLeaf && k === 'entry') continue
140
+ // `$children` (a self-nesting subtree), `$disabled` (a delivery state) and `$ref`
141
+ // (a reference to a record of the same push) are CONTENT, not identity sigils —
142
+ // kept, so a change to any of them stays visible.
143
+ if (k === '$children' || k === '$disabled' || k === '$ref') {
132
144
  out[k] = stripSigils(v)
133
145
  continue
134
146
  }
@@ -218,11 +230,10 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
218
230
  * @param {{ resolve: (model: string, value: *) => object }} [params.refs] - what a
219
231
  * reference sends (`refResolver`). Without it a reference is sent as written.
220
232
  * @returns {{ entities: object[], warnings: string[], refusals: string[] }} each
221
- * entity is `{ id, uuid, model, file, document, pending }` — `document` is the
222
- * section-keyed body, and `pending` the references it WAITS on, left out of it:
223
- * `{ path, model, name, required }`, one per reference whose record this backend has
224
- * not minted yet. `refusals` names the records that cannot be sent as written, one
225
- * line each; a caller that sends must not send while any is present.
233
+ * entity is `{ id, uuid, slug, model, file, document, namesNew }` — `document` is the
234
+ * section-keyed body, and `namesNew` how many of its references name a record this
235
+ * push creates, by `$ref`. `refusals` names the records that cannot be sent as
236
+ * written, one line each; a caller that sends must not send while any is present.
226
237
  */
227
238
  export function recordsToEntities({
228
239
  label,
@@ -324,10 +335,7 @@ export function recordsToEntities({
324
335
  )
325
336
  }
326
337
  if (!enc.misplaced.length && !enc.shape.length) {
327
- // A required reference that waits for its record is owed, not missing: the push
328
- // that completes the record sends it (see `pending` below).
329
- const waiting = new Set(enc.pending.map((p) => `"${p.path}"`))
330
- const missing = missingRequired(layout, sections).filter((m) => !waiting.has(m))
338
+ const missing = missingRequired(layout, sections)
331
339
  if (missing.length) {
332
340
  refusals.push(
333
341
  `${where}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
@@ -364,7 +372,7 @@ export function recordsToEntities({
364
372
  // unique within its schema folder, and an authored `$id` need not be.
365
373
  file: `entities/${label}/${slug}.json`,
366
374
  document,
367
- pending: enc.pending,
375
+ namesNew: enc.namesNew,
368
376
  })
369
377
  }
370
378
  return { entities, warnings, refusals }
@@ -374,7 +382,7 @@ export function recordsToEntities({
374
382
  // schema, the keys written flat in a schema written by section, and values of the
375
383
  // wrong shape for their section.
376
384
  function encoder(sourceLocale, translations, refs = null) {
377
- return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], pending: [], unresolved: [] }
385
+ return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
378
386
  }
379
387
 
380
388
  // A flat record: its keys, minus the record's own, are the one section's fields.
@@ -478,10 +486,10 @@ function encodeRecord(fields, value, enc, prefix, reserved) {
478
486
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
479
487
 
480
488
  // A reference, as a push sends it: the uuid this backend minted for the record the file
481
- // names (`refResolver`). A list of them is each of those. One whose record this backend
482
- // has not minted yet is left out and noted as WAITING (`enc.pending`) — the push that
483
- // completes it sends it. Without a resolver, and for a reference open to several Models
484
- // (which our schemas do not author), the value is sent as written.
489
+ // names (`refResolver`) — or, for a record it has not minted, which this push creates,
490
+ // `{ $ref: <that record's $id> }`, the backend's name for a record of the same package.
491
+ // A list of them is each of those. Without a resolver, and for a reference open to
492
+ // several Models (which our schemas do not author), the value is sent as written.
485
493
  function encodeReference(value, field, enc, path) {
486
494
  if (!enc.refs || typeof field.model !== 'string') return value
487
495
  if (field.multiple === true) {
@@ -490,27 +498,23 @@ function encodeReference(value, field, enc, path) {
490
498
  return undefined
491
499
  }
492
500
  const sent = []
493
- const waiting = []
494
501
  value.forEach((one, i) => {
495
- const uuid = resolveReference(one, field, enc, `${path}[${i}]`, waiting)
496
- if (uuid !== undefined) sent.push(uuid)
502
+ const ref = resolveReference(one, field, enc, `${path}[${i}]`)
503
+ if (ref !== undefined) sent.push(ref)
497
504
  })
498
- // A required list with nothing to send yet waits whole; otherwise what is known goes now.
499
- const required = field.required === true && sent.length === 0
500
- for (const w of waiting) enc.pending.push({ ...w, required })
501
505
  return sent
502
506
  }
503
- const waiting = []
504
- const uuid = resolveReference(value, field, enc, path, waiting)
505
- for (const w of waiting) enc.pending.push({ ...w, required: field.required === true })
506
- return uuid
507
+ return resolveReference(value, field, enc, path)
507
508
  }
508
509
 
509
- function resolveReference(value, field, enc, path, waiting) {
510
+ function resolveReference(value, field, enc, path) {
510
511
  const answer = enc.refs.resolve(field.model, value)
511
512
  if (answer.uuid) return answer.uuid
512
- if (answer.pending) waiting.push({ path, model: field.model, name: value })
513
- else enc.unresolved.push({ path, model: field.model, value, ...answer })
513
+ if (answer.ref) {
514
+ enc.namesNew++
515
+ return { $ref: answer.ref }
516
+ }
517
+ enc.unresolved.push({ path, model: field.model, value, ...answer })
514
518
  return undefined
515
519
  }
516
520
 
@@ -527,31 +531,34 @@ function describeUnresolved({ path, model, value, invalid, ambiguous }) {
527
531
  // own id (mapped like a handle's), or the backend's id of a record the project does not
528
532
  // hold, which is what a pull writes for one.
529
533
  //
530
- // ⛔ A record this backend has not minted yet — never pushed there — answers `pending`: a
531
- // uuid it did not mint is never sent (see `buildRecordEntities`), so its referrer waits.
534
+ // ⭐ A record this backend has not minted yet — never pushed there — answers with its
535
+ // `$id` (`ref`): it is new, so this push creates it, and the backend takes
536
+ // `{ $ref: <$id> }` for a record of the same package. A uuid it did not mint is never
537
+ // sent (see `buildRecordEntities`).
532
538
  const AMBIGUOUS = Symbol('ambiguous')
533
539
  function refResolver(readSchemas, recordMap) {
534
- const byModel = new Map() // Model → handle → the record's own id (null before its first push)
535
- const ownIds = new Set()
540
+ const byModel = new Map() // Model → handle → the record: its own id (null before its first push) and `$id`
541
+ const byOwnId = new Map() // the record's own id → the record
536
542
  for (const { declaration, flat } of readSchemas) {
537
543
  let names = byModel.get(declaration.name)
538
544
  if (!names) byModel.set(declaration.name, (names = new Map()))
539
545
  for (const rec of flat) {
540
546
  const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
541
- names.set(rec.slug, names.has(rec.slug) ? AMBIGUOUS : own)
542
- if (own) ownIds.add(own)
547
+ const record = { own, id: rec.$id }
548
+ names.set(rec.slug, names.has(rec.slug) ? AMBIGUOUS : record)
549
+ if (own) byOwnId.set(own, record)
543
550
  }
544
551
  }
545
- const minted = (own) => (own && recordMap[own] ? { uuid: recordMap[own] } : { pending: true })
552
+ const sent = ({ own, id }) => (own && recordMap[own] ? { uuid: recordMap[own] } : { ref: id })
546
553
  return {
547
554
  resolve(model, value) {
548
555
  if (typeof value !== 'string' || value === '') return { invalid: true }
549
556
  const names = byModel.get(model)
550
557
  if (names?.has(value)) {
551
- const own = names.get(value)
552
- return own === AMBIGUOUS ? { ambiguous: true } : minted(own)
558
+ const record = names.get(value)
559
+ return record === AMBIGUOUS ? { ambiguous: true } : sent(record)
553
560
  }
554
- if (isUuid(value)) return ownIds.has(value) ? minted(value) : { uuid: value }
561
+ if (isUuid(value)) return byOwnId.has(value) ? sent(byOwnId.get(value)) : { uuid: value }
555
562
  return { missing: true }
556
563
  },
557
564
  }
@@ -873,6 +880,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
873
880
  folder: { ...folder, nodes: [] },
874
881
  recordsDirExists: pool.exists,
875
882
  sendFolder: sendsFolder(pool, []),
883
+ declarations: new Map(),
884
+ keyTyped: new Map(),
876
885
  }
877
886
  }
878
887
 
@@ -970,6 +979,24 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
970
979
  )
971
980
  }
972
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
+
973
1000
  const sourceLocale =
974
1001
  opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
975
1002
 
@@ -1008,12 +1035,19 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1008
1035
 
1009
1036
  // ⭐ EVERY MODEL'S RECORDS ARE READ BEFORE ANY IS MAPPED: a reference names a record
1010
1037
  // of another Model by its handle (`speaker: ada`), and the push sends that record's
1011
- // uuid in its place (`refResolver`).
1038
+ // uuid in its place — or its `$id`, while it is new (`refResolver`).
1012
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()
1013
1047
  for (const [schema, poolEntities] of poolBySchema) {
1014
1048
  const label = poolEntities[0].dirs.join('/')
1015
1049
  const modelName = modelFor(schema, `${pool.dir}/${label}/`)
1016
- const declaration = await declarationFor(modelName)
1050
+ const declaration = (await declarationFor(modelName)) || (await typedFor(schema))
1017
1051
  if (!declaration) {
1018
1052
  // An explicit schema the author asked for is a hard error; one only the
1019
1053
  // folder's name supplied is a soft skip, reported below.
@@ -1049,6 +1083,16 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1049
1083
  // ⚠️ A multi-record file (array YAML, BibTeX) contributes several records
1050
1084
  // from one path, so the slug — not the file stem — completes the id.
1051
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
+ }
1052
1096
  flat.push(rec)
1053
1097
  sourceBySlug.set(r.slug, r)
1054
1098
  produced.push({ id: rec.$id, slug: r.slug })
@@ -1139,7 +1183,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1139
1183
  const hasRecords = poolBySchema.has(schema)
1140
1184
  const resolved = hasRecords
1141
1185
  ? !unresolved.has(schema)
1142
- : Boolean(await declarationFor(modelFor(schema, `query "${name}"`)))
1186
+ : Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema)))
1143
1187
  if (!resolved) {
1144
1188
  if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
1145
1189
  schemaless.push({ name, model: resolveSelfScope(schema, scope) })
@@ -1177,6 +1221,12 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1177
1221
  folder: { ...folder, nodes },
1178
1222
  recordsDirExists: pool.exists,
1179
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,
1180
1230
  }
1181
1231
  }
1182
1232
 
@@ -157,7 +157,56 @@ function buildDataSchemaEntities(dataSchemas, scope) {
157
157
  // way; item_ref `options` get the full `@org/x/<section>` path (§10.1).
158
158
  ...toDataSchemaDeclaration(normalized, { name: scoped(ref), resolveName: scoped, resolveOptions }),
159
159
  }))
160
- return { entities, scoped, org }
160
+ return { entities: referencedFirst(entities), scoped, org }
161
+ }
162
+
163
+ /**
164
+ * The data schemas in an order a backend can install one at a time: a schema another one in
165
+ * the package references comes BEFORE it — alphabetical otherwise, so the order is stable.
166
+ *
167
+ * ⭐ WHY. A backend installs a package's data schemas in the order they arrive, and refuses a
168
+ * reference to a Model it holds neither yet nor from before: measured 2026-09-25, a foundation
169
+ * whose `exhibit` references its own `specimen` was refused whole — *"entity_ref target
170
+ * `@x/specimen` matches no model declared in this fixture or registered on the host"* — because
171
+ * `exhibit` sorts first. Alphabetical order was right only by luck (`speaker` before `talk`).
172
+ *
173
+ * ⚠️ A CYCLE CANNOT BE ORDERED — `author` names `book` and `book` names `author` — and keeps
174
+ * the order the walk gives it. Whether one package may reference itself in any order is the
175
+ * backend's to accept; this only stops refusing what an order can fix. A schema naming ITSELF
176
+ * is not a dependency.
177
+ *
178
+ * @param {Array<{ name: string, sections?: object }>} entities - lowered declarations
179
+ * @returns {Array} the same entities, referenced ones first
180
+ */
181
+ function referencedFirst(entities) {
182
+ const byName = new Map(entities.map((e) => [e.name, e]))
183
+ const targets = (entity) => {
184
+ const out = new Set()
185
+ const walk = (fields) => {
186
+ for (const field of Object.values(fields || {})) {
187
+ if (!field || typeof field !== 'object') continue
188
+ if (field.type === 'entity_ref') for (const m of [field.model].flat()) out.add(m)
189
+ // `options: '@org/x/<section>'` names a section of Model `@org/x`.
190
+ if (field.type === 'item_ref' && typeof field.options === 'string') {
191
+ out.add(field.options.split('/').slice(0, 2).join('/'))
192
+ }
193
+ if (field.type === 'section') walk(field.fields)
194
+ }
195
+ }
196
+ for (const section of Object.values(entity.sections || {})) walk(section.fields)
197
+ return [...out].filter((name) => name !== entity.name && byName.has(name)).sort()
198
+ }
199
+ const ordered = []
200
+ const state = new Map() // name → 'visiting' | 'done'
201
+ const visit = (entity) => {
202
+ if (state.get(entity.name)) return // done, or on the walk now — a cycle
203
+ state.set(entity.name, 'visiting')
204
+ for (const name of targets(entity)) visit(byName.get(name))
205
+ state.set(entity.name, 'done')
206
+ ordered.push(entity)
207
+ }
208
+ for (const entity of entities) visit(entity)
209
+ return ordered
161
210
  }
162
211
 
163
212
  // The shared `.uwx` envelope (uwx/1 + exporter + timestamp) around an entity list.