uniweb 0.68.0 → 0.69.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",
3
- "version": "0.68.0",
3
+ "version": "0.69.1",
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/content-writer": "^0.3.4",
45
44
  "@uniweb/core": "^0.29.8",
46
45
  "@uniweb/runtime": "^0.27.4",
47
- "@uniweb/kit": "^0.19.4",
48
46
  "@uniweb/schemas": "^0.7.0",
49
- "@uniweb/semantic-parser": "^1.4.1"
47
+ "@uniweb/semantic-parser": "^1.4.1",
48
+ "@uniweb/content-writer": "^0.3.4",
49
+ "@uniweb/kit": "^0.19.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.62.0",
52
+ "@uniweb/build": "^0.63.1",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -896,7 +896,7 @@ The body — the value of the schema's content field, `article_body.content`.
896
896
 
897
897
  **A component receives it differently from how the file writes it**, the same way on a static site and a hosted one: the brief's fields at the top (`article.title`), every other section under its name (`article.article_body.content`, `article.article_body.author`), and `$name`, the handle. Lists carry the brief alone unless the query says otherwise (`deferred:`, below). Full rules: `reference/entity-content.md`.
898
898
 
899
- **A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name, or its `slug:`) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (a second pass when that record is new too), and a pull writes the name back.
899
+ **A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name, or its `slug:`) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (in the same push when that record is new too), and a pull writes the name back.
900
900
 
901
901
  **`records/folder.yml` — optional, and only for folders.** It is the one file in `records/` that is not a record, it moves with the directory (`paths.records`), and like a pages folder's `folder.yml` it describes the folder it sits in. Every record sits at the top of the site's records folder unless `folder.yml` places it in a sub-folder. Add one only when a query needs to ask for a *slice* of the records rather than all of them — most sites never do:
902
902
 
@@ -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
@@ -1186,12 +1271,9 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1186
1271
  // ⚠️ Until 2026-09-23 this said the site's recorded ORG resolves it and must be
1187
1272
  // passed in — true before 2026-09-22, and read as a reason to pass it.
1188
1273
  const pkg = await comparisonEmit(siteDir, { backend, priorHashes, sendAll })
1189
- // A record held back for a reference (`pushInPasses`) is not in either lane, and is
1190
- // still unpushed.
1191
1274
  const changed =
1192
1275
  (pkg.siteContent?.entityCount || 0) +
1193
- (pkg.records?.entityCount || 0) +
1194
- (pkg.waiting || []).filter((w) => w.held).length
1276
+ (pkg.records?.entityCount || 0)
1195
1277
  return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
1196
1278
  }
1197
1279
 
@@ -1222,7 +1304,7 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1222
1304
  */
1223
1305
  async function comparisonEmit(
1224
1306
  siteDir,
1225
- { backend = null, priorHashes = {}, sendAll = false } = {}
1307
+ { backend = null, priorHashes = {}, sendAll = false, declarations = null } = {}
1226
1308
  ) {
1227
1309
  const applied = readAppliedInjections(siteDir, backend)
1228
1310
  // ⛔ Per backend: an asset id is minted by one and means nothing to another, so a
@@ -1232,7 +1314,13 @@ async function comparisonEmit(
1232
1314
  const queryUuids = readQueryUuids(siteDir, backend)
1233
1315
  return emitSyncPackages(siteDir, {
1234
1316
  backend,
1235
- 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 }),
1236
1324
  priorHashes,
1237
1325
  sendAll,
1238
1326
  ...applied,
@@ -1263,13 +1351,69 @@ async function comparisonEmit(
1263
1351
  * ⭐ Re-banking is the correct answer rather than clearing, because after a pull the
1264
1352
  * on-disk state IS the agreed state: it came from the backend. A push with no edits
1265
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
1266
1360
  */
1267
- export async function rebankSyncHashes(siteDir, backend = null) {
1268
- 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 })
1269
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
+ }
1270
1379
  return Object.keys(pkg.hashes || {}).length
1271
1380
  }
1272
1381
 
