@uniweb/build 0.63.0 → 0.63.1
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 +1 -1
- package/src/site/queries-config.js +9 -5
- package/src/uwx/data-key-types.js +53 -0
- package/src/uwx/index.js +9 -0
- package/src/uwx/queries-config.js +1 -0
- package/src/uwx/record-items.js +312 -0
- package/src/uwx/records-project.js +94 -17
- package/src/uwx/records.js +51 -7
- package/src/uwx/self-scope.js +66 -6
- package/src/uwx/site.js +6 -3
- package/src/uwx/sync-package.js +49 -2
- package/src/uwx/sync-store.js +7 -3
package/package.json
CHANGED
|
@@ -361,7 +361,7 @@ async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
|
|
|
361
361
|
)
|
|
362
362
|
if (pending.length === 0) return
|
|
363
363
|
|
|
364
|
-
const dataSchemas =
|
|
364
|
+
const dataSchemas = foundationDataSchemas(siteRoot, siteYml)
|
|
365
365
|
if (!dataSchemas) return
|
|
366
366
|
|
|
367
367
|
for (const decl of pending) {
|
|
@@ -447,11 +447,15 @@ function localFoundationSrcDir(siteRoot, siteYml) {
|
|
|
447
447
|
|
|
448
448
|
/** The data schemas a site's foundation declares, or null when unresolvable. */
|
|
449
449
|
export function foundationDataSchemas(siteRoot, siteYml) {
|
|
450
|
-
return
|
|
450
|
+
return foundationSchemaJson(siteRoot, siteYml)?.dataSchemas || null
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
-
/**
|
|
454
|
-
|
|
453
|
+
/**
|
|
454
|
+
* A site's foundation's built `schema.json` whole — its section types (each with its `data:`)
|
|
455
|
+
* beside `_self` and `dataSchemas` — or null when the foundation is not in the project or not
|
|
456
|
+
* built.
|
|
457
|
+
*/
|
|
458
|
+
export function foundationSchemaJson(siteRoot, siteYml) {
|
|
455
459
|
if (!siteYml?.foundation) return null
|
|
456
460
|
let info
|
|
457
461
|
try {
|
|
@@ -463,7 +467,7 @@ function loadFoundationDataSchemas(siteRoot, siteYml) {
|
|
|
463
467
|
const schemaPath = join(info.path, 'dist', 'meta', 'schema.json')
|
|
464
468
|
if (!existsSync(schemaPath)) return null
|
|
465
469
|
try {
|
|
466
|
-
return JSON.parse(readFileSync(schemaPath, 'utf8'))
|
|
470
|
+
return JSON.parse(readFileSync(schemaPath, 'utf8')) || null
|
|
467
471
|
} catch {
|
|
468
472
|
return null
|
|
469
473
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ⭐ A NAME THAT IS A DATA KEY, NOT A DATA SCHEMA — its records are of the key's type.
|
|
2
|
+
//
|
|
3
|
+
// [Diego, 2026-09-25] — *"if team is just a data key for the `@std/member` type and that type
|
|
4
|
+
// has a schema, it should not be understood as missing a schema and becoming static data."*
|
|
5
|
+
//
|
|
6
|
+
// A query that declares no `schema:` takes its own name (`team` → `@/team`), and so does its
|
|
7
|
+
// records folder (`records/team/`). When no data schema has that name, but the foundation's
|
|
8
|
+
// section types declare the data key of that name with a type — `data: { team: '@/member' }` in a
|
|
9
|
+
// `meta.js` — the records ARE of that type: a push sends them as its entities, and the query names
|
|
10
|
+
// it on the wire. ⛔ Until 2026-09-25 they were read as having no data schema at all and went out
|
|
11
|
+
// as static files, although the one section that reads them declared what they are.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ THIS DECIDES ONLY WHICH DATA SCHEMA THE RECORDS BELONG TO. Where they live on disk, how the
|
|
14
|
+
// site builds them and how their translations are keyed stay keyed by the name (`team`), as the
|
|
15
|
+
// author wrote it — the records are where the author put them, and the query still reads them.
|
|
16
|
+
//
|
|
17
|
+
// ⛔ A key two section types declare with different types names no type: nothing says which.
|
|
18
|
+
|
|
19
|
+
import { declaredKeys } from '@uniweb/core/data-keys'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The type each data key of a foundation's section types names, where every declaration of the
|
|
23
|
+
* key agrees on one — from its built `schema.json`, whose section types sit at the top level
|
|
24
|
+
* beside `_self` and `dataSchemas`, each with its `data:`.
|
|
25
|
+
*
|
|
26
|
+
* @param {object|null} foundationSchema - a foundation's built `dist/meta/schema.json`
|
|
27
|
+
* @returns {Map<string, string>} data key → its schema ref (`team` → `@/member`)
|
|
28
|
+
*/
|
|
29
|
+
export function dataKeyTypes(foundationSchema) {
|
|
30
|
+
const types = new Map()
|
|
31
|
+
const conflicted = new Set()
|
|
32
|
+
for (const [name, entry] of Object.entries(foundationSchema || {})) {
|
|
33
|
+
if (name === '_self' || name === 'dataSchemas' || !entry || typeof entry !== 'object') continue
|
|
34
|
+
for (const [key, ref] of declaredKeys(entry.data)) {
|
|
35
|
+
if (!ref) continue
|
|
36
|
+
if (types.has(key) && types.get(key) !== ref) conflicted.add(key)
|
|
37
|
+
else types.set(key, ref)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const key of conflicted) types.delete(key)
|
|
41
|
+
return types
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The data key a name-defaulted ref stands for — `@/team` → `team` — or null for any other ref.
|
|
46
|
+
*
|
|
47
|
+
* @param {unknown} ref
|
|
48
|
+
* @returns {string|null}
|
|
49
|
+
*/
|
|
50
|
+
export function keyOfDefaultRef(ref) {
|
|
51
|
+
const m = typeof ref === 'string' ? /^@\/([^/]+)$/.exec(ref) : null
|
|
52
|
+
return m ? m[1] : null
|
|
53
|
+
}
|
package/src/uwx/index.js
CHANGED
|
@@ -57,6 +57,15 @@ export { buildFolderEntity,
|
|
|
57
57
|
collectFolderItemUuids,
|
|
58
58
|
stampFolderItemUuids
|
|
59
59
|
} from './folder.js'
|
|
60
|
+
// The identity of a record's list items — banked per record from what a push sent and
|
|
61
|
+
// what came back, stamped on the next send.
|
|
62
|
+
export {
|
|
63
|
+
recordItemLists,
|
|
64
|
+
harvestRecordItems,
|
|
65
|
+
storedRecordItems,
|
|
66
|
+
stampRecordItems,
|
|
67
|
+
reprintRecordItems
|
|
68
|
+
} from './record-items.js'
|
|
60
69
|
export {
|
|
61
70
|
recordsToProject,
|
|
62
71
|
declarationsToQueriesYml,
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// The identity of a record's LIST ITEMS — what a push must remember so that the next send of a
|
|
2
|
+
// record updates its stored items instead of replacing them.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ AN ITEM OF A LIST WITHOUT A `$uuid` IS A NEW ROW. A record's `many` sections are `multi`
|
|
5
|
+
// sections on the backend, so a record re-sent with its list items uuid-less would insert them all
|
|
6
|
+
// and delete every stored one. The backend refuses that, whole (`identity_required`, naming the
|
|
7
|
+
// section) — measured 2026-09-25 on an edit of a talk whose `sessions` it held. A single section
|
|
8
|
+
// needs nothing: its one item is matched by section. So every LIST of a record carries its items'
|
|
9
|
+
// `$uuid`s once the backend holds them: a `many` section, a list nested in a section or in an
|
|
10
|
+
// item, and a self-nesting list's `$children`, at every depth.
|
|
11
|
+
//
|
|
12
|
+
// ⭐ BANKED PER RECORD, FROM WHAT WAS SENT AND WHAT CAME BACK. An item has no file and no name, so
|
|
13
|
+
// nothing on disk can carry its identity — the fourth kind in the producer's ledger, beside
|
|
14
|
+
// site-content units, records and folder placements. The backend's response mirrors the package
|
|
15
|
+
// item for item ("the producer walks both trees in lockstep"), so a push pairs each item it sent
|
|
16
|
+
// with the `$uuid` returned at the same place, and banks both: that uuid, and a fingerprint of the
|
|
17
|
+
// item as it was sent (`harvestRecordItems`). A pull pairs the same way — the files it writes hold
|
|
18
|
+
// the stored items in stored order.
|
|
19
|
+
//
|
|
20
|
+
// ⭐ MATCHED BY CONTENT, THEN BY PLACE (`stampRecordItems`). The next send gives an item the uuid of
|
|
21
|
+
// the banked item with the same fingerprint — its own fields unchanged, wherever it moved — and
|
|
22
|
+
// otherwise the one banked at its own position: an item edited where it stands. An item's own lists
|
|
23
|
+
// are then matched the same way under the item it took. Anything else goes without one, as
|
|
24
|
+
// a new row, and a banked item nothing claims is left out of the send, which deletes it. ⛔ No uuid
|
|
25
|
+
// is stamped twice: two items claiming one row is the failure identity exists to prevent.
|
|
26
|
+
//
|
|
27
|
+
// THE BANK IS FLAT, one line per item — it is committed, in `sync.json`: each item under its
|
|
28
|
+
// place in the record, its value its `$uuid` and, when known, its fingerprint —
|
|
29
|
+
// `"sessions[0]": "<uuid> <fingerprint>"`. A list's place is its path within its container, the
|
|
30
|
+
// same on both sides: `sessions` for a `many` section, `details.notes` for a list inside a single
|
|
31
|
+
// section, `printings` inside an item, `$children` for the items nested under an item of a
|
|
32
|
+
// self-nesting list — so `parts[0].$children[1]` is the second part nested under the first.
|
|
33
|
+
|
|
34
|
+
import { entityContentHash } from './records.js'
|
|
35
|
+
|
|
36
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
37
|
+
|
|
38
|
+
// An item's OWN content: its fields, with the lists nested in it left out — they are matched
|
|
39
|
+
// on their own, under it. ⛔ Not the item whole: a parent that moved while one of its children
|
|
40
|
+
// changed then matches nothing, and it comes back new with its whole subtree (measured
|
|
41
|
+
// 2026-09-25 on a tree reordered while a nested part was renamed).
|
|
42
|
+
function ownContent(def, value) {
|
|
43
|
+
if (!isPlainObject(value)) return value
|
|
44
|
+
const out = {}
|
|
45
|
+
for (const [key, v] of Object.entries(value)) {
|
|
46
|
+
if (key === '$children' && def?.self_nesting === true) continue
|
|
47
|
+
const field = def?.fields?.[key]
|
|
48
|
+
if (field?.type === 'section') {
|
|
49
|
+
if (field.multiple === true) continue
|
|
50
|
+
out[key] = ownContent(field, v)
|
|
51
|
+
} else {
|
|
52
|
+
out[key] = v
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// An item's own content in 16 hex characters — `$uuid`s aside, like every content hash of ours.
|
|
59
|
+
const fingerprint = (def, item) => entityContentHash(ownContent(def, item)).slice(0, 16)
|
|
60
|
+
|
|
61
|
+
// Each list of a container — a single section's value, or one item of a list — by its path.
|
|
62
|
+
function eachList(def, value, prefix, visit) {
|
|
63
|
+
if (!isPlainObject(value)) return
|
|
64
|
+
for (const [key, field] of Object.entries(def?.fields || {})) {
|
|
65
|
+
if (field?.type !== 'section') continue
|
|
66
|
+
if (field.multiple === true) {
|
|
67
|
+
if (Array.isArray(value[key])) visit(`${prefix}${key}`, field, value[key])
|
|
68
|
+
} else {
|
|
69
|
+
eachList(field, value[key], `${prefix}${key}.`, visit)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (def?.self_nesting === true && Array.isArray(value.$children)) {
|
|
73
|
+
visit(`${prefix}$children`, def, value.$children)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Each list of a record document: its `many` sections, and the lists inside its single ones.
|
|
78
|
+
function eachRecordList(declaration, document, visit) {
|
|
79
|
+
for (const [name, def] of Object.entries(declaration?.sections || {})) {
|
|
80
|
+
if (!def || typeof def !== 'object') continue
|
|
81
|
+
const value = document?.[name]
|
|
82
|
+
if (def.multiple === true) {
|
|
83
|
+
if (Array.isArray(value)) visit(name, def, value)
|
|
84
|
+
} else {
|
|
85
|
+
eachList(def, value, `${name}.`, visit)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function itemsOf(def, items) {
|
|
91
|
+
return items.map((item) => {
|
|
92
|
+
const node = { h: fingerprint(def, item) }
|
|
93
|
+
const nested = {}
|
|
94
|
+
eachList(def, item, '', (path, d, list) => {
|
|
95
|
+
nested[path] = itemsOf(d, list)
|
|
96
|
+
})
|
|
97
|
+
if (Object.keys(nested).length) node.c = nested
|
|
98
|
+
return node
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The lists of one record document, as sent: each list's items by path, each item its
|
|
104
|
+
* fingerprint (`h`) and its own lists (`c`). What `harvestRecordItems` pairs with the
|
|
105
|
+
* document the backend returns. Not stored — a bank is (`harvestRecordItems`).
|
|
106
|
+
*
|
|
107
|
+
* @param {object} document - the record's `$`-document
|
|
108
|
+
* @param {object} declaration - its data schema's declaration (`{ name, sections }`)
|
|
109
|
+
* @returns {Object<string, Array<{ h: string, c?: object }>>} empty when the record has no list
|
|
110
|
+
*/
|
|
111
|
+
export function recordItemLists(document, declaration) {
|
|
112
|
+
const lists = {}
|
|
113
|
+
eachRecordList(declaration, document, (path, def, items) => {
|
|
114
|
+
lists[path] = itemsOf(def, items)
|
|
115
|
+
})
|
|
116
|
+
return lists
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether a record's lists hold any item at all. */
|
|
120
|
+
export function hasListItems(lists) {
|
|
121
|
+
return Object.values(lists || {}).some((items) => items.length > 0)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const valueAt = (value, path) =>
|
|
125
|
+
path.split('.').reduce((v, key) => (isPlainObject(v) ? v[key] : undefined), value)
|
|
126
|
+
|
|
127
|
+
const uuidOf = (item) => (isPlainObject(item) && typeof item.$uuid === 'string' && item.$uuid ? item.$uuid : null)
|
|
128
|
+
|
|
129
|
+
function pairItems(ours, theirs, at, bank) {
|
|
130
|
+
// Paired by position: the backend returns each list as it was sent. A list that came back
|
|
131
|
+
// with another length is still paired, but without fingerprints, so the next send matches
|
|
132
|
+
// its items by place alone.
|
|
133
|
+
const exact = ours.length === theirs.length
|
|
134
|
+
theirs.forEach((item, k) => {
|
|
135
|
+
const uuid = uuidOf(item)
|
|
136
|
+
if (!uuid) return
|
|
137
|
+
const place = `${at}[${k}]`
|
|
138
|
+
bank[place] = exact ? `${uuid} ${ours[k].h}` : uuid
|
|
139
|
+
for (const [path, sub] of Object.entries(ours[k]?.c || {})) {
|
|
140
|
+
const list = valueAt(item, path)
|
|
141
|
+
if (Array.isArray(list)) pairItems(sub, list, `${place}.${path}`, bank)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The bank entry for one record: each list item's `$uuid` in the document the backend
|
|
148
|
+
* returned (a push's finalized document, or a pull's), with its fingerprint as sent, paired
|
|
149
|
+
* by place with the lists as sent (`recordItemLists`).
|
|
150
|
+
*
|
|
151
|
+
* @param {object} lists - `recordItemLists` of the document as sent
|
|
152
|
+
* @param {object} returned - the backend's document for the same record
|
|
153
|
+
* @returns {Object<string, string>} place → `"<uuid> <fingerprint>"` (or `"<uuid>"`)
|
|
154
|
+
*/
|
|
155
|
+
export function harvestRecordItems(lists, returned) {
|
|
156
|
+
const bank = {}
|
|
157
|
+
for (const [path, ours] of Object.entries(lists || {})) {
|
|
158
|
+
const theirs = valueAt(returned, path)
|
|
159
|
+
if (Array.isArray(theirs)) pairItems(ours, theirs, path, bank)
|
|
160
|
+
}
|
|
161
|
+
return bank
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A list in the backend's document: an array of items that each carry a `$uuid`.
|
|
165
|
+
const isStoredList = (v) => Array.isArray(v) && v.length > 0 && v.every((item) => uuidOf(item))
|
|
166
|
+
|
|
167
|
+
function storedLists(container, prefix, bank) {
|
|
168
|
+
for (const [key, value] of Object.entries(container || {})) {
|
|
169
|
+
if (key.startsWith('$') && key !== '$children') continue
|
|
170
|
+
if (isStoredList(value)) {
|
|
171
|
+
value.forEach((item, k) => {
|
|
172
|
+
const place = `${prefix}${key}[${k}]`
|
|
173
|
+
bank[place] = item.$uuid
|
|
174
|
+
storedLists(item, `${place}.`, bank)
|
|
175
|
+
})
|
|
176
|
+
} else if (uuidOf(value)) {
|
|
177
|
+
// A section: its lists sit under its name.
|
|
178
|
+
storedLists(value, `${prefix}${key}.`, bank)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The bank entry for one record read from the backend's document ALONE — for a record whose
|
|
185
|
+
* items were never banked, like one pushed by a CLI from before this. No fingerprints: what
|
|
186
|
+
* was sent is unknown, so the next send matches each item by place.
|
|
187
|
+
*
|
|
188
|
+
* Read by shape: a list is an array whose every item carries a `$uuid`, and a section is an
|
|
189
|
+
* object carrying one — a reference is a string or an `{ schema, entity }`, and a localized
|
|
190
|
+
* value a `{ lang: … }` map, neither of which does.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} returned - the backend's document for one record
|
|
193
|
+
* @returns {Object<string, string>} place → `"<uuid>"`
|
|
194
|
+
*/
|
|
195
|
+
export function storedRecordItems(returned) {
|
|
196
|
+
const bank = {}
|
|
197
|
+
storedLists(returned, '', bank)
|
|
198
|
+
return bank
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The bank's items grouped by list: list path → { index → { u, h, place } }.
|
|
202
|
+
function bankedLists(bank) {
|
|
203
|
+
const lists = new Map()
|
|
204
|
+
for (const [place, value] of Object.entries(bank || {})) {
|
|
205
|
+
const m = /^(.*)\[(\d+)\]$/.exec(place)
|
|
206
|
+
if (!m || typeof value !== 'string') continue
|
|
207
|
+
const [u, h] = value.split(' ')
|
|
208
|
+
if (!u) continue
|
|
209
|
+
if (!lists.has(m[1])) lists.set(m[1], [])
|
|
210
|
+
lists.get(m[1])[Number(m[2])] = { u, h: h || null, place }
|
|
211
|
+
}
|
|
212
|
+
return lists
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Which banked item each item takes: the same fingerprint first, then the same place.
|
|
216
|
+
function matchItems(prints, banked) {
|
|
217
|
+
const assigned = new Array(prints.length).fill(null)
|
|
218
|
+
const taken = new Set()
|
|
219
|
+
const byPrint = new Map()
|
|
220
|
+
banked.forEach((b, j) => {
|
|
221
|
+
if (!b?.h) return
|
|
222
|
+
if (!byPrint.has(b.h)) byPrint.set(b.h, [])
|
|
223
|
+
byPrint.get(b.h).push(j)
|
|
224
|
+
})
|
|
225
|
+
prints.forEach((h, i) => {
|
|
226
|
+
const queue = byPrint.get(h)
|
|
227
|
+
while (queue?.length) {
|
|
228
|
+
const j = queue.shift()
|
|
229
|
+
if (taken.has(j)) continue
|
|
230
|
+
assigned[i] = banked[j]
|
|
231
|
+
taken.add(j)
|
|
232
|
+
break
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
prints.forEach((_, i) => {
|
|
236
|
+
if (assigned[i] || taken.has(i) || !banked[i]) return
|
|
237
|
+
assigned[i] = banked[i]
|
|
238
|
+
taken.add(i)
|
|
239
|
+
})
|
|
240
|
+
return assigned
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function stampList(def, items, at, lists, counts, claimed) {
|
|
244
|
+
const banked = (at !== null && lists.get(at)) || []
|
|
245
|
+
const assigned = matchItems(items.map((item) => fingerprint(def, item)), banked)
|
|
246
|
+
items.forEach((item, i) => {
|
|
247
|
+
const b = assigned[i]
|
|
248
|
+
if (b && !claimed.has(b.u)) {
|
|
249
|
+
item.$uuid = b.u
|
|
250
|
+
claimed.add(b.u)
|
|
251
|
+
counts.stamped++
|
|
252
|
+
} else {
|
|
253
|
+
counts.unknown++
|
|
254
|
+
}
|
|
255
|
+
// An item's own lists are banked under the place of the item it matched — a new item's
|
|
256
|
+
// nested items are new too.
|
|
257
|
+
const under = b && item.$uuid === b.u ? b.place : null
|
|
258
|
+
eachList(def, item, '', (path, d, list) =>
|
|
259
|
+
stampList(d, list, under === null ? null : `${under}.${path}`, lists, counts, claimed)
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Stamp a record document about to be sent with its list items' banked `$uuid`s.
|
|
266
|
+
*
|
|
267
|
+
* @param {object} document - the record's `$`-document; stamped in place
|
|
268
|
+
* @param {object} declaration - its data schema's declaration
|
|
269
|
+
* @param {Object<string,string>} bank - this record's entry (`harvestRecordItems` /
|
|
270
|
+
* `storedRecordItems`)
|
|
271
|
+
* @returns {{ stamped: number, unknown: number }} items given a uuid, and items sent as new
|
|
272
|
+
*/
|
|
273
|
+
export function stampRecordItems(document, declaration, bank) {
|
|
274
|
+
const counts = { stamped: 0, unknown: 0 }
|
|
275
|
+
const claimed = new Set()
|
|
276
|
+
const lists = bankedLists(bank)
|
|
277
|
+
eachRecordList(declaration, document, (path, def, items) => {
|
|
278
|
+
stampList(def, items, path, lists, counts, claimed)
|
|
279
|
+
})
|
|
280
|
+
return counts
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function flatPrints(lists, at, out) {
|
|
284
|
+
for (const [path, items] of Object.entries(lists || {})) {
|
|
285
|
+
items.forEach((node, k) => {
|
|
286
|
+
const place = `${at}${path}[${k}]`
|
|
287
|
+
out[place] = node.h
|
|
288
|
+
flatPrints(node.c, `${place}.`, out)
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
return out
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A bank entry with its fingerprints taken again from the record as the NEXT send will
|
|
296
|
+
* build it, each uuid kept at its place. For a record that named a new record by `$ref`:
|
|
297
|
+
* once that record is minted the next send names it by uuid, so an item holding such a
|
|
298
|
+
* reference is fingerprinted anew — the same item, another encoding.
|
|
299
|
+
*
|
|
300
|
+
* @param {Object<string,string>} bank - this record's entry
|
|
301
|
+
* @param {object} lists - `recordItemLists` of the record as the next send builds it
|
|
302
|
+
* @returns {Object<string,string>}
|
|
303
|
+
*/
|
|
304
|
+
export function reprintRecordItems(bank, lists) {
|
|
305
|
+
const prints = flatPrints(lists, '', {})
|
|
306
|
+
const out = {}
|
|
307
|
+
for (const [place, value] of Object.entries(bank || {})) {
|
|
308
|
+
const [u] = String(value).split(' ')
|
|
309
|
+
out[place] = prints[place] ? `${u} ${prints[place]}` : value
|
|
310
|
+
}
|
|
311
|
+
return out
|
|
312
|
+
}
|
|
@@ -41,11 +41,12 @@ import yaml from 'js-yaml'
|
|
|
41
41
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
42
42
|
import { parseFrontmatter } from './entity-source.js'
|
|
43
43
|
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
44
|
-
import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
|
|
45
|
-
import {
|
|
44
|
+
import { defaultSchema, deferredFromSchema, foundationDataSchemas, foundationSchemaJson } from './queries-config.js'
|
|
45
|
+
import { dataKeyTypes } from './data-key-types.js'
|
|
46
|
+
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
46
47
|
import { folderYmlPath } from '../site/records-config.js'
|
|
47
48
|
import { contentBodyTarget } from './record-layout.js'
|
|
48
|
-
import { unresolveSelfScope, refuseOrgOption } from './self-scope.js'
|
|
49
|
+
import { unresolveSelfScope, resolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
|
|
49
50
|
import { parseCatalogRef } from '../site/foundation-ref.js'
|
|
50
51
|
import { unwrapLocalized } from './backfill.js'
|
|
51
52
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
@@ -153,11 +154,62 @@ function indexFolder(folderDoc) {
|
|
|
153
154
|
// `schemaForPoolDirs`, and deliberately not a second rule: if the two disagreed,
|
|
154
155
|
// a pulled record would land somewhere the next build reads as a different
|
|
155
156
|
// schema — silently, because both paths are well-formed.
|
|
156
|
-
function recordDirFor(recordsRoot, model, scope) {
|
|
157
|
-
const dirs = poolDirsForSchema(unresolveSelfScope(model, scope))
|
|
157
|
+
function recordDirFor(recordsRoot, model, scope, own) {
|
|
158
|
+
const dirs = poolDirsForSchema(unresolveSelfScope(model, scope, own))
|
|
158
159
|
return dirs ? join(recordsRoot, ...dirs) : null
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
// ⭐ A PULLED RECORD GOES BACK INTO THE FILE THAT HOLDS IT — wherever under the records
|
|
163
|
+
// directory that is, so long as its folder reads as the same Model. Two folders can:
|
|
164
|
+
// under a foundation in `@std`, `records/person/` (`@/person`, qualified into `@std`) and
|
|
165
|
+
// `records/std/person/` both read as `@std/person`. ⛔ Looked for in the one folder the
|
|
166
|
+
// pull derived, as until 2026-09-25, a record the author kept in the other was written
|
|
167
|
+
// again beside it — two files, one record — and the next push was refused.
|
|
168
|
+
//
|
|
169
|
+
// ⭐ And a folder named for a data key the foundation types, when no data schema has that name,
|
|
170
|
+
// reads as that type — `records/team/` as `@std/member` for `data: { team: '@/member' }`
|
|
171
|
+
// (`data-key-types.js`), the same rule a push reads it by.
|
|
172
|
+
function recordDirsReadingAs(recordsRoot, model, scope, { own = null, keyTypes = null } = {}) {
|
|
173
|
+
const dirs = []
|
|
174
|
+
if (!existsSync(recordsRoot)) return dirs
|
|
175
|
+
const visible = (dir) =>
|
|
176
|
+
readdirSync(dir, { withFileTypes: true })
|
|
177
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
|
|
178
|
+
.map((e) => e.name)
|
|
179
|
+
for (const first of visible(recordsRoot)) {
|
|
180
|
+
const at = [first]
|
|
181
|
+
const keyType = !own?.has(first) ? keyTypes?.get(first) : null
|
|
182
|
+
if (
|
|
183
|
+
resolveSelfScope(schemaForPoolDirs(at), scope) === model ||
|
|
184
|
+
(keyType && resolveSelfScope(keyType, scope) === model)
|
|
185
|
+
) {
|
|
186
|
+
dirs.push(join(recordsRoot, first))
|
|
187
|
+
}
|
|
188
|
+
for (const second of visible(join(recordsRoot, first))) {
|
|
189
|
+
if (resolveSelfScope(schemaForPoolDirs([first, second]), scope) === model) {
|
|
190
|
+
dirs.push(join(recordsRoot, first, second))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return dirs
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// What the site's foundation declares, when its declarations are in this project: its own
|
|
198
|
+
// data-schema names (`ownSchemaNames`) and the types of its data keys (`dataKeyTypes`). Both
|
|
199
|
+
// null otherwise — a clone's foundation is not in the project.
|
|
200
|
+
function siteFoundationDeclarations(siteRoot) {
|
|
201
|
+
let siteYml = null
|
|
202
|
+
try {
|
|
203
|
+
siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || null
|
|
204
|
+
} catch {
|
|
205
|
+
return { own: null, keyTypes: null }
|
|
206
|
+
}
|
|
207
|
+
const schema = siteYml ? foundationSchemaJson(siteRoot, siteYml) : null
|
|
208
|
+
return schema
|
|
209
|
+
? { own: ownSchemaNames(schema.dataSchemas || {}), keyTypes: dataKeyTypes(schema) }
|
|
210
|
+
: { own: null, keyTypes: null }
|
|
211
|
+
}
|
|
212
|
+
|
|
161
213
|
// ⚠️ Undoing the producer's self-scope resolution (`unresolveSelfScope`) lives in
|
|
162
214
|
// `./self-scope.js`, beside the forward rule it inverts. It did not show before
|
|
163
215
|
// records were placed by their model: every record went to
|
|
@@ -231,7 +283,7 @@ function isDerivedDeferred(d, dataSchemas) {
|
|
|
231
283
|
return d.deferred.every((f) => a.has(f))
|
|
232
284
|
}
|
|
233
285
|
|
|
234
|
-
function declToFileShape(wire, dataSchemas = null, scope = null) {
|
|
286
|
+
function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null) {
|
|
235
287
|
// ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
|
|
236
288
|
// `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
|
|
237
289
|
// (`site.js::queriesNested`), and both checks below are keyed by the author's
|
|
@@ -239,7 +291,7 @@ function declToFileShape(wire, dataSchemas = null, scope = null) {
|
|
|
239
291
|
// explicit schema the author never had — and the derived-`deferred` lookup would
|
|
240
292
|
// miss, persisting a derivation into their file (the 2026-08-29 defect).
|
|
241
293
|
const d = scope && typeof wire.schema === 'string'
|
|
242
|
-
? { ...wire, schema: unresolveSelfScope(wire.schema, scope) }
|
|
294
|
+
? { ...wire, schema: unresolveSelfScope(wire.schema, scope, own) }
|
|
243
295
|
: wire
|
|
244
296
|
const name = d.name || d.$id
|
|
245
297
|
const decl = {}
|
|
@@ -262,7 +314,11 @@ function declToFileShape(wire, dataSchemas = null, scope = null) {
|
|
|
262
314
|
decl.source = source
|
|
263
315
|
}
|
|
264
316
|
|
|
265
|
-
|
|
317
|
+
// The default is the query's name — or, when no data schema has that name, the type the
|
|
318
|
+
// foundation declares for the data key of that name (`data-key-types.js`), which is what a
|
|
319
|
+
// push sent in its place. Either one stays unwritten, so the terse file stays terse.
|
|
320
|
+
const keyDefault = name && !own?.has(name) ? keyTypes?.get(name) : null
|
|
321
|
+
if (d.schema && d.schema !== defaultSchema(name) && d.schema !== keyDefault) decl.schema = d.schema
|
|
266
322
|
setIf(decl, 'sort', d.sort)
|
|
267
323
|
setIf(decl, 'where', d.where)
|
|
268
324
|
setIf(decl, 'limit', d.limit)
|
|
@@ -348,10 +404,14 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
|
|
|
348
404
|
const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
|
|
349
405
|
const selfScope =
|
|
350
406
|
scope !== undefined ? scope : (parseCatalogRef(document?.info?.foundation)?.scope ?? null)
|
|
407
|
+
// Which of the scope's schemas are the foundation's own — the rest keep their scope
|
|
408
|
+
// (`unresolveSelfScope`) — and the types of its data keys.
|
|
409
|
+
const own = ownSchemaNames(dataSchemas)
|
|
410
|
+
const keyTypes = siteYml ? dataKeyTypes(foundationSchemaJson(siteRoot, siteYml)) : null
|
|
351
411
|
|
|
352
412
|
const queries = {}
|
|
353
413
|
for (const d of decls) {
|
|
354
|
-
const { name, decl } = declToFileShape(d, dataSchemas, selfScope)
|
|
414
|
+
const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes)
|
|
355
415
|
if (!name) continue
|
|
356
416
|
queries[name] = decl
|
|
357
417
|
}
|
|
@@ -477,6 +537,10 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
477
537
|
// `@scope/x` model is placed back where the author wrote it. ⛔ It was the site
|
|
478
538
|
// owner's org, read from `sync.json`, until 2026-09-22 (`self-scope.js`).
|
|
479
539
|
const selfScope = opts.scope ?? null
|
|
540
|
+
// Which of that scope's Models are the foundation's own — the rest keep their scope, so
|
|
541
|
+
// a standard `@std/person` under a foundation in `@std` is placed in `records/std/person/`
|
|
542
|
+
// (`unresolveSelfScope`).
|
|
543
|
+
const { own, keyTypes } = siteFoundationDeclarations(siteRoot)
|
|
480
544
|
if (typeof resolveDeclaration !== 'function') {
|
|
481
545
|
throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
|
|
482
546
|
}
|
|
@@ -514,7 +578,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
514
578
|
restoreAssetRefs(recordDocs, backendState.assets || {})
|
|
515
579
|
// This backend's record map, inverted: its uuid → the record's own id.
|
|
516
580
|
const recordMap = backendState.records || {}
|
|
517
|
-
const ownIdByTheirs = new Map(Object.entries(recordMap).map(([
|
|
581
|
+
const ownIdByTheirs = new Map(Object.entries(recordMap).map(([ownId, theirs]) => [theirs, ownId]))
|
|
518
582
|
// own id → their uuid, for every record this pull wrote. Recorded at the end.
|
|
519
583
|
const learned = {}
|
|
520
584
|
// uuid → the path under `records/` the record landed at. Only this loop knows
|
|
@@ -522,6 +586,16 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
522
586
|
// re-derived (a second rule could pick a different extension and the folder
|
|
523
587
|
// would name a file that is not there).
|
|
524
588
|
const poolPathByUuid = new Map()
|
|
589
|
+
// The file holding a record already, by its own id: the derived folder first, then any
|
|
590
|
+
// other that reads as the same Model (`recordDirsReadingAs`).
|
|
591
|
+
const readingAs = (model) => recordDirsReadingAs(recordsRoot, model, selfScope, { own, keyTypes })
|
|
592
|
+
const findRecordFile = (poolDir, model, ownId) =>
|
|
593
|
+
findRecordFileByUuid(poolDir, ownId) ||
|
|
594
|
+
readingAs(model)
|
|
595
|
+
.filter((dir) => dir !== poolDir)
|
|
596
|
+
.map((dir) => findRecordFileByUuid(dir, ownId))
|
|
597
|
+
.find(Boolean) ||
|
|
598
|
+
null
|
|
525
599
|
|
|
526
600
|
for (const document of recordDocs) {
|
|
527
601
|
const where = locate(document, folderIndex)
|
|
@@ -536,7 +610,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
536
610
|
continue
|
|
537
611
|
}
|
|
538
612
|
|
|
539
|
-
const poolDir = recordDirFor(recordsRoot, schema, selfScope)
|
|
613
|
+
const poolDir = recordDirFor(recordsRoot, schema, selfScope, own)
|
|
540
614
|
if (!poolDir) {
|
|
541
615
|
skipped.push({
|
|
542
616
|
uuid: document.$uuid,
|
|
@@ -550,8 +624,8 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
550
624
|
// the first backend a record reaches minted the id it keeps — so the backend's
|
|
551
625
|
// uuid is the own id, exactly as before identity was keyed by backend.
|
|
552
626
|
const theirs = document.$uuid || null
|
|
553
|
-
const
|
|
554
|
-
const existing =
|
|
627
|
+
const ownId = theirs ? ownIdByTheirs.get(theirs) || theirs : null
|
|
628
|
+
const existing = ownId ? findRecordFile(poolDir, schema, ownId) : null
|
|
555
629
|
|
|
556
630
|
let filePath
|
|
557
631
|
let format
|
|
@@ -561,8 +635,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
561
635
|
format = existing.format
|
|
562
636
|
isNew = false
|
|
563
637
|
} else {
|
|
564
|
-
|
|
565
|
-
|
|
638
|
+
// A new record goes where the project already keeps records of its Model — its derived
|
|
639
|
+
// folder, else another that reads as the Model (`records/team/` for `@std/member`).
|
|
640
|
+
const home = existsSync(poolDir) ? poolDir : readingAs(schema)[0] || poolDir
|
|
641
|
+
format = defaultFormat(home, declaration)
|
|
642
|
+
filePath = join(home, where.slug + EXT_FOR_FORMAT[format])
|
|
566
643
|
isNew = true
|
|
567
644
|
}
|
|
568
645
|
|
|
@@ -572,7 +649,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
572
649
|
|
|
573
650
|
// ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
|
|
574
651
|
// backend must not overwrite the identity the record already has.
|
|
575
|
-
const toWrite =
|
|
652
|
+
const toWrite = ownId && ownId !== theirs ? { ...document, $uuid: ownId } : document
|
|
576
653
|
let status
|
|
577
654
|
try {
|
|
578
655
|
status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName })
|
|
@@ -591,7 +668,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
591
668
|
// terms, so that is what `folderToFolderYml` will look them up by.
|
|
592
669
|
if (theirs) {
|
|
593
670
|
poolPathByUuid.set(theirs, relative(recordsRoot, filePath).split(sep).join('/'))
|
|
594
|
-
if (
|
|
671
|
+
if (ownId) learned[ownId] = theirs
|
|
595
672
|
}
|
|
596
673
|
}
|
|
597
674
|
|
package/src/uwx/records.js
CHANGED
|
@@ -32,13 +32,14 @@
|
|
|
32
32
|
// reference or held back, and the CLI pushed again to complete it.
|
|
33
33
|
// ⚠️ item_ref and file fields are still sent as the file writes them.
|
|
34
34
|
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
35
|
+
// ⭐ A record of a `many` section — an ITEM of a record's list — carries the `$uuid` the backend
|
|
36
|
+
// gave it once the backend holds it, from a bank the CLI keeps per record (`record-items.js`,
|
|
37
|
+
// stamped in `sync-package.js`). Sent without it, a record whose list the backend holds is
|
|
38
|
+
// REFUSED, whole (`identity_required`): applied, it would re-identify every stored item —
|
|
39
|
+
// measured 2026-09-25 on an edit of a talk, before the bank existed.
|
|
40
40
|
|
|
41
41
|
import { readBackendState } from './sync-store.js'
|
|
42
|
+
import { dataKeyTypes, keyOfDefaultRef } from './data-key-types.js'
|
|
42
43
|
import { readFileSync, existsSync } from 'node:fs'
|
|
43
44
|
import yaml from 'js-yaml'
|
|
44
45
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
@@ -879,6 +880,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
879
880
|
folder: { ...folder, nodes: [] },
|
|
880
881
|
recordsDirExists: pool.exists,
|
|
881
882
|
sendFolder: sendsFolder(pool, []),
|
|
883
|
+
declarations: new Map(),
|
|
884
|
+
keyTyped: new Map(),
|
|
882
885
|
}
|
|
883
886
|
}
|
|
884
887
|
|
|
@@ -976,6 +979,24 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
976
979
|
)
|
|
977
980
|
}
|
|
978
981
|
|
|
982
|
+
// ⭐ A NAME THAT IS A DATA KEY, NOT A DATA SCHEMA [Diego, 2026-09-25] (`data-key-types.js`):
|
|
983
|
+
// a records folder or a query whose name-defaulted schema (`@/team`) resolves to nothing,
|
|
984
|
+
// while the foundation's section types type the data key of that name (`team: '@/member'`),
|
|
985
|
+
// holds records of that type. `keyTyped` records each such name for the `queries` Section,
|
|
986
|
+
// which must name the same data schema (`sync-package.js` hands it on). ⛔ Never for a schema
|
|
987
|
+
// a query asked for explicitly: that one is the author's, and fails loudly.
|
|
988
|
+
const keyTypes = dataKeyTypes(localSchema)
|
|
989
|
+
const keyTyped = new Map()
|
|
990
|
+
const typedFor = async (schema) => {
|
|
991
|
+
const key = keyOfDefaultRef(schema)
|
|
992
|
+
const type = key ? keyTypes.get(key) : null
|
|
993
|
+
if (!type || explicitBy.has(schema)) return null
|
|
994
|
+
const declaration = await declarationFor(modelFor(type, `data key "${key}"`))
|
|
995
|
+
if (!declaration) return null
|
|
996
|
+
keyTyped.set(schema, type)
|
|
997
|
+
return declaration
|
|
998
|
+
}
|
|
999
|
+
|
|
979
1000
|
const sourceLocale =
|
|
980
1001
|
opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
|
|
981
1002
|
|
|
@@ -1016,10 +1037,17 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1016
1037
|
// of another Model by its handle (`speaker: ada`), and the push sends that record's
|
|
1017
1038
|
// uuid in its place — or its `$id`, while it is new (`refResolver`).
|
|
1018
1039
|
const readSchemas = []
|
|
1040
|
+
// ⛔ ONE RECORD IN TWO FILES — the same own id, its `$uuid`, twice — is refused, naming
|
|
1041
|
+
// both. It is not two records, and sent, both would claim one entity. A pull could write
|
|
1042
|
+
// one until 2026-09-25: under a foundation in `@std`, a standard `@std/person` record was
|
|
1043
|
+
// written again into `records/person/`, beside the author's `records/std/person/`
|
|
1044
|
+
// (`self-scope.js::unresolveSelfScope`), and a push then reported only a name two
|
|
1045
|
+
// records share.
|
|
1046
|
+
const fileByOwnId = new Map()
|
|
1019
1047
|
for (const [schema, poolEntities] of poolBySchema) {
|
|
1020
1048
|
const label = poolEntities[0].dirs.join('/')
|
|
1021
1049
|
const modelName = modelFor(schema, `${pool.dir}/${label}/`)
|
|
1022
|
-
const declaration = await declarationFor(modelName)
|
|
1050
|
+
const declaration = (await declarationFor(modelName)) || (await typedFor(schema))
|
|
1023
1051
|
if (!declaration) {
|
|
1024
1052
|
// An explicit schema the author asked for is a hard error; one only the
|
|
1025
1053
|
// folder's name supplied is a soft skip, reported below.
|
|
@@ -1055,6 +1083,16 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1055
1083
|
// ⚠️ A multi-record file (array YAML, BibTeX) contributes several records
|
|
1056
1084
|
// from one path, so the slug — not the file stem — completes the id.
|
|
1057
1085
|
rec.$id = rec.$id || [...pooled.dirs, r.slug].join('/')
|
|
1086
|
+
const ownId = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
|
|
1087
|
+
const firstFile = ownId ? fileByOwnId.get(ownId) : undefined
|
|
1088
|
+
if (firstFile && firstFile !== pooled.relPath) {
|
|
1089
|
+
refusals.push(
|
|
1090
|
+
`${pooled.relPath}: holds the same record as ${firstFile} — both carry ` +
|
|
1091
|
+
`\`$uuid: ${ownId}\` — so a push would send one record twice. Keep one of them.`
|
|
1092
|
+
)
|
|
1093
|
+
} else if (ownId) {
|
|
1094
|
+
fileByOwnId.set(ownId, pooled.relPath)
|
|
1095
|
+
}
|
|
1058
1096
|
flat.push(rec)
|
|
1059
1097
|
sourceBySlug.set(r.slug, r)
|
|
1060
1098
|
produced.push({ id: rec.$id, slug: r.slug })
|
|
@@ -1145,7 +1183,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1145
1183
|
const hasRecords = poolBySchema.has(schema)
|
|
1146
1184
|
const resolved = hasRecords
|
|
1147
1185
|
? !unresolved.has(schema)
|
|
1148
|
-
: Boolean(await declarationFor(modelFor(schema, `query "${name}"`)))
|
|
1186
|
+
: Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema)))
|
|
1149
1187
|
if (!resolved) {
|
|
1150
1188
|
if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
|
|
1151
1189
|
schemaless.push({ name, model: resolveSelfScope(schema, scope) })
|
|
@@ -1183,6 +1221,12 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1183
1221
|
folder: { ...folder, nodes },
|
|
1184
1222
|
recordsDirExists: pool.exists,
|
|
1185
1223
|
sendFolder: sendsFolder(pool, entities),
|
|
1224
|
+
// Each entity's declaration, by its Model — what walks a record's lists
|
|
1225
|
+
// (`record-items.js`).
|
|
1226
|
+
declarations: new Map(readSchemas.map(({ declaration }) => [declaration.name, declaration])),
|
|
1227
|
+
// A name-defaulted schema that stands for the type of the data key of its name
|
|
1228
|
+
// (`@/team` → `@/member`) — see `typedFor` above.
|
|
1229
|
+
keyTyped,
|
|
1186
1230
|
}
|
|
1187
1231
|
}
|
|
1188
1232
|
|
package/src/uwx/self-scope.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { readFileSync } from 'node:fs'
|
|
34
34
|
import { join, resolve } from 'node:path'
|
|
35
35
|
import yaml from 'js-yaml'
|
|
36
|
+
import { isStandardSchema } from '@uniweb/schemas'
|
|
36
37
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
37
38
|
import { detectFoundationType, parseCatalogRef } from '../site/foundation-ref.js'
|
|
38
39
|
import { readFoundationName } from '../schema.js'
|
|
@@ -66,7 +67,8 @@ export function resolveSelfScope(ref, scope) {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
|
-
* The inverse, for a pull: `@<scope>/x` → `@/x`
|
|
70
|
+
* The inverse, for a pull: `@<scope>/x` → `@/x` — when `x` is one of the foundation's
|
|
71
|
+
* OWN data schemas.
|
|
70
72
|
*
|
|
71
73
|
* ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
|
|
72
74
|
* on both ends. A record authored under `records/article/` comes back as
|
|
@@ -76,17 +78,51 @@ export function resolveSelfScope(ref, scope) {
|
|
|
76
78
|
* relied on the query-name default comes back with an explicit schema it never
|
|
77
79
|
* had.
|
|
78
80
|
*
|
|
81
|
+
* ⛔ AND NOT EVERY `@<scope>/x` WAS AN `@/x`: the forward rule is not one-to-one. A
|
|
82
|
+
* foundation in `@std` — every template's — whose site also uses the STANDARD
|
|
83
|
+
* `@std/person` ships both its own `@/specimen` and that `@std/person` as `@std/…`.
|
|
84
|
+
* Inverted whole, as it was until 2026-09-25, the standard schema came back as the
|
|
85
|
+
* foundation's own: a clone put `@std/person` records in `records/person/` and
|
|
86
|
+
* rewrote the query to `@/person`, and a pull into a working copy wrote them BESIDE
|
|
87
|
+
* the author's `records/std/person/`, after which every reference to a person named
|
|
88
|
+
* two records and the next push was refused. So `x` is written back as `@/x` only
|
|
89
|
+
* when it is the foundation's own: one of `own`, the names its declarations define
|
|
90
|
+
* (`ownSchemaNames`) — or, when those cannot be read here (its foundation is not in
|
|
91
|
+
* this project, as after a clone), any name that is not a standard schema of `@std`.
|
|
92
|
+
*
|
|
79
93
|
* A model scoped to ANY OTHER scope is left alone: it genuinely is that org's, and
|
|
80
94
|
* `@/` would be a lie.
|
|
81
95
|
*
|
|
82
96
|
* @param {unknown} ref
|
|
83
97
|
* @param {unknown} scope - the scope the pulled records were qualified with
|
|
98
|
+
* @param {Set<string>|null} [own] - the names of the data schemas the foundation
|
|
99
|
+
* defines, when known
|
|
84
100
|
* @returns {unknown}
|
|
85
101
|
*/
|
|
86
|
-
export function unresolveSelfScope(ref, scope) {
|
|
102
|
+
export function unresolveSelfScope(ref, scope, own = null) {
|
|
87
103
|
const handle = bareOrg(scope)
|
|
88
|
-
if (typeof ref !== 'string' || !handle) return ref
|
|
89
|
-
|
|
104
|
+
if (typeof ref !== 'string' || !handle || !ref.startsWith(`@${handle}/`)) return ref
|
|
105
|
+
const name = ref.slice(handle.length + 2)
|
|
106
|
+
const isOwn = own ? own.has(name) : !(handle === 'std' && isStandardSchema(name))
|
|
107
|
+
return isOwn ? `@/${name}` : ref
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The names of the data schemas a foundation DEFINES — the `@/x` keys of its built
|
|
112
|
+
* declarations (`dist/meta/schema.json` → `dataSchemas`), which is what `register`
|
|
113
|
+
* registers. A schema it only references keeps its scope there (`@std/person`) and is
|
|
114
|
+
* not one of them. Null when there are no declarations to read.
|
|
115
|
+
*
|
|
116
|
+
* @param {object|null} dataSchemas - a foundation's `dataSchemas` map
|
|
117
|
+
* @returns {Set<string>|null}
|
|
118
|
+
*/
|
|
119
|
+
export function ownSchemaNames(dataSchemas) {
|
|
120
|
+
if (!dataSchemas || typeof dataSchemas !== 'object') return null
|
|
121
|
+
return new Set(
|
|
122
|
+
Object.keys(dataSchemas)
|
|
123
|
+
.filter((ref) => ref.startsWith('@/'))
|
|
124
|
+
.map((ref) => ref.slice(2))
|
|
125
|
+
)
|
|
90
126
|
}
|
|
91
127
|
|
|
92
128
|
/**
|
|
@@ -95,7 +131,8 @@ export function unresolveSelfScope(ref, scope) {
|
|
|
95
131
|
*
|
|
96
132
|
* - `foundation` given (a pull passes the pinned ref the pushed site carries) — a
|
|
97
133
|
* catalog ref's scope, or a local foundation's, resolved from that value.
|
|
98
|
-
* - else `foundationDir` — that local foundation's
|
|
134
|
+
* - else `foundationDir` — that local foundation's; when its name has none, the scope of
|
|
135
|
+
* the release `site.yml::foundation` pins for it, if that names the same foundation.
|
|
99
136
|
* - else `site.yml::foundation`, the same way.
|
|
100
137
|
*
|
|
101
138
|
* A local foundation's scope is the one in its name, read by the one rule
|
|
@@ -112,7 +149,10 @@ export function unresolveSelfScope(ref, scope) {
|
|
|
112
149
|
* @returns {Promise<string|null>}
|
|
113
150
|
*/
|
|
114
151
|
export async function siteSelfScope(siteRoot, { foundation, foundationDir } = {}) {
|
|
115
|
-
if (foundation === undefined && foundationDir)
|
|
152
|
+
if (foundation === undefined && foundationDir) {
|
|
153
|
+
const dir = resolve(foundationDir)
|
|
154
|
+
return (await localFoundationScope(dir)) ?? (await pinnedReleaseScope(siteRoot, dir))
|
|
155
|
+
}
|
|
116
156
|
const declared = foundation !== undefined ? foundation : readDeclaredFoundation(siteRoot)
|
|
117
157
|
const value = declared && typeof declared === 'object' ? declared.name : declared
|
|
118
158
|
if (typeof value !== 'string' || !value) return null
|
|
@@ -136,6 +176,26 @@ async function localFoundationScope(dir) {
|
|
|
136
176
|
}
|
|
137
177
|
}
|
|
138
178
|
|
|
179
|
+
// ⭐ A LOCAL FOUNDATION WHOSE NAME CARRIES NO SCOPE, handed in as the source of the release
|
|
180
|
+
// the site pins (`site.yml::foundation: '@std/lab@0.1.0'`, the same name): that release's
|
|
181
|
+
// scope. The site's pin IS the foundation's name with its scope, and the local folder is its
|
|
182
|
+
// source. It arises when a registration was written to a file (`register --scope @std -o`),
|
|
183
|
+
// which writes nothing into the source — so a push given the source (`--foundation <dir>`)
|
|
184
|
+
// found a bare name and shipped every `@/x` unresolved (measured 2026-09-25: records of `@/team`
|
|
185
|
+
// shipped as static files). ⛔ Only for the same name: a pin of another foundation says
|
|
186
|
+
// nothing about this one's scope.
|
|
187
|
+
async function pinnedReleaseScope(siteRoot, dir) {
|
|
188
|
+
const declared = readDeclaredFoundation(siteRoot)
|
|
189
|
+
const ref = parseCatalogRef(declared && typeof declared === 'object' ? declared.name : declared)
|
|
190
|
+
if (!ref?.scope) return null
|
|
191
|
+
try {
|
|
192
|
+
const { name } = await readFoundationName(dir)
|
|
193
|
+
return splitFoundationName(name).bare === ref.name ? ref.scope : null
|
|
194
|
+
} catch {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
139
199
|
function readDeclaredFoundation(siteRoot) {
|
|
140
200
|
try {
|
|
141
201
|
const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
|
package/src/uwx/site.js
CHANGED
|
@@ -919,7 +919,7 @@ function externalSource(d) {
|
|
|
919
919
|
return source
|
|
920
920
|
}
|
|
921
921
|
|
|
922
|
-
function queriesNested(declarations, uuids = null, scope = null) {
|
|
922
|
+
function queriesNested(declarations, uuids = null, scope = null, keyTyped = null) {
|
|
923
923
|
const out = []
|
|
924
924
|
for (const [name, d] of Object.entries(declarations)) {
|
|
925
925
|
refuseUnder(d.where, `queries.${name}`)
|
|
@@ -936,7 +936,10 @@ function queriesNested(declarations, uuids = null, scope = null) {
|
|
|
936
936
|
// stored under, so a verbatim `@/member` beside records stored as `@acme/member`
|
|
937
937
|
// names nothing: the query resolves no Model and the page that binds it renders
|
|
938
938
|
// empty. The pull puts the author's `@/` back (`records-project.js::declarationsToQueriesYml`).
|
|
939
|
-
|
|
939
|
+
// A query whose name-defaulted schema stands for the type of the data key of its name
|
|
940
|
+
// names that type — the data schema its records are sent as (`data-key-types.js`).
|
|
941
|
+
const schema = (!d.schemaExplicit && keyTyped?.get(d.schema)) || d.schema
|
|
942
|
+
setIf(data, 'schema', resolveSelfScope(schema, scope))
|
|
940
943
|
setIf(data, 'sort', d.sort)
|
|
941
944
|
// Legacy `filter:` is not synced — it is translated to `where` upstream
|
|
942
945
|
// (the canonical predicate). No legacy fields on the wire.
|
|
@@ -1545,7 +1548,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1545
1548
|
// ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
|
|
1546
1549
|
// consumer sees, leave the identifier alone.
|
|
1547
1550
|
const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
|
|
1548
|
-
doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope)
|
|
1551
|
+
doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope, opts.keyTyped)
|
|
1549
1552
|
// Emitted ONLY when the file declares the key — see the header above
|
|
1550
1553
|
// `serviceRecords`: on a replaced Section, absent and empty are different
|
|
1551
1554
|
// requests and one of them is destructive.
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// lane (the idempotent no-op).
|
|
21
21
|
|
|
22
22
|
import { buildRecordEntities, entityContentHash } from './records.js'
|
|
23
|
+
import { recordItemLists, hasListItems, stampRecordItems } from './record-items.js'
|
|
23
24
|
import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
24
25
|
import { buildFolderEntity } from './folder.js'
|
|
25
26
|
import { siteProjectToDocument } from './site.js'
|
|
@@ -217,6 +218,10 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
217
218
|
* stamped onto the site-content document so the backend matches our items
|
|
218
219
|
* instead of re-minting them (which deletes and recreates every page and
|
|
219
220
|
* section row). Sourced from a pull or a push response, cached by the caller.
|
|
221
|
+
* @param {Object<string,Object<string,string>>} [opts.recordItemUuids] - record `$uuid` → the
|
|
222
|
+
* identity of its list items, as a previous push or pull banked it
|
|
223
|
+
* (`record-items.js`); stamped onto each record the backend holds, so a re-sent
|
|
224
|
+
* record updates its items instead of replacing them.
|
|
220
225
|
* @param {Object<string,string>} [opts.itemBaseVersions] - record `$uuid` → opaque
|
|
221
226
|
* per-ITEM `version`. Narrowed at emit to the records the package carries and
|
|
222
227
|
* sent as `entries[].item_base_versions`, so the backend can refuse only the
|
|
@@ -253,7 +258,11 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
253
258
|
* records: { buffer, entityCount, index, models }|null,
|
|
254
259
|
* hashes: Object<string,string>, warnings: string[], refusals: string[],
|
|
255
260
|
* skipped: number, schemaless: Array<{name: string, model: string}>,
|
|
256
|
-
* localAssets: string[], applied: object, namesNew: string[]
|
|
261
|
+
* localAssets: string[], applied: object, namesNew: string[],
|
|
262
|
+
* recordItemIdentity: { stamped: number, unknown: number, unbanked: string[] } }>}
|
|
263
|
+
* Each record entry of `records.index` carries `lists`: the record's list items as
|
|
264
|
+
* sent (`record-items.js::recordItemLists`), for the caller to bank against the
|
|
265
|
+
* document the backend returns.
|
|
257
266
|
* `namesNew` lists the records this package sends whose references name a record it
|
|
258
267
|
* creates — by `$ref`, where the next push will send a uuid — as keys of `hashes`. A
|
|
259
268
|
* caller that banks `hashes` re-banks these once the push is applied.
|
|
@@ -347,6 +356,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
347
356
|
// must name the Model its records are stored under, and both are qualified
|
|
348
357
|
// from one `@/x` by one rule (`./self-scope.js`) — so they take one scope.
|
|
349
358
|
scope,
|
|
359
|
+
// …and the same data schema, where a name stands for the type of its data key
|
|
360
|
+
// (`@/team` → `@/member`, `data-key-types.js`): decided once, by the records.
|
|
361
|
+
keyTyped: col.keyTyped,
|
|
350
362
|
// Withhold the `$services`/`$secrets` Sections when the caller has
|
|
351
363
|
// determined the file is not asking for anything new by them. Passed
|
|
352
364
|
// through rather than decided here: the last-agreed state is project
|
|
@@ -452,6 +464,32 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
452
464
|
for (const e of col.entities) collectFrom(e.document)
|
|
453
465
|
const localAssets = [...localAssetSet]
|
|
454
466
|
|
|
467
|
+
// ⭐ THE IDENTITY OF A RECORD'S LIST ITEMS (`record-items.js`), taken on each document as it
|
|
468
|
+
// will be sent — after every rewrite above. `opts.recordItemUuids` is the caller's bank: per
|
|
469
|
+
// record (its uuid on this backend), each list's items as a previous push or pull left them.
|
|
470
|
+
// A record this backend has not minted has only new items, and one it holds with no bank entry
|
|
471
|
+
// is named in `unbanked`, so the caller can recover its items' identity before sending it.
|
|
472
|
+
// Stamping does not move a content hash: `$uuid` is stripped from every one.
|
|
473
|
+
const recordItemBank =
|
|
474
|
+
opts.recordItemUuids && typeof opts.recordItemUuids === 'object' ? opts.recordItemUuids : null
|
|
475
|
+
const recordItemIdentity = { stamped: 0, unknown: 0, unbanked: [] }
|
|
476
|
+
const unbankedIds = new Set()
|
|
477
|
+
for (const e of col.entities) {
|
|
478
|
+
const declaration = col.declarations?.get(e.model)
|
|
479
|
+
if (!declaration) continue
|
|
480
|
+
e.lists = recordItemLists(e.document, declaration)
|
|
481
|
+
const uuid = e.document.$uuid
|
|
482
|
+
if (!uuid || !hasListItems(e.lists)) continue
|
|
483
|
+
const banked = recordItemBank?.[uuid]
|
|
484
|
+
if (!banked) {
|
|
485
|
+
unbankedIds.add(e.id)
|
|
486
|
+
continue
|
|
487
|
+
}
|
|
488
|
+
const n = stampRecordItems(e.document, declaration, banked)
|
|
489
|
+
recordItemIdentity.stamped += n.stamped
|
|
490
|
+
recordItemIdentity.unknown += n.unknown
|
|
491
|
+
}
|
|
492
|
+
|
|
455
493
|
const siteEntity = siteDoc
|
|
456
494
|
? { id: siteDoc.$id, model: siteDoc.$schema, file: 'entities/site-content.json', document: siteDoc }
|
|
457
495
|
: null
|
|
@@ -492,7 +530,12 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
492
530
|
// leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
|
|
493
531
|
// back-fill (backfillEntityUuids skips it — the folder has no uuid to write).
|
|
494
532
|
const entities = [folder, ...changedRecords.map((r) => r.entity)].map(stamp)
|
|
495
|
-
|
|
533
|
+
// Each record's lists as sent ride on its index entry: the caller pairs them with the
|
|
534
|
+
// document the backend returns, to bank the items' identity (`record-items.js`).
|
|
535
|
+
const index = [{ kind: 'folder' }, ...changedRecords.map((r) => ({ ...r.index, lists: r.entity.lists || {} }))]
|
|
536
|
+
for (const r of changedRecords) {
|
|
537
|
+
if (unbankedIds.has(r.entity.id)) recordItemIdentity.unbanked.push(r.entity.document.$uuid)
|
|
538
|
+
}
|
|
496
539
|
// The folder references every record's Model via `entry.model` — including records
|
|
497
540
|
// filtered out here by send-only-changed. Declare them all (the backend rejects a
|
|
498
541
|
// folder that references an undeclared Model).
|
|
@@ -545,5 +588,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
545
588
|
// no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
|
|
546
589
|
// before is the index-loss signature the backend refuses — the caller reports it.
|
|
547
590
|
itemIdentity,
|
|
591
|
+
// The records' list items: how many took a banked uuid and how many go as new, and the
|
|
592
|
+
// records this package SENDS that the backend holds with list items and no bank entry
|
|
593
|
+
// (their uuids) — sent as they are, the backend refuses each one (`identity_required`).
|
|
594
|
+
recordItemIdentity,
|
|
548
595
|
}
|
|
549
596
|
}
|
package/src/uwx/sync-store.js
CHANGED
|
@@ -43,8 +43,12 @@ export const SYNC_STORE_FILE = 'sync.json'
|
|
|
43
43
|
/** Current on-disk shape. Bumped only for a breaking layout change. */
|
|
44
44
|
const VERSION = 1
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Map sections — merged entry by entry. Anything else in a patch replaces.
|
|
48
|
+
* `recordItems` is keyed by a record's uuid here, each entry that record's list items
|
|
49
|
+
* (`record-items.js`) — replaced whole, one record at a time.
|
|
50
|
+
*/
|
|
51
|
+
const MAP_SECTIONS = new Set(['records', 'items', 'queries', 'folders', 'assets', 'recordItems'])
|
|
48
52
|
|
|
49
53
|
const storePath = (siteDir) => join(siteDir, SYNC_STORE_FILE)
|
|
50
54
|
|
|
@@ -207,7 +211,7 @@ export function updateBackendState(siteDir, origin, patch) {
|
|
|
207
211
|
*
|
|
208
212
|
* @param {string} siteDir
|
|
209
213
|
* @param {string} origin
|
|
210
|
-
* @param {string} section - one of `records` `items` `queries` `folders` `assets`
|
|
214
|
+
* @param {string} section - one of `records` `items` `queries` `folders` `assets` `recordItems`
|
|
211
215
|
* @param {Record<string, *>} entries
|
|
212
216
|
* @param {(next: *, prior: *) => *} [merge]
|
|
213
217
|
* @returns {{ added: string[], changed: string[], written: boolean }}
|