@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.
- package/package.json +5 -5
- package/src/site/queries-config.js +9 -5
- package/src/uwx/data-key-types.js +53 -0
- package/src/uwx/folder.js +15 -8
- package/src/uwx/index.js +9 -0
- package/src/uwx/queries-config.js +1 -0
- package/src/uwx/record-items.js +312 -0
- package/src/uwx/records-project.js +94 -17
- package/src/uwx/records.js +108 -58
- package/src/uwx/registry-package.js +50 -1
- package/src/uwx/self-scope.js +66 -6
- package/src/uwx/site.js +6 -3
- package/src/uwx/sync-package.js +66 -65
- package/src/uwx/sync-store.js +7 -3
|
@@ -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 {
|
|
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
|
-
|
|
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(([
|
|
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
|
|
554
|
-
const existing =
|
|
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
|
-
|
|
565
|
-
|
|
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 =
|
|
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 (
|
|
671
|
+
if (ownId) learned[ownId] = theirs
|
|
595
672
|
}
|
|
596
673
|
}
|
|
597
674
|
|
package/src/uwx/records.js
CHANGED
|
@@ -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
|
|
29
|
-
// push
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
//
|
|
35
|
-
//
|
|
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.
|
|
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
|
|
104
|
-
// `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema,
|
|
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.
|
|
120
|
-
// payload-local handle
|
|
121
|
-
//
|
|
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 &&
|
|
129
|
-
// `$children` (a self-nesting subtree)
|
|
130
|
-
//
|
|
131
|
-
|
|
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,
|
|
222
|
-
* section-keyed body, and `
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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: [],
|
|
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`)
|
|
482
|
-
//
|
|
483
|
-
//
|
|
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
|
|
496
|
-
if (
|
|
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
|
-
|
|
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
|
|
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.
|
|
513
|
-
|
|
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
|
-
//
|
|
531
|
-
//
|
|
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
|
|
535
|
-
const
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
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
|
|
552
|
-
return
|
|
558
|
+
const record = names.get(value)
|
|
559
|
+
return record === AMBIGUOUS ? { ambiguous: true } : sent(record)
|
|
553
560
|
}
|
|
554
|
-
if (isUuid(value)) return
|
|
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.
|