1382
+ /**
1383
+ * Re-bank the hashes of the records a push sent naming a record it created — `keys`,
1384
+ * the emit's `namesNew` — as the next push will hash them, and fingerprint their list
1385
+ * items the same way.
1386
+ *
1387
+ * ⭐ Such a record names its new record by `$ref`, and the backend stores the uuid it
1388
+ * mints. The next push names it by that uuid: the same reference, and another hash — so
1389
+ * the hash the push banked describes a document no later emit builds, and the next push
1390
+ * would send every one of these records again. Re-sending is not harmless: a record's
1391
+ * list-section items go without the identity a re-send needs (`identity_required`).
1392
+ *
1393
+ * ⚖️ Only these keys, over the hashes the push just banked: the site-content lane and
1394
+ * the folder are banked as that push sent them, and a caller's own emit options (a
1395
+ * publish that does not declare services) are not the comparison emit's.
1396
+ */
1397
+ export async function rebankRecordHashes(siteDir, backend, keys) {
1398
+ if (!keys?.length) return
1399
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true })
1400
+ const hashes = { ...readSyncCache(siteDir, backend) }
1401
+ for (const key of keys) if (pkg.hashes?.[key]) hashes[key] = pkg.hashes[key]
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)
1415
+ }
1416
+
1273
1417
  /**
1274
1418
  * What a push did with the site's `template:` intent — read from the backend's own
1275
1419
  * reply, never inferred from what we sent.
@@ -1747,6 +1891,18 @@ export async function pushSyncPackages({
1747
1891
  }
1748
1892
  for (const w of bf.warnings) note(`! ${w}`)
1749
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
+ )
1750
1906
  draftsNotKept = bf.notKeptAsDrafts || []
1751
1907
  if (bf.updated.length)
1752
1908
  wrote.push(`wrote ${bf.updated.length} record file(s)`)
@@ -1774,6 +1930,15 @@ export async function pushSyncPackages({
1774
1930
  // Entities absent from finalized[] (skipped, or not editable) keep their cached
1775
1931
  // value — absence is not invalidation.
1776
1932
  writeSyncCache(siteDir, client.origin, hashes, applied)
1933
+ // A record that named a record this push created is banked as the next push will send
1934
+ // it — by that record's uuid, which the back-fill above just recorded.
1935
+ if (records && pkg.namesNew?.length) {
1936
+ try {
1937
+ await rebankRecordHashes(siteDir, client.origin, pkg.namesNew)
1938
+ } catch (err) {
1939
+ note(`! Could not re-bank ${pkg.namesNew.length} record(s) that name a new record: ${err.message}. The next push sends them again.`)
1940
+ }
1941
+ }
1777
1942
  mergeHarvested()
1778
1943
  // Re-base the page attribution: our emitted document and the backend's post-write
1779
1944
  // copy of it are the two sides' new agreed state. Only when the site-content lane
@@ -1850,78 +2015,3 @@ export async function pushSyncPackages({
1850
2015
  }
1851
2016
  return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
1852
2017
  }
1853
-
1854
- /**
1855
- * Push, then push again while records WAIT for a reference's record — the verbs' way to
1856
- * send a package (`push`, `publish`).
1857
- *
1858
- * ⭐ A reference is sent as the uuid the backend minted for the record it names, so a
1859
- * record that points at one created by the same push cannot be completed in that push
1860
- * (`@uniweb/build`'s `emitSyncPackages` → `waiting`). The first pass creates what it can —
1861
- * records whose waiting references are not required go without them — and each further
1862
- * pass, emitted afresh from the state the last one banked, sends what has become
1863
- * nameable. A chain of required references takes one pass per link; two records that
1864
- * each require the other can never be completed, and the push stops, naming them.
1865
- *
1866
- * @param {object} params
1867
- * @param {object} params.pkg - the first pass's package (`emitSyncPackages`)
1868
- * @param {() => Promise<object>} params.reemit - the next pass's package, emitted afresh
1869
- * @param {Function} [params.push] - sends one package; `pushSyncPackages` (a test's seam)
1870
- * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[], passes: number }>}
1871
- */
1872
- export async function pushInPasses({ client, siteDir, pkg, report, reemit, maxPasses = 12, push = pushSyncPackages }) {
1873
- const { note, error } = report
1874
- let current = pkg
1875
- let passes = 1
1876
- let result = await push({ client, siteDir, pkg: current, report })
1877
- let finalizedTotal = result.finalizedTotal || 0
1878
- const wrote = [...(result.wrote || [])]
1879
- while (result.exitCode === 0 && current.waiting?.length) {
1880
- const n = current.waiting.length
1881
- note(
1882
- `${n} record${n === 1 ? '' : 's'} name${n === 1 ? 's' : ''} a record this push created — ` +
1883
- `pushing again to complete ${n === 1 ? 'its references' : 'their references'}.`
1884
- )
1885
- let next
1886
- try {
1887
- next = await reemit()
1888
- } catch (err) {
1889
- error(`Could not build the next pass: ${err.message}`)
1890
- return { ...result, exitCode: 1, finalizedTotal, wrote, passes }
1891
- }
1892
- if (refuseUnsendableRecords(next.refusals, report)) {
1893
- return { ...result, exitCode: 1, finalizedTotal, wrote, passes }
1894
- }
1895
- // Nothing became nameable: every record still waiting waits on one that waits too.
1896
- if (waitingKey(next.waiting) === waitingKey(current.waiting) || passes >= maxPasses) {
1897
- error(
1898
- 'These records could not be completed — each requires a reference to a record that ' +
1899
- 'is itself waiting to be created:'
1900
- )
1901
- for (const w of next.waiting || []) {
1902
- const refs = w.pending.map((p) => `${p.path} → "${p.name}"`).join(', ')
1903
- note(` ${w.id} (${w.model}): ${refs}`)
1904
- }
1905
- note(
1906
- 'Push one of them without that reference first — or make it optional in the schema — ' +
1907
- 'then add it back and push again.'
1908
- )
1909
- return { ...result, exitCode: 1, finalizedTotal, wrote, passes }
1910
- }
1911
- current = next
1912
- passes++
1913
- result = await push({ client, siteDir, pkg: current, report })
1914
- finalizedTotal += result.finalizedTotal || 0
1915
- for (const w of result.wrote || []) if (!wrote.includes(w)) wrote.push(w)
1916
- }
1917
- return { ...result, finalizedTotal, wrote, passes }
1918
- }
1919
-
1920
- // Which records wait, and on what — two passes that leave it the same made no progress.
1921
- function waitingKey(waiting) {
1922
- return JSON.stringify(
1923
- (waiting || [])
1924
- .map((w) => [w.model, w.id, w.pending.map((p) => `${p.path}:${p.name}`).sort()])
1925
- .sort()
1926
- )
1927
- }
@@ -86,12 +86,13 @@ import {
86
86
  readItemBaseVersions,
87
87
  ensureItemUuids,
88
88
  readFolderItemUuids,
89
+ readRecordItemUuids,
90
+ recoverRecordItemUuids,
89
91
  ensureSiteExists,
90
92
  refuseUnsendableRecords,
91
93
  clearRemoteSyncStateIfUnbound,
92
94
  dropSiteBoundValues,
93
- readItemUuids,
94
- pushInPasses
95
+ pushSyncPackages
95
96
  } from '../backend/site-sync.js'
