uniweb 0.33.0 → 0.33.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.33.0",
3
+ "version": "0.33.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,13 +41,13 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "^0.13.1",
45
- "@uniweb/semantic-parser": "^1.3.1",
46
44
  "@uniweb/core": "^0.13.1",
47
- "@uniweb/kit": "^0.14.0"
45
+ "@uniweb/kit": "^0.14.0",
46
+ "@uniweb/semantic-parser": "^1.3.1",
47
+ "@uniweb/runtime": "^0.13.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.29.0",
50
+ "@uniweb/build": "^0.29.1",
51
51
  "@uniweb/content-reader": "^1.2.4",
52
52
  "@uniweb/semantic-parser": "^1.3.1"
53
53
  },
@@ -27,6 +27,7 @@ import {
27
27
  computeUnitHashes,
28
28
  collectUnitUuids,
29
29
  collectFolderItemUuids,
30
+ collectCollectionUuids,
30
31
  readAssetMap
31
32
  } from '@uniweb/build/uwx'
32
33
 
@@ -241,6 +242,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
241
242
  const prior = readSyncCacheFile(siteDir)
242
243
  const dropped = [
243
244
  'itemUuids',
245
+ 'collectionUuids',
244
246
  'hashes',
245
247
  'baseVersions',
246
248
  'unitBases',
@@ -248,6 +250,9 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
248
250
  ].filter((k) => prior[k] && Object.keys(prior[k]).length)
