@uniweb/build 0.61.0 → 0.62.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 +6 -6
- 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/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 +370 -201
- package/src/uwx/registry-package.js +3 -3
- package/src/uwx/sync-package.js +66 -4
- 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'
|
|
@@ -231,8 +231,8 @@ function makeOptionsResolver(dataSchemas, scoped) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
function itemBearingSectionName(normalized, ref) {
|
|
234
|
-
// fields-form → the
|
|
235
|
-
if (normalized.fields) return
|
|
234
|
+
// fields-form → the shorthand's one section, always named `brief`
|
|
235
|
+
if (normalized.fields) return SHORTHAND_SECTION
|
|
236
236
|
// sections-form → the first item-bearing (single/multi) section
|
|
237
237
|
for (const [name, sec] of Object.entries(normalized.sections || {})) {
|
|
238
238
|
const kind = sec.kind || 'single'
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -33,6 +33,18 @@ const SITE_ENTITY_KEY = 'site-content'
|
|
|
33
33
|
|
|
34
34
|
const cacheKey = (entity) => `${entity.model} ${entity.id}`
|
|
35
35
|
|
|
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
|
+
|
|
36
48
|
// Attach the push gate's optimistic-concurrency token to an entity, keyed by the
|
|
37
49
|
// BACKEND uuid in its document body (`$uuid`). A never-synced entity has no
|
|
38
50
|
// `$uuid` and therefore no base — correctly unconditional, since there is no
|
|
@@ -253,7 +265,11 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
253
265
|
* records: { buffer, entityCount, index, models }|null,
|
|
254
266
|
* hashes: Object<string,string>, warnings: string[], refusals: string[],
|
|
255
267
|
* skipped: number, schemaless: Array<{name: string, model: string}>,
|
|
256
|
-
* localAssets: string[], applied: object
|
|
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`).
|
|
257
273
|
* `refusals` names the records that cannot be sent as written, one line each
|
|
258
274
|
* (`records.js::recordsToEntities`). A caller that submits must not submit while
|
|
259
275
|
* any is present: the backend would refuse the records lane — or, for a record
|
|
@@ -300,10 +316,43 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
300
316
|
})
|
|
301
317
|
const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
|
|
302
318
|
|
|
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
|
+
|
|
303
352
|
// The folder rides over the FULL record set (before filtering) so its references
|
|
304
353
|
// are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
|
|
305
354
|
const folder = buildFolderEntity({
|
|
306
|
-
recordEntities:
|
|
355
|
+
recordEntities: placedEntities,
|
|
307
356
|
...(sourceLocale ? { sourceLocale } : {}),
|
|
308
357
|
// ⭐ Every record in the directory, at the top of the folder or in the
|
|
309
358
|
// sub-folder `folder.yml` places it in — and NOTHING when there is no folder to
|
|
@@ -311,7 +360,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
311
360
|
// non-empty tree: a `folder.yml` whose folders hold only records that resolved
|
|
312
361
|
// no schema is a tree of empty branches, and sending it would replace the
|
|
313
362
|
// backend's folder with them (measured on the `dynamic` template, 2026-09-21).
|
|
314
|
-
folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],
|
|
363
|
+
folderNodes: col.sendFolder === true ? withoutRecords(col.folder?.nodes ?? [], unplaced) : [],
|
|
315
364
|
// ⛔ Whether the file side HAS a folder to state (`records.js::sendsFolder`) —
|
|
316
365
|
// not whether it holds anything. A site with no records directory sends none
|
|
317
366
|
// and leaves the backend's alone; an empty directory sends an empty folder,
|
|
@@ -469,7 +518,17 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
469
518
|
// changed() has side effects (hashes/skipped), so evaluate every entity exactly
|
|
470
519
|
// once, in a stable order: folder, then each record.
|
|
471
520
|
const folderChanged = folder ? changed(folder) : false
|
|
472
|
-
|
|
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) => ({
|
|
528
|
+
entity: e,
|
|
529
|
+
index: placedIndex[i],
|
|
530
|
+
changed: held.has(e.id) ? keepBanked(e) : changed(e),
|
|
531
|
+
}))
|
|
473
532
|
const changedRecords = recordChanged.filter((r) => r.changed)
|
|
474
533
|
|
|
475
534
|
let records = null
|
|
@@ -523,6 +582,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
523
582
|
|
|
524
583
|
return {
|
|
525
584
|
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,
|
|
526
588
|
refusals: col.refusals || [],
|
|
527
589
|
schemaless: col.schemaless, localAssets, applied,
|
|
528
590
|
// { 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
|
|