96
97
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
97
98
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
@@ -943,36 +944,45 @@ export async function publish(args = []) {
943
944
  say.dim('Services unchanged.')
944
945
  }
945
946
 
946
- // The emit's options, read afresh for each pass (`pushInPasses`): a pass banks
947
- // hashes, identity and base versions that the next one must build on.
948
- //
949
947
  // publish rides the same gated push as `uniweb push`: if an app author has
950
948
  // edited since this clone last synced, the push is refused rather than
951
949
  // overwriting them, and nothing goes live. `--force` drops the precondition.
952
950
  const forced = args.includes('--force')
953
- const emitOptions = ({ priorHashes, itemUuids }) => ({
954
- backend: client.origin,
955
- ...(declaration.declare ? {} : { declareServices: false }),
956
- // Placement identity for the folder — see writeFolderItemUuids.
957
- folderItemUuids: readFolderItemUuids(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
- })
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
+ }
973
973
  let pkg
974
974
  try {
975
- pkg = await emitSyncPackages(siteDir, emitOptions({ priorHashes, itemUuids }))
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
+ }
976
986
  } catch (err) {
977
987
  say.err(`Could not build the sync package: ${err.message}`)
978
988
  return { exitCode: 1 }
@@ -987,19 +997,11 @@ export async function publish(args = []) {
987
997
  error: (m) => say.err(m),
988
998
  dim: (s) => `${c.dim}${s}${c.reset}`
989
999
  }
990
- const pushResult = await pushInPasses({
1000
+ const pushResult = await pushSyncPackages({
991
1001
  client,
992
1002
  siteDir,
993
1003
  pkg,
994
- report,
995
- reemit: () =>
996
- emitSyncPackages(
997
- siteDir,
998
- emitOptions({
999
- priorHashes: readSyncCache(siteDir, client.origin),
1000
- itemUuids: readItemUuids(siteDir, client.origin)
1001
- })
1002
- )
1004
+ report
1003
1005
  })
1004
1006
  if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
1005
1007
  const siteUuid = pushResult.boundSiteUuid
@@ -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,12 +82,14 @@ import {
82
82
  readItemUuids,
83
83
  readFolderItemUuids,
84
84
  readQueryUuids,
85
+ readRecordItemUuids,
86
+ recoverRecordItemUuids,
85
87
  ensureItemUuids,
86
88
  refuseUnsendableRecords,
87
89
  ensureSiteExists,
88
90
  clearRemoteSyncStateIfUnbound,
89
91
  dropSiteBoundValues,
90
- pushInPasses
92
+ pushSyncPackages
91
93
  } from '../backend/site-sync.js'
92
94
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
93
95
 
@@ -496,43 +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 })
499
- // The emit's options, read afresh for each pass (`pushInPasses`): a pass banks
500
- // hashes, identity and base versions that the next one must build on.
501
- const emitOptions = ({ priorHashes, sendAll, itemUuids }) => ({
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
- })
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
+ }
533
535
  let pkg