249
251
  updateSyncCache(siteDir, {
250
252
  itemUuids: {},
253
+ // Remote-derived exactly like itemUuids — it holds the OLD site's collection
254
+ // ids, and surviving the drop it would offer them for the new site's sections.
255
+ collectionUuids: {},
251
256
  hashes: {},
252
257
  baseVersions: {},
253
258
  unitBases: {},
@@ -468,6 +473,32 @@ export function readItemUuids(siteDir) {
468
473
  export function readFolderItemUuids(siteDir) {
469
474
  return readMap(siteDir, 'folderItemUuids')
470
475
  }
476
+
477
+ /**
478
+ * Collection-declaration identity: `{ <collection name>: <backend $uuid> }`.
479
+ *
480
+ * ⛔ THE THIRD MAP, AND EACH ONE EXISTS FOR THE SAME REASON. `itemUuids` is keyed by
481
+ * the file an item projects to. Two kinds of item have no file of their own — a
482
+ * folder's placements, and a collection DECLARATION (they all live in one
483
+ * `collections/collections.yml`) — so a path-keyed map has no shape either could
484
+ * occupy, and a push re-sends that whole section uuid-less.
485
+ *
486
+ * The backend refuses an all-blank section over stored items rather than applying it,
487
+ * because applying it would insert every record fresh and delete every stored row —
488
+ * content survives, identity does not. So `push` worked once and every push after was
489
+ * refused. Measured 2026-08-29; collab framework-backend-812b.
490
+ *
491
+ * ⭐ Keyed by NAME, which the backend enforces unique within the section and uses as
492
+ * its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
493
+ * handle the backend skips on parse and never stores.
494
+ */
495
+ export function readCollectionUuids(siteDir) {
496
+ return readMap(siteDir, 'collectionUuids')
497
+ }
498
+ export function writeCollectionUuids(siteDir, map) {
499
+ if (!map || !Object.keys(map).length) return
500
+ updateSyncCache(siteDir, { collectionUuids: map })
501
+ }
471
502
  export function writeFolderItemUuids(siteDir, map) {
472
503
  if (!map || !Object.keys(map).length) return
473
504
  updateSyncCache(siteDir, { folderItemUuids: map })
@@ -948,20 +979,72 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
948
979
  // and others never settling, which reads like a content problem rather than a
949
980
  // resolution one. Measured on matinee 2026-08-29: `status` reported 4 changed
950
981
  // immediately after a successful push; passing the org took it to 1.
982
+ const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
983
+ const changed =
984
+ (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
985
+ return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
986
+ }
987
+
988
+ /**
989
+ * The emit whose hashes are comparable with the banked ones — ONE definition.
990
+ *
991
+ * ⛔ EVERY DEFECT IN THIS AREA HAS BEEN A WRITER AND A READER DISAGREEING ABOUT
992
+ * WHICH DOCUMENT A HASH DESCRIBES, so the option set that makes them agree must
993
+ * have a single home. Assembled from three sources, each for a stated reason:
994
+ *
995
+ * · BANKED the injections the push applied before hashing — serve URLs and
996
+ * the pinned foundation ref, which only a round trip produces.
997
+ * · RE-DERIVED asset identity, from the COMMITTED `assets.json`, so a moved map
998
+ * reads as changed rather than matching a copy of itself.
999
+ * · RECORDED the site's org, which resolves a foundation-relative `@/x` into
1000
+ * the `@org/x` the push keyed its hashes by, and the collection
1001
+ * identity a push stamps — so `status` hashes the same document a
1002
+ * push would send rather than one missing a section's `$uuid`s.
1003
+ *
1004
+ * Offline by design — measured at zero HTTP requests, a property the cross-client
1005
+ * flows rely on.
1006
+ */
1007
+ async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {}) {
951
1008
  const applied = readAppliedInjections(siteDir)
952
1009
  const assetIds = readAssetMap(siteDir)
953
1010
  const org = readSiteOrg(siteDir)
954
- const pkg = await emitSyncPackages(siteDir, {
1011
+ const collectionUuids = readCollectionUuids(siteDir)
1012
+ return emitSyncPackages(siteDir, {
955
1013
  resolveModel: makeModelResolver({ client: null, offline: true }),
956
1014
  priorHashes,
957
1015
  sendAll,
958
1016
  ...applied,
1017
+ ...(Object.keys(collectionUuids).length ? { collectionUuids } : {}),
959
1018
  ...(Object.keys(assetIds).length ? { assetIds } : {}),
960
1019
  ...(org ? { org } : {})
961
1020
  })
962
- const changed =
963
- (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
964
- return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
1021
+ }
1022
+
1023
+ /**
1024
+ * Re-bank the send-only-changed hashes over the files as they NOW stand.
1025
+ *
1026
+ * ⛔ FOR A WRITER THAT REWRITES THE WORKING TREE — today, `uniweb pull`.
1027
+ *
1028
+ * A pull projects the backend's document into source files, and that projection is
1029
+ * canonical rather than byte-identical to what was there: it moves section ordering
1030
+ * out of filename prefixes (`1-hero.md` → `hero.md` plus an explicit `sections:`
1031
+ * list) and stamps each section's `id`. Lossless, and a different document.
1032
+ *
1033
+ * ⚠️ `pull` already knew this for the OTHER map — it clears the `local` unit base
1034
+ * because "what we would emit from them is not byte-identical to it, so the old
1035
+ * local base no longer describes anything". That reasoning was never carried to the
1036
+ * hashes, which were left STALE rather than unknown: the next `uniweb status`
1037
+ * reported the site as having unpushed content immediately after a pull, forever.
1038
+ * Measured on matinee 2026-08-29 — `push → pull` reported 1 changed of 8.
1039
+ *
1040
+ * ⭐ Re-banking is the correct answer rather than clearing, because after a pull the
1041
+ * on-disk state IS the agreed state: it came from the backend. A push with no edits
1042
+ * in between should send nothing, and clearing would make it send everything.
1043
+ */
1044
+ export async function rebankSyncHashes(siteDir) {
1045
+ const pkg = await comparisonEmit(siteDir, { sendAll: true })
1046
+ writeSyncCache(siteDir, pkg.hashes || {}, pkg.applied || {})
1047
+ return Object.keys(pkg.hashes || {}).length
965
1048
  }
966
1049
 
967
1050
  /**
@@ -1063,18 +1146,47 @@ export async function pushSyncPackages({
1063
1146
  return null
1064
1147
  }
1065
1148
  if (problem?.reason === 'identity_required') {
1149
+ // ⛔ THE REFUSAL IS PER-SECTION, NOT PER-PUSH, AND WE USED TO SAY OTHERWISE.
1150
+ //
1151
+ // The backend asks, of each section independently: is this section's ENTIRE
1152
+ // incoming record set uuid-less while the stored entity already has items in
1153
+ // it? So a push can carry correct identity for most of the document and still
1154
+ // be refused over one section it stamps none of — and the old wording ("this
1155
+ // copy has no record of the site's item identity", "the recovery … could not
1156
+ // reach the backend", "`uniweb pull` also restores it") was wrong on all
1157
+ // three counts in exactly that case: the copy has identity, no recovery was
1158
+ // attempted, and a pull changes nothing.
1159
+ //
1160
+ // ⭐ THE BACKEND ALREADY SENDS WHAT LOCATES IT — `section_id`,
1161
+ // `records_without_uuid`, `stored_items` — and this branch discarded every
1162
+ // one of them, so every refusal anyone collected was missing the only fields
1163
+ // that say WHERE. (Named by backend in collab `framework-backend-812b`,
1164
+ // 2026-08-28: "the offending section and both counts have been in the body of
1165
+ // every refusal you have collected".)
1166
+ const n = problem.records_without_uuid
1167
+ const stored = problem.stored_items
1168
+ // `section_name` landed backend-side 2026-08-28 (`90e7cd7e`), replacing an
1169
+ // i64 row id we could not resolve. The fallback stays for an older backend,
1170
+ // not because the swap is pending — it is done.
1171
+ const where = problem.section_name ?? problem.section_id
1066
1172
  error(
1067
- `${label} push refused — this copy has no record of the site's item identity.`
1068
- )
1069
- note(
1070
- 'Nothing was written. Pushing without it would have replaced the identity of every stored item.'
1071
- )
1072
- note(
1073
- 'The recovery normally runs automatically, so it likely could not reach the backend.'
1173
+ `${label} push refused — one section carries no item identity` +
1174
+ (where === undefined ? '.' : ` (section ${where}).`)
1074
1175
  )
1176
+ if (Number.isInteger(n) && Number.isInteger(stored)) {
1177
+ note(
1178
+ `That section sent ${n} record(s), none carrying a \`$uuid\`, while ${stored} item(s) are already stored there.`
1179
+ )
1180
+ }
1075
1181
  note(
1076
- 'Check your connection and re-run; `uniweb pull` also restores it.'
1182
+ 'Nothing was written. Applying it would have replaced the identity of every stored item in that section.'
1077
1183
  )
1184
+ // ⚠️ Deliberately NOT "run `uniweb pull`". A pull re-harvests identity from
1185
+ // the backend's own document, so it fixes a LOST cache — and does nothing at
1186
+ // all when the cache is intact and one section simply has no entry in it,
1187
+ // which is the case this message now names. Suggesting it there sends the
1188
+ // user to re-fetch a map they already have.
1189
+ if (problem.detail) note(String(problem.detail))
1078
1190
  return null
1079
1191
  }
1080
1192
  if (problem?.reason === 'stale_base') {
@@ -1264,6 +1376,16 @@ export async function pushSyncPackages({
1264
1376
  }
1265
1377
  harvest(finalized)
1266
1378
  siteFinalizedDoc = finalized[0]?.document || null
1379
+ // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
1380
+ // placements below. These items have no file to back-fill into — they all
1381
+ // come from one `collections/collections.yml` — so the only place their
1382
+ // `$uuid` can live is the cache, keyed by the name the backend enforces
1383
+ // unique. Without it every push after the first re-sends the whole
1384
+ // `collections` section uuid-less and is refused.
1385
+ if (siteFinalizedDoc) {
1386
+ const collectionIds = collectCollectionUuids(siteFinalizedDoc)
1387
+ if (Object.keys(collectionIds).length) writeCollectionUuids(siteDir, collectionIds)
1388
+ }
1267
1389
  finalizedTotal += finalized.length
1268
1390
  } else {
1269
1391
  const payload = await postLane('site-content', () =>
@@ -75,7 +75,8 @@ import {
75
75
  resolveCollectionsConfig,
76
76
  readZip,
77
77
  computeUnitHashes,
78
- collectUnitUuids
78
+ collectUnitUuids,
79
+ collectCollectionUuids
79
80
  } from '@uniweb/build/uwx'
80
81
  import {
81
82
  readWritten,
@@ -84,6 +85,8 @@ import {
84
85
  } from '../utils/pull-written.js'
85
86
  import {
86
87
  makeModelResolver,
88
+ rebankSyncHashes,
89
+ writeCollectionUuids,
87
90
  mergeBaseVersions,
88
91
  mergeItemBaseVersions,
89
92
  writeUnitBases,
@@ -686,6 +689,10 @@ export async function pull(args = [], deps = {}) {
686
689
  // Per-item identity for the next push. Without it the backend reads our
687
690
  // records as new and re-mints every page and section row.
688
691
  writeItemUuids(siteDir, collectUnitUuids(siteDoc))
692
+ // The collections section's identity has no file to live in either — same
693
+ // reason, same remedy, keyed by name. A pull is the other route by which a
694
+ // copy can recover it (see readCollectionUuids).
695
+ writeCollectionUuids(siteDir, collectCollectionUuids(siteDoc))
689
696
  // Bring the media down BEFORE projecting: a newly-landed asset gains a map
690
697
  // entry, and the projection reads that map to put authored paths back. Run
691
698
  // after, and this pull's new assets would project as URLs and only restore
@@ -840,6 +847,33 @@ export async function pull(args = [], deps = {}) {
840
847
  removed
841
848
  )
842
849
 
850
+ // ⛔ RE-BANK THE SEND-ONLY-CHANGED HASHES OVER WHAT WE JUST WROTE.
851
+ //
852
+ // The projection above is canonical, not byte-identical to what was on disk: it
853
+ // moves section ordering out of filename prefixes (`1-hero.md` → `hero.md` plus an
854
+ // explicit `sections:` list) and stamps each section's `id`. Lossless, and a
855
+ // different document — which is exactly why the `local` unit base is cleared above.
856
+ //
857
+ // ⚠️ That same reasoning was never carried to the hashes, so they were left STALE
858
+ // rather than unknown, and `uniweb status` reported unpushed content immediately
859
+ // after a pull, permanently. Measured on matinee 2026-08-29: push → pull reported
860
+ // 1 changed of 8, with nothing edited in between.
861
+ //
862
+ // Re-banking rather than clearing, because after a pull the on-disk state IS the
863
+ // agreed state — it came from the backend, so a push with no edits should send
864
+ // nothing. Clearing would make it send everything.
865
+ //
866
+ // Best-effort: a failure here costs an unnecessary re-send on the next push, never
867
+ // wrong content, and must not fail a pull whose files are already written.
868
+ if (!dryRun) {
869
+ try {
870
+ await rebankSyncHashes(siteDir)
871
+ } catch (err) {
872
+ note(`! could not re-bank the sync cache: ${err.message}`)
873
+ note(' The next push will re-send content that is already current.')
874
+ }
875
+ }
876
+
843
877
  success(
844
878
  `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
845
879
  (deleted ? `, ${deleted} deleted` : '')
@@ -85,6 +85,7 @@ import {
85
85
  readItemBaseVersions,
86
86
  readItemUuids,
87
87
  readFolderItemUuids,
88
+ readCollectionUuids,
88
89
  ensureItemUuids,
89
90
  ensureSiteExists,
90
91
  clearRemoteSyncStateIfUnbound,
@@ -474,6 +475,9 @@ export async function push(args = [], deps = {}) {
474
475
  pkg = await emitSyncPackages(siteDir, {
475
476
  // Placement identity for the folder — see writeFolderItemUuids.
476
477
  folderItemUuids: readFolderItemUuids(siteDir),
478
+ // Identity for the `collections` section — see readCollectionUuids. Keyed by
479
+ // name, because a declaration has no file for a path-keyed map to hold.
480
+ collectionUuids: readCollectionUuids(siteDir),
477
481
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
478
482
  ...(asOrg ? { org: asOrg } : {}),
479
483
  ...(foundationDir ? { foundationDir } : {}),
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-28T16:19:53.330Z",
3
+ "generatedAt": "2026-08-28T23:49:23.885Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.29.0",
6
+ "version": "0.29.1",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -112,7 +112,7 @@
112
112
  "deps": []
113
113
  },
114
114
  "@uniweb/unipress": {
115
- "version": "0.8.15",
115
+ "version": "0.8.16",
116
116
  "path": "framework/unipress",
117
117
  "deps": [
118
118
  "@uniweb/build",