@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.
@@ -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'
@@ -33,18 +34,6 @@ const SITE_ENTITY_KEY = 'site-content'
33
34
 
34
35
  const cacheKey = (entity) => `${entity.model} ${entity.id}`
35
36
 
36
- // The folder's placements without the records a push leaves out — `{ kind: 'ref' }` leaves
37
- // naming them by `$entityId`, inside any branch.
38
- function withoutRecords(nodes, ids) {
39
- if (!ids.size) return nodes
40
- const out = []
41
- for (const node of nodes || []) {
42
- if (node?.kind === 'branch') out.push({ ...node, $children: withoutRecords(node.$children, ids) })
43
- else if (!ids.has(node?.$entityId)) out.push(node)
44
- }
45
- return out
46
- }
47
-
48
37
  // Attach the push gate's optimistic-concurrency token to an entity, keyed by the
49
38
  // BACKEND uuid in its document body (`$uuid`). A never-synced entity has no
50
39
  // `$uuid` and therefore no base — correctly unconditional, since there is no
@@ -229,6 +218,10 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
229
218
  * stamped onto the site-content document so the backend matches our items
230
219
  * instead of re-minting them (which deletes and recreates every page and
231
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.
232
225
  * @param {Object<string,string>} [opts.itemBaseVersions] - record `$uuid` → opaque
233
226
  * per-ITEM `version`. Narrowed at emit to the records the package carries and
234
227
  * sent as `entries[].item_base_versions`, so the backend can refuse only the
@@ -265,11 +258,14 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
265
258
  * records: { buffer, entityCount, index, models }|null,
266
259
  * hashes: Object<string,string>, warnings: string[], refusals: string[],
267
260
  * skipped: number, schemaless: Array<{name: string, model: string}>,
268
- * localAssets: string[], applied: object,
269
- * waiting: Array<{id: string, model: string, slug: string, held: boolean, pending: object[]}> }>}
270
- * `waiting` lists the records whose references name a record this backend has not
271
- * minted yet — sent without them, or held back. A caller that sends pushes again
272
- * once this push has been applied (`records.js::encodeReference`).
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.
266
+ * `namesNew` lists the records this package sends whose references name a record it
267
+ * creates — by `$ref`, where the next push will send a uuid — as keys of `hashes`. A
268
+ * caller that banks `hashes` re-banks these once the push is applied.
273
269
  * `refusals` names the records that cannot be sent as written, one line each
274
270
  * (`records.js::recordsToEntities`). A caller that submits must not submit while
275
271
  * any is present: the backend would refuse the records lane — or, for a record
@@ -316,43 +312,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
316
312
  })
317
313
  const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
318
314
 
319
- // ⭐ RECORDS WAITING FOR A REFERENCE (`records.js::encodeReference`). A record whose
320
- // reference names a record this backend has not minted yet cannot name it on the wire,
321
- // so this push sends what it can and the next pass completes it (the CLI's
322
- // `pushInPasses`):
323
- // · new, and nothing it waits for is required → sent now without those references,
324
- // so after one pass every record exists, whatever refers to what;
325
- // · new, and a required reference waits → HELD and placed nowhere: it cannot be
326
- // created without it;
327
- // · already on this backend → HELD, left as it is there: sent without a reference it
328
- // has, it would lose that reference until the next pass.
329
- // A held record is not sent and keeps the hash last banked for it, so the next pass
330
- // sees it as changed.
331
- const held = new Set()
332
- const unplaced = new Set()
333
- for (const e of col.entities) {
334
- if (!e.pending?.length) continue
335
- if (e.uuid) held.add(e.id)
336
- else if (e.pending.some((p) => p.required)) {
337
- held.add(e.id)
338
- unplaced.add(e.id)
339
- }
340
- }
341
- const waiting = col.entities
342
- .filter((e) => e.pending?.length)
343
- .map((e) => ({ id: e.id, model: e.model, slug: e.slug, held: held.has(e.id), pending: e.pending }))
344
- const placedEntities = []
345
- const placedIndex = []
346
- col.entities.forEach((e, i) => {
347
- if (unplaced.has(e.id)) return
348
- placedEntities.push(e)
349
- placedIndex.push(col.index[i])
350
- })
351
-
352
315
  // The folder rides over the FULL record set (before filtering) so its references
353
316
  // are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
