uniweb 0.69.0 → 0.69.2

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",
3
- "version": "0.69.0",
3
+ "version": "0.69.2",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "^0.27.4",
44
+ "@uniweb/content-writer": "^0.3.4",
45
45
  "@uniweb/core": "^0.29.8",
46
46
  "@uniweb/kit": "^0.19.4",
47
+ "@uniweb/runtime": "^0.27.4",
47
48
  "@uniweb/semantic-parser": "^1.4.1",
48
- "@uniweb/schemas": "^0.7.0",
49
- "@uniweb/content-writer": "^0.3.4"
49
+ "@uniweb/schemas": "^0.7.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.63.0",
52
+ "@uniweb/build": "^0.63.2",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -42,7 +42,10 @@ import {
42
42
  updateBackendMap,
43
43
  clearBackendSections,
44
44
  normalizeBackendOrigin,
45
- removeYamlScalar
45
+ removeYamlScalar,
46
+ harvestRecordItems,
47
+ storedRecordItems,
48
+ reprintRecordItems
46
49
  } from '@uniweb/build/uwx'
47
50
 
48
51
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -62,6 +65,21 @@ function entityDocFromUwx(buf) {
62
65
  return null
63
66
  }
64
67
 
68
+ // Every entity `$`-document of a `.uwx` — the folder lane holds the folder and its records.
69
+ function entityDocsFromUwx(buf) {
70
+ const docs = []
71
+ if (!buf || buf.length < 2 || buf[0] !== 0x50 || buf[1] !== 0x4b) return docs
72
+ for (const [name, data] of readZip(buf)) {
73
+ if (name === 'manifest.json' || !name.endsWith('.json')) continue
74
+ try {
75
+ docs.push(JSON.parse(data.toString('utf8')))
76
+ } catch {
77
+ // not a document
78
+ }
79
+ }
80
+ return docs
81
+ }
82
+
65
83
  /**
66
84
  * Turn an entity-grained staleness refusal into a file-level account.
67
85
  *
@@ -793,6 +811,73 @@ export function writeItemUuids(siteDir, backend, map) {
793
811
  updateBackendMap(siteDir, backend, 'items', map)
794
812
  }
795
813
 
814
+ /**
815
+ * The identity of each record's list items:
816
+ * `{ <record $uuid>: { <place>: "<item $uuid> <fingerprint>" } }`.
817
+ *
818
+ * ⛔ THE FOURTH MAP, FOR THE SAME REASON AS THE OTHER THREE. An item of a record's `many`
819
+ * section — or of a list nested in one, or of a tree's `$children` — is a `multi` row with no
820
+ * file and no name, so neither a path-keyed map nor the record's own file can hold it. Sent
821
+ * without it, the record is refused whole (`identity_required`), since applying it would
822
+ * replace every stored item: measured 2026-09-25 on an edit of a talk's title. Banked per
823
+ * record from a push's finalized documents (paired by place with what was sent) and from a
824
+ * pull; matched on the next send by content, then by place (`@uniweb/build`'s
825
+ * `record-items.js`). Each record's entry is replaced whole.
826
+ */
827
+ export function readRecordItemUuids(siteDir, backend) {
828
+ return readBackendState(siteDir, backend).recordItems || {}
829
+ }
830
+ export function writeRecordItemUuids(siteDir, backend, map) {
831
+ if (!map || !Object.keys(map).length) return
832
+ updateBackendMap(siteDir, backend, 'recordItems', map)
833
+ }
834
+
835
+ // The records' list items, banked from the documents the backend returned for them —
836
+ // `returned` by `$uuid` — paired by place with the lists as sent, which ride on each record's
837
+ // index entry (`lists`). A record keeps an entry only while it has list items, or had one.
838
+ function bankRecordItems(siteDir, backend, pairs) {
839
+ const prior = readRecordItemUuids(siteDir, backend)
840
+ const bank = {}
841
+ for (const { uuid, lists, returned } of pairs) {
842
+ if (!uuid || !lists || !returned) continue
843
+ const items = harvestRecordItems(lists, returned)
844
+ if (Object.keys(items).length || prior[uuid]) bank[uuid] = items
845
+ }
846
+ writeRecordItemUuids(siteDir, backend, bank)
847
+ return Object.keys(bank).length
848
+ }
849
+
850
+ /**
851
+ * Recover the list-item identity of records the backend holds and this copy never banked —
852
+ * `uuids`, the emit's `recordItemIdentity.unbanked` — from the backend's own documents: one read
853
+ * of the folder lane, and nothing written but `sync.json`. The send that follows matches their
854
+ * items by place alone (`storedRecordItems`): what was sent before is unknown here.
855
+ *
856
+ * Best-effort, like `ensureItemUuids`: a failure leaves them unbanked, and the backend refuses
857
+ * the send rather than replace their items.
858
+ *
859
+ * @returns {Promise<number>} how many records were recovered
860
+ */
861
+ export async function recoverRecordItemUuids({ client, siteDir, uuids, note }) {
862
+ const siteUuid = readBackendState(siteDir, client.origin).site?.uuid || null
863
+ if (!siteUuid || !uuids?.length) return 0
864
+ try {
865
+ const res = await client.pullFolder(siteUuid)
866
+ if (!res?.ok) return 0
867
+ const wanted = new Set(uuids)
868
+ const bank = {}
869
+ for (const doc of entityDocsFromUwx(Buffer.from(await res.arrayBuffer()))) {
870
+ if (wanted.has(doc?.$uuid)) bank[doc.$uuid] = storedRecordItems(doc)
871
+ }
872
+ writeRecordItemUuids(siteDir, client.origin, bank)
873
+ const n = Object.keys(bank).length
874
+ if (n) note?.(`Recovered the identity of the list items of ${n} record(s) from the backend.`)
875
+ return n
876
+ } catch {
877
+ return 0
878
+ }
879
+ }
880
+
796
881
  /**
797
882
  * The workspace this site was created in on `backend` — its owner, as the create
798
883
  * answered it — in the header's form: `@handle`, a unit's bare uuid when that unit has
@@ -1219,7 +1304,7 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1219
1304
  */
