@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/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'
|
|
@@ -33,18 +34,6 @@ const SITE_ENTITY_KEY = 'site-content'
|
|
|
33
34
|
|
|
34
35
|
const cacheKey = (entity) => `${entity.model} ${entity.id}`
|
|
35
36
|
|
|
36
|
-
// The folder's placements without the records a push leaves out — `{ kind: 'ref' }` leaves
|
|
37
|
-
// naming them by `$entityId`, inside any branch.
|
|
38
|
-
function withoutRecords(nodes, ids) {
|
|
39
|
-
if (!ids.size) return nodes
|
|
40
|
-
const out = []
|
|
41
|
-
for (const node of nodes || []) {
|
|
42
|
-
if (node?.kind === 'branch') out.push({ ...node, $children: withoutRecords(node.$children, ids) })
|
|
43
|
-
else if (!ids.has(node?.$entityId)) out.push(node)
|
|
44
|
-
}
|
|
45
|
-
return out
|
|
46
|
-
}
|
|
47
|
-
|
|
48
37
|
// Attach the push gate's optimistic-concurrency token to an entity, keyed by the
|
|
49
38
|
// BACKEND uuid in its document body (`$uuid`). A never-synced entity has no
|
|
50
39
|
// `$uuid` and therefore no base — correctly unconditional, since there is no
|
|
@@ -229,6 +218,10 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
229
218
|
* stamped onto the site-content document so the backend matches our items
|
|
230
219
|
* instead of re-minting them (which deletes and recreates every page and
|
|
231
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.
|
|
232
225
|
* @param {Object<string,string>} [opts.itemBaseVersions] - record `$uuid` → opaque
|
|
233
226
|
* per-ITEM `version`. Narrowed at emit to the records the package carries and
|
|
234
227
|
* sent as `entries[].item_base_versions`, so the backend can refuse only the
|
|
@@ -265,11 +258,14 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
265
258
|
* records: { buffer, entityCount, index, models }|null,
|
|
266
259
|
* hashes: Object<string,string>, warnings: string[], refusals: string[],
|
|
267
260
|
* skipped: number, schemaless: Array<{name: string, model: string}>,
|
|
268
|
-
* localAssets: string[], applied: object,
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
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.
|
|
266
|
+
* `namesNew` lists the records this package sends whose references name a record it
|
|
267
|
+
* creates — by `$ref`, where the next push will send a uuid — as keys of `hashes`. A
|
|
268
|
+
* caller that banks `hashes` re-banks these once the push is applied.
|
|
273
269
|
* `refusals` names the records that cannot be sent as written, one line each
|
|
274
270
|
* (`records.js::recordsToEntities`). A caller that submits must not submit while
|
|
275
271
|
* any is present: the backend would refuse the records lane — or, for a record
|
|
@@ -316,43 +312,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
316
312
|
})
|
|
317
313
|
const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
|
|
318
314
|
|
|
319
|
-
// ⭐ RECORDS WAITING FOR A REFERENCE (`records.js::encodeReference`). A record whose
|
|
320
|
-
// reference names a record this backend has not minted yet cannot name it on the wire,
|
|
321
|
-
// so this push sends what it can and the next pass completes it (the CLI's
|
|
322
|
-
// `pushInPasses`):
|
|
323
|
-
// · new, and nothing it waits for is required → sent now without those references,
|
|
324
|
-
// so after one pass every record exists, whatever refers to what;
|
|
325
|
-
// · new, and a required reference waits → HELD and placed nowhere: it cannot be
|
|
326
|
-
// created without it;
|
|
327
|
-
// · already on this backend → HELD, left as it is there: sent without a reference it
|
|
328
|
-
// has, it would lose that reference until the next pass.
|
|
329
|
-
// A held record is not sent and keeps the hash last banked for it, so the next pass
|
|
330
|
-
// sees it as changed.
|
|
331
|
-
const held = new Set()
|
|
332
|
-
const unplaced = new Set()
|
|
333
|
-
for (const e of col.entities) {
|
|
334
|
-
if (!e.pending?.length) continue
|
|
335
|
-
if (e.uuid) held.add(e.id)
|
|
336
|
-
else if (e.pending.some((p) => p.required)) {
|
|
337
|
-
held.add(e.id)
|
|
338
|
-
unplaced.add(e.id)
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
const waiting = col.entities
|
|
342
|
-
.filter((e) => e.pending?.length)
|
|
343
|
-
.map((e) => ({ id: e.id, model: e.model, slug: e.slug, held: held.has(e.id), pending: e.pending }))
|
|
344
|
-
const placedEntities = []
|
|
345
|
-
const placedIndex = []
|
|
346
|
-
col.entities.forEach((e, i) => {
|
|
347
|
-
if (unplaced.has(e.id)) return
|
|
348
|
-
placedEntities.push(e)
|
|
349
|
-
placedIndex.push(col.index[i])
|
|
350
|
-
})
|
|
351
|
-
|
|
352
315
|
// The folder rides over the FULL record set (before filtering) so its references
|
|
353
316
|
// are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
|
|
354
317
|
const folder = buildFolderEntity({
|
|
355
|
-
recordEntities:
|
|
318
|
+
recordEntities: col.entities,
|
|
356
319
|
...(sourceLocale ? { sourceLocale } : {}),
|
|
357
320
|
// ⭐ Every record in the directory, at the top of the folder or in the
|
|
358
321
|
// sub-folder `folder.yml` places it in — and NOTHING when there is no folder to
|
|
@@ -360,7 +323,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
360
323
|
// non-empty tree: a `folder.yml` whose folders hold only records that resolved
|
|
361
324
|
// no schema is a tree of empty branches, and sending it would replace the
|
|
362
325
|
// backend's folder with them (measured on the `dynamic` template, 2026-09-21).
|
|
363
|
-
folderNodes: col.sendFolder === true ?
|
|
326
|
+
folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],
|
|
364
327
|
// ⛔ Whether the file side HAS a folder to state (`records.js::sendsFolder`) —
|
|
365
328
|
// not whether it holds anything. A site with no records directory sends none
|
|
366
329
|
// and leaves the backend's alone; an empty directory sends an empty folder,
|
|
@@ -393,6 +356,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
393
356
|
// must name the Model its records are stored under, and both are qualified
|
|
394
357
|
// from one `@/x` by one rule (`./self-scope.js`) — so they take one scope.
|
|
395
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,
|
|
396
362
|
// Withhold the `$services`/`$secrets` Sections when the caller has
|
|
397
363
|
// determined the file is not asking for anything new by them. Passed
|
|
398
364
|
// through rather than decided here: the last-agreed state is project
|
|
@@ -498,6 +464,32 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
498
464
|
for (const e of col.entities) collectFrom(e.document)
|
|
499
465
|
const localAssets = [...localAssetSet]
|
|
500
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
|
+
|
|
501
493
|
const siteEntity = siteDoc
|
|
502
494
|
? { id: siteDoc.$id, model: siteDoc.$schema, file: 'entities/site-content.json', document: siteDoc }
|
|
503
495
|
: null
|
|
@@ -518,26 +510,32 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
518
510
|
// changed() has side effects (hashes/skipped), so evaluate every entity exactly
|
|
519
511
|
// once, in a stable order: folder, then each record.
|
|
520
512
|
const folderChanged = folder ? changed(folder) : false
|
|
521
|
-
|
|
522
|
-
const keepBanked = (entity) => {
|
|
523
|
-
const key = cacheKey(entity)
|
|
524
|
-
if (priorHashes[key] !== undefined) hashes[key] = priorHashes[key]
|
|
525
|
-
return false
|
|
526
|
-
}
|
|
527
|
-
const recordChanged = placedEntities.map((e, i) => ({
|
|
513
|
+
const recordChanged = col.entities.map((e, i) => ({
|
|
528
514
|
entity: e,
|
|
529
|
-
index:
|
|
530
|
-
changed:
|
|
515
|
+
index: col.index[i],
|
|
516
|
+
changed: changed(e),
|
|
531
517
|
}))
|
|
532
518
|
const changedRecords = recordChanged.filter((r) => r.changed)
|
|
533
519
|
|
|
534
520
|
let records = null
|
|
521
|
+
// ⭐ A RECORD THAT NAMES A RECORD THIS PUSH CREATES names it by `$ref`
|
|
522
|
+
// (`records.js::encodeReference`), and the backend stores the uuid it mints for it. The
|
|
523
|
+
// next push sends that uuid: the same reference, another encoding — and another hash.
|
|
524
|
+
// So the caller re-banks these records once the push is applied, or the next push
|
|
525
|
+
// sends every one of them again.
|
|
526
|
+
let namesNew = []
|
|
535
527
|
if (folder && (folderChanged || changedRecords.length > 0)) {
|
|
528
|
+
namesNew = changedRecords.filter((r) => r.entity.namesNew > 0).map((r) => cacheKey(r.entity))
|
|
536
529
|
// Folder first (always, for the `$ref` closure), then changed records. The
|
|
537
530
|
// leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
|
|
538
531
|
// back-fill (backfillEntityUuids skips it — the folder has no uuid to write).
|
|
539
532
|
const entities = [folder, ...changedRecords.map((r) => r.entity)].map(stamp)
|
|
540
|
-
|
|
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
|
+
}
|
|
541
539
|
// The folder references every record's Model via `entry.model` — including records
|
|
542
540
|
// filtered out here by send-only-changed. Declare them all (the backend rejects a
|
|
543
541
|
// folder that references an undeclared Model).
|
|
@@ -582,14 +580,17 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
582
580
|
|
|
583
581
|
return {
|
|
584
582
|
siteContent, records, siteContentUuid, hashes, warnings, skipped,
|
|
585
|
-
// Records
|
|
586
|
-
|
|
587
|
-
waiting,
|
|
583
|
+
// Records that name a record this push creates — see `namesNew` above.
|
|
584
|
+
namesNew,
|
|
588
585
|
refusals: col.refusals || [],
|
|
589
586
|
schemaless: col.schemaless, localAssets, applied,
|
|
590
587
|
// { stamped, unknown } when identity was applied; null when the caller passed
|
|
591
588
|
// no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
|
|
592
589
|
// before is the index-loss signature the backend refuses — the caller reports it.
|
|
593
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,
|
|
594
595
|
}
|
|
595
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 }}
|