354
317
  const folder = buildFolderEntity({
355
- recordEntities: placedEntities,
318
+ recordEntities: col.entities,
356
319
  ...(sourceLocale ? { sourceLocale } : {}),
357
320
  // ⭐ Every record in the directory, at the top of the folder or in the
358
321
  // sub-folder `folder.yml` places it in — and NOTHING when there is no folder to
@@ -360,7 +323,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
360
323
  // non-empty tree: a `folder.yml` whose folders hold only records that resolved
361
324
  // no schema is a tree of empty branches, and sending it would replace the
362
325
  // backend's folder with them (measured on the `dynamic` template, 2026-09-21).
363
- folderNodes: col.sendFolder === true ? withoutRecords(col.folder?.nodes ?? [], unplaced) : [],
326
+ folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],
364
327
  // ⛔ Whether the file side HAS a folder to state (`records.js::sendsFolder`) —
365
328
  // not whether it holds anything. A site with no records directory sends none
366
329
  // and leaves the backend's alone; an empty directory sends an empty folder,
@@ -393,6 +356,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
393
356
  // must name the Model its records are stored under, and both are qualified
394
357
  // from one `@/x` by one rule (`./self-scope.js`) — so they take one scope.
395
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,
396
362
  // Withhold the `$services`/`$secrets` Sections when the caller has
397
363
  // determined the file is not asking for anything new by them. Passed
398
364
  // through rather than decided here: the last-agreed state is project
@@ -498,6 +464,32 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
498
464
  for (const e of col.entities) collectFrom(e.document)
499
465
  const localAssets = [...localAssetSet]
500
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
+
501
493
  const siteEntity = siteDoc
502
494
  ? { id: siteDoc.$id, model: siteDoc.$schema, file: 'entities/site-content.json', document: siteDoc }
503
495
  : null
@@ -518,26 +510,32 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
518
510
  // changed() has side effects (hashes/skipped), so evaluate every entity exactly
519
511
  // once, in a stable order: folder, then each record.
520
512
  const folderChanged = folder ? changed(folder) : false
521
- // A held record keeps its last banked hash — it was not sent (see `held` above).
522
- const keepBanked = (entity) => {
523
- const key = cacheKey(entity)
524
- if (priorHashes[key] !== undefined) hashes[key] = priorHashes[key]
525
- return false
526
- }
527
- const recordChanged = placedEntities.map((e, i) => ({
513
+ const recordChanged = col.entities.map((e, i) => ({
528
514
  entity: e,
529
- index: placedIndex[i],
530
- changed: held.has(e.id) ? keepBanked(e) : changed(e),
515
+ index: col.index[i],
516
+ changed: changed(e),
531
517
  }))
532
518
  const changedRecords = recordChanged.filter((r) => r.changed)
533
519
 
534
520
  let records = null
521
+ // ⭐ A RECORD THAT NAMES A RECORD THIS PUSH CREATES names it by `$ref`
522
+ // (`records.js::encodeReference`), and the backend stores the uuid it mints for it. The
523
+ // next push sends that uuid: the same reference, another encoding — and another hash.
524
+ // So the caller re-banks these records once the push is applied, or the next push
525
+ // sends every one of them again.
526
+ let namesNew = []
535
527
  if (folder && (folderChanged || changedRecords.length > 0)) {
528
+ namesNew = changedRecords.filter((r) => r.entity.namesNew > 0).map((r) => cacheKey(r.entity))
536
529
  // Folder first (always, for the `$ref` closure), then changed records. The
537
530
  // leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
538
531
  // back-fill (backfillEntityUuids skips it — the folder has no uuid to write).
539
532
  const entities = [folder, ...changedRecords.map((r) => r.entity)].map(stamp)
540
- 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
+ }
541
539
  // The folder references every record's Model via `entry.model` — including records
542
540
  // filtered out here by send-only-changed. Declare them all (the backend rejects a
543
541
  // folder that references an undeclared Model).
@@ -582,14 +580,17 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
582
580
 
583
581
  return {
584
582
  siteContent, records, siteContentUuid, hashes, warnings, skipped,
585
- // Records waiting for a reference's record — see `held` above. Non-empty ⇒ the caller
586
- // pushes again once this push has been applied.
587
- waiting,
583
+ // Records that name a record this push creates — see `namesNew` above.
584
+ namesNew,
588
585
  refusals: col.refusals || [],
589
586
  schemaless: col.schemaless, localAssets, applied,
590
587
  // { stamped, unknown } when identity was applied; null when the caller passed
591
588
  // no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
592
589
  // before is the index-loss signature the backend refuses — the caller reports it.
593
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,
594
595
  }
595
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 }}