1220
1305
  async function comparisonEmit(
1221
1306
  siteDir,
1222
- { backend = null, priorHashes = {}, sendAll = false } = {}
1307
+ { backend = null, priorHashes = {}, sendAll = false, declarations = null } = {}
1223
1308
  ) {
1224
1309
  const applied = readAppliedInjections(siteDir, backend)
1225
1310
  // ⛔ Per backend: an asset id is minted by one and means nothing to another, so a
@@ -1229,7 +1314,13 @@ async function comparisonEmit(
1229
1314
  const queryUuids = readQueryUuids(siteDir, backend)
1230
1315
  return emitSyncPackages(siteDir, {
1231
1316
  backend,
1232
- resolveModel: makeModelResolver({ client: null, offline: true }),
1317
+ // Offline, always. A caller that has just read the Models from the backend — a pull —
1318
+ // may hand them in: the declarations a push would resolve, so the hashes still compare.
1319
+ // Without them a site whose foundation is not in the project (a clone's) resolves none
1320
+ // of its Models here, and nothing is banked.
1321
+ resolveModel: declarations
1322
+ ? async (name) => declarations.get(name) ?? null
1323
+ : makeModelResolver({ client: null, offline: true }),
1233
1324
  priorHashes,
1234
1325
  sendAll,
1235
1326
  ...applied,
@@ -1260,16 +1351,38 @@ async function comparisonEmit(
1260
1351
  * ⭐ Re-banking is the correct answer rather than clearing, because after a pull the
1261
1352
  * on-disk state IS the agreed state: it came from the backend. A push with no edits
1262
1353
  * in between should send nothing, and clearing would make it send everything.
1354
+ *
1355
+ * @param {object} [opts]
1356
+ * @param {object[]} [opts.recordDocs] - the record documents the pull took; their list items'
1357
+ * identity is banked against the files just written (`readRecordItemUuids`)
1358
+ * @param {Map<string,object>} [opts.declarations] - the Models the pull read, by name — so a
1359
+ * site whose foundation is not in the project (a clone's) can be re-banked offline
1263
1360
  */
1264
- export async function rebankSyncHashes(siteDir, backend = null) {
1265
- const pkg = await comparisonEmit(siteDir, { backend, sendAll: true })
1361
+ export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, declarations } = {}) {
1362
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations })
1266
1363
  writeSyncCache(siteDir, backend, pkg.hashes || {}, pkg.applied || {})
1364
+ // ⭐ And the identity of the records' list items, from the documents the pull just took:
1365
+ // the files it wrote hold the stored items in stored order, so the emit over them pairs
1366
+ // with each document item for item.
1367
+ if (recordDocs?.length) {
1368
+ const byUuid = new Map(recordDocs.filter((d) => d?.$uuid).map((d) => [d.$uuid, d]))
1369
+ const recordUuids = readBackendState(siteDir, backend).records || {}
1370
+ bankRecordItems(
1371
+ siteDir,
1372
+ backend,
1373
+ (pkg.records?.index || []).map((entry) => {
1374
+ const uuid = entry.ownId ? recordUuids[entry.ownId] : null
1375
+ return { uuid, lists: entry.lists, returned: uuid ? byUuid.get(uuid) : null }
1376
+ })
1377
+ )
1378
+ }
1267
1379
  return Object.keys(pkg.hashes || {}).length
1268
1380
  }
1269
1381
 
1270
1382
  /**
1271
1383
  * Re-bank the hashes of the records a push sent naming a record it created — `keys`,
1272
- * the emit's `namesNew` — as the next push will hash them.
1384
+ * the emit's `namesNew` — as the next push will hash them, and fingerprint their list
1385
+ * items the same way.
1273
1386
  *
1274
1387
  * ⭐ Such a record names its new record by `$ref`, and the backend stores the uuid it
1275
1388
  * mints. The next push names it by that uuid: the same reference, and another hash — so
@@ -1287,6 +1400,18 @@ export async function rebankRecordHashes(siteDir, backend, keys) {
1287
1400
  const hashes = { ...readSyncCache(siteDir, backend) }
1288
1401
  for (const key of keys) if (pkg.hashes?.[key]) hashes[key] = pkg.hashes[key]
1289
1402
  writeSyncCache(siteDir, backend, hashes, readAppliedInjections(siteDir, backend))
1403
+ // Their list items too: an item that named a new record is fingerprinted anew, each uuid kept
1404
+ // at its place (`reprintRecordItems`) — or the next send finds it by place alone.
1405
+ const wanted = new Set(keys)
1406
+ const recordUuids = readBackendState(siteDir, backend).records || {}
1407
+ const bank = readRecordItemUuids(siteDir, backend)
1408
+ const reprinted = {}
1409
+ for (const entry of pkg.records?.index || []) {
1410
+ if (!wanted.has(`${entry.model} ${entry.id}`)) continue
1411
+ const uuid = entry.ownId ? recordUuids[entry.ownId] : null
1412
+ if (uuid && bank[uuid]) reprinted[uuid] = reprintRecordItems(bank[uuid], entry.lists)
1413
+ }
1414
+ writeRecordItemUuids(siteDir, backend, reprinted)
1290
1415
  }
1291
1416
 
1292
1417
  /**
@@ -1766,6 +1891,18 @@ export async function pushSyncPackages({
1766
1891
  }
1767
1892
  for (const w of bf.warnings) note(`! ${w}`)
1768
1893
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
1894
+ // ⭐ BANK THE RECORDS' LIST ITEMS: each item's `$uuid` in the document the backend returned,
1895
+ // paired by place with the lists as sent — so the next send of a record updates its items
1896
+ // rather than being refused for replacing them (`readRecordItemUuids`).
1897
+ bankRecordItems(
1898
+ siteDir,
1899
+ client.origin,
1900
+ finalized.map((f) => ({
1901
+ uuid: f?.uuid,
1902
+ lists: Number.isInteger(f?.index) ? records.index?.[f.index]?.lists : null,
1903
+ returned: f?.document
1904
+ }))
1905
+ )
1769
1906
  draftsNotKept = bf.notKeptAsDrafts || []
1770
1907
  if (bf.updated.length)
1771
1908
  wrote.push(`wrote ${bf.updated.length} record file(s)`)
@@ -86,6 +86,8 @@ import {
86
86
  readItemBaseVersions,
87
87
  ensureItemUuids,
88
88
  readFolderItemUuids,
89
+ readRecordItemUuids,
90
+ recoverRecordItemUuids,
89
91
  ensureSiteExists,
90
92
  refuseUnsendableRecords,
91
93
  clearRemoteSyncStateIfUnbound,
@@ -946,28 +948,41 @@ export async function publish(args = []) {
946
948
  // edited since this clone last synced, the push is refused rather than
947
949
  // overwriting them, and nothing goes live. `--force` drops the precondition.
948
950
  const forced = args.includes('--force')
951
+ const emitOptions = {
952
+ backend: client.origin,
953
+ ...(declaration.declare ? {} : { declareServices: false }),
954
+ // Placement identity for the folder — see writeFolderItemUuids.
955
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
956
+ // Identity for the records' list items — see readRecordItemUuids.
957
+ recordItemUuids: readRecordItemUuids(siteDir, client.origin),
958
+ ...(foundationDir ? { foundationDir } : {}),
959
+ resolveModel,
960
+ priorHashes,
961
+ itemUuids,
962
+ ...(forced
963
+ ? {}
964
+ : {
965
+ baseVersions: readBaseVersions(siteDir, client.origin),
966
+ itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
967
+ }),
968
+ ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
969
+ ...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
970
+ ...(assetRewrite ? { assetRewrite } : {}),
971
+ ...(assetIds ? { assetIds } : {})
972
+ }
949
973
  let pkg
950
974
  try {
951
- pkg = await emitSyncPackages(siteDir, {
952
- backend: client.origin,
953
- ...(declaration.declare ? {} : { declareServices: false }),
954
- // Placement identity for the folder — see writeFolderItemUuids.
955
- folderItemUuids: readFolderItemUuids(siteDir, client.origin),
956
- ...(foundationDir ? { foundationDir } : {}),
957
- resolveModel,
958
- priorHashes,
959
- itemUuids,
960
- ...(forced
961
- ? {}
962
- : {
963
- baseVersions: readBaseVersions(siteDir, client.origin),
964
- itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
965
- }),
966
- ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
967
- ...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
968
- ...(assetRewrite ? { assetRewrite } : {}),
969
- ...(assetIds ? { assetIds } : {})
970
- })
975
+ pkg = await emitSyncPackages(siteDir, emitOptions)
976
+ // Records the backend holds whose list items this copy never banked — see `uniweb push`.
977
+ const unbanked = pkg.recordItemIdentity?.unbanked
978
+ if (unbanked?.length) {
979
+ if (await recoverRecordItemUuids({ client, siteDir, uuids: unbanked, note: (m) => say.dim(m) })) {
980
+ pkg = await emitSyncPackages(siteDir, {
981
+ ...emitOptions,
982
+ recordItemUuids: readRecordItemUuids(siteDir, client.origin)
983
+ })
984
+ }
985
+ }
971
986
  } catch (err) {
972
987
  say.err(`Could not build the sync package: ${err.message}`)
973
988
  return { exitCode: 1 }
@@ -77,6 +77,7 @@ import {
77
77
  computeUnitHashes,
78
78
  collectUnitUuids,
79
79
  collectQueryUuids,
80
+ collectFolderItemUuids,
80
81
  documentSchema
81
82
  } from '@uniweb/build/uwx'
82
83
  import {
@@ -91,7 +92,8 @@ import {
91
92
  mergeBaseVersions,
92
93
  mergeItemBaseVersions,
93
94
  writeUnitBases,
94
- writeItemUuids
95
+ writeItemUuids,
96
+ writeFolderItemUuids
95
97
  } from '../backend/site-sync.js'
96
98
  import { resolveWorkspace } from '../backend/workspace.js'
97
99
  import {
@@ -849,6 +851,9 @@ export async function pull(args = [], deps = {}) {
849
851
  client.pullSiteContent(siteContentUuid, { etag: conditional ? etagContent : undefined })
850
852
  )
851
853
  if (content?.refused) return { exitCode: 1 }
854
+ // The Models the pulled site's queries name, as the push qualified them — the re-bank
855
+ // below resolves with them too (see there).
856
+ let pulledQueryModels = []
852
857
  if (content && !content.notModified) {
853
858
  const siteDoc =
854
859
  content.docs &&
@@ -856,6 +861,9 @@ export async function pull(args = [], deps = {}) {
856
861
  content.docs[0] ||
857
862
  null)
858
863
  if (siteDoc) {
864
+ pulledQueryModels = (Array.isArray(siteDoc.queries) ? siteDoc.queries : [])
865
+ .map((q) => q?.schema)
866
+ .filter((s) => typeof s === 'string' && /^@[^/]+\//.test(s))
859
867
  // Re-base the page attribution. The pulled document IS the backend's own
860
868
  // representation, so it becomes the remote base directly. We deliberately
861
869
  // CLEAR the local base rather than reuse it: our source files were just
@@ -962,6 +970,12 @@ export async function pull(args = [], deps = {}) {
962
970
  // holds a folder uuid). Models are resolved by name (async) up front, so
963
971
  // recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
964
972
  let folderLane = null
973
+ // The record documents this pull took — the re-bank below pairs their list items with the
974
+ // files just written.
975
+ let pulledRecordDocs = []
976
+ // The Models this pull read from the backend, by name — the re-bank below resolves with
977
+ // them, since a clone's foundation is not in the project to resolve them offline.
978
+ let pulledDeclarations = null
965
979
  if (!noRecords) {
966
980
  const folder = await getDocs('records', () =>
967
981
  client.pullFolder(siteContentUuid, { etag: conditional ? etagFolder : undefined })
@@ -970,8 +984,10 @@ export async function pull(args = [], deps = {}) {
970
984
  if (folder?.refused) return { exitCode: 1 }
971
985
  if (folder && !folder.notModified && folder.docs?.length) {
972
986
  const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
987
+ pulledRecordDocs = recordDocs
973
988
  const resolveModel = makeModelResolver({ client })
974
989
  const declByModel = new Map()
990
+ pulledDeclarations = declByModel
975
991
  for (const model of [
976
992
  ...new Set(recordDocs.map((d) => documentSchema(d)).filter(Boolean))
977
993
  ]) {
@@ -998,6 +1014,12 @@ export async function pull(args = [], deps = {}) {
998
1014
  scope: await scopeFor(null)
999
1015
  }
1000
1016
  })
1017
+ // ⭐ THE FOLDER'S PLACEMENT IDENTITY, from the folder this pull took — as a push banks
1018
+ // it from the folder it sent. ⛔ Only a push banked it until 2026-09-25, so a copy that
1019
+ // had never pushed (every clone) sent its first folder with its placements uuid-less,
1020
+ // and the backend refused that push (`identity_required`, section `contents`) —
1021
+ // measured on a template clone's first edit.
1022
+ if (folderDoc) writeFolderItemUuids(siteDir, client.origin, collectFolderItemUuids(folderDoc))
1001
1023
  // The folder's organization, `folder.yml` in the records directory — named as
1002
1024
  // the author would write it (`records/folder.yml`, or under `paths.records`).
1003
1025
  if (report.records === 'updated') {
@@ -1069,9 +1091,30 @@ export async function pull(args = [], deps = {}) {
1069
1091
  //
1070
1092
  // Best-effort: a failure here costs an unnecessary re-send on the next push, never
1071
1093
  // wrong content, and must not fail a pull whose files are already written.
1072
- if (!dryRun) {
1094
+ // Nothing projected — both lanes unchanged (304) — leaves the banked state describing the
1095
+ // files as they are, so there is nothing to re-bank. Re-banking anyway only failed, noisily,
1096
+ // where the Models cannot be resolved offline (a clone's).
1097
+ const projected = Boolean(content && !content.notModified) || Boolean(folderLane && !folderLane.notModified)
1098
+ if (!dryRun && projected) {
1073
1099
  try {
1074
- await rebankSyncHashes(siteDir, client.origin)
1100
+ // ⭐ With the Models this pull read, and those its queries name — a query may name a
1101
+ // schema no record uses yet. The re-bank is offline, and a site whose foundation is not
1102
+ // in the project (a clone's) resolves none of them otherwise: every template clone
1103
+ // said "could not re-bank" until 2026-09-25, and banked nothing.
1104
+ const declarations = pulledDeclarations || new Map()
1105
+ const readModel = makeModelResolver({ client })
1106
+ for (const model of pulledQueryModels) {
1107
+ if (declarations.has(model)) continue
1108
+ try {
1109
+ declarations.set(model, await readModel(model))
1110
+ } catch {
1111
+ // Unreadable here: the re-bank says so if it needs it.
1112
+ }
1113
+ }
1114
+ await rebankSyncHashes(siteDir, client.origin, {
1115
+ recordDocs: pulledRecordDocs,
1116
+ ...(declarations.size ? { declarations } : {})
1117
+ })
1075
1118
  } catch (err) {
1076
1119
  note(`! could not re-bank the sync cache: ${err.message}`)
1077
1120
  note(' The next push will re-send content that is already current.')
@@ -82,6 +82,8 @@ import {
82
82
  readItemUuids,
83
83
  readFolderItemUuids,
84
84
  readQueryUuids,
85
+ readRecordItemUuids,
86
+ recoverRecordItemUuids,
85
87
  ensureItemUuids,
86
88
  refuseUnsendableRecords,
87
89
  ensureSiteExists,
@@ -496,40 +498,55 @@ export async function push(args = [], deps = {}) {
496
498
  output || dryRun
497
499
  ? readItemUuids(siteDir, client.origin)
498
500
  : await ensureItemUuids({ client, siteDir, note })
501
+ const emitOptions = {
502
+ backend: client.origin,
503
+ // Placement identity for the folder — see writeFolderItemUuids.
504
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
505
+ // Identity for the `queries` section — see readQueryUuids. Keyed by
506
+ // name, because a declaration has no file for a path-keyed map to hold.
507
+ queryUuids: readQueryUuids(siteDir, client.origin),
508
+ // Identity for the records' list items — see readRecordItemUuids.
509
+ recordItemUuids: readRecordItemUuids(siteDir, client.origin),
510
+ ...(foundationDir ? { foundationDir } : {}),
511
+ resolveModel: makeModelResolver({
512
+ client,
513
+ offline: Boolean(output) || dryRun
514
+ }),
515
+ priorHashes,
516
+ sendAll,
517
+ itemUuids,
518
+ // The PINNED foundation ref from the bring-along above, stamped over the
519
+ // authored `site.yml` string. Delivery is version-pinned end to end, so an
520
+ // unpinned local name on the wire names code no host can serve. Absent when
521
+ // the site already references a registry ref or URL — then site.yml's own
522
+ // value rides verbatim.
523
+ ...(fnd.ref ? { injectInfo: { foundation: fnd.ref } } : {}),
524
+ // Both grains are dropped together by --force: one flag, one meaning,
525
+ // no partial-force mode.
526
+ ...(force
527
+ ? {}
528
+ : {
529
+ baseVersions: readBaseVersions(siteDir, client.origin),
530
+ itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
531
+ }),
532
+ ...(assetRewrite ? { assetRewrite } : {}),
533
+ ...(assetIds ? { assetIds } : {})
534
+ }
499
535
  let pkg
500
536
  try {
501
- pkg = await emitSyncPackages(siteDir, {
502
- backend: client.origin,
503
- // Placement identity for the folder — see writeFolderItemUuids.
504
- folderItemUuids: readFolderItemUuids(siteDir, client.origin),
505
- // Identity for the `queries` section — see readQueryUuids. Keyed by
506
- // name, because a declaration has no file for a path-keyed map to hold.
507
- queryUuids: readQueryUuids(siteDir, client.origin),
508
- ...(foundationDir ? { foundationDir } : {}),
509
- resolveModel: makeModelResolver({
510
- client,
511
- offline: Boolean(output) || dryRun
512
- }),
513
- priorHashes,
514
- sendAll,
515
- itemUuids,
516
- // The PINNED foundation ref from the bring-along above, stamped over the
517
- // authored `site.yml` string. Delivery is version-pinned end to end, so an
518
- // unpinned local name on the wire names code no host can serve. Absent when
519
- // the site already references a registry ref or URL — then site.yml's own
520
- // value rides verbatim.
521
- ...(fnd.ref ? { injectInfo: { foundation: fnd.ref } } : {}),
522
- // Both grains are dropped together by --force: one flag, one meaning,
523
- // no partial-force mode.
524
- ...(force
525
- ? {}
526
- : {
527
- baseVersions: readBaseVersions(siteDir, client.origin),
528
- itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
529
- }),
530
- ...(assetRewrite ? { assetRewrite } : {}),
531
- ...(assetIds ? { assetIds } : {})
532
- })
537
+ pkg = await emitSyncPackages(siteDir, emitOptions)
538
+ // A record the backend holds whose list items this copy never banked — pushed before the
539
+ // bank existed, say — is refused if sent without them. Recover them from the backend, and
540
+ // build the package again with them.
541
+ const unbanked = pkg.recordItemIdentity?.unbanked
542
+ if (unbanked?.length && !output && !dryRun) {
543
+ if (await recoverRecordItemUuids({ client, siteDir, uuids: unbanked, note })) {
544
+ pkg = await emitSyncPackages(siteDir, {
545
+ ...emitOptions,
546
+ recordItemUuids: readRecordItemUuids(siteDir, client.origin)
547
+ })
548
+ }
549
+ }
533
550
  } catch (err) {
534
551
  error(`Could not build the sync package: ${err.message}`)
535
552
  return { exitCode: 2 }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-25T11:51:15.075Z",
3
+ "generatedAt": "2026-09-25T13:50:26.904Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.2",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.63.0",
13
+ "version": "0.63.2",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.17.0",
119
+ "version": "0.17.2",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },