@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.62.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/runtime": "^0.27.4",
78
- "@uniweb/core": "^0.29.8"
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 = loadFoundationDataSchemas(siteRoot, siteYml)
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 loadFoundationDataSchemas(siteRoot, siteYml)
450
+ return foundationSchemaJson(siteRoot, siteYml)?.dataSchemas || null
451
451
  }
452
452
 
453
- /** The foundation's built data-schema map, or null when there is nothing to read. */
454
- function loadFoundationDataSchemas(siteRoot, siteYml) {
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'))?.dataSchemas || null
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, ... }` with
11
- // `entry: <uuid>` once the record was minted (back-filled into its file),
12
- // or `$ref: "<id>"` while brand-new (resolved within this payload).
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 → `$ref` handle (resolved within this payload to the minted
59
- // entity).
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.$ref = entity.id // the payload-local handle
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 neither `entry` nor `$ref` is a placement pointing at nothing —
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 `$ref` instead.
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,
@@ -7,5 +7,6 @@ export {
7
7
  defaultSchema,
8
8
  deferredFromSchema,
9
9
  foundationDataSchemas,
10
+ foundationSchemaJson,
10
11
  QUERIES_YML_RELPATH,
11
12
  } from '../site/queries-config.js'
@@ -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
+ }