uniweb 0.69.1 → 0.69.3

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.1",
3
+ "version": "0.69.3",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,14 +43,14 @@
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "^0.29.8",
45
45
  "@uniweb/runtime": "^0.27.4",
46
- "@uniweb/schemas": "^0.7.0",
47
- "@uniweb/semantic-parser": "^1.4.1",
48
46
  "@uniweb/content-writer": "^0.3.4",
49
- "@uniweb/kit": "^0.19.4"
47
+ "@uniweb/schemas": "^0.7.0",
48
+ "@uniweb/kit": "^0.19.4",
49
+ "@uniweb/semantic-parser": "^1.4.1"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.63.1",
53
52
  "@uniweb/content-reader": "^1.2.5",
53
+ "@uniweb/build": "^0.63.2",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -1196,37 +1196,94 @@ export function refuseUnsendableRecords(refusals, { error, note }) {
1196
1196
  * left alone. (The map sat in the gitignored cache until 2026-09-20, so a fresh
1197
1197
  * `git clone` or a deleted `.uniweb/` was what emptied it; it is committed now.)
1198
1198
  *
1199
+ * ⚠️ A PARTIAL map is not this function's: it runs before the package is built, when
1200
+ * nothing yet says which items went without identity. `recoverUnbankedIdentity` runs
1201
+ * after, and fills in what is missing.
1202
+ *
1199
1203
  * @returns {Promise<Object<string,string>>} the map (possibly empty)
1200
1204
  */
1201
1205
  export async function ensureItemUuids({ client, siteDir, note }) {
1202
1206
  const cached = readItemUuids(siteDir, client.origin)
1203
1207
  if (Object.keys(cached).length) return cached
1204
- // A site that has never been pushed has no identity to recover — and nothing to
1205
- // lose, since every item is genuinely new.
1208
+ const harvested = await storedItemUuids({ client, siteDir })
1209
+ if (!harvested || !Object.keys(harvested).length) return cached
1210
+ writeItemUuids(siteDir, client.origin, harvested)
1211
+ note?.(`Recovered identity for ${Object.keys(harvested).length} item(s) from the backend.`)
1212
+ return harvested
1213
+ }
1214
+
1215
+ /**
1216
+ * Each page and section `$uuid` the backend holds for the site this copy is bound to, by
1217
+ * the path it projects to — one read of its own document. Null when the copy is bound to
1218
+ * no site there (a site never pushed has no identity to recover, and nothing to lose,
1219
+ * since every item is genuinely new) or the read fails.
1220
+ *
1221
+ * Best-effort: a failure leaves the push identity-blind, which the backend refuses rather
1222
+ * than silently applying. Better to hit that than to guess.
1223
+ *
1224
+ * @returns {Promise<Object<string,string>|null>}
1225
+ */
1226
+ async function storedItemUuids({ client, siteDir }) {
1206
1227
  // The site to recover identity against — this backend's, from sync.json. It read
1207
1228
  // `site.yml::$uuid`, so after step 4 it found nothing and never recovered.
1208
1229
  const siteContentUuid = readBackendState(siteDir, client.origin).site?.uuid || null
1209
- if (!siteContentUuid) return cached
1230
+ if (!siteContentUuid) return null
1210
1231
  try {
1211
1232
  const res = await client.pullSiteContent(siteContentUuid)
1212
- if (!res?.ok) return cached
1233
+ if (!res?.ok) return null
1213
1234
  const doc = entityDocFromUwx(Buffer.from(await res.arrayBuffer()))
1214
- if (!doc) return cached
1215
- const harvested = collectUnitUuids(doc)
1216
- if (Object.keys(harvested).length) {
1217
- writeItemUuids(siteDir, client.origin, harvested)
1218
- note?.(
1219
- `Recovered identity for ${Object.keys(harvested).length} item(s) from the backend.`
1220
- )
1221
- }
1222
- return harvested
1235
+ return doc ? collectUnitUuids(doc) : null
1223
1236
  } catch {
1224
- // Best-effort: a failure here leaves the push identity-blind, which the backend
1225
- // refuses rather than silently applying. Better to hit that than to guess.
1226
- return cached
1237
+ return null
1227
1238
  }
1228
1239
  }
1229
1240
 
1241
+ /**
1242
+ * Fill in the page and section identity this copy LACKS for the site it is bound to, from
1243
+ * the backend's own document. What the copy already holds is kept.
1244
+ *
1245
+ * ⛔ `ensureItemUuids` recovers only an EMPTY map, and a map can be partial: until
1246
+ * 2026-09-25 a push whose records were refused after its pages landed saved the pages'
1247
+ * identity nowhere (`pushSyncPackages`), leaving the map with `site.yml` alone. Every push
1248
+ * after that sent the pages with no `$uuid` while the backend held them, and was refused —
1249
+ * "one section carries no item identity (section pages)" — with nothing ever recovering it.
1250
+ *
1251
+ * @returns {Promise<number>} how many pages and sections were recovered
1252
+ */
1253
+ export async function recoverItemUuids({ client, siteDir, note }) {
1254
+ const cached = readItemUuids(siteDir, client.origin)
1255
+ const harvested = await storedItemUuids({ client, siteDir })
1256
+ if (!harvested) return 0
1257
+ const missing = Object.keys(harvested).filter((path) => !cached[path])
1258
+ if (!missing.length) return 0
1259
+ writeItemUuids(siteDir, client.origin, { ...harvested, ...cached })
1260
+ note?.(`Recovered identity for ${missing.length} item(s) from the backend.`)
1261
+ return missing.length
1262
+ }
1263
+
1264
+ /**
1265
+ * Recover the identity a package just built went out without, when the backend may hold it —
1266
+ * a page or section (`itemIdentity.unknown`), or a record's list items
1267
+ * (`recordItemIdentity.unbanked`) — and say what to build the package again with.
1268
+ *
1269
+ * A page added since the last push is also without identity, and costs one read that finds
1270
+ * nothing. That is the price of never sending as new an item the backend already holds.
1271
+ *
1272
+ * @returns {Promise<object|null>} emit options to build the package again with, or null
1273
+ * when nothing was recovered
1274
+ */
1275
+ export async function recoverUnbankedIdentity({ client, siteDir, pkg, note }) {
1276
+ let again = null
1277
+ if (pkg?.itemIdentity?.unknown > 0 && (await recoverItemUuids({ client, siteDir, note }))) {
1278
+ again = { ...again, itemUuids: readItemUuids(siteDir, client.origin) }
1279
+ }
1280
+ const unbanked = pkg?.recordItemIdentity?.unbanked
1281
+ if (unbanked?.length && (await recoverRecordItemUuids({ client, siteDir, uuids: unbanked, note }))) {
1282
+ again = { ...again, recordItemUuids: readRecordItemUuids(siteDir, client.origin) }
1283
+ }
1284
+ return again
1285
+ }
1286
+
1230
1287
  export function readUnitBases(siteDir, backend) {
1231
1288
  const v = readMap(siteDir, backend, 'unitBases')
1232
1289
  return { local: v.local || {}, remote: v.remote || {} }
@@ -1791,6 +1848,73 @@ export async function pushSyncPackages({
1791
1848
  // The backend's post-write copy of the site-content document, kept for the
1792
1849
  // remote-side unit base (see writeUnitBases).
1793
1850
  let siteFinalizedDoc = null
1851
+ // ⭐ WHAT THE SITE-CONTENT LANE LANDED IS SETTLED THE MOMENT IT LANDS — its query and item
1852
+ // identity, its unit bases and the files it now holds as synced — on either path to it.
1853
+ // ⛔ Until 2026-09-25 the item identity and the bases were settled at the END, after the
1854
+ // records lane, so a records lane that was refused returned before them: the pages were
1855
+ // stored, their identity was saved nowhere, and every later push sent them without it and
1856
+ // was refused — "one section carries no item identity (section pages)" — while the
1857
+ // queries, banked at once, were kept. And the queries were banked on the update path
1858
+ // only, so a site created with no media never saved theirs.
1859
+ const settleSiteContent = () => {
1860
+ // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's placements
1861
+ // below. These items have no file to back-fill into — they all come from one
1862
+ // `collections/collections.yml` — so the only place their `$uuid` can live is
1863
+ // `sync.json` (this backend's `queries` map), keyed by the name the backend enforces
1864
+ // unique. Without it every push after the first re-sends the whole `collections`
1865
+ // section uuid-less and is refused.
1866
+ if (siteFinalizedDoc) {
1867
+ const recordIds = collectQueryUuids(siteFinalizedDoc)
1868
+ if (Object.keys(recordIds).length) writeQueryUuids(siteDir, client.origin, recordIds)
1869
+ }
1870
+ // Re-base the page attribution: our emitted document and the backend's post-write
1871
+ // copy of it are the two sides' new agreed state.
1872
+ const patch = {}
1873
+ const ours = sentSiteDoc
1874
+ if (ours) patch.local = computeUnitHashes(ours)
1875
+ // `finalized[].document` is the backend's own representation, read back from
1876
+ // the stored row — the only remote-side base a push can produce.
1877
+ const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
1878
+ if (theirs) {
1879
+ patch.remote = computeUnitHashes(theirs)
1880
+ // A unit the backend kept for someone else is not agreed state: its remote base
1881
+ // stays where it was, so a refusal names it as changed upstream.
1882
+ const before = readUnitBases(siteDir, client.origin).remote
1883
+ for (const path of notHeld.kept) {
1884
+ if (before[path]) patch.remote[path] = before[path]
1885
+ else delete patch.remote[path]
1886
+ }
1887
+ }
1888
+ if (Object.keys(patch).length) writeUnitBases(siteDir, client.origin, patch)
1889
+ // The backend's post-write document carries `$uuid` per item at every nesting
1890
+ // level, so the push that just landed also re-arms identity for the next one.
1891
+ // Replaced wholesale: an item that no longer exists must not keep a uuid that
1892
+ // would re-target something else.
1893
+ if (theirs) writeItemUuids(siteDir, client.origin, collectUnitUuids(theirs))
1894
+ else {
1895
+ // ⛔ BANKING IS BEST-EFFORT AND ITS FAILURE USED TO BE SILENT — say it here,
1896
+ // because the cost lands two commands away and names something else.
1897
+ //
1898
+ // Without `finalized[0].document` (carrying `pages`) this push banks NO item
1899
+ // identity. The push still reports success. The next `push`/`publish` then
1900
+ // emits with no `$uuid` per item, and the backend refuses — correctly, since
1901
+ // silently re-identifying every stored row is far worse. But that refusal
1902
+ // reads as a stale-token or a producer bug, with nothing pointing back at the
1903
+ // push that failed to bank.
1904
+ //
1905
+ // ⚠️ We cannot tell WHY it is absent from here — a response shape that
1906
+ // changed, a lane that shipped nothing, a backend that does not echo the
1907
+ // document. Report the observable fact and let the operator carry it.
1908
+ note(
1909
+ 'identity not banked: this push returned no post-write document, so no per-item ' +
1910
+ '$uuid was stored. The next push or publish will be identity-blind and the backend ' +
1911
+ 'may refuse it. `uniweb pull` re-arms identity if that happens.'
1912
+ )
1913
+ }
1914
+ // The files of the units the backend now holds as ours are the synced version — the
1915
+ // ancestor the next `pull --merge` merges from.
1916
+ if (heldUnits.length) recordPushedUnits(siteDir, heldUnits)
1917
+ }
1794
1918
  if (siteContent) {
1795
1919
  if (siteContentUuid) {
1796
1920
  const finalized = await pushLane(
@@ -1812,18 +1936,8 @@ export async function pushSyncPackages({
1812
1936
  }
1813
1937
  harvestSiteContent(finalized)
1814
1938
  siteFinalizedDoc = finalized[0]?.document || null
1815
- // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
1816
- // placements below. These items have no file to back-fill into — they all
1817
- // come from one `collections/collections.yml` — so the only place their
1818
- // `$uuid` can live is `sync.json` (this backend's `queries` map), keyed by the
1819
- // name the backend enforces unique. Without it every push after the first re-sends the whole
1820
- // `collections` section uuid-less and is refused.
1821
- if (siteFinalizedDoc) {
1822
- const recordIds = collectQueryUuids(siteFinalizedDoc)
1823
- if (Object.keys(recordIds).length)
1824
- writeQueryUuids(siteDir, client.origin, recordIds)
1825
- }
1826
1939
  finalizedTotal += finalized.length
1940
+ settleSiteContent()
1827
1941
  } else {
1828
1942
  const payload = await postLane(
1829
1943
  'site-content',
@@ -1857,6 +1971,7 @@ export async function pushSyncPackages({
1857
1971
  harvestSiteContent(createdFinalized)
1858
1972
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
1859
1973
  finalizedTotal += createdFinalized?.length ?? 1
1974
+ settleSiteContent()
1860
1975
  }
1861
1976
  }
1862
1977
 
@@ -1940,56 +2055,7 @@ export async function pushSyncPackages({
1940
2055
  }
1941
2056
  }
1942
2057
  mergeHarvested()
1943
- // Re-base the page attribution: our emitted document and the backend's post-write
1944
- // copy of it are the two sides' new agreed state. Only when the site-content lane
1945
- // actually shipped — a push that skipped it left that state where it was.
1946
- if (siteContent) {
1947
- const patch = {}
1948
- const ours = sentSiteDoc
1949
- if (ours) patch.local = computeUnitHashes(ours)
1950
- // `finalized[].document` is the backend's own representation, read back from
1951
- // the stored row — the only remote-side base a push can produce.
1952
- const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
1953
- if (theirs) {
1954
- patch.remote = computeUnitHashes(theirs)
1955
- // A unit the backend kept for someone else is not agreed state: its remote base
1956
- // stays where it was, so a refusal names it as changed upstream.
1957
- const before = readUnitBases(siteDir, client.origin).remote
1958
- for (const path of notHeld.kept) {
1959
- if (before[path]) patch.remote[path] = before[path]
1960
- else delete patch.remote[path]
1961
- }
1962
- }
1963
- if (Object.keys(patch).length) writeUnitBases(siteDir, client.origin, patch)
1964
- // The backend's post-write document carries `$uuid` per item at every nesting
1965
- // level, so the push that just landed also re-arms identity for the next one.
1966
- // Replaced wholesale: an item that no longer exists must not keep a uuid that
1967
- // would re-target something else.
1968
- if (theirs) writeItemUuids(siteDir, client.origin, collectUnitUuids(theirs))
1969
- else {
1970
- // ⛔ BANKING IS BEST-EFFORT AND ITS FAILURE USED TO BE SILENT — say it here,
1971
- // because the cost lands two commands away and names something else.
1972
- //
1973
- // Without `finalized[0].document` (carrying `pages`) this push banks NO item
1974
- // identity. The push still reports success. The next `push`/`publish` then
1975
- // emits with no `$uuid` per item, and the backend refuses — correctly, since
1976
- // silently re-identifying every stored row is far worse. But that refusal
1977
- // reads as a stale-token or a producer bug, with nothing pointing back at the
1978
- // push that failed to bank.
1979
- //
1980
- // ⚠️ We cannot tell WHY it is absent from here — a response shape that
1981
- // changed, a lane that shipped nothing, a backend that does not echo the
1982
- // document. Report the observable fact and let the operator carry it.
1983
- note(
1984
- 'identity not banked: this push returned no post-write document, so no per-item ' +
1985
- '$uuid was stored. The next push or publish will be identity-blind and the backend ' +
1986
- 'may refuse it. `uniweb pull` re-arms identity if that happens.'
1987
- )
1988
- }
1989
- }
1990
- // The files of the units the backend now holds as ours are the synced version — the
1991
- // ancestor the next `pull --merge` merges from.
1992
- if (heldUnits.length) recordPushedUnits(siteDir, heldUnits)
2058
+ // (The site-content lane's own state was settled when it landed — `settleSiteContent`.)
1993
2059
  // What the backend holds that this copy doesn't. None of it was overwritten, and no
1994
2060
  // later push will overwrite it — but the files are behind until a pull.
1995
2061
  if (notHeld.kept.length || notHeld.foreign.length) {
@@ -87,7 +87,7 @@ import {
87
87
  ensureItemUuids,
88
88
  readFolderItemUuids,
89
89
  readRecordItemUuids,
90
- recoverRecordItemUuids,
90
+ recoverUnbankedIdentity,
91
91
  ensureSiteExists,
92
92
  refuseUnsendableRecords,
93
93
  clearRemoteSyncStateIfUnbound,
@@ -973,16 +973,9 @@ export async function publish(args = []) {
973
973
  let pkg
974
974
  try {
975
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
+ // Identity the backend holds and this copy lacks — see `uniweb push`.
977
+ const again = await recoverUnbankedIdentity({ client, siteDir, pkg, note: (m) => say.dim(m) })
978
+ if (again) pkg = await emitSyncPackages(siteDir, { ...emitOptions, ...again })
986
979
  } catch (err) {
987
980
  say.err(`Could not build the sync package: ${err.message}`)
988
981
  return { exitCode: 1 }
@@ -83,7 +83,7 @@ import {
83
83
  readFolderItemUuids,
84
84
  readQueryUuids,
85
85
  readRecordItemUuids,
86
- recoverRecordItemUuids,
86
+ recoverUnbankedIdentity,
87
87
  ensureItemUuids,
88
88
  refuseUnsendableRecords,
89
89
  ensureSiteExists,
@@ -535,18 +535,12 @@ export async function push(args = [], deps = {}) {
535
535
  let pkg
536
536
  try {
537
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
- }
538
+ // A page, section or record list item the backend holds and this copy has no identity
539
+ // for — a map left partial, or a record pushed before its items were banked — is refused
540
+ // if sent without it. Recover it from the backend, and build the package again with it.
541
+ const again =
542
+ output || dryRun ? null : await recoverUnbankedIdentity({ client, siteDir, pkg, note })
543
+ if (again) pkg = await emitSyncPackages(siteDir, { ...emitOptions, ...again })
550
544
  } catch (err) {
551
545
  error(`Could not build the sync package: ${err.message}`)
552
546
  return { exitCode: 2 }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-25T13:32:50.848Z",
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.1",
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.1",
119
+ "version": "0.17.2",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },