@uniweb/build 0.59.0 → 0.60.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/resolve-data-schema.js +28 -5
- package/src/schema.js +7 -4
- package/src/uwx/backfill.js +30 -68
- package/src/uwx/data-schema.js +3 -5
- package/src/uwx/emit-surface.json +1 -1
- package/src/uwx/entity-document.js +22 -1
- package/src/uwx/folder.js +13 -9
- package/src/uwx/index.js +1 -1
- package/src/uwx/project-writer.js +49 -6
- package/src/uwx/records-project.js +31 -14
- package/src/uwx/records.js +135 -35
- package/src/uwx/self-scope.js +1 -1
- package/src/uwx/site.js +6 -6
- package/src/uwx/sync-package.js +12 -7
- package/src/validate-data.js +85 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
-
"@uniweb/
|
|
60
|
+
"@uniweb/content-reader": "^1.2.5",
|
|
61
61
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
62
62
|
"@uniweb/content-writer": "^0.3.4",
|
|
63
63
|
"@uniweb/projections": "^0.7.3",
|
|
64
|
-
"@uniweb/
|
|
64
|
+
"@uniweb/schemas": "^0.5.0",
|
|
65
65
|
"@uniweb/theming": "^0.1.15"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
@@ -74,8 +74,8 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/core": "^0.29.8",
|
|
78
|
+
"@uniweb/runtime": "^0.27.4"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
|
@@ -287,17 +287,40 @@ async function collectSchemasFromExports(packageDir) {
|
|
|
287
287
|
// Source 2: a bare `schemas/` directory of schema files, one schema per file
|
|
288
288
|
// (named by basename). Returns `{}` when the directory is absent.
|
|
289
289
|
async function collectSchemasFromDir(dir) {
|
|
290
|
-
if (!existsSync(dir)) return {}
|
|
291
290
|
const out = {}
|
|
292
|
-
for (const file of
|
|
293
|
-
const ext = extname(file)
|
|
294
|
-
if (!SCHEMA_EXTENSIONS.includes(ext)) continue
|
|
295
|
-
const name = basename(file, ext)
|
|
291
|
+
for (const { file, name } of schemaFilesIn(dir)) {
|
|
296
292
|
out[`@/${name}`] = validateAndNormalizeSchema(await loadSchemaFile(join(dir, file)), `@/${name}`)
|
|
297
293
|
}
|
|
298
294
|
return out
|
|
299
295
|
}
|
|
300
296
|
|
|
297
|
+
// The schema files directly in a folder, one schema per file, named by basename.
|
|
298
|
+
// ⭐ A leading `_` leaves a file out — a draft — as it does for a record.
|
|
299
|
+
function schemaFilesIn(dir) {
|
|
300
|
+
if (!existsSync(dir)) return []
|
|
301
|
+
return readdirSync(dir)
|
|
302
|
+
.sort()
|
|
303
|
+
.filter((file) => SCHEMA_EXTENSIONS.includes(extname(file)) && !file.startsWith('_'))
|
|
304
|
+
.map((file) => ({ file, name: basename(file, extname(file)) }))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The data schemas a foundation DEFINES: one `@/<name>` per file in its `schemas/`
|
|
309
|
+
* folder, a file named with a leading `_` left out.
|
|
310
|
+
*
|
|
311
|
+
* ⭐ THE FOLDER IS THE DECLARATION [Diego, 2026-09-24] — the same rule as for a
|
|
312
|
+
* record: placing the file there is what makes it one. Until then a foundation's
|
|
313
|
+
* data schemas were only the ones a section binding reached, so a type only the app
|
|
314
|
+
* uses — a lesson, a quiz, a learner's progress, which no section renders — was
|
|
315
|
+
* never in `schema.json` and never registered.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} srcDir - the foundation's source root
|
|
318
|
+
* @returns {string[]}
|
|
319
|
+
*/
|
|
320
|
+
export function ownSchemaRefs(srcDir) {
|
|
321
|
+
return schemaFilesIn(join(srcDir, 'schemas')).map(({ name }) => `@/${name}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
301
324
|
// Resolve a package's module entry FILE (absolute) from its package.json —
|
|
302
325
|
// `exports['.']` (string or a conditional import/default/node), else `main`, else
|
|
303
326
|
// `index.js`. Loads a schemas package's own exports without self-resolution.
|
package/src/schema.js
CHANGED
|
@@ -17,7 +17,7 @@ import { isFontVar } from '@uniweb/theming'
|
|
|
17
17
|
import { join, dirname, extname, basename } from 'node:path'
|
|
18
18
|
import { pathToFileURL } from 'node:url'
|
|
19
19
|
import { inferTitle } from './utils/infer-title.js'
|
|
20
|
-
import { collectSchemaRefs, buildDataSchemaMap } from './resolve-data-schema.js'
|
|
20
|
+
import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
|
|
21
21
|
import {
|
|
22
22
|
composeSupports,
|
|
23
23
|
deriveRecordsSupport,
|
|
@@ -960,10 +960,13 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
|
|
|
960
960
|
// Discover section types
|
|
961
961
|
const components = await discoverComponents(srcDir, sectionPaths)
|
|
962
962
|
|
|
963
|
-
// Resolve the data schemas
|
|
964
|
-
//
|
|
963
|
+
// Resolve the data schemas the foundation DEFINES — every file in its `schemas/`
|
|
964
|
+
// folder (`ownSchemaRefs`) — and the ones its section bindings reference, carried
|
|
965
|
+
// in schema.json for the editor/platform (see named-data-schemas.md); `register`
|
|
966
|
+
// submits the `@/` ones. Bound refs come first, so a foundation whose schemas are
|
|
967
|
+
// all bound builds the same schema.json it always did.
|
|
965
968
|
const dataSchemas = await buildDataSchemaMap(
|
|
966
|
-
collectSchemaRefs(components, foundationConfig.data),
|
|
969
|
+
new Set([...collectSchemaRefs(components, foundationConfig.data), ...ownSchemaRefs(srcDir)]),
|
|
967
970
|
{ srcDir },
|
|
968
971
|
)
|
|
969
972
|
|
package/src/uwx/backfill.js
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
// canonical). It reads from the SOURCE file (not the backend's finalized document),
|
|
13
13
|
// so field values round-trip untouched and no inverse decode is needed in v1.
|
|
14
14
|
//
|
|
15
|
+
// ⛔ A PUSH NEVER RENDERS THE BACKEND'S DOCUMENT OVER THE AUTHOR'S FILE. It did, for
|
|
16
|
+
// a record's first push, from 2026-05-29 to 2026-09-23 ("variant A"), and that
|
|
17
|
+
// document is not the author's file: it carries the serve URL the push put where the
|
|
18
|
+
// author wrote `/images/x.png`, and only the brief section's declared fields. So a
|
|
19
|
+
// first push rewrote the record's images to a backend route and deleted every key the
|
|
20
|
+
// Model does not declare, and every field of any other section. What a backend holds
|
|
21
|
+
// reaches the file on a pull (`records-project.js`), which restores asset paths first.
|
|
22
|
+
//
|
|
15
23
|
// Single-record YAML/JSON/markdown files are rendered/back-filled in place.
|
|
16
24
|
// Multi-record files — array-form YAML/JSON and BibTeX (many records in one
|
|
17
25
|
// file) — are grouped by file and written once, one `$uuid` per record keyed by
|
|
@@ -213,27 +221,19 @@ function briefSectionOf(declaration) {
|
|
|
213
221
|
return entry ? { name: entry[0], ...entry[1] } : null
|
|
214
222
|
}
|
|
215
223
|
|
|
216
|
-
// Whether a Model's brief section declares a CONTENT body field (a markup `text`
|
|
217
|
-
// field, or a `format: prosemirror` json field) — the md-body target. A markdown
|
|
218
|
-
// source file can only be safely rendered from the document when its body has a
|
|
219
|
-
// field home — otherwise the body would be lost (variant B then).
|
|
220
|
-
function briefHasContentBody(declaration) {
|
|
221
|
-
const brief = briefSectionOf(declaration)
|
|
222
|
-
return Object.values(brief?.fields || {}).some((f) => isContentBodyField(f))
|
|
223
|
-
}
|
|
224
|
-
|
|
225
224
|
/**
|
|
226
|
-
* Render
|
|
227
|
-
*
|
|
228
|
-
*
|
|
225
|
+
* Render an entity `document` a pull brought back to its source-file authoring shape
|
|
226
|
+
* — the file becomes a projection of the backend's state, which is what a pull is
|
|
227
|
+
* (`writeRecordFile`, `records-project.js`). A push never uses it (header ⛔). For a
|
|
228
|
+
* flat/brief entity: the entity `$uuid` + the brief section's fields (localized unwrapped,
|
|
229
229
|
* date/scalars verbatim), with the brief record's own `$uuid` DROPPED (the backend
|
|
230
|
-
* matches a single-section item by singularity) and `$
|
|
230
|
+
* matches a single-section item by singularity) and `$schema`/`$id`/`$meta` omitted.
|
|
231
231
|
* A disabled entity (`$disabled: true`) writes `draft: true`, and an enabled one writes
|
|
232
232
|
* no `draft:` at all.
|
|
233
233
|
* For markdown, the content body field becomes the body; for YAML/JSON it stays a field.
|
|
234
234
|
*
|
|
235
235
|
* @param {object} params
|
|
236
|
-
* @param {object} params.document -
|
|
236
|
+
* @param {object} params.document - pulled `{ $uuid, $schema, <brief>: {…} }`
|
|
237
237
|
* @param {object} params.declaration - the Model declaration (`brief` + `sections`)
|
|
238
238
|
* @param {string} params.format - 'yaml' | 'json' | 'md'
|
|
239
239
|
* @param {string} [params.sourceLocale]
|
|
@@ -296,40 +296,26 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
|
|
|
296
296
|
return yaml.dump(record) // yaml / yaml
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
// Write text only when it differs from what's on disk (idempotent).
|
|
300
|
-
function writeIfChanged(filePath, text) {
|
|
301
|
-
let current = ''
|
|
302
|
-
try {
|
|
303
|
-
current = readFileSync(filePath, 'utf8')
|
|
304
|
-
} catch {
|
|
305
|
-
// new file / unreadable — treat as a change
|
|
306
|
-
}
|
|
307
|
-
if (text === current) return 'unchanged'
|
|
308
|
-
writeFileSync(filePath, text)
|
|
309
|
-
return 'updated'
|
|
310
|
-
}
|
|
311
|
-
|
|
312
299
|
/**
|
|
313
300
|
* Back-fill the sync response into the source files. Correlation is by **`index`**
|
|
314
301
|
* — `finalized[i].index` is the 0-based position of the entity in the submitted
|
|
315
302
|
* sequence, which equals the producer's `index` array order (the backend does not
|
|
316
|
-
* echo `$id`).
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* get a per-entry `$uuid` keyed by slug, grouped so each file is written once;
|
|
321
|
-
* BibTeX stays deferred.
|
|
303
|
+
* echo `$id`). A single-record file gains the entity `$uuid` and nothing else —
|
|
304
|
+
* never the returned `document` (see the ⛔ in the header). Multi-record YAML/JSON
|
|
305
|
+
* files get a per-entry `$uuid` keyed by slug, and BibTeX one per cite key, grouped
|
|
306
|
+
* so each file is written once.
|
|
322
307
|
*
|
|
323
308
|
* @param {object} params
|
|
324
309
|
* @param {object[]} params.index - the emitter's per-entity index, in submit
|
|
325
|
-
* order: `{ id, model, slug, sourceFile, format?, multiRecord?,
|
|
310
|
+
* order: `{ id, model, slug, sourceFile, format?, multiRecord?, ownId?, draft? }`.
|
|
326
311
|
* @param {object[]} params.finalized - response entries `{ index, uuid, changed?, document? }`.
|
|
327
|
-
*
|
|
312
|
+
* `document` is read for one thing only: whether a record sent as a draft
|
|
313
|
+
* came back disabled.
|
|
328
314
|
* @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[], mapped: Object<string,string>, notKeptAsDrafts: string[] }}
|
|
329
315
|
* `mapped` is own id → the uuid this backend minted, for the caller to record per backend.
|
|
330
316
|
* `notKeptAsDrafts` names records sent as drafts whose returned document is not disabled.
|
|
331
317
|
*/
|
|
332
|
-
export function backfillEntityUuids({ index, finalized
|
|
318
|
+
export function backfillEntityUuids({ index, finalized }) {
|
|
333
319
|
const updated = []
|
|
334
320
|
const unchanged = []
|
|
335
321
|
const deferred = []
|
|
@@ -364,10 +350,9 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
|
364
350
|
// ⛔ A DRAFT THE BACKEND DID NOT KEEP AS ONE. A backend is obliged to echo
|
|
365
351
|
// `$disabled: true` on the document of an entity it stored disabled. One that predates
|
|
366
352
|
// the key skips it without a word and stores the record enabled, so it is delivered
|
|
367
|
-
// once the site is published.
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
// with no document there is nothing to judge.
|
|
353
|
+
// once the site is published. The file keeps its `draft: true` — only the uuid is
|
|
354
|
+
// written — and the caller is told. Only a returned document that lacks the flag
|
|
355
|
+
// counts: with no document there is nothing to judge.
|
|
371
356
|
const draftNotKept = entry.draft === true && fin.document && fin.document.$disabled !== true
|
|
372
357
|
if (draftNotKept) notKeptAsDrafts.push(entry.sourceFile || entry.id)
|
|
373
358
|
if (!entry.sourceFile) {
|
|
@@ -383,9 +368,9 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
|
383
368
|
|
|
384
369
|
// ⛔ A record that already carries its OWN id is NOT rewritten. Its id is stable
|
|
385
370
|
// and travels with the file; this backend's uuid belongs in the map, not in the
|
|
386
|
-
// author's file. And it must not be:
|
|
387
|
-
// the
|
|
388
|
-
//
|
|
371
|
+
// author's file. And it must not be: the write below puts THIS backend's `$uuid` in
|
|
372
|
+
// the file, so a second backend would silently overwrite the record's identity
|
|
373
|
+
// with its own.
|
|
389
374
|
if (entry.ownId) {
|
|
390
375
|
ownIdFiles.add(entry.sourceFile)
|
|
391
376
|
continue
|
|
@@ -404,31 +389,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
|
404
389
|
continue
|
|
405
390
|
}
|
|
406
391
|
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
// field for its body). Otherwise variant B: back-fill the uuid in place.
|
|
410
|
-
const canRenderA =
|
|
411
|
-
!draftNotKept &&
|
|
412
|
-
fin.document &&
|
|
413
|
-
entry.declaration &&
|
|
414
|
-
(entry.format !== 'md' || briefHasContentBody(entry.declaration))
|
|
415
|
-
let res
|
|
416
|
-
if (canRenderA) {
|
|
417
|
-
try {
|
|
418
|
-
const text = renderEntityDocument({
|
|
419
|
-
document: fin.document,
|
|
420
|
-
declaration: entry.declaration,
|
|
421
|
-
format: entry.format,
|
|
422
|
-
sourceLocale,
|
|
423
|
-
})
|
|
424
|
-
res = { status: writeIfChanged(entry.sourceFile, text) }
|
|
425
|
-
} catch (err) {
|
|
426
|
-
warnings.push(`${entry.sourceFile}: ${err.message}; fell back to uuid back-fill`)
|
|
427
|
-
res = backfillUuid(entry.sourceFile, uuid)
|
|
428
|
-
}
|
|
429
|
-
} else {
|
|
430
|
-
res = backfillUuid(entry.sourceFile, uuid)
|
|
431
|
-
}
|
|
392
|
+
// The uuid, into the author's own file — never the returned document (header ⛔).
|
|
393
|
+
const res = backfillUuid(entry.sourceFile, uuid)
|
|
432
394
|
if (res.status === 'updated') updated.push(entry.sourceFile)
|
|
433
395
|
else if (res.status === 'unchanged') unchanged.push(entry.sourceFile)
|
|
434
396
|
else if (res.status === 'deferred') deferred.push({ index: i, id: entry.id, file: entry.sourceFile, reason: res.message })
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -79,7 +79,7 @@ export function isContentBodyField(field) {
|
|
|
79
79
|
* (item_ref) ref to its full `@org/model/<section>` path. Falls back to
|
|
80
80
|
* `resolveName` (model only) when not supplied.
|
|
81
81
|
* @returns {Object} the declaration (`{ name, label?, description?, source_locale?,
|
|
82
|
-
*
|
|
82
|
+
* linkable?, sections }`).
|
|
83
83
|
*/
|
|
84
84
|
export function toDataSchemaDeclaration(normalized, { name, resolveName, resolveOptions } = {}) {
|
|
85
85
|
if (!name) throw new Error('toDataSchemaDeclaration: a registry name is required')
|
|
@@ -104,10 +104,8 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
|
|
|
104
104
|
if (normalized.label) decl.label = normalized.label
|
|
105
105
|
if (normalized.description) decl.description = normalized.description
|
|
106
106
|
if (normalized.sourceLocale) decl.source_locale = normalized.sourceLocale
|
|
107
|
-
// `creatable_by
|
|
108
|
-
//
|
|
109
|
-
// meaning; sending it explicitly would state a policy the author did not.
|
|
110
|
-
if (normalized.creatableBy) decl.creatable_by = normalized.creatableBy
|
|
107
|
+
// ⛔ No `creatable_by`: retired 2026-09-23 — Models are open, and `@uniweb/schemas`
|
|
108
|
+
// refuses the key. (It was emitted here when authored.)
|
|
111
109
|
|
|
112
110
|
// `linkable` — DERIVED AND AUTHORED, and the two compose in one direction only.
|
|
113
111
|
//
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Distinct from package.js's emitEntityPackage (the register / site-content lane,
|
|
4
4
|
// whose per-entity body is the legacy `items[]` shape). The sync lane's per-entity
|
|
5
5
|
// body is the section-keyed `$`-document (see docs/reference/entity-content.md):
|
|
6
|
-
// `$uuid?` then `$id` `$
|
|
6
|
+
// `$uuid?` then `$id` `$schema`, then one key per top-level section.
|
|
7
7
|
//
|
|
8
8
|
// Identity rides in the BODY — the backend reads `$id` always, `$uuid` when
|
|
9
9
|
// present, and MINTS `$uuid` on first sync. The manifest is the INDEX, not a copy
|
|
@@ -25,6 +25,27 @@ import {
|
|
|
25
25
|
computePackageSha256,
|
|
26
26
|
} from './manifest.js'
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The data schema a `$`-document names — its scoped name (`@scope/name`), or null.
|
|
30
|
+
*
|
|
31
|
+
* ⭐ `$schema` is the key, agreed with backend on 2026-09-24 to match the query wire's
|
|
32
|
+
* `schema` for the same value: what the two lanes exchange is a data schema, and the
|
|
33
|
+
* scoped name is how we address one in the registry of the backend we talk to. ⛔ Not
|
|
34
|
+
* an identity ACROSS backends — each has its own registry, so one name on two of them
|
|
35
|
+
* names two data schemas [Diego, 2026-09-24]. It was `$model` until
|
|
36
|
+
* then, and ⛔ nothing reads that spelling — no production backend existed to keep a
|
|
37
|
+
* window for [Diego, 2026-09-24]. The value comes back as it is, so a caller can still
|
|
38
|
+
* tell an id from a name and say which it met.
|
|
39
|
+
* Record: `kb/framework/build/uwx-format.md` § *The data schema an entity names*.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} doc - an entity `$`-document
|
|
42
|
+
* @returns {string|null}
|
|
43
|
+
*/
|
|
44
|
+
export function documentSchema(doc) {
|
|
45
|
+
const value = doc?.$schema
|
|
46
|
+
return typeof value === 'string' && value ? value : null
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
// `exporter.instance`: caller-override -> env UNIWEB_INSTANCE_ID -> "unknown".
|
|
29
50
|
// Provenance only; neutralized in the digest, so its value is inert.
|
|
30
51
|
function defaultExporter() {
|
package/src/uwx/folder.js
CHANGED
|
@@ -52,18 +52,22 @@ export const FOLDER_MODEL_NAME = '@uniweb/folder'
|
|
|
52
52
|
export const FOLDER_ENTITY_KEY = '@folder'
|
|
53
53
|
|
|
54
54
|
// Point one authored leaf at the record entity it names. The folder's `contents`
|
|
55
|
-
// field is polymorphic (it can reference any
|
|
56
|
-
// entity_ref OPEN form `{
|
|
57
|
-
// valid when the field pins a single
|
|
55
|
+
// field is polymorphic (it can reference any data schema), so the ref uses the
|
|
56
|
+
// entity_ref OPEN form `{ schema, entity }` — not a bare uuid (a bare uuid is only
|
|
57
|
+
// valid when the field pins a single schema). Known uuid → `entry: { schema, entity
|
|
58
58
|
// }`; brand-new → `$ref` handle (resolved within this payload to the minted
|
|
59
59
|
// entity).
|
|
60
60
|
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
61
|
+
// ⭐ `schema` carries the data schema's SCOPED NAME (`@std/article`), never a Model id
|
|
62
|
+
// — agreed with backend 2026-09-24: a Model id is the backend's internal identity and
|
|
63
|
+
// does not cross `/dev`, while a scoped name is how we address a data schema in the
|
|
64
|
+
// registry of the backend we talk to. ⛔ Neither is an identity across backends: each
|
|
65
|
+
// has its own registry [Diego, 2026-09-24]. (A TODO here asked
|
|
66
|
+
// for the id until then; the agreement settled it the other way.) The key was `model`
|
|
67
|
+
// until the same date. Record: `kb/framework/build/uwx-format.md` § 8.
|
|
64
68
|
function refLeaf(entity) {
|
|
65
69
|
const leaf = { kind: 'ref', name: entity.slug }
|
|
66
|
-
if (entity.uuid) leaf.entry = {
|
|
70
|
+
if (entity.uuid) leaf.entry = { schema: entity.model, entity: entity.uuid }
|
|
67
71
|
else leaf.$ref = entity.id // the payload-local handle
|
|
68
72
|
return leaf
|
|
69
73
|
}
|
|
@@ -103,7 +107,7 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
|
|
|
103
107
|
return out
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
// The uuid of the record a leaf references — once minted, `entry: {
|
|
110
|
+
// The uuid of the record a leaf references — once minted, `entry: { schema, entity }`
|
|
107
111
|
// (a bare uuid is tolerated). A brand-new record's leaf carries `$ref` instead.
|
|
108
112
|
function recordOf(item) {
|
|
109
113
|
if (item?.kind !== 'ref') return null
|
|
@@ -255,7 +259,7 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
|
|
|
255
259
|
|
|
256
260
|
const document = {
|
|
257
261
|
$id: FOLDER_ENTITY_KEY,
|
|
258
|
-
$
|
|
262
|
+
$schema: FOLDER_MODEL_NAME,
|
|
259
263
|
contents,
|
|
260
264
|
}
|
|
261
265
|
// Re-arm placement identity. Without it a second send reads as "every item is
|
package/src/uwx/index.js
CHANGED
|
@@ -21,7 +21,7 @@ export {
|
|
|
21
21
|
PACKAGE_SHA256_ASSUMPTIONS,
|
|
22
22
|
} from './manifest.js'
|
|
23
23
|
export { emitEntityPackage } from './package.js'
|
|
24
|
-
export { emitEntitySyncPackage } from './entity-document.js'
|
|
24
|
+
export { emitEntitySyncPackage, documentSchema } from './entity-document.js'
|
|
25
25
|
export {
|
|
26
26
|
FOUNDATION_SCHEMA_TYPE_UUID,
|
|
27
27
|
SITE_CONTENT_TYPE_UUID,
|
|
@@ -212,7 +212,12 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
|
212
212
|
if (value === null || value === undefined) {
|
|
213
213
|
delete existing[key]
|
|
214
214
|
} else if (typeof value === 'object' && !Array.isArray(value) && !replace.includes(key)) {
|
|
215
|
-
|
|
215
|
+
// ⛔ Merged only INTO AN OBJECT. Spread, a string becomes its characters: a
|
|
216
|
+
// pull wrote `events: '@/event'` back as `events: { '0': '@', '1': '/', …,
|
|
217
|
+
// schema: '@/event' }` (measured 2026-09-23). Anything else that is not an
|
|
218
|
+
// object has nothing to keep, so the incoming value replaces it.
|
|
219
|
+
const prior = existing[key]
|
|
220
|
+
existing[key] = isPlainObject(prior) ? { ...prior, ...value } : value
|
|
216
221
|
} else {
|
|
217
222
|
existing[key] = value
|
|
218
223
|
}
|
|
@@ -220,6 +225,8 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
|
220
225
|
return writeIfChanged(filePath, yaml.dump(existing, YAML_DUMP_OPTS))
|
|
221
226
|
}
|
|
222
227
|
|
|
228
|
+
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
|
|
229
|
+
|
|
223
230
|
/**
|
|
224
231
|
* Merge `config` into `site.yml` (shallow). Preserves keys not present in the
|
|
225
232
|
* update (foundation, base, paths, …).
|
|
@@ -290,9 +297,45 @@ export function writeMergedYaml(filePath, projected, managedKeys) {
|
|
|
290
297
|
* @returns {'updated'|'unchanged'}
|
|
291
298
|
*/
|
|
292
299
|
export function writeQueriesConfig(siteRoot, queries) {
|
|
293
|
-
|
|
300
|
+
const filePath = queriesYmlPath(siteRoot)
|
|
301
|
+
let authored = {}
|
|
302
|
+
try {
|
|
303
|
+
authored = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
|
|
304
|
+
} catch {
|
|
305
|
+
// missing / invalid → nothing authored to keep
|
|
306
|
+
}
|
|
307
|
+
// ⭐ A query the file already states is left as the author wrote it — in the same
|
|
308
|
+
// words, or in a shorthand (`team: '@/person'` is `{ schema: '@/person' }`, a bare
|
|
309
|
+
// `events:` is `{}`), which a pull brings back in its long form. And a pull that
|
|
310
|
+
// restates every query writes nothing: the file is re-dumped whole otherwise, which
|
|
311
|
+
// rewrites a bare `events:` as `events: null` and flow style as block style.
|
|
312
|
+
const changes = {}
|
|
313
|
+
for (const [name, decl] of Object.entries(queries)) {
|
|
314
|
+
if (restates(authored?.[name], decl)) continue
|
|
315
|
+
changes[name] = decl
|
|
316
|
+
}
|
|
317
|
+
if (Object.keys(changes).length === 0) return 'unchanged'
|
|
318
|
+
// ⛔ Each query WHOLE (`replace`), as this function's header always said. Merged into
|
|
319
|
+
// the local one — what the call did until 2026-09-23 — a key the backend's
|
|
320
|
+
// declaration no longer has survived every pull: the defect `writeSiteConfig`
|
|
321
|
+
// records for the declaration keys ("a stale `limit` survived every pull").
|
|
322
|
+
return mergeYamlConfig(filePath, changes, { replace: Object.keys(changes) })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Whether a pulled declaration says only what the author's entry already says.
|
|
326
|
+
function restates(authored, decl) {
|
|
327
|
+
const keys = Object.keys(decl || {})
|
|
328
|
+
if (typeof authored === 'string') return keys.length === 1 && decl.schema === authored
|
|
329
|
+
if (authored === null) return keys.length === 0
|
|
330
|
+
return isPlainObject(authored) && canonicalJson(authored) === canonicalJson(decl)
|
|
294
331
|
}
|
|
295
332
|
|
|
333
|
+
// JSON with every object's keys sorted — equal for equal values, whatever the order.
|
|
334
|
+
const canonicalJson = (value) =>
|
|
335
|
+
JSON.stringify(value, (_, v) =>
|
|
336
|
+
isPlainObject(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
|
|
337
|
+
)
|
|
338
|
+
|
|
296
339
|
/**
|
|
297
340
|
* Write the records folder's organization — `folder.yml` in the records directory
|
|
298
341
|
* (`records/`, or wherever `site.yml::paths.records` puts it), as a LIST.
|
|
@@ -324,13 +367,13 @@ export function writeThemeFile(siteRoot, theme) {
|
|
|
324
367
|
}
|
|
325
368
|
|
|
326
369
|
/**
|
|
327
|
-
* Render a
|
|
328
|
-
* (
|
|
329
|
-
*
|
|
370
|
+
* Render a collection-record `document` a pull brought back to its source-file shape
|
|
371
|
+
* (via renderEntityDocument) and write it idempotently. The record half of the
|
|
372
|
+
* collections lane's write step.
|
|
330
373
|
*
|
|
331
374
|
* @param {object} opts
|
|
332
375
|
* @param {string} opts.filePath
|
|
333
|
-
* @param {object} opts.document -
|
|
376
|
+
* @param {object} opts.document - pulled `{ $uuid, $schema, <brief>: {…} }`
|
|
334
377
|
* @param {object} opts.declaration - the record's data-schema declaration
|
|
335
378
|
* @param {'yaml'|'json'|'md'} opts.format
|
|
336
379
|
* @param {string} [opts.sourceLocale]
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Identity & placement:
|
|
8
8
|
// - a record's slug comes from the FOLDER document — each ref leaf is
|
|
9
|
-
// `{ entry: {
|
|
9
|
+
// `{ entry: { schema, entity: <uuid> }, name: <slug> }`, at the top of the folder
|
|
10
10
|
// or inside a branch (its `$children`) whose `name` names the sub-folder. The
|
|
11
11
|
// folder is the authoritative organization on a read (the record document's own
|
|
12
12
|
// `$id` envelope is not guaranteed to be echoed back), with the record
|
|
13
13
|
// document's `$id` (its position in the directory) as a fallback when present.
|
|
14
|
-
// - the record's directory comes from its
|
|
14
|
+
// - the record's directory comes from its data schema (`$schema`, read by
|
|
15
|
+
// `documentSchema`) — `records/{schema}/` is where
|
|
15
16
|
// a record of that model lives (`site.yml::paths.records` moves it). Not from any
|
|
16
17
|
// query: a query has no directory, and which query selects a record is not a
|
|
17
18
|
// fact about it.
|
|
@@ -21,7 +22,8 @@
|
|
|
21
22
|
// brief has a content body field, else YAML.
|
|
22
23
|
//
|
|
23
24
|
// Field rendering reuses renderEntityDocument (via writeRecordFile) — localized
|
|
24
|
-
// unwrap, date handling, content-body→body are already inverted there.
|
|
25
|
+
// unwrap, date handling, content-body→body are already inverted there. Asset paths
|
|
26
|
+
// are restored before it runs (`restoreAssetRefs`), as the content lane does.
|
|
25
27
|
//
|
|
26
28
|
// Deferred: array-form & BibTeX multi-record files (a pulled record is placed as
|
|
27
29
|
// its own single-record file; merging into an existing array file is a later
|
|
@@ -30,6 +32,8 @@
|
|
|
30
32
|
// record is reported.
|
|
31
33
|
|
|
32
34
|
import { readBackendState, updateBackendMap } from './sync-store.js'
|
|
35
|
+
import { restoreAssetRefs } from './asset-map.js'
|
|
36
|
+
import { documentSchema } from './entity-document.js'
|
|
33
37
|
import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs'
|
|
34
38
|
import { join, resolve, relative, extname, basename, sep } from 'node:path'
|
|
35
39
|
import yaml from 'js-yaml'
|
|
@@ -116,7 +120,7 @@ function briefHasContentBody(declaration) {
|
|
|
116
120
|
// folder is a self-nesting tree under `contents`, nesting via `$children` (the
|
|
117
121
|
// site-content invariant — folder.js). A leaf sits in a branch whose `name` is
|
|
118
122
|
// the collection; the leaf's `name` is the slug (the handle) and its `entry` is
|
|
119
|
-
// the entity_ref open form `{
|
|
123
|
+
// the entity_ref open form `{ schema, entity: <uuid> }`. ⛔ `path_segment` is not
|
|
120
124
|
// read: the store renamed it on 2026-09-04 and a pull emits the new shape only —
|
|
121
125
|
// a reader that kept the old key would index every record as
|
|
122
126
|
// `{ folderPath: null, slug: undefined }` and rewrite records.yml with
|
|
@@ -131,7 +135,7 @@ function indexFolder(folderDoc) {
|
|
|
131
135
|
if (node?.kind === 'branch') {
|
|
132
136
|
walk(node.$children, node.name ?? folderPath)
|
|
133
137
|
} else if (node?.kind === 'ref' && node.entry) {
|
|
134
|
-
// `entry` is `{
|
|
138
|
+
// `entry` is `{ schema, entity: <uuid> }`; tolerate a bare uuid defensively.
|
|
135
139
|
const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
|
|
136
140
|
if (uuid) byUuid.set(uuid, { folderPath, slug: node.name })
|
|
137
141
|
}
|
|
@@ -456,11 +460,11 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
456
460
|
*
|
|
457
461
|
* @param {object} params
|
|
458
462
|
* @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
|
|
459
|
-
* @param {object[]} params.recordDocs - record `$`-documents `{ $uuid?, $id?, $
|
|
463
|
+
* @param {object[]} params.recordDocs - record `$`-documents `{ $uuid?, $id?, $schema, <brief> }`
|
|
460
464
|
* @param {string} params.siteRoot
|
|
461
465
|
* @param {object} params.opts
|
|
462
466
|
* @param {(modelName: string) => object|null|undefined} params.opts.resolveDeclaration
|
|
463
|
-
* - resolve a
|
|
467
|
+
* - resolve a data schema's declaration by its scoped name (`documentSchema`).
|
|
464
468
|
* @param {string|null} [params.opts.scope] - the scope the push qualified a record's
|
|
465
469
|
* `@/x` Model with — the site's foundation's — so a `@scope/x` model is placed
|
|
466
470
|
* back where the author wrote it. The caller resolves it up front, as it does
|
|
@@ -495,8 +499,16 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
495
499
|
const unchanged = []
|
|
496
500
|
const skipped = []
|
|
497
501
|
const warnings = []
|
|
502
|
+
const backendState = opts.backend ? readBackendState(siteRoot, opts.backend) : {}
|
|
503
|
+
// ⛔ PUT THE AUTHOR'S ASSET PATHS BACK BEFORE ANYTHING IS RENDERED — the step the
|
|
504
|
+
// content lane takes (`site-project.js`), for the same reason. A pushed record goes
|
|
505
|
+
// up with the serve URL the push put where its author wrote `/images/x.png`, and it
|
|
506
|
+
// comes back that way; rendered as is, a pull rewrites the author's file to a
|
|
507
|
+
// backend route. Asset ids and fingerprints are per backend, so a projection tied
|
|
508
|
+
// to none restores nothing. Missing here until 2026-09-23.
|
|
509
|
+
restoreAssetRefs(recordDocs, backendState.assets || {})
|
|
498
510
|
// This backend's record map, inverted: its uuid → the record's own id.
|
|
499
|
-
const recordMap =
|
|
511
|
+
const recordMap = backendState.records || {}
|
|
500
512
|
const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
|
|
501
513
|
// own id → their uuid, for every record this pull wrote. Recorded at the end.
|
|
502
514
|
const learned = {}
|
|
@@ -512,18 +524,19 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
512
524
|
skipped.push({ uuid: document.$uuid, reason: 'no slug (not in the folder, no $id)' })
|
|
513
525
|
continue
|
|
514
526
|
}
|
|
515
|
-
const
|
|
527
|
+
const schema = documentSchema(document)
|
|
528
|
+
const declaration = schema ? resolveDeclaration(schema) : null
|
|
516
529
|
if (!declaration) {
|
|
517
|
-
skipped.push({ uuid: document.$uuid, slug: where.slug, reason: `unresolved model ${
|
|
530
|
+
skipped.push({ uuid: document.$uuid, slug: where.slug, reason: `unresolved model ${schema || '(none)'}` })
|
|
518
531
|
continue
|
|
519
532
|
}
|
|
520
533
|
|
|
521
|
-
const poolDir = recordDirFor(recordsRoot,
|
|
534
|
+
const poolDir = recordDirFor(recordsRoot, schema, selfScope)
|
|
522
535
|
if (!poolDir) {
|
|
523
536
|
skipped.push({
|
|
524
537
|
uuid: document.$uuid,
|
|
525
538
|
slug: where.slug,
|
|
526
|
-
reason: `model ${
|
|
539
|
+
reason: `model ${schema} names no pool folder (expected @/name or @org/name)`,
|
|
527
540
|
})
|
|
528
541
|
continue
|
|
529
542
|
}
|
|
@@ -550,7 +563,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
550
563
|
|
|
551
564
|
// The free-form home for this record's content body (locale-independent); a
|
|
552
565
|
// target-locale full-doc body is written under locales/freeform/{locale}/here.
|
|
553
|
-
const freeformRelPath = buildFreeformRecordPath(
|
|
566
|
+
const freeformRelPath = buildFreeformRecordPath(schema, where.slug)
|
|
554
567
|
|
|
555
568
|
// ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
|
|
556
569
|
// backend must not overwrite the identity the record already has.
|
|
@@ -559,7 +572,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
559
572
|
try {
|
|
560
573
|
status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath })
|
|
561
574
|
} catch (err) {
|
|
562
|
-
|
|
575
|
+
// ⛔ A record that could not be written was not placed, so it is a SKIP, not a
|
|
576
|
+
// warning: a caller that counts what it placed (the CLI's pull) must see it.
|
|
577
|
+
// Until 2026-09-24 it was a warning, and a pull over records with no Sections —
|
|
578
|
+
// what a partly applied push left — reported every one of them as taken.
|
|
579
|
+
skipped.push({ uuid: document.$uuid, slug: where.slug, reason: err.message })
|
|
563
580
|
continue
|
|
564
581
|
}
|
|
565
582
|
if (status === 'unchanged') unchanged.push(filePath)
|
package/src/uwx/records.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// referenced BY NAME, on the entity-content SYNC lane.
|
|
3
3
|
//
|
|
4
4
|
// Each record becomes a section-keyed `$`-document (docs/reference/entity-content.md):
|
|
5
|
-
// `$id` (the producer-local handle), `$
|
|
5
|
+
// `$id` (the producer-local handle), `$schema` (its data schema, by scoped name), and
|
|
6
6
|
// each SINGLE section keyed by its name — the brief plus any sibling singles, not
|
|
7
7
|
// the brief alone. The backend MINTS `$uuid` on first sync and
|
|
8
8
|
// returns it in the finalized response; the verb back-fills it into the source
|
|
@@ -75,7 +75,7 @@ const SKIP_KEYS = new Set([
|
|
|
75
75
|
'slug',
|
|
76
76
|
'$id',
|
|
77
77
|
'$uuid',
|
|
78
|
-
'$
|
|
78
|
+
'$schema',
|
|
79
79
|
'$owner',
|
|
80
80
|
'$unit',
|
|
81
81
|
'$meta',
|
|
@@ -85,7 +85,7 @@ const SKIP_KEYS = new Set([
|
|
|
85
85
|
'draft',
|
|
86
86
|
])
|
|
87
87
|
|
|
88
|
-
// Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$
|
|
88
|
+
// Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$schema`/… — never
|
|
89
89
|
// field data; the sigil-exclusivity invariant guarantees this) at every level,
|
|
90
90
|
// so a back-filled `$uuid` doesn't change the hash. Two are exceptions, because
|
|
91
91
|
// they are content rather than identity:
|
|
@@ -103,7 +103,7 @@ function stripSigils(value) {
|
|
|
103
103
|
// encoding rather than the reference made the folder's hash unreproducible.
|
|
104
104
|
//
|
|
105
105
|
// `refLeaf` (uwx/folder.js) emits `$ref: <the record's $id>` — the pool position
|
|
106
|
-
// `<dirs>/<slug>` — while the record is brand-new, and `entry: {
|
|
106
|
+
// `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema, entity:
|
|
107
107
|
// <uuid> }` once it has been minted.
|
|
108
108
|
// Both denote the same record. A push hashes the folder BEFORE submitting, then
|
|
109
109
|
// back-fills the minted `$uuid` into every record's source file — so the very
|
|
@@ -205,8 +205,10 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
205
205
|
* @param {string} [params.sourceLocale] - locale for localized-field wrap
|
|
206
206
|
* @param {object} [params.translations] - `{ locale: { hash: tgt } }` for wrapping
|
|
207
207
|
* localized scalar fields per-locale (from loadLocaleTranslations)
|
|
208
|
-
* @returns {{ entities: object[], warnings: string[] }} each
|
|
209
|
-
* `{ id, uuid, model, file, document }` — `document` is the section-keyed
|
|
208
|
+
* @returns {{ entities: object[], warnings: string[], refusals: string[] }} each
|
|
209
|
+
* entity is `{ id, uuid, model, file, document }` — `document` is the section-keyed
|
|
210
|
+
* body. `refusals` names the records that cannot be sent as written, one line
|
|
211
|
+
* each; a caller that sends must not send while any is present.
|
|
210
212
|
*/
|
|
211
213
|
export function recordsToEntities({
|
|
212
214
|
label,
|
|
@@ -259,6 +261,14 @@ export function recordsToEntities({
|
|
|
259
261
|
if (!fieldByKey.has(key)) fieldByKey.set(key, field)
|
|
260
262
|
}
|
|
261
263
|
}
|
|
264
|
+
// Every section by name, single or list — to recognize a record written BY SECTION
|
|
265
|
+
// (`details: { title: … }`), the shape docs/reference/entity-content.md gives a
|
|
266
|
+
// sections-form record. This mapper reads a record's fields from the top of its
|
|
267
|
+
// file, so such a record is refused below rather than sent without them.
|
|
268
|
+
const sectionNames = new Set(sectionEntries.map(([name]) => name))
|
|
269
|
+
const listSections = new Set(
|
|
270
|
+
sectionEntries.filter(([, s]) => s && s.multiple === true).map(([name]) => name)
|
|
271
|
+
)
|
|
262
272
|
|
|
263
273
|
// The markdown body of a `.md` record is the value of the Model's CONTENT body
|
|
264
274
|
// field — a markup `text` field (raw source string) or a `format: prosemirror`
|
|
@@ -276,6 +286,8 @@ export function recordsToEntities({
|
|
|
276
286
|
|
|
277
287
|
const entities = []
|
|
278
288
|
const warnings = []
|
|
289
|
+
// Records that cannot be sent as written — see the ⛔ at the end of the loop.
|
|
290
|
+
const refusals = []
|
|
279
291
|
if (contentMatches.length > 1) {
|
|
280
292
|
warnings.push(
|
|
281
293
|
`${label}: ${declaration.name} has more than one content ` +
|
|
@@ -299,7 +311,7 @@ export function recordsToEntities({
|
|
|
299
311
|
// only the fallback for a record that did not arrive that way.
|
|
300
312
|
//
|
|
301
313
|
// The qualification is a CONSTRAINT, not a style: the sync response is keyed per
|
|
302
|
-
// (`$
|
|
314
|
+
// (`$schema`, `$id`), so a bare slug would collide whenever two schema folders
|
|
303
315
|
// resolving to one Model reuse one (see the duplicate check). ⇒ Do not describe this
|
|
304
316
|
// value as "the slug" — the folder leaf's `name` is the bare segment, and
|
|
305
317
|
// conflating the two has already misdirected a naming decision.
|
|
@@ -326,31 +338,74 @@ export function recordsToEntities({
|
|
|
326
338
|
}
|
|
327
339
|
if (Object.keys(data).length) sectionData[secName] = data
|
|
328
340
|
}
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
341
|
+
// Author keys on no record section (only identity/transport keys in SKIP_KEYS are
|
|
342
|
+
// exempt). A key naming a SECTION is not unknown: the record is written by
|
|
343
|
+
// section, refused below.
|
|
344
|
+
const bySection = []
|
|
345
|
+
const undeclared = []
|
|
332
346
|
for (const key of Object.keys(record)) {
|
|
333
347
|
if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
348
|
+
if (sectionNames.has(key)) bySection.push(key)
|
|
349
|
+
else undeclared.push(key)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ⛔ A RECORD THE BACKEND WOULD REFUSE — OR WOULD STORE EMPTY — IS REFUSED HERE,
|
|
353
|
+
// before anything is sent. Two cases are visible from here, and the second is only
|
|
354
|
+
// checked when the first does not already explain it:
|
|
355
|
+
// - a record written by section, whose sections' contents this mapper cannot
|
|
356
|
+
// send (it reads fields from the top of the file). Sent, it arrives with an
|
|
357
|
+
// empty brief: refused when the brief has a `required` field, and otherwise
|
|
358
|
+
// STORED EMPTY, in silence — so this stays whatever the backend does with a
|
|
359
|
+
// refusal;
|
|
360
|
+
// - a `required` field the send would lack — the brief is always sent, another
|
|
361
|
+
// single section only when the record fills it. The backend refuses it too,
|
|
362
|
+
// but names the field, not the file.
|
|
363
|
+
// Measured 2026-09-23: a record written by section went up with an empty brief and
|
|
364
|
+
// the lane was refused — and the refused lane left the folder's entries and
|
|
365
|
+
// entities with no data behind, so every later push of the site's records was
|
|
366
|
+
// refused. The backend's push has been one transaction since 2026-09-24; a refusal
|
|
367
|
+
// now writes nothing.
|
|
368
|
+
// ⛔ And what a push cannot carry is refused too, because it is lost twice over: the
|
|
369
|
+
// backend never receives it, and the next pull writes the file without it — an
|
|
370
|
+
// undeclared key (`tags:` beside a schema with no `tags`), or a markdown body where
|
|
371
|
+
// the schema has no field to hold it. Both were warnings until 2026-09-24
|
|
372
|
+
// ("not synced"), and a pull then deleted the value from the author's file.
|
|
373
|
+
if (undeclared.length) {
|
|
374
|
+
const one = undeclared.length === 1
|
|
375
|
+
refusals.push(
|
|
376
|
+
`${label}/${slug}: ${quoted(undeclared)} ${one ? 'is not a field' : 'are not fields'} of ` +
|
|
377
|
+
`${declaration.name} — a push cannot carry ${one ? 'it' : 'them'}, and the next pull would ` +
|
|
378
|
+
`drop ${one ? 'it' : 'them'} from the file. Declare ${one ? 'it' : 'them'} in the schema, ` +
|
|
379
|
+
`or remove ${one ? 'it' : 'them'}.`
|
|
337
380
|
)
|
|
338
381
|
}
|
|
339
382
|
if (hasBody && !bodyTarget) {
|
|
340
|
-
|
|
341
|
-
`${label}/${slug}: markdown body
|
|
342
|
-
|
|
383
|
+
refusals.push(
|
|
384
|
+
`${label}/${slug}: the file has a markdown body, and ${declaration.name} has no field for ` +
|
|
385
|
+
'it (a `markdown` or `richtext` field) — a push cannot carry it. Add one to the schema, ' +
|
|
386
|
+
'or move the text into a field.'
|
|
343
387
|
)
|
|
344
388
|
}
|
|
389
|
+
if (bySection.length) {
|
|
390
|
+
refusals.push(sectionShapeRefusal(`${label}/${slug}`, bySection, listSections, declaration.name))
|
|
391
|
+
} else {
|
|
392
|
+
const missing = missingRequired(recordSections, sectionData, briefName)
|
|
393
|
+
if (missing.length) {
|
|
394
|
+
refusals.push(
|
|
395
|
+
`${label}/${slug}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
|
|
396
|
+
`has no value for ${missing.length === 1 ? 'it' : 'them'}.`
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
345
400
|
|
|
346
|
-
// The `$`-document, in canonical key order: `$uuid?`, `$id`, `$
|
|
401
|
+
// The `$`-document, in canonical key order: `$uuid?`, `$id`, `$schema`, `$disabled?`,
|
|
347
402
|
// then each populated section in declared order (the brief always present as the
|
|
348
403
|
// card). `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
|
|
349
404
|
// its side.
|
|
350
405
|
const document = {}
|
|
351
406
|
if (uuid) document.$uuid = uuid
|
|
352
407
|
document.$id = id
|
|
353
|
-
document.$
|
|
408
|
+
document.$schema = declaration.name
|
|
354
409
|
// ⭐ A DRAFT IS SENT AS A DISABLED ENTITY: it stays in the folder and is never
|
|
355
410
|
// publicly delivered. Only `true` travels. An absent key means enabled [Diego,
|
|
356
411
|
// 2026-09-21], so a record whose `draft: true` is removed is sent without the key
|
|
@@ -373,7 +428,51 @@ export function recordsToEntities({
|
|
|
373
428
|
document,
|
|
374
429
|
})
|
|
375
430
|
}
|
|
376
|
-
return { entities, warnings }
|
|
431
|
+
return { entities, warnings, refusals }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// "a", "a and b", "a, b and c" — items are already formatted.
|
|
435
|
+
function listOf(items) {
|
|
436
|
+
if (items.length <= 1) return items.join('')
|
|
437
|
+
return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const quoted = (keys) => listOf(keys.map((k) => `"${k}"`))
|
|
441
|
+
|
|
442
|
+
// Why a record written by section cannot be sent, and what to do about each kind of
|
|
443
|
+
// section it names: a single section's fields can move to the top of the file; a
|
|
444
|
+
// list section has no file form a push can send.
|
|
445
|
+
function sectionShapeRefusal(where, keys, listSections, modelName) {
|
|
446
|
+
const singles = keys.filter((k) => !listSections.has(k))
|
|
447
|
+
const lists = keys.filter((k) => listSections.has(k))
|
|
448
|
+
const one = keys.length === 1
|
|
449
|
+
let message =
|
|
450
|
+
`${where}: ${quoted(keys)} ${one ? 'is a section' : 'are sections'} of ${modelName}, and a ` +
|
|
451
|
+
`push reads a record's fields from the top of its file — what ${one ? 'it holds' : 'they hold'} ` +
|
|
452
|
+
'would not be sent.'
|
|
453
|
+
if (singles.length) message += ` Move the fields of ${quoted(singles)} up a level.`
|
|
454
|
+
if (lists.length) {
|
|
455
|
+
message +=
|
|
456
|
+
` ${quoted(lists)} ${lists.length === 1 ? 'holds' : 'hold'} a list, which a push cannot ` +
|
|
457
|
+
'send from a file yet.'
|
|
458
|
+
}
|
|
459
|
+
return message
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// The `required` fields a record's send would lack. The brief is always sent; another
|
|
463
|
+
// single section only when the record fills it, so an empty one is not checked. A
|
|
464
|
+
// field counts as present when the send carries a value for it (`null` is none).
|
|
465
|
+
function missingRequired(recordSections, sectionData, briefName) {
|
|
466
|
+
const missing = []
|
|
467
|
+
for (const [secName, sec] of recordSections) {
|
|
468
|
+
const data = sectionData[secName]
|
|
469
|
+
if (secName !== briefName && !data) continue
|
|
470
|
+
for (const [key, field] of Object.entries(sec.fields || {})) {
|
|
471
|
+
if (field?.required !== true || data?.[key] != null) continue
|
|
472
|
+
missing.push(secName === briefName ? `"${key}"` : `"${key}" (section "${secName}")`)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return missing
|
|
377
476
|
}
|
|
378
477
|
|
|
379
478
|
// Post-pass: override a collection record's localized CONTENT body with a per-locale
|
|
@@ -544,7 +643,7 @@ function resolveDeclaration(schema, modelName) {
|
|
|
544
643
|
* (e.g. site-content) into one sync package. First sync sends no `$uuid` (the
|
|
545
644
|
* backend mints); re-sync round-trips the back-filled `$uuid`. Throws on an
|
|
546
645
|
* unresolvable EXPLICIT Model, an invalid `records/folder.yml`, or a duplicate
|
|
547
|
-
* ($
|
|
646
|
+
* ($schema, $id) within the submission.
|
|
548
647
|
*
|
|
549
648
|
* @param {string} siteRoot - directory containing site.yml
|
|
550
649
|
* @param {object} [opts]
|
|
@@ -558,8 +657,9 @@ function resolveDeclaration(schema, modelName) {
|
|
|
558
657
|
* Defaults to the site's foundation's (`siteSelfScope`); a caller that has it
|
|
559
658
|
* already passes it, so one emit reads it once.
|
|
560
659
|
* @returns {Promise<{ entities: object[], index: object[], warnings: string[],
|
|
561
|
-
* schemaless: Array<{name: string, model: string}>,
|
|
562
|
-
* folder: object, recordsDirExists: boolean }>}
|
|
660
|
+
* refusals: string[], schemaless: Array<{name: string, model: string}>,
|
|
661
|
+
* colConfig: object, folder: object, recordsDirExists: boolean }>}
|
|
662
|
+
* `refusals` — records that cannot be sent as written (`recordsToEntities`).
|
|
563
663
|
* `schemaless` lists the QUERIES whose schema resolved to nothing (the
|
|
564
664
|
* convention-default soft-skip) — their records are not pushed as entities, and
|
|
565
665
|
* the composite deploy delivers those queries statically instead.
|
|
@@ -597,6 +697,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
597
697
|
throw new Error(`uwx/records: ${recordsCfg.file} is invalid —\n ${folder.errors.join('\n ')}`)
|
|
598
698
|
}
|
|
599
699
|
const warnings = [...pool.errors, ...folder.warnings]
|
|
700
|
+
const refusals = []
|
|
600
701
|
|
|
601
702
|
const colConfig = opts.queriesConfig || (await resolveQueriesConfig(siteRoot))
|
|
602
703
|
const queries = siteQueries(colConfig.declarations)
|
|
@@ -606,6 +707,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
606
707
|
entities: [],
|
|
607
708
|
index: [],
|
|
608
709
|
warnings,
|
|
710
|
+
refusals,
|
|
609
711
|
schemaless: [],
|
|
610
712
|
colConfig,
|
|
611
713
|
folder: { ...folder, nodes: [] },
|
|
@@ -657,7 +759,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
657
759
|
// ⭐ Resolving BEFORE `declarationFor` is what keeps this to one line of behaviour:
|
|
658
760
|
// `resolveDeclaration` already matches a fully-qualified name against the
|
|
659
761
|
// foundation's `@/`-keyed `dataSchemas`, so a resolved name looks up correctly and
|
|
660
|
-
// `declaration.name` — the value that becomes `$
|
|
762
|
+
// `declaration.name` — the value that becomes `$schema` — is the resolved one.
|
|
661
763
|
//
|
|
662
764
|
// ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
|
|
663
765
|
// (`site.js::queriesNested`): a query's `schema` must name exactly the Model
|
|
@@ -740,7 +842,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
740
842
|
const producedBy = new Map()
|
|
741
843
|
// Schemas (as written) whose Model resolved to nothing — soft-skipped.
|
|
742
844
|
const unresolved = new Set()
|
|
743
|
-
// The sync response is keyed per ($
|
|
845
|
+
// The sync response is keyed per ($schema, $id), so the pair must be unique
|
|
744
846
|
// within one submission.
|
|
745
847
|
const seen = new Set()
|
|
746
848
|
|
|
@@ -821,14 +923,14 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
821
923
|
const dupKey = `${e.model} ${e.id}`
|
|
822
924
|
if (seen.has(dupKey)) {
|
|
823
925
|
throw new Error(
|
|
824
|
-
`uwx/records: duplicate ($
|
|
926
|
+
`uwx/records: duplicate ($schema, $id) in one sync — "${e.id}" of ` +
|
|
825
927
|
`${e.model} comes from more than one record. Each record in a schema ` +
|
|
826
928
|
'folder needs its own slug; make the slugs unique.'
|
|
827
929
|
)
|
|
828
930
|
}
|
|
829
931
|
seen.add(dupKey)
|
|
830
932
|
// The verb back-fills the minted `$uuid` into this source file, matched
|
|
831
|
-
// back from the finalized response by ($
|
|
933
|
+
// back from the finalized response by ($schema, $id).
|
|
832
934
|
const src = sourceBySlug.get(e.slug)
|
|
833
935
|
index.push({
|
|
834
936
|
id: e.id,
|
|
@@ -843,16 +945,13 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
843
945
|
ownId: ownIds.get(e.slug) ?? null,
|
|
844
946
|
format: src ? src.format : null,
|
|
845
947
|
multiRecord: src ? src.multiRecord : false,
|
|
846
|
-
// The Model declaration, so the back-fill can render the finalized
|
|
847
|
-
// document → authoring shape (variant A): unwrap localized fields, route
|
|
848
|
-
// the content body field to the md body, drop the brief record `$uuid`.
|
|
849
|
-
declaration,
|
|
850
948
|
// Sent disabled, so the back-fill can check that the backend kept it that way.
|
|
851
949
|
draft: e.document.$disabled === true,
|
|
852
950
|
})
|
|
853
951
|
}
|
|
854
952
|
entities.push(...mappedOut.entities)
|
|
855
953
|
warnings.push(...mappedOut.warnings)
|
|
954
|
+
refusals.push(...mappedOut.refusals)
|
|
856
955
|
}
|
|
857
956
|
|
|
858
957
|
// Queries whose schema resolved to nothing — not pushed as entities. The composite
|
|
@@ -903,6 +1002,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
903
1002
|
entities,
|
|
904
1003
|
index,
|
|
905
1004
|
warnings,
|
|
1005
|
+
refusals,
|
|
906
1006
|
schemaless,
|
|
907
1007
|
colConfig,
|
|
908
1008
|
folder: { ...folder, nodes },
|
|
@@ -968,11 +1068,11 @@ export function filterChanged(entities, index, { priorHashes = {}, sendAll = fal
|
|
|
968
1068
|
* @param {object} [opts] - buildRecordEntities opts, plus `priorHashes`,
|
|
969
1069
|
* `sendAll`, `exporter`, `exportedAt`.
|
|
970
1070
|
* @returns {Promise<{ buffer: Buffer|null, models: string[], entityCount: number,
|
|
971
|
-
* warnings: string[],
|
|
972
|
-
* skipped: number }>}
|
|
1071
|
+
* warnings: string[], refusals: string[], index: object[],
|
|
1072
|
+
* hashes: Object<string,string>, skipped: number }>}
|
|
973
1073
|
*/
|
|
974
1074
|
export async function emitRecordSyncPackage(siteRoot, opts = {}) {
|
|
975
|
-
const { entities, index, warnings, recordsDirExists } = await buildRecordEntities(siteRoot, opts)
|
|
1075
|
+
const { entities, index, warnings, refusals, recordsDirExists } = await buildRecordEntities(siteRoot, opts)
|
|
976
1076
|
if (entities.length === 0) {
|
|
977
1077
|
throw new Error(
|
|
978
1078
|
'uwx/records: no records to export — ' +
|
|
@@ -989,7 +1089,7 @@ export async function emitRecordSyncPackage(siteRoot, opts = {}) {
|
|
|
989
1089
|
|
|
990
1090
|
const sentModels = [...new Set(sendEntities.map((e) => e.model))]
|
|
991
1091
|
if (sendEntities.length === 0) {
|
|
992
|
-
return { buffer: null, models: sentModels, entityCount: 0, warnings, index: [], hashes, skipped }
|
|
1092
|
+
return { buffer: null, models: sentModels, entityCount: 0, warnings, refusals, index: [], hashes, skipped }
|
|
993
1093
|
}
|
|
994
1094
|
|
|
995
1095
|
const buffer = emitEntitySyncPackage({
|
|
@@ -1000,5 +1100,5 @@ export async function emitRecordSyncPackage(siteRoot, opts = {}) {
|
|
|
1000
1100
|
exportedAt: opts.exportedAt,
|
|
1001
1101
|
})
|
|
1002
1102
|
|
|
1003
|
-
return { buffer, models: sentModels, entityCount: sendEntities.length, warnings, index: sendIndex, hashes, skipped }
|
|
1103
|
+
return { buffer, models: sentModels, entityCount: sendEntities.length, warnings, refusals, index: sendIndex, hashes, skipped }
|
|
1004
1104
|
}
|
package/src/uwx/self-scope.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// ⭐ ONE RULE FOR EVERY PATH THAT SHIPS A REF FROM ONE PUBLISH, and its inverse:
|
|
11
11
|
//
|
|
12
|
-
// - a record's `$
|
|
12
|
+
// - a record's `$schema` `records.js::buildRecordEntities`
|
|
13
13
|
// - a query's `schema` `site.js::queriesNested` (the `queries` Section)
|
|
14
14
|
// - the author's spelling, on pull `records-project.js` (placement and declarations)
|
|
15
15
|
//
|
package/src/uwx/site.js
CHANGED
|
@@ -850,7 +850,7 @@ export function isSiteRelativeExtensionUrl(decl) {
|
|
|
850
850
|
* @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
|
|
851
851
|
* response or a pull. Absent on a first sync, where minting is correct.
|
|
852
852
|
* @param {string} [org] the publish org. A foundation-relative `schema` (`@/x`)
|
|
853
|
-
* is qualified with it (`./self-scope.js`), exactly as the records' `$
|
|
853
|
+
* is qualified with it (`./self-scope.js`), exactly as the records' `$schema`
|
|
854
854
|
* is — see the note at the `schema` line below.
|
|
855
855
|
*/
|
|
856
856
|
// ⛔ KEYS THAT MUST NOT REACH THE WIRE. Everything else on an authored declaration
|
|
@@ -930,7 +930,7 @@ function queriesNested(declarations, uuids = null, scope = null) {
|
|
|
930
930
|
// the runtime fetches it from (never the records service).
|
|
931
931
|
const source = d.path ? { path: d.path } : d.url !== undefined ? externalSource(d) : d.source
|
|
932
932
|
setIf(data, 'source', source)
|
|
933
|
-
// ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME SCOPE AS THE RECORDS' `$
|
|
933
|
+
// ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME SCOPE AS THE RECORDS' `$schema`
|
|
934
934
|
// (`records.js::buildRecordEntities`) — the site's foundation's. A consumer
|
|
935
935
|
// answers a query by matching this name against the Models its records were
|
|
936
936
|
// stored under, so a verbatim `@/member` beside records stored as `@acme/member`
|
|
@@ -1242,7 +1242,7 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1242
1242
|
* `siteSelfScope`; pass the scope the records are emitted with. With none
|
|
1243
1243
|
* known, `@/x` ships as written.
|
|
1244
1244
|
* @returns {Promise<object>} the section-keyed `$`-document:
|
|
1245
|
-
* `{ $uuid?, $id, $
|
|
1245
|
+
* `{ $uuid?, $id, $schema, info, settings?, pages, layout_sections, extensions,
|
|
1246
1246
|
* queries, services?, secrets? }`
|
|
1247
1247
|
*/
|
|
1248
1248
|
export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
@@ -1518,7 +1518,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1518
1518
|
// config (the records themselves are separate entities; this is just the config).
|
|
1519
1519
|
const colConfig = await resolveQueriesConfig(siteRoot, { siteYml })
|
|
1520
1520
|
|
|
1521
|
-
// `$uuid?` then `$id` `$
|
|
1521
|
+
// `$uuid?` then `$id` `$schema`, then sections in Model-declared order.
|
|
1522
1522
|
const doc = {}
|
|
1523
1523
|
// ⭐ From `sync.json`, keyed by the backend this document is being produced FOR.
|
|
1524
1524
|
// It was `site.yml::$uuid` — one scalar with no way to say which backend minted it.
|
|
@@ -1529,7 +1529,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1529
1529
|
(opts.backend ? readBackendState(siteRoot, opts.backend).site?.uuid : undefined)
|
|
1530
1530
|
if (entityUuid) doc.$uuid = entityUuid
|
|
1531
1531
|
doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
|
|
1532
|
-
doc.$
|
|
1532
|
+
doc.$schema = SITE_MODEL_NAME
|
|
1533
1533
|
doc.info = info
|
|
1534
1534
|
// Emitted only when the file declares something — see `settingsNested`.
|
|
1535
1535
|
const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations })
|
|
@@ -1594,7 +1594,7 @@ export async function emitSiteSyncPackage(siteRoot, opts = {}) {
|
|
|
1594
1594
|
entities: [
|
|
1595
1595
|
{
|
|
1596
1596
|
id: document.$id,
|
|
1597
|
-
model: document.$
|
|
1597
|
+
model: document.$schema,
|
|
1598
1598
|
file: 'entities/site-content.json',
|
|
1599
1599
|
document,
|
|
1600
1600
|
},
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -71,7 +71,7 @@ function emitLane(entities, exporter, exportedAt, extraModels = []) {
|
|
|
71
71
|
return { buffer, entityCount: entities.length, models }
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
// Collect the Models referenced by the folder's `ref` leaves (`entry.
|
|
74
|
+
// Collect the Models referenced by the folder's `ref` leaves (`entry.schema`), walking
|
|
75
75
|
// the contents/$children tree. The folder is built from the FULL record set, so it
|
|
76
76
|
// references every record's Model — including records the send-only-changed filter
|
|
77
77
|
// drops from THIS package (a re-push where the folder changed but the records didn't).
|
|
@@ -79,7 +79,7 @@ function emitLane(entities, exporter, exportedAt, extraModels = []) {
|
|
|
79
79
|
// ride even when their record entities don't.
|
|
80
80
|
function collectReferencedModels(node, acc) {
|
|
81
81
|
if (!node || typeof node !== 'object') return acc
|
|
82
|
-
if (node.entry && typeof node.entry.
|
|
82
|
+
if (node.entry && typeof node.entry.schema === 'string') acc.add(node.entry.schema)
|
|
83
83
|
for (const key of ['$children', 'contents']) {
|
|
84
84
|
if (Array.isArray(node[key])) for (const child of node[key]) collectReferencedModels(child, acc)
|
|
85
85
|
}
|
|
@@ -251,9 +251,13 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
251
251
|
* @returns {Promise<{
|
|
252
252
|
* siteContent: { buffer, entityCount, index, models }|null,
|
|
253
253
|
* records: { buffer, entityCount, index, models }|null,
|
|
254
|
-
* hashes: Object<string,string>, warnings: string[],
|
|
255
|
-
* schemaless: Array<{name: string, model: string}>,
|
|
256
|
-
* applied: object }>}
|
|
254
|
+
* hashes: Object<string,string>, warnings: string[], refusals: string[],
|
|
255
|
+
* skipped: number, schemaless: Array<{name: string, model: string}>,
|
|
256
|
+
* localAssets: string[], applied: object }>}
|
|
257
|
+
* `refusals` names the records that cannot be sent as written, one line each
|
|
258
|
+
* (`records.js::recordsToEntities`). A caller that submits must not submit while
|
|
259
|
+
* any is present: the backend would refuse the records lane — or, for a record
|
|
260
|
+
* written by section whose brief requires nothing, store it empty.
|
|
257
261
|
* `schemaless` lists queries that resolved no data schema (soft-skipped from
|
|
258
262
|
* the sync) — the composite deploy delivers these statically via the data ball.
|
|
259
263
|
* `localAssets` lists the site-root local media refs (`/images/x.png`) the deploy
|
|
@@ -277,7 +281,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
277
281
|
const exportedAt = opts.exportedAt
|
|
278
282
|
refuseOrgOption(opts, 'emitSyncPackages')
|
|
279
283
|
// ⭐ ONE SCOPE FOR THE WHOLE EMIT — the site's foundation's, the one `register`
|
|
280
|
-
// stored its Models under — so a record's `$
|
|
284
|
+
// stored its Models under — so a record's `$schema` and a query's `schema` are
|
|
281
285
|
// qualified alike. Read once here and handed to both. ⛔ It was the org that owns
|
|
282
286
|
// the SITE until 2026-09-22 (`self-scope.js`).
|
|
283
287
|
const scope =
|
|
@@ -446,7 +450,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
446
450
|
const localAssets = [...localAssetSet]
|
|
447
451
|
|
|
448
452
|
const siteEntity = siteDoc
|
|
449
|
-
? { id: siteDoc.$id, model: siteDoc.$
|
|
453
|
+
? { id: siteDoc.$id, model: siteDoc.$schema, file: 'entities/site-content.json', document: siteDoc }
|
|
450
454
|
: null
|
|
451
455
|
|
|
452
456
|
// One hash map over every entity (both lanes) — the sync-cache the caller persists.
|
|
@@ -519,6 +523,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
519
523
|
|
|
520
524
|
return {
|
|
521
525
|
siteContent, records, siteContentUuid, hashes, warnings, skipped,
|
|
526
|
+
refusals: col.refusals || [],
|
|
522
527
|
schemaless: col.schemaless, localAssets, applied,
|
|
523
528
|
// { stamped, unknown } when identity was applied; null when the caller passed
|
|
524
529
|
// no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
|
package/src/validate-data.js
CHANGED
|
@@ -31,15 +31,17 @@ 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, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
|
|
35
|
-
import { validateAndNormalizeSchema } from './resolve-data-schema.js'
|
|
34
|
+
import { validateItem, isStaticallyCheckable, validateBound, flatRecordFields } from '@uniweb/schemas/conform'
|
|
35
|
+
import { validateAndNormalizeSchema, buildDataSchemaMap } from './resolve-data-schema.js'
|
|
36
36
|
|
|
37
37
|
// The pure checker, re-exported so `@uniweb/build/validate` stays the one import
|
|
38
38
|
// path callers know (`uniweb validate`, the CLI, and the contract tests all
|
|
39
39
|
// reach it here) even though the implementation moved next to the vocabulary.
|
|
40
40
|
export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
|
|
41
41
|
import { buildSchema } from './schema.js'
|
|
42
|
-
import { resolveRecordsDir } from './site/entity-pool.js'
|
|
42
|
+
import { resolveRecordsDir, readEntityPool, groupPoolBySchema } from './site/entity-pool.js'
|
|
43
|
+
import { readEntityFile } from './uwx/entity-source.js'
|
|
44
|
+
import { isContentBodyField } from './uwx/data-schema.js'
|
|
43
45
|
import { toFetchList } from './site/data-fetcher.js'
|
|
44
46
|
import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
|
|
45
47
|
import { collectSiteContent } from './site/content-collector.js'
|
|
@@ -195,8 +197,11 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
195
197
|
const schema = dataSchemas[ref]
|
|
196
198
|
if (!schema) continue // build guarantees refs resolve; defensive skip
|
|
197
199
|
|
|
200
|
+
// ⭐ A sections-form schema is checked like any other now (`isStaticallyCheckable`,
|
|
201
|
+
// 2026-09-24) — a record can be flat or written by section. What is left out is a
|
|
202
|
+
// schema whose ROOT IS A LIST: it describes a whole value, not one record.
|
|
198
203
|
if (!isStaticallyCheckable(schema)) {
|
|
199
|
-
deferred.push({ route: page.route, section: type, key, reason: '
|
|
204
|
+
deferred.push({ route: page.route, section: type, key, reason: 'its root is a list, not a record', ref })
|
|
200
205
|
continue
|
|
201
206
|
}
|
|
202
207
|
|
|
@@ -215,6 +220,8 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
215
220
|
const violations = []
|
|
216
221
|
const schemasSeen = new Set()
|
|
217
222
|
let recordCount = 0
|
|
223
|
+
// (schema, slug) of every record this pass checked, so pass 5 checks the rest.
|
|
224
|
+
const checkedRecords = new Set()
|
|
218
225
|
|
|
219
226
|
for (const entry of work.values()) {
|
|
220
227
|
const { records, error } = await resolveRecords(entry.path, { byQuery, siteRoot })
|
|
@@ -227,6 +234,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
227
234
|
const items = Array.isArray(records) ? records : [records]
|
|
228
235
|
items.forEach((item, idx) => {
|
|
229
236
|
recordCount++
|
|
237
|
+
if (item && typeof item.slug === 'string') checkedRecords.add(recordKey(entry.ref, item.slug))
|
|
230
238
|
for (const finding of validateItem(entry.schema, item)) {
|
|
231
239
|
violations.push({
|
|
232
240
|
file: entry.path,
|
|
@@ -253,6 +261,16 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
253
261
|
for (const ref of blocks.schemas) schemasSeen.add(ref)
|
|
254
262
|
recordCount += blocks.checked
|
|
255
263
|
|
|
264
|
+
// Pass 5 — every record file no section's binding reached, against the data schema
|
|
265
|
+
// its folder names. ⭐ The set a push sends: every file in the records directory is
|
|
266
|
+
// a record and every record is pushed, whether or not a section reads it — and
|
|
267
|
+
// until 2026-09-24 one that no section read was never checked here.
|
|
268
|
+
const files = await validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths: config.paths, checked: checkedRecords })
|
|
269
|
+
violations.push(...files.violations)
|
|
270
|
+
setupErrors.push(...files.setupErrors)
|
|
271
|
+
for (const ref of files.schemas) schemasSeen.add(ref)
|
|
272
|
+
recordCount += files.checked
|
|
273
|
+
|
|
256
274
|
return {
|
|
257
275
|
violations,
|
|
258
276
|
deferred,
|
|
@@ -266,6 +284,69 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
266
284
|
}
|
|
267
285
|
}
|
|
268
286
|
|
|
287
|
+
const recordKey = (ref, slug) => `${ref}\u0000${slug}`
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Check the records in the records directory that pass 2 did not, each against the
|
|
291
|
+
* data schema its folder names (`records/member/` → `@/member`).
|
|
292
|
+
*
|
|
293
|
+
* A record is read the way a push reads it (`uwx/entity-source.js`), and a markdown
|
|
294
|
+
* record's body is the value of its schema's content body field unless its
|
|
295
|
+
* frontmatter sets one — which is what a push sends it as (`uwx/records.js`).
|
|
296
|
+
*
|
|
297
|
+
* A folder whose schema resolves to nothing is left out in silence: its records are
|
|
298
|
+
* schema-less, delivered as files, and the push says so itself.
|
|
299
|
+
*
|
|
300
|
+
* @returns {Promise<{ violations: object[], setupErrors: object[], schemas: Set<string>, checked: number }>}
|
|
301
|
+
*/
|
|
302
|
+
async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths, checked }) {
|
|
303
|
+
const out = { violations: [], setupErrors: [], schemas: new Set(), checked: 0 }
|
|
304
|
+
const pool = await readEntityPool(siteRoot, { dir: resolveRecordsDir(siteRoot, paths).rel })
|
|
305
|
+
for (const [ref, entities] of groupPoolBySchema(pool.entities)) {
|
|
306
|
+
const schema = await schemaForRecords(ref, { srcDir, dataSchemas })
|
|
307
|
+
if (!schema || !isStaticallyCheckable(schema)) continue
|
|
308
|
+
for (const pooled of entities) {
|
|
309
|
+
let records
|
|
310
|
+
try {
|
|
311
|
+
records = await readEntityFile(pooled.absPath)
|
|
312
|
+
} catch (err) {
|
|
313
|
+
out.setupErrors.push({ file: pooled.relPath, message: err.message, users: [] })
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
for (const r of records) {
|
|
317
|
+
if (!r.slug || checked.has(recordKey(ref, r.slug))) continue
|
|
318
|
+
out.checked++
|
|
319
|
+
out.schemas.add(ref)
|
|
320
|
+
for (const finding of validateItem(schema, withBody(schema, r))) {
|
|
321
|
+
out.violations.push({ file: pooled.relPath, schema: ref, item: r.slug, users: [], ...finding })
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return out
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The schema a records folder names — the one a section binds if any does, else
|
|
330
|
+
// resolved the way every other ref is. Null when nothing resolves.
|
|
331
|
+
async function schemaForRecords(ref, { srcDir, dataSchemas }) {
|
|
332
|
+
if (dataSchemas[ref]) return dataSchemas[ref]
|
|
333
|
+
try {
|
|
334
|
+
return (await buildDataSchemaMap([ref], { srcDir }))[ref] || null
|
|
335
|
+
} catch {
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// A record as a push sends it: a markdown body fills the schema's content body field
|
|
341
|
+
// when the frontmatter does not set it.
|
|
342
|
+
function withBody(schema, r) {
|
|
343
|
+
const record = { ...(r.data || {}) }
|
|
344
|
+
if (typeof r.body !== 'string' || r.body.trim() === '') return record
|
|
345
|
+
const key = Object.entries(flatRecordFields(schema) || {}).find(([, f]) => isContentBodyField(f))?.[0]
|
|
346
|
+
if (key && record[key] === undefined) record[key] = r.body
|
|
347
|
+
return record
|
|
348
|
+
}
|
|
349
|
+
|
|
269
350
|
/**
|
|
270
351
|
* Check each ```md:<tag> concept block against `@std/<tag>`, when that schema
|
|
271
352
|
* exists.
|