@uniweb/build 0.59.1 → 0.61.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/index.js +0 -1
- package/src/resolve-data-schema.js +28 -5
- package/src/schema.js +7 -4
- package/src/uwx/backfill.js +30 -68
- 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 +108 -153
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
|