@uniweb/build 0.62.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 +5 -5
- package/src/site/queries-config.js +9 -5
- package/src/uwx/data-key-types.js +53 -0
- package/src/uwx/folder.js +15 -8
- 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 +108 -58
- package/src/uwx/registry-package.js +50 -1
- package/src/uwx/self-scope.js +66 -6
- package/src/uwx/site.js +6 -3
- package/src/uwx/sync-package.js +66 -65
- package/src/uwx/sync-store.js +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.1",
|
|
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": {
|
|
@@ -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/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/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
|
+
}
|