@uniweb/build 0.61.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/i18n/freeform.js +3 -1
- package/src/i18n/records.js +161 -80
- package/src/resolve-data-schema.js +11 -1
- package/src/runtime-schema.js +14 -16
- package/src/site/queries-config.js +68 -13
- package/src/site/query-processor.js +310 -67
- package/src/uwx/backfill.js +115 -52
- package/src/uwx/data-schema.js +16 -11
- package/src/uwx/folder.js +15 -8
- package/src/uwx/project-writer.js +4 -2
- package/src/uwx/record-layout.js +61 -0
- package/src/uwx/records-project.js +23 -18
- package/src/uwx/records.js +387 -212
- package/src/uwx/registry-package.js +53 -4
- package/src/uwx/sync-package.js +18 -2
- package/src/uwx/uuid.js +7 -0
- package/src/validate-data.js +57 -14
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
27
27
|
import { join } from 'node:path'
|
|
28
28
|
|
|
29
|
-
import { toDataSchemaDeclaration } from './data-schema.js'
|
|
29
|
+
import { toDataSchemaDeclaration, SHORTHAND_SECTION } from './data-schema.js'
|
|
30
30
|
import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
|
|
31
31
|
|
|
32
32
|
const FOUNDATION_SCHEMA = '@uniweb/foundation-schema'
|
|
@@ -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.
|
|
@@ -231,8 +280,8 @@ function makeOptionsResolver(dataSchemas, scoped) {
|
|
|
231
280
|
}
|
|
232
281
|
|
|
233
282
|
function itemBearingSectionName(normalized, ref) {
|
|
234
|
-
// fields-form → the
|
|
235
|
-
if (normalized.fields) return
|
|
283
|
+
// fields-form → the shorthand's one section, always named `brief`
|
|
284
|
+
if (normalized.fields) return SHORTHAND_SECTION
|
|
236
285
|
// sections-form → the first item-bearing (single/multi) section
|
|
237
286
|
for (const [name, sec] of Object.entries(normalized.sections || {})) {
|
|
238
287
|
const kind = sec.kind || 'single'
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -253,7 +253,10 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
253
253
|
* records: { buffer, entityCount, index, models }|null,
|
|
254
254
|
* hashes: Object<string,string>, warnings: string[], refusals: string[],
|
|
255
255
|
* skipped: number, schemaless: Array<{name: string, model: string}>,
|
|
256
|
-
* localAssets: string[], applied: object }>}
|
|
256
|
+
* localAssets: string[], applied: object, namesNew: string[] }>}
|
|
257
|
+
* `namesNew` lists the records this package sends whose references name a record it
|
|
258
|
+
* creates — by `$ref`, where the next push will send a uuid — as keys of `hashes`. A
|
|
259
|
+
* caller that banks `hashes` re-banks these once the push is applied.
|
|
257
260
|
* `refusals` names the records that cannot be sent as written, one line each
|
|
258
261
|
* (`records.js::recordsToEntities`). A caller that submits must not submit while
|
|
259
262
|
* any is present: the backend would refuse the records lane — or, for a record
|
|
@@ -469,11 +472,22 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
469
472
|
// changed() has side effects (hashes/skipped), so evaluate every entity exactly
|
|
470
473
|
// once, in a stable order: folder, then each record.
|
|
471
474
|
const folderChanged = folder ? changed(folder) : false
|
|
472
|
-
const recordChanged = col.entities.map((e, i) => ({
|
|
475
|
+
const recordChanged = col.entities.map((e, i) => ({
|
|
476
|
+
entity: e,
|
|
477
|
+
index: col.index[i],
|
|
478
|
+
changed: changed(e),
|
|
479
|
+
}))
|
|
473
480
|
const changedRecords = recordChanged.filter((r) => r.changed)
|
|
474
481
|
|
|
475
482
|
let records = null
|
|
483
|
+
// ⭐ A RECORD THAT NAMES A RECORD THIS PUSH CREATES names it by `$ref`
|
|
484
|
+
// (`records.js::encodeReference`), and the backend stores the uuid it mints for it. The
|
|
485
|
+
// next push sends that uuid: the same reference, another encoding — and another hash.
|
|
486
|
+
// So the caller re-banks these records once the push is applied, or the next push
|
|
487
|
+
// sends every one of them again.
|
|
488
|
+
let namesNew = []
|
|
476
489
|
if (folder && (folderChanged || changedRecords.length > 0)) {
|
|
490
|
+
namesNew = changedRecords.filter((r) => r.entity.namesNew > 0).map((r) => cacheKey(r.entity))
|
|
477
491
|
// Folder first (always, for the `$ref` closure), then changed records. The
|
|
478
492
|
// leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
|
|
479
493
|
// back-fill (backfillEntityUuids skips it — the folder has no uuid to write).
|
|
@@ -523,6 +537,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
523
537
|
|
|
524
538
|
return {
|
|
525
539
|
siteContent, records, siteContentUuid, hashes, warnings, skipped,
|
|
540
|
+
// Records that name a record this push creates — see `namesNew` above.
|
|
541
|
+
namesNew,
|
|
526
542
|
refusals: col.refusals || [],
|
|
527
543
|
schemaless: col.schemaless, localAssets, applied,
|
|
528
544
|
// { stamped, unknown } when identity was applied; null when the caller passed
|
package/src/uwx/uuid.js
CHANGED
|
@@ -42,3 +42,10 @@ export function mintUuidV7() {
|
|
|
42
42
|
hex.slice(20)
|
|
43
43
|
)
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
47
|
+
|
|
48
|
+
/** Is this a uuid (any version), as the exchange writes one? */
|
|
49
|
+
export function isUuid(value) {
|
|
50
|
+
return typeof value === 'string' && UUID_SHAPE.test(value)
|
|
51
|
+
}
|
package/src/validate-data.js
CHANGED
|
@@ -31,7 +31,8 @@ import { YAML_OPTIONS } from './utils/yaml-schema.js'
|
|
|
31
31
|
import { queryNameFromUrl, declaredKeys, fillDeclaredKeys, fetchLevels, pageRouteQuery } from '@uniweb/core'
|
|
32
32
|
import { parentRouteOf } from '@uniweb/core/route-match'
|
|
33
33
|
|
|
34
|
-
import { validateItem, validateBound,
|
|
34
|
+
import { validateItem, validateRecordFile, validateBound, contentBodyField, rootListSection, referencesOf } from '@uniweb/schemas/conform'
|
|
35
|
+
import { isUuid } from './uwx/uuid.js'
|
|
35
36
|
import { validateAndNormalizeSchema, buildDataSchemaMap } from './resolve-data-schema.js'
|
|
36
37
|
|
|
37
38
|
// The pure checker, re-exported so `@uniweb/build/validate` stays the one import
|
|
@@ -41,7 +42,6 @@ export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
|
|
|
41
42
|
import { buildSchema } from './schema.js'
|
|
42
43
|
import { resolveRecordsDir, readEntityPool, groupPoolBySchema } from './site/entity-pool.js'
|
|
43
44
|
import { readEntityFile } from './uwx/entity-source.js'
|
|
44
|
-
import { isContentBodyField } from './uwx/data-schema.js'
|
|
45
45
|
import { toFetchList } from './site/data-fetcher.js'
|
|
46
46
|
import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
|
|
47
47
|
import { collectSiteContent } from './site/content-collector.js'
|
|
@@ -310,9 +310,16 @@ function listViolation(finding, label, base) {
|
|
|
310
310
|
* Check the records in the records directory that pass 2 did not, each against the
|
|
311
311
|
* data schema its folder names (`records/member/` → `@/member`).
|
|
312
312
|
*
|
|
313
|
-
* A record is
|
|
314
|
-
*
|
|
315
|
-
*
|
|
313
|
+
* A record is checked as its FILE holds it (`validateRecordFile`) — read the way a push
|
|
314
|
+
* reads it (`uwx/entity-source.js`), a markdown body in its schema's content body field
|
|
315
|
+
* unless the file sets that field, which is what a push sends it as (`uwx/records.js`).
|
|
316
|
+
* So a finding names the path an author edits (`article.title`), and a field written in
|
|
317
|
+
* the retired flat form is reported under the rule `section`. Pass 2 checks the records
|
|
318
|
+
* a section receives, in the shape it receives them (`validateItem`).
|
|
319
|
+
*
|
|
320
|
+
* Every record's references are checked here too, whichever pass checked the rest: one
|
|
321
|
+
* that names no record of its schema is reported under the rule `ref`, since a push
|
|
322
|
+
* cannot send it.
|
|
316
323
|
*
|
|
317
324
|
* A folder whose schema resolves to nothing is left out in silence: its records are
|
|
318
325
|
* schema-less, delivered as files, and the push says so itself.
|
|
@@ -322,11 +329,14 @@ function listViolation(finding, label, base) {
|
|
|
322
329
|
async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths, checked }) {
|
|
323
330
|
const out = { violations: [], setupErrors: [], schemas: new Set(), checked: 0 }
|
|
324
331
|
const pool = await readEntityPool(siteRoot, { dir: resolveRecordsDir(siteRoot, paths).rel })
|
|
332
|
+
// Every record is read first: a reference names a record of another schema by its
|
|
333
|
+
// name — its file's, or its `slug:` — or by the id its file carries.
|
|
334
|
+
const read = []
|
|
335
|
+
const names = new Map() // schema (as its folder names it) → the names and ids of its records
|
|
325
336
|
for (const [ref, entities] of groupPoolBySchema(pool.entities)) {
|
|
326
337
|
const schema = await schemaForRecords(ref, { srcDir, dataSchemas })
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (!schema) continue
|
|
338
|
+
const known = names.get(ref) ?? new Set()
|
|
339
|
+
names.set(ref, known)
|
|
330
340
|
for (const pooled of entities) {
|
|
331
341
|
let records
|
|
332
342
|
try {
|
|
@@ -336,13 +346,39 @@ async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths, check
|
|
|
336
346
|
continue
|
|
337
347
|
}
|
|
338
348
|
for (const r of records) {
|
|
339
|
-
if (
|
|
349
|
+
if (r.slug) known.add(String(r.slug))
|
|
350
|
+
if (typeof r.data?.$uuid === 'string') known.add(r.data.$uuid)
|
|
351
|
+
}
|
|
352
|
+
// An entity of a list-rooted schema too: it holds the list under its section's key.
|
|
353
|
+
// ⛔ Until 2026-09-24 such a folder was skipped here without a word.
|
|
354
|
+
if (schema) read.push({ ref, schema, pooled, records })
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const { ref, schema, pooled, records } of read) {
|
|
358
|
+
for (const r of records) {
|
|
359
|
+
if (!r.slug) continue
|
|
360
|
+
const record = withBody(schema, r)
|
|
361
|
+
if (!checked.has(recordKey(ref, r.slug))) {
|
|
340
362
|
out.checked++
|
|
341
363
|
out.schemas.add(ref)
|
|
342
|
-
for (const finding of
|
|
364
|
+
for (const finding of validateRecordFile(schema, record)) {
|
|
343
365
|
out.violations.push({ file: pooled.relPath, schema: ref, item: r.slug, users: [], ...finding })
|
|
344
366
|
}
|
|
345
367
|
}
|
|
368
|
+
// ⭐ A reference must name a record — a push refuses one that does not. A uuid is
|
|
369
|
+
// left alone: it may be a record the backend holds and this project does not.
|
|
370
|
+
for (const { path, ref: target, value } of referencesOf(schema, record)) {
|
|
371
|
+
if (typeof value !== 'string' || isUuid(value) || names.get(target)?.has(value)) continue
|
|
372
|
+
out.violations.push({
|
|
373
|
+
file: pooled.relPath,
|
|
374
|
+
schema: ref,
|
|
375
|
+
item: r.slug,
|
|
376
|
+
users: [],
|
|
377
|
+
field: path,
|
|
378
|
+
rule: 'ref',
|
|
379
|
+
message: `names "${value}", and no record of ${target} is called that`,
|
|
380
|
+
})
|
|
381
|
+
}
|
|
346
382
|
}
|
|
347
383
|
}
|
|
348
384
|
return out
|
|
@@ -359,13 +395,20 @@ async function schemaForRecords(ref, { srcDir, dataSchemas }) {
|
|
|
359
395
|
}
|
|
360
396
|
}
|
|
361
397
|
|
|
362
|
-
// A record as
|
|
363
|
-
// when the
|
|
398
|
+
// A record as its file holds it, with a markdown body in the schema's content body field
|
|
399
|
+
// — under its section, in a file written by section — when the file does not set it.
|
|
364
400
|
function withBody(schema, r) {
|
|
365
401
|
const record = { ...(r.data || {}) }
|
|
366
402
|
if (typeof r.body !== 'string' || r.body.trim() === '') return record
|
|
367
|
-
const
|
|
368
|
-
if (
|
|
403
|
+
const target = contentBodyField(schema)
|
|
404
|
+
if (!target) return record
|
|
405
|
+
if (!target.fileSection) {
|
|
406
|
+
if (record[target.key] === undefined) record[target.key] = r.body
|
|
407
|
+
return record
|
|
408
|
+
}
|
|
409
|
+
const held = record[target.fileSection]
|
|
410
|
+
if (held != null && (typeof held !== 'object' || Array.isArray(held))) return record
|
|
411
|
+
if (held?.[target.key] === undefined) record[target.fileSection] = { ...(held || {}), [target.key]: r.body }
|
|
369
412
|
return record
|
|
370
413
|
}
|
|
371
414
|
|