534
536
  try {
535
- pkg = await emitSyncPackages(siteDir, emitOptions({ priorHashes, sendAll, itemUuids }))
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
+ }
536
550
  } catch (err) {
537
551
  error(`Could not build the sync package: ${err.message}`)
538
552
  return { exitCode: 2 }
@@ -598,9 +612,8 @@ export async function push(args = [], deps = {}) {
598
612
  }
599
613
 
600
614
  // Submit both lanes, back-fill the minted uuids, and persist the send-only-changed
601
- // cache — again for any record whose reference names a record this push created.
602
- // Shared with `uniweb publish` via ../backend/site-sync.js.
603
- const result = await pushInPasses({
615
+ // cache. Shared with `uniweb publish` via ../backend/site-sync.js.
616
+ const result = await pushSyncPackages({
604
617
  client,
605
618
  siteDir,
606
619
  pkg,
@@ -609,16 +622,7 @@ export async function push(args = [], deps = {}) {
609
622
  note,
610
623
  error,
611
624
  dim: (s) => `${colors.dim}${s}${colors.reset}`
612
- },
613
- reemit: () =>
614
- emitSyncPackages(
615
- siteDir,
616
- emitOptions({
617
- priorHashes: readSyncCache(siteDir, client.origin),
618
- sendAll: false,
619
- itemUuids: readItemUuids(siteDir, client.origin)
620
- })
621
- )
625
+ }
622
626
  })
623
627
  if (result.exitCode !== 0) return { exitCode: result.exitCode }
624
628
  success(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-24T21:52:18.260Z",
3
+ "generatedAt": "2026-09-25T13:32:50.848Z",
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.62.0",
13
+ "version": "0.63.1",
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.1",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.10.5",
129
+ "version": "0.10.6",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",