@uniweb/build 0.62.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/uwx/folder.js +15 -8
- package/src/uwx/records.js +61 -55
- package/src/uwx/registry-package.js +50 -1
- package/src/uwx/sync-package.js +18 -64
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
"@uniweb/content-reader": "^1.2.5",
|
|
61
61
|
"@uniweb/content-writer": "^0.3.4",
|
|
62
62
|
"@uniweb/schemas": "^0.7.0",
|
|
63
|
+
"@uniweb/projections": "^0.7.3",
|
|
63
64
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
64
|
-
"@uniweb/theming": "^0.1.15"
|
|
65
|
-
"@uniweb/projections": "^0.7.3"
|
|
65
|
+
"@uniweb/theming": "^0.1.15"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
68
|
"@uniweb/runtime": "^0.27.4"
|
|
@@ -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": {
|
package/src/uwx/folder.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
// - `contents` is the self-nesting tree (an array), nesting via `$children` — the
|
|
8
8
|
// same mechanism site-content pages/sections use. Each node holds REFERENCES,
|
|
9
9
|
// never content:
|
|
10
|
-
// - a LEAF references one record entity: `{ kind: 'ref', name,
|
|
11
|
-
// `entry: <uuid
|
|
12
|
-
// or
|
|
10
|
+
// - a LEAF references one record entity: `{ kind: 'ref', name, entry }` —
|
|
11
|
+
// `entry: { schema, entity: <uuid> }` once the record was minted (back-filled
|
|
12
|
+
// into its file), or `entry: { $ref: "<$id>" }` while brand-new (resolved
|
|
13
|
+
// within this payload).
|
|
13
14
|
// - a BRANCH is a sub-folder: `{ kind: 'branch', name, label?, $children }`.
|
|
14
15
|
//
|
|
15
16
|
// ⭐ A LEAF'S `name` IS ITS RECORD'S HANDLE — the slug, the records service's `$name` —
|
|
@@ -55,8 +56,13 @@ export const FOLDER_ENTITY_KEY = '@folder'
|
|
|
55
56
|
// field is polymorphic (it can reference any data schema), so the ref uses the
|
|
56
57
|
// entity_ref OPEN form `{ schema, entity }` — not a bare uuid (a bare uuid is only
|
|
57
58
|
// valid when the field pins a single schema). Known uuid → `entry: { schema, entity
|
|
58
|
-
// }`; brand-new →
|
|
59
|
-
//
|
|
59
|
+
// }`; brand-new → `entry: { $ref: <$id> }`, the record's payload-local handle, which
|
|
60
|
+
// the backend resolves to the uuid it mints for that record in the same package.
|
|
61
|
+
//
|
|
62
|
+
// ⛔ `$ref` IS THE REFERENCE'S VALUE, NOT A KEY OF THE LEAF (2026-09-25). A backend
|
|
63
|
+
// resolves `{ $ref }` wherever a reference value goes, and refuses the key form it
|
|
64
|
+
// replaced, `{ kind: 'ref', name, $ref }`. There is no window: a CLI that writes the key
|
|
65
|
+
// form cannot push a site with a record new to that backend.
|
|
60
66
|
//
|
|
61
67
|
// ⭐ `schema` carries the data schema's SCOPED NAME (`@std/article`), never a Model id
|
|
62
68
|
// — agreed with backend 2026-09-24: a Model id is the backend's internal identity and
|
|
@@ -68,7 +74,7 @@ export const FOLDER_ENTITY_KEY = '@folder'
|
|
|
68
74
|
function refLeaf(entity) {
|
|
69
75
|
const leaf = { kind: 'ref', name: entity.slug }
|
|
70
76
|
if (entity.uuid) leaf.entry = { schema: entity.model, entity: entity.uuid }
|
|
71
|
-
else leaf
|
|
77
|
+
else leaf.entry = { $ref: entity.id } // the payload-local handle
|
|
72
78
|
return leaf
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -76,7 +82,7 @@ function refLeaf(entity) {
|
|
|
76
82
|
* Turn the placed records into folder `contents`.
|
|
77
83
|
*
|
|
78
84
|
* ⛔ A LEAF WHOSE ENTITY IS MISSING IS DROPPED AND REPORTED, never emitted empty.
|
|
79
|
-
* A `ref` with
|
|
85
|
+
* A `ref` with no `entry` is a placement pointing at nothing —
|
|
80
86
|
* the backend cannot resolve it, and the failure would surface there rather than
|
|
81
87
|
* here, as somebody else's error.
|
|
82
88
|
*
|
|
@@ -108,7 +114,8 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
|
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
// The uuid of the record a leaf references — once minted, `entry: { schema, entity }`
|
|
111
|
-
// (a bare uuid is tolerated). A brand-new record's leaf carries
|
|
117
|
+
// (a bare uuid is tolerated). A brand-new record's leaf carries `entry: { $ref }`, and
|
|
118
|
+
// has none.
|
|
112
119
|
function recordOf(item) {
|
|
113
120
|
if (item?.kind !== 'ref') return null
|
|
114
121
|
const e = item.entry
|
package/src/uwx/records.js
CHANGED
|
@@ -25,14 +25,18 @@
|
|
|
25
25
|
//
|
|
26
26
|
// ⭐ A REFERENCE (`entity_ref`) NAMES ITS RECORD BY HANDLE in a file — `speaker: ada`,
|
|
27
27
|
// the target's file name or its `slug:` — and is sent as the uuid THIS backend minted for
|
|
28
|
-
// that record (`refResolver`). A target this backend has not minted yet
|
|
29
|
-
// push
|
|
30
|
-
//
|
|
31
|
-
//
|
|
28
|
+
// that record (`refResolver`). A target this backend has not minted yet is new, so this
|
|
29
|
+
// push creates it, and the reference names it by its `$id` in the package —
|
|
30
|
+
// `speaker: { $ref: "speaker/ada" }` — which the backend resolves to the uuid it mints.
|
|
31
|
+
// One push creates both. ⛔ Until 2026-09-25 such a referrer WAITED, sent without the
|
|
32
|
+
// reference or held back, and the CLI pushed again to complete it.
|
|
32
33
|
// ⚠️ item_ref and file fields are still sent as the file writes them.
|
|
33
34
|
//
|
|
34
35
|
// ⚠️ A record of a `many` section is sent without a `$uuid`, so a push that changes a
|
|
35
|
-
// record
|
|
36
|
+
// record whose section the backend already holds is REFUSED, whole (`identity_required`):
|
|
37
|
+
// applied, it would re-identify every stored item. Measured 2026-09-25 — this said such a
|
|
38
|
+
// push "replaces that section's records" until then. Its first push, which creates the
|
|
39
|
+
// items, is not affected.
|
|
36
40
|
|
|
37
41
|
import { readBackendState } from './sync-store.js'
|
|
38
42
|
import { readFileSync, existsSync } from 'node:fs'
|
|
@@ -85,7 +89,7 @@ const RECORD_KEYS = new Set([
|
|
|
85
89
|
|
|
86
90
|
// Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$schema`/… — never
|
|
87
91
|
// field data; the sigil-exclusivity invariant guarantees this) at every level,
|
|
88
|
-
// so a back-filled `$uuid` doesn't change the hash.
|
|
92
|
+
// so a back-filled `$uuid` doesn't change the hash. Three are exceptions, because
|
|
89
93
|
// they are content rather than identity:
|
|
90
94
|
// - `$children` is STRUCTURAL content (a self-nesting record's subtree, e.g.
|
|
91
95
|
// site-content's nested pages/sections), so it is KEPT and recursed into —
|
|
@@ -94,15 +98,21 @@ const RECORD_KEYS = new Set([
|
|
|
94
98
|
// - `$disabled` is a record's delivery STATE (`draft: true` on the file side). It
|
|
95
99
|
// is KEPT so that drafting or un-drafting a record with nothing else changed is
|
|
96
100
|
// still sent; stripped, "send only changed" would never send the toggle.
|
|
101
|
+
// - `$ref` is a REFERENCE to a record the same push creates (`speaker: { $ref:
|
|
102
|
+
// "speaker/ada" }`), and which record it names is content. Once the backend has
|
|
103
|
+
// minted that record the next push names it by uuid instead — so the CLI re-banks a
|
|
104
|
+
// record that sent a `$ref` after its push (`namesNew`, `sync-package.js`). The
|
|
105
|
+
// folder's ref leaf is not this case: see below.
|
|
97
106
|
function stripSigils(value) {
|
|
98
107
|
if (Array.isArray(value)) return value.map(stripSigils)
|
|
99
108
|
if (value && typeof value === 'object') {
|
|
100
109
|
// ⛔ A `@uniweb/folder` REF LEAF ENCODES ONE REFERENCE TWO WAYS, and hashing the
|
|
101
110
|
// encoding rather than the reference made the folder's hash unreproducible.
|
|
102
111
|
//
|
|
103
|
-
// `refLeaf` (uwx/folder.js) emits
|
|
104
|
-
// `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema,
|
|
105
|
-
// <uuid> }` once it has been minted.
|
|
112
|
+
// `refLeaf` (uwx/folder.js) emits `entry: { $ref: <the record's $id> }` — the pool
|
|
113
|
+
// position `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema,
|
|
114
|
+
// entity: <uuid> }` once it has been minted. (`$ref` was a key of the leaf itself
|
|
115
|
+
// until 2026-09-25, when the backend made it a reference's value.)
|
|
106
116
|
// Both denote the same record. A push hashes the folder BEFORE submitting, then
|
|
107
117
|
// back-fills the minted `$uuid` into every record's source file — so the very
|
|
108
118
|
// next emit builds the OTHER encoding, and the hash the push just banked can
|
|
@@ -116,19 +126,20 @@ function stripSigils(value) {
|
|
|
116
126
|
// `name` (the record's handle) at its position inside the branch that holds it.
|
|
117
127
|
// Two siblings may share a name — records of different schemas can — and position
|
|
118
128
|
// still tells them apart; a leaf that comes to reference another record arrives
|
|
119
|
-
// with that record changed, which re-sends the folder anyway.
|
|
120
|
-
// payload-local handle
|
|
121
|
-
//
|
|
129
|
+
// with that record changed, which re-sends the folder anyway. `entry` carries a
|
|
130
|
+
// payload-local handle or an identity, and both are exactly what `$uuid` is
|
|
131
|
+
// stripped for.
|
|
122
132
|
//
|
|
123
133
|
// ⚖️ The previous rule kept `$ref` "so a reference change is visible". It still
|
|
124
134
|
// is: point a leaf at a different record and its `name` moves with it.
|
|
125
135
|
const isFolderRefLeaf = value.kind === 'ref'
|
|
126
136
|
const out = {}
|
|
127
137
|
for (const [k, v] of Object.entries(value)) {
|
|
128
|
-
if (isFolderRefLeaf &&
|
|
129
|
-
// `$children` (a self-nesting subtree)
|
|
130
|
-
//
|
|
131
|
-
|
|
138
|
+
if (isFolderRefLeaf && k === 'entry') continue
|
|
139
|
+
// `$children` (a self-nesting subtree), `$disabled` (a delivery state) and `$ref`
|
|
140
|
+
// (a reference to a record of the same push) are CONTENT, not identity sigils —
|
|
141
|
+
// kept, so a change to any of them stays visible.
|
|
142
|
+
if (k === '$children' || k === '$disabled' || k === '$ref') {
|
|
132
143
|
out[k] = stripSigils(v)
|
|
133
144
|
continue
|
|
134
145
|
}
|
|
@@ -218,11 +229,10 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
218
229
|
* @param {{ resolve: (model: string, value: *) => object }} [params.refs] - what a
|
|
219
230
|
* reference sends (`refResolver`). Without it a reference is sent as written.
|
|
220
231
|
* @returns {{ entities: object[], warnings: string[], refusals: string[] }} each
|
|
221
|
-
* entity is `{ id, uuid, model, file, document,
|
|
222
|
-
* section-keyed body, and `
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* line each; a caller that sends must not send while any is present.
|
|
232
|
+
* entity is `{ id, uuid, slug, model, file, document, namesNew }` — `document` is the
|
|
233
|
+
* section-keyed body, and `namesNew` how many of its references name a record this
|
|
234
|
+
* push creates, by `$ref`. `refusals` names the records that cannot be sent as
|
|
235
|
+
* written, one line each; a caller that sends must not send while any is present.
|
|
226
236
|
*/
|
|
227
237
|
export function recordsToEntities({
|
|
228
238
|
label,
|
|
@@ -324,10 +334,7 @@ export function recordsToEntities({
|
|
|
324
334
|
)
|
|
325
335
|
}
|
|
326
336
|
if (!enc.misplaced.length && !enc.shape.length) {
|
|
327
|
-
|
|
328
|
-
// that completes the record sends it (see `pending` below).
|
|
329
|
-
const waiting = new Set(enc.pending.map((p) => `"${p.path}"`))
|
|
330
|
-
const missing = missingRequired(layout, sections).filter((m) => !waiting.has(m))
|
|
337
|
+
const missing = missingRequired(layout, sections)
|
|
331
338
|
if (missing.length) {
|
|
332
339
|
refusals.push(
|
|
333
340
|
`${where}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
|
|
@@ -364,7 +371,7 @@ export function recordsToEntities({
|
|
|
364
371
|
// unique within its schema folder, and an authored `$id` need not be.
|
|
365
372
|
file: `entities/${label}/${slug}.json`,
|
|
366
373
|
document,
|
|
367
|
-
|
|
374
|
+
namesNew: enc.namesNew,
|
|
368
375
|
})
|
|
369
376
|
}
|
|
370
377
|
return { entities, warnings, refusals }
|
|
@@ -374,7 +381,7 @@ export function recordsToEntities({
|
|
|
374
381
|
// schema, the keys written flat in a schema written by section, and values of the
|
|
375
382
|
// wrong shape for their section.
|
|
376
383
|
function encoder(sourceLocale, translations, refs = null) {
|
|
377
|
-
return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [],
|
|
384
|
+
return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
|
|
378
385
|
}
|
|
379
386
|
|
|
380
387
|
// A flat record: its keys, minus the record's own, are the one section's fields.
|
|
@@ -478,10 +485,10 @@ function encodeRecord(fields, value, enc, prefix, reserved) {
|
|
|
478
485
|
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
479
486
|
|
|
480
487
|
// A reference, as a push sends it: the uuid this backend minted for the record the file
|
|
481
|
-
// names (`refResolver`)
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
// (which our schemas do not author), the value is sent as written.
|
|
488
|
+
// names (`refResolver`) — or, for a record it has not minted, which this push creates,
|
|
489
|
+
// `{ $ref: <that record's $id> }`, the backend's name for a record of the same package.
|
|
490
|
+
// A list of them is each of those. Without a resolver, and for a reference open to
|
|
491
|
+
// several Models (which our schemas do not author), the value is sent as written.
|
|
485
492
|
function encodeReference(value, field, enc, path) {
|
|
486
493
|
if (!enc.refs || typeof field.model !== 'string') return value
|
|
487
494
|
if (field.multiple === true) {
|
|
@@ -490,27 +497,23 @@ function encodeReference(value, field, enc, path) {
|
|
|
490
497
|
return undefined
|
|
491
498
|
}
|
|
492
499
|
const sent = []
|
|
493
|
-
const waiting = []
|
|
494
500
|
value.forEach((one, i) => {
|
|
495
|
-
const
|
|
496
|
-
if (
|
|
501
|
+
const ref = resolveReference(one, field, enc, `${path}[${i}]`)
|
|
502
|
+
if (ref !== undefined) sent.push(ref)
|
|
497
503
|
})
|
|
498
|
-
// A required list with nothing to send yet waits whole; otherwise what is known goes now.
|
|
499
|
-
const required = field.required === true && sent.length === 0
|
|
500
|
-
for (const w of waiting) enc.pending.push({ ...w, required })
|
|
501
504
|
return sent
|
|
502
505
|
}
|
|
503
|
-
|
|
504
|
-
const uuid = resolveReference(value, field, enc, path, waiting)
|
|
505
|
-
for (const w of waiting) enc.pending.push({ ...w, required: field.required === true })
|
|
506
|
-
return uuid
|
|
506
|
+
return resolveReference(value, field, enc, path)
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
-
function resolveReference(value, field, enc, path
|
|
509
|
+
function resolveReference(value, field, enc, path) {
|
|
510
510
|
const answer = enc.refs.resolve(field.model, value)
|
|
511
511
|
if (answer.uuid) return answer.uuid
|
|
512
|
-
if (answer.
|
|
513
|
-
|
|
512
|
+
if (answer.ref) {
|
|
513
|
+
enc.namesNew++
|
|
514
|
+
return { $ref: answer.ref }
|
|
515
|
+
}
|
|
516
|
+
enc.unresolved.push({ path, model: field.model, value, ...answer })
|
|
514
517
|
return undefined
|
|
515
518
|
}
|
|
516
519
|
|
|
@@ -527,31 +530,34 @@ function describeUnresolved({ path, model, value, invalid, ambiguous }) {
|
|
|
527
530
|
// own id (mapped like a handle's), or the backend's id of a record the project does not
|
|
528
531
|
// hold, which is what a pull writes for one.
|
|
529
532
|
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
533
|
+
// ⭐ A record this backend has not minted yet — never pushed there — answers with its
|
|
534
|
+
// `$id` (`ref`): it is new, so this push creates it, and the backend takes
|
|
535
|
+
// `{ $ref: <$id> }` for a record of the same package. A uuid it did not mint is never
|
|
536
|
+
// sent (see `buildRecordEntities`).
|
|
532
537
|
const AMBIGUOUS = Symbol('ambiguous')
|
|
533
538
|
function refResolver(readSchemas, recordMap) {
|
|
534
|
-
const byModel = new Map() // Model → handle → the record
|
|
535
|
-
const
|
|
539
|
+
const byModel = new Map() // Model → handle → the record: its own id (null before its first push) and `$id`
|
|
540
|
+
const byOwnId = new Map() // the record's own id → the record
|
|
536
541
|
for (const { declaration, flat } of readSchemas) {
|
|
537
542
|
let names = byModel.get(declaration.name)
|
|
538
543
|
if (!names) byModel.set(declaration.name, (names = new Map()))
|
|
539
544
|
for (const rec of flat) {
|
|
540
545
|
const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
|
|
541
|
-
|
|
542
|
-
|
|
546
|
+
const record = { own, id: rec.$id }
|
|
547
|
+
names.set(rec.slug, names.has(rec.slug) ? AMBIGUOUS : record)
|
|
548
|
+
if (own) byOwnId.set(own, record)
|
|
543
549
|
}
|
|
544
550
|
}
|
|
545
|
-
const
|
|
551
|
+
const sent = ({ own, id }) => (own && recordMap[own] ? { uuid: recordMap[own] } : { ref: id })
|
|
546
552
|
return {
|
|
547
553
|
resolve(model, value) {
|
|
548
554
|
if (typeof value !== 'string' || value === '') return { invalid: true }
|
|
549
555
|
const names = byModel.get(model)
|
|
550
556
|
if (names?.has(value)) {
|
|
551
|
-
const
|
|
552
|
-
return
|
|
557
|
+
const record = names.get(value)
|
|
558
|
+
return record === AMBIGUOUS ? { ambiguous: true } : sent(record)
|
|
553
559
|
}
|
|
554
|
-
if (isUuid(value)) return
|
|
560
|
+
if (isUuid(value)) return byOwnId.has(value) ? sent(byOwnId.get(value)) : { uuid: value }
|
|
555
561
|
return { missing: true }
|
|
556
562
|
},
|
|
557
563
|
}
|
|
@@ -1008,7 +1014,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1008
1014
|
|
|
1009
1015
|
// ⭐ EVERY MODEL'S RECORDS ARE READ BEFORE ANY IS MAPPED: a reference names a record
|
|
1010
1016
|
// of another Model by its handle (`speaker: ada`), and the push sends that record's
|
|
1011
|
-
// uuid in its place (`refResolver`).
|
|
1017
|
+
// uuid in its place — or its `$id`, while it is new (`refResolver`).
|
|
1012
1018
|
const readSchemas = []
|
|
1013
1019
|
for (const [schema, poolEntities] of poolBySchema) {
|
|
1014
1020
|
const label = poolEntities[0].dirs.join('/')
|
|
@@ -157,7 +157,56 @@ function buildDataSchemaEntities(dataSchemas, scope) {
|
|
|
157
157
|
// way; item_ref `options` get the full `@org/x/<section>` path (§10.1).
|
|
158
158
|
...toDataSchemaDeclaration(normalized, { name: scoped(ref), resolveName: scoped, resolveOptions }),
|
|
159
159
|
}))
|
|
160
|
-
return { entities, scoped, org }
|
|
160
|
+
return { entities: referencedFirst(entities), scoped, org }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The data schemas in an order a backend can install one at a time: a schema another one in
|
|
165
|
+
* the package references comes BEFORE it — alphabetical otherwise, so the order is stable.
|
|
166
|
+
*
|
|
167
|
+
* ⭐ WHY. A backend installs a package's data schemas in the order they arrive, and refuses a
|
|
168
|
+
* reference to a Model it holds neither yet nor from before: measured 2026-09-25, a foundation
|
|
169
|
+
* whose `exhibit` references its own `specimen` was refused whole — *"entity_ref target
|
|
170
|
+
* `@x/specimen` matches no model declared in this fixture or registered on the host"* — because
|
|
171
|
+
* `exhibit` sorts first. Alphabetical order was right only by luck (`speaker` before `talk`).
|
|
172
|
+
*
|
|
173
|
+
* ⚠️ A CYCLE CANNOT BE ORDERED — `author` names `book` and `book` names `author` — and keeps
|
|
174
|
+
* the order the walk gives it. Whether one package may reference itself in any order is the
|
|
175
|
+
* backend's to accept; this only stops refusing what an order can fix. A schema naming ITSELF
|
|
176
|
+
* is not a dependency.
|
|
177
|
+
*
|
|
178
|
+
* @param {Array<{ name: string, sections?: object }>} entities - lowered declarations
|
|
179
|
+
* @returns {Array} the same entities, referenced ones first
|
|
180
|
+
*/
|
|
181
|
+
function referencedFirst(entities) {
|
|
182
|
+
const byName = new Map(entities.map((e) => [e.name, e]))
|
|
183
|
+
const targets = (entity) => {
|
|
184
|
+
const out = new Set()
|
|
185
|
+
const walk = (fields) => {
|
|
186
|
+
for (const field of Object.values(fields || {})) {
|
|
187
|
+
if (!field || typeof field !== 'object') continue
|
|
188
|
+
if (field.type === 'entity_ref') for (const m of [field.model].flat()) out.add(m)
|
|
189
|
+
// `options: '@org/x/<section>'` names a section of Model `@org/x`.
|
|
190
|
+
if (field.type === 'item_ref' && typeof field.options === 'string') {
|
|
191
|
+
out.add(field.options.split('/').slice(0, 2).join('/'))
|
|
192
|
+
}
|
|
193
|
+
if (field.type === 'section') walk(field.fields)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const section of Object.values(entity.sections || {})) walk(section.fields)
|
|
197
|
+
return [...out].filter((name) => name !== entity.name && byName.has(name)).sort()
|
|
198
|
+
}
|
|
199
|
+
const ordered = []
|
|
200
|
+
const state = new Map() // name → 'visiting' | 'done'
|
|
201
|
+
const visit = (entity) => {
|
|
202
|
+
if (state.get(entity.name)) return // done, or on the walk now — a cycle
|
|
203
|
+
state.set(entity.name, 'visiting')
|
|
204
|
+
for (const name of targets(entity)) visit(byName.get(name))
|
|
205
|
+
state.set(entity.name, 'done')
|
|
206
|
+
ordered.push(entity)
|
|
207
|
+
}
|
|
208
|
+
for (const entity of entities) visit(entity)
|
|
209
|
+
return ordered
|
|
161
210
|
}
|
|
162
211
|
|
|
163
212
|
// The shared `.uwx` envelope (uwx/1 + exporter + timestamp) around an entity list.
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -33,18 +33,6 @@ const SITE_ENTITY_KEY = 'site-content'
|
|
|
33
33
|
|
|
34
34
|
const cacheKey = (entity) => `${entity.model} ${entity.id}`
|
|
35
35
|
|
|
36
|
-
// The folder's placements without the records a push leaves out — `{ kind: 'ref' }` leaves
|
|
37
|
-
// naming them by `$entityId`, inside any branch.
|
|
38
|
-
function withoutRecords(nodes, ids) {
|
|
39
|
-
if (!ids.size) return nodes
|
|
40
|
-
const out = []
|
|
41
|
-
for (const node of nodes || []) {
|
|
42
|
-
if (node?.kind === 'branch') out.push({ ...node, $children: withoutRecords(node.$children, ids) })
|
|
43
|
-
else if (!ids.has(node?.$entityId)) out.push(node)
|
|
44
|
-
}
|
|
45
|
-
return out
|
|
46
|
-
}
|
|
47
|
-
|
|
48
36
|
// Attach the push gate's optimistic-concurrency token to an entity, keyed by the
|
|
49
37
|
// BACKEND uuid in its document body (`$uuid`). A never-synced entity has no
|
|
50
38
|
// `$uuid` and therefore no base — correctly unconditional, since there is no
|
|
@@ -265,11 +253,10 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
265
253
|
* records: { buffer, entityCount, index, models }|null,
|
|
266
254
|
* hashes: Object<string,string>, warnings: string[], refusals: string[],
|
|
267
255
|
* skipped: number, schemaless: Array<{name: string, model: string}>,
|
|
268
|
-
* localAssets: string[], applied: object,
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* once this push has been applied (`records.js::encodeReference`).
|
|
256
|
+
* localAssets: string[], applied: object, namesNew: string[] }>}
|
|
257
|
+
* `namesNew` lists the records this package sends whose references name a record it
|
|
258
|
+
* creates — by `$ref`, where the next push will send a uuid — as keys of `hashes`. A
|
|
259
|
+
* caller that banks `hashes` re-banks these once the push is applied.
|
|
273
260
|
* `refusals` names the records that cannot be sent as written, one line each
|
|
274
261
|
* (`records.js::recordsToEntities`). A caller that submits must not submit while
|
|
275
262
|
* any is present: the backend would refuse the records lane — or, for a record
|
|
@@ -316,43 +303,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
316
303
|
})
|
|
317
304
|
const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
|
|
318
305
|
|
|
319
|
-
// ⭐ RECORDS WAITING FOR A REFERENCE (`records.js::encodeReference`). A record whose
|
|
320
|
-
// reference names a record this backend has not minted yet cannot name it on the wire,
|
|
321
|
-
// so this push sends what it can and the next pass completes it (the CLI's
|
|
322
|
-
// `pushInPasses`):
|
|
323
|
-
// · new, and nothing it waits for is required → sent now without those references,
|
|
324
|
-
// so after one pass every record exists, whatever refers to what;
|
|
325
|
-
// · new, and a required reference waits → HELD and placed nowhere: it cannot be
|
|
326
|
-
// created without it;
|
|
327
|
-
// · already on this backend → HELD, left as it is there: sent without a reference it
|
|
328
|
-
// has, it would lose that reference until the next pass.
|
|
329
|
-
// A held record is not sent and keeps the hash last banked for it, so the next pass
|
|
330
|
-
// sees it as changed.
|
|
331
|
-
const held = new Set()
|
|
332
|
-
const unplaced = new Set()
|
|
333
|
-
for (const e of col.entities) {
|
|
334
|
-
if (!e.pending?.length) continue
|
|
335
|
-
if (e.uuid) held.add(e.id)
|
|
336
|
-
else if (e.pending.some((p) => p.required)) {
|
|
337
|
-
held.add(e.id)
|
|
338
|
-
unplaced.add(e.id)
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
const waiting = col.entities
|
|
342
|
-
.filter((e) => e.pending?.length)
|
|
343
|
-
.map((e) => ({ id: e.id, model: e.model, slug: e.slug, held: held.has(e.id), pending: e.pending }))
|
|
344
|
-
const placedEntities = []
|
|
345
|
-
const placedIndex = []
|
|
346
|
-
col.entities.forEach((e, i) => {
|
|
347
|
-
if (unplaced.has(e.id)) return
|
|
348
|
-
placedEntities.push(e)
|
|
349
|
-
placedIndex.push(col.index[i])
|
|
350
|
-
})
|
|
351
|
-
|
|
352
306
|
// The folder rides over the FULL record set (before filtering) so its references
|
|
353
307
|
// are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
|
|
354
308
|
const folder = buildFolderEntity({
|
|
355
|
-
recordEntities:
|
|
309
|
+
recordEntities: col.entities,
|
|
356
310
|
...(sourceLocale ? { sourceLocale } : {}),
|
|
357
311
|
// ⭐ Every record in the directory, at the top of the folder or in the
|
|
358
312
|
// sub-folder `folder.yml` places it in — and NOTHING when there is no folder to
|
|
@@ -360,7 +314,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
360
314
|
// non-empty tree: a `folder.yml` whose folders hold only records that resolved
|
|
361
315
|
// no schema is a tree of empty branches, and sending it would replace the
|
|
362
316
|
// backend's folder with them (measured on the `dynamic` template, 2026-09-21).
|
|
363
|
-
folderNodes: col.sendFolder === true ?
|
|
317
|
+
folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],
|
|
364
318
|
// ⛔ Whether the file side HAS a folder to state (`records.js::sendsFolder`) —
|
|
365
319
|
// not whether it holds anything. A site with no records directory sends none
|
|
366
320
|
// and leaves the backend's alone; an empty directory sends an empty folder,
|
|
@@ -518,21 +472,22 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
518
472
|
// changed() has side effects (hashes/skipped), so evaluate every entity exactly
|
|
519
473
|
// once, in a stable order: folder, then each record.
|
|
520
474
|
const folderChanged = folder ? changed(folder) : false
|
|
521
|
-
|
|
522
|
-
const keepBanked = (entity) => {
|
|
523
|
-
const key = cacheKey(entity)
|
|
524
|
-
if (priorHashes[key] !== undefined) hashes[key] = priorHashes[key]
|
|
525
|
-
return false
|
|
526
|
-
}
|
|
527
|
-
const recordChanged = placedEntities.map((e, i) => ({
|
|
475
|
+
const recordChanged = col.entities.map((e, i) => ({
|
|
528
476
|
entity: e,
|
|
529
|
-
index:
|
|
530
|
-
changed:
|
|
477
|
+
index: col.index[i],
|
|
478
|
+
changed: changed(e),
|
|
531
479
|
}))
|
|
532
480
|
const changedRecords = recordChanged.filter((r) => r.changed)
|
|
533
481
|
|
|
534
482
|
let records = null
|
|
483
|
+
// ⭐ A RECORD THAT NAMES A RECORD THIS PUSH CREATES names it by `$ref`
|
|
484
|
+
// (`records.js::encodeReference`), and the backend stores the uuid it mints for it. The
|
|
485
|
+
// next push sends that uuid: the same reference, another encoding — and another hash.
|
|
486
|
+
// So the caller re-banks these records once the push is applied, or the next push
|
|
487
|
+
// sends every one of them again.
|
|
488
|
+
let namesNew = []
|
|
535
489
|
if (folder && (folderChanged || changedRecords.length > 0)) {
|
|
490
|
+
namesNew = changedRecords.filter((r) => r.entity.namesNew > 0).map((r) => cacheKey(r.entity))
|
|
536
491
|
// Folder first (always, for the `$ref` closure), then changed records. The
|
|
537
492
|
// leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
|
|
538
493
|
// back-fill (backfillEntityUuids skips it — the folder has no uuid to write).
|
|
@@ -582,9 +537,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
582
537
|
|
|
583
538
|
return {
|
|
584
539
|
siteContent, records, siteContentUuid, hashes, warnings, skipped,
|
|
585
|
-
// Records
|
|
586
|
-
|
|
587
|
-
waiting,
|
|
540
|
+
// Records that name a record this push creates — see `namesNew` above.
|
|
541
|
+
namesNew,
|
|
588
542
|
refusals: col.refusals || [],
|
|
589
543
|
schemaless: col.schemaless, localAssets, applied,
|
|
590
544
|
// { stamped, unknown } when identity was applied; null when the caller passed
|