uniweb 0.65.0 → 0.66.0

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.
@@ -15,6 +15,7 @@ import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from '
15
15
  import { join, dirname, relative, isAbsolute } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
+ import { recordWritten } from '../utils/pull-written.js'
18
19
  import { humanBytes } from '../utils/bytes.js'
19
20
  import { isAuthoredPreview } from '../utils/preview.js'
20
21
  import {
@@ -33,6 +34,7 @@ import {
33
34
  diffSiteUnits,
34
35
  describeSiteDiff,
35
36
  computeUnitHashes,
37
+ collectSiteUnits,
36
38
  collectUnitUuids,
37
39
  collectFolderItemUuids,
38
40
  collectQueryUuids,
@@ -91,6 +93,141 @@ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
91
93
  }
92
94
  }
93
95
 
96
+ // A unit in the form it is compared in across the two representations: keys sorted,
97
+ // `$`-keys dropped (`$uuid`, `$id` — identity and payload handles, not content).
98
+ const canonicalUnit = (v) =>
99
+ Array.isArray(v)
100
+ ? v.map(canonicalUnit)
101
+ : v && typeof v === 'object'
102
+ ? Object.fromEntries(
103
+ Object.keys(v)
104
+ .filter((k) => !k.startsWith('$'))
105
+ .sort()
106
+ .map((k) => [k, canonicalUnit(v[k])])
107
+ )
108
+ : v
109
+
110
+ /**
111
+ * Whether the backend's post-write copy of a unit is the content we sent.
112
+ *
113
+ * Compared on OUR keys only. The two are not byte-comparable — the backend's copy
114
+ * carries fields we don't emit (`params`, `theme_override`, its own `$uuid`) and
115
+ * orders keys its own way, which is why `site-diff.js` never hashes one side against
116
+ * the other — but what we emit, it stores verbatim: measured 2026-09-23 against a
117
+ * live `uniwebd`, 15 of 15 units equal on our keys after a push.
118
+ */
119
+ function writtenAsSent(sent, written) {
120
+ if (!sent || !written) return false
121
+ const onOurKeys = {}
122
+ for (const k of Object.keys(sent)) if (!k.startsWith('$')) onOurKeys[k] = written[k]
123
+ return JSON.stringify(canonicalUnit(sent)) === JSON.stringify(canonicalUnit(onOurKeys))
124
+ }
125
+
126
+ /**
127
+ * The post-write tokens a site-content push may bank: only those that describe
128
+ * content this copy HOLDS.
129
+ *
130
+ * ⛔ BANKING EVERY RETURNED TOKEN LOST OTHER WRITERS' WORK — measured 2026-09-23 on a
131
+ * live `uniwebd`, two copies of one site:
132
+ *
133
+ * 1. B edits section X and pushes.
134
+ * 2. A, which has not pulled, pushes an edit to section Y. The per-item gate reads
135
+ * A's X as untouched (`pkg == base`) and keeps B's X — correct. But the response
136
+ * carries X's token for B's content, and A banked it.
137
+ * 3. A's next push of X then reads as "only you changed it" (`host == base`) and
138
+ * overwrites B's edit, with no refusal.
139
+ *
140
+ * The entity token did the same to ADDITIONS: a section B added survived A's first
141
+ * push (created after A's base ⇒ KEEP), A banked a base newer than it, and A's next
142
+ * push deleted it (created at-or-before the base ⇒ "you dropped it").
143
+ *
144
+ * So a unit's token is banked only when its post-write copy is what we sent — our own
145
+ * write, or a unit nobody touched. A unit the backend kept for someone else keeps the
146
+ * token we already hold, which is the one for OUR content, so the next push that
147
+ * changes it is `pkg != base, host != base` and refused. And while the backend holds
148
+ * units we don't, the entity token stays where it was, so the next push keeps them.
149
+ *
150
+ * With either document missing there is nothing to compare, and the tokens are banked
151
+ * as the backend sent them — the contract is that `finalized[].document` is always
152
+ * there.
153
+ *
154
+ * @returns {{ version: string|null, itemVersions: Object<string,string>|null,
155
+ * held: string[], kept: string[], foreign: string[] }} `held`: units the backend
156
+ * holds as we sent them; `kept`: units it kept someone else's content for;
157
+ * `foreign`: units it holds that we did not send. All paths.
158
+ */
159
+ export function heldTokens({ sent, written, version = null, itemVersions = null }) {
160
+ if (!sent || !written) return { version, itemVersions, held: [], kept: [], foreign: [] }
161
+ const ours = collectSiteUnits(sent)
162
+ const theirs = collectSiteUnits(written)
163
+ const uuidAt = collectUnitUuids(written)
164
+ const units = new Set(Object.values(uuidAt))
165
+ const held = []
166
+ const kept = []
167
+ const foreign = []
168
+ const items = {}
169
+ for (const [path, uuid] of Object.entries(uuidAt)) {
170
+ if (!ours.has(path)) {
171
+ foreign.push(path)
172
+ continue
173
+ }
174
+ if (!writtenAsSent(ours.get(path), theirs.get(path))) {
175
+ kept.push(path)
176
+ continue
177
+ }
178
+ held.push(path)
179
+ if (itemVersions?.[uuid]) items[uuid] = itemVersions[uuid]
180
+ }
181
+ // A token for an item that is not a unit (a `queries` declaration) is never sent
182
+ // back — the emit narrows preconditions to units (`withBaseVersion`) — so it is
183
+ // banked as before: it can neither arm nor disarm anything.
184
+ for (const [uuid, token] of Object.entries(itemVersions || {})) {
185
+ if (!units.has(uuid)) items[uuid] = token
186
+ }
187
+ return {
188
+ version: foreign.length ? null : version,
189
+ itemVersions: itemVersions ? items : null,
190
+ held: held.sort(),
191
+ kept: kept.sort(),
192
+ foreign: foreign.sort()
193
+ }
194
+ }
195
+
196
+ // The files a unit projects to, under the site's own roots — `site.yml::paths` can
197
+ // relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
198
+ // to three files.
199
+ function unitFilesOf(siteDir, unitPath) {
200
+ if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
201
+ let paths = {}
202
+ try {
203
+ paths = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths || {}
204
+ } catch {
205
+ /* no or unreadable site.yml — the defaults are right */
206
+ }
207
+ for (const root of ['pages', 'layout']) {
208
+ if (unitPath.startsWith(`${root}/`)) return [`${paths[root] || root}${unitPath.slice(root.length)}`]
209
+ }
210
+ return []
211
+ }
212
+
213
+ /**
214
+ * Record, as synced, the files of the units a push left on the backend as we sent them.
215
+ *
216
+ * ⭐ WHAT A PUSH DELIVERED IS THE NEXT MERGE'S ANCESTOR. `pull --merge` finds the
217
+ * version a file last synced at by the hash in `pull-written.json`. A pull records what
218
+ * it wrote; this records what a push delivered — the units `heldTokens` says the
219
+ * backend holds as ours, never one it kept for someone else. Without it, after a merge
220
+ * and a push the ancestor was the pull's raw output, which nobody commits: the next
221
+ * merge found no common version and put both whole files under conflict markers, for
222
+ * edits on different lines (measured 2026-09-23).
223
+ */
224
+ function recordPushedUnits(siteDir, unitPaths) {
225
+ const files = new Set()
226
+ for (const p of unitPaths) for (const f of unitFilesOf(siteDir, p)) files.add(f)
227
+ const present = [...files].map((f) => join(siteDir, f)).filter((abs) => existsSync(abs))
228
+ if (present.length) recordWritten(siteDir, present)
229
+ }
230
+
94
231
  // Pull the finalized entities out of the restore response. The backend returns
95
232
  // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
96
233
  // carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
@@ -112,10 +249,10 @@ export function extractFinalized(payload) {
112
249
  uuid: d?.uuid ?? d?.document?.$uuid ?? null,
113
250
  changed: d?.changed,
114
251
  // Post-write optimistic-concurrency token, read back from the stored row.
115
- // Cache it UNCONDITIONALLY — do not branch on `changed` and do not infer:
116
- // the backend pins "zero-write ⇒ version unmoved" with a test, so a no-op
117
- // resubmit returns the value we already hold, and anything else is theirs
118
- // to report, not ours to derive.
252
+ // Do not branch on `changed`: the backend pins "zero-write ⇒ version unmoved"
253
+ // with a test, so a no-op resubmit returns the value we already hold. ⛔ But a
254
+ // site-content push banks it only while the stored document holds nothing we
255
+ // don't — see `heldTokens`, and what banking it unconditionally deleted.
119
256
  version: typeof d?.version === 'string' ? d.version : null,
120
257
  // Per-item post-write tokens, `{ recordUuid: <opaque> }` (backend `d7e46335`).
121
258
  // Same map shape and same key name the PULL manifest entry stamps, deliberately
@@ -548,8 +685,12 @@ export function readItemBaseVersions(siteDir, backend) {
548
685
  }
549
686
  export function mergeItemBaseVersions(siteDir, backend, versions) {
550
687
  if (!versions || !Object.keys(versions).length) return
688
+ // ⛔ The read names the backend. Without it the read keyed nothing, came back `{}`,
689
+ // and this "merge" replaced the map with the last response — dropping the token
690
+ // `heldTokens` withholds for a unit the backend kept for someone else, and with it
691
+ // the precondition that refuses the next push of that unit (2026-09-23).
551
692
  updateSyncCache(siteDir, backend, {
552
- itemBaseVersions: { ...readItemBaseVersions(siteDir), ...versions }
693
+ itemBaseVersions: { ...readItemBaseVersions(siteDir, backend), ...versions }
553
694
  })
554
695
  }
555
696
  export function mergeBaseVersions(siteDir, backend, versions) {
@@ -935,6 +1076,31 @@ async function recordAndDescribeOwner({ client, siteDir, payload, note }) {
935
1076
  return org
936
1077
  }
937
1078
 
1079
+ /**
1080
+ * Stop a push or publish before it sends anything, when a record cannot be sent as
1081
+ * written — the emit's `refusals` (`@uniweb/build`'s `recordsToEntities`).
1082
+ *
1083
+ * ⛔ Checked BEFORE the site is created or a byte uploaded, not only before the send:
1084
+ * both are writes of their own, which a refused records lane does not undo. (The lane
1085
+ * itself is one transaction on the backend since 2026-09-24, so a refusal there writes
1086
+ * nothing. Before that, a refused lane left the folder's entries and entities with no
1087
+ * data behind, and every later push of the site's records was refused — measured
1088
+ * 2026-09-23, from one record written by section.)
1089
+ *
1090
+ * @param {string[]|undefined} refusals
1091
+ * @param {{ error: (m: string) => void, note: (m: string) => void }} report
1092
+ * @returns {boolean} true when the caller must stop
1093
+ */
1094
+ export function refuseUnsendableRecords(refusals, { error, note }) {
1095
+ if (!refusals?.length) return false
1096
+ const n = refusals.length
1097
+ error(
1098
+ `${n === 1 ? 'A record' : `${n} records`} cannot be pushed as written — no content was sent.`
1099
+ )
1100
+ for (const r of refusals) note(` ${r}`)
1101
+ return true
1102
+ }
1103
+
938
1104
  /**
939
1105
  * Guarantee we have per-item identity before an identity-bearing push.
940
1106
  *
@@ -1440,11 +1606,11 @@ export async function pushSyncPackages({
1440
1606
  const harvest = (finalized) => {
1441
1607
  for (const f of finalized || []) {
1442
1608
  if (f.uuid && f.version) newVersions[f.uuid] = f.version
1443
- // Unconditionally, and NOT gated on `changed` — same rule as the entity
1444
- // token: the backend pins "zero-write ⇒ version unmoved", so a no-op
1445
- // resubmit hands back the value we already hold. An older backend omits the
1446
- // field entirely, which leaves the cached tokens alone and degrades to the
1447
- // entity grain, exactly as before.
1609
+ // NOT gated on `changed` — same rule as the entity token: the backend pins
1610
+ // "zero-write ⇒ version unmoved", so a no-op resubmit hands back the value we
1611
+ // already hold. (The site-content lane filters first, `harvestSiteContent`.)
1612
+ // An older backend omits the field entirely, which leaves the cached tokens
1613
+ // alone and degrades to the entity grain, exactly as before.
1448
1614
  if (f.itemVersions) Object.assign(newItemVersions, f.itemVersions)
1449
1615
  }
1450
1616
  }
@@ -1453,6 +1619,27 @@ export async function pushSyncPackages({
1453
1619
  mergeBaseVersions(siteDir, client.origin, newVersions)
1454
1620
  mergeItemBaseVersions(siteDir, client.origin, newItemVersions)
1455
1621
  }
1622
+ // ⛔ The site-content lane banks only the tokens for content this copy holds — the
1623
+ // document we sent against the one the backend stored (`heldTokens`, and the two
1624
+ // losses banking everything caused). What the backend kept for someone else is
1625
+ // collected here, to re-base attribution and to say so.
1626
+ const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
1627
+ const notHeld = { kept: [], foreign: [] }
1628
+ const heldUnits = []
1629
+ const harvestSiteContent = (finalized) => {
1630
+ for (const f of finalized || []) {
1631
+ const held = heldTokens({
1632
+ sent: sentSiteDoc,
1633
+ written: f.document,
1634
+ version: f.version,
1635
+ itemVersions: f.itemVersions
1636
+ })
1637
+ harvest([{ ...f, version: held.version, itemVersions: held.itemVersions }])
1638
+ heldUnits.push(...held.held)
1639
+ notHeld.kept.push(...held.kept)
1640
+ notHeld.foreign.push(...held.foreign)
1641
+ }
1642
+ }
1456
1643
  // The backend's post-write copy of the site-content document, kept for the
1457
1644
  // remote-side unit base (see writeUnitBases).
1458
1645
  let siteFinalizedDoc = null
@@ -1475,7 +1662,7 @@ export async function pushSyncPackages({
1475
1662
  mergeHarvested()
1476
1663
  return { exitCode: 1, finalizedTotal, wrote }
1477
1664
  }
1478
- harvest(finalized)
1665
+ harvestSiteContent(finalized)
1479
1666
  siteFinalizedDoc = finalized[0]?.document || null
1480
1667
  // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
1481
1668
  // placements below. These items have no file to back-fill into — they all
@@ -1519,7 +1706,7 @@ export async function pushSyncPackages({
1519
1706
  if (createdOrg)
1520
1707
  wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
1521
1708
  const createdFinalized = extractFinalized(payload)
1522
- harvest(createdFinalized)
1709
+ harvestSiteContent(createdFinalized)
1523
1710
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
1524
1711
  finalizedTotal += createdFinalized?.length ?? 1
1525
1712
  }
@@ -1589,12 +1776,21 @@ export async function pushSyncPackages({
1589
1776
  // actually shipped — a push that skipped it left that state where it was.
1590
1777
  if (siteContent) {
1591
1778
  const patch = {}
1592
- const ours = entityDocFromUwx(siteContent.buffer)
1779
+ const ours = sentSiteDoc
1593
1780
  if (ours) patch.local = computeUnitHashes(ours)
1594
1781
  // `finalized[].document` is the backend's own representation, read back from
1595
1782
  // the stored row — the only remote-side base a push can produce.
1596
1783
  const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
1597
- if (theirs) patch.remote = computeUnitHashes(theirs)
1784
+ if (theirs) {
1785
+ patch.remote = computeUnitHashes(theirs)
1786
+ // A unit the backend kept for someone else is not agreed state: its remote base
1787
+ // stays where it was, so a refusal names it as changed upstream.
1788
+ const before = readUnitBases(siteDir, client.origin).remote
1789
+ for (const path of notHeld.kept) {
1790
+ if (before[path]) patch.remote[path] = before[path]
1791
+ else delete patch.remote[path]
1792
+ }
1793
+ }
1598
1794
  if (Object.keys(patch).length) writeUnitBases(siteDir, client.origin, patch)
1599
1795
  // The backend's post-write document carries `$uuid` per item at every nesting
1600
1796
  // level, so the push that just landed also re-arms identity for the next one.
@@ -1622,6 +1818,16 @@ export async function pushSyncPackages({
1622
1818
  )
1623
1819
  }
1624
1820
  }
1821
+ // The files of the units the backend now holds as ours are the synced version — the
1822
+ // ancestor the next `pull --merge` merges from.
1823
+ if (heldUnits.length) recordPushedUnits(siteDir, heldUnits)
1824
+ // What the backend holds that this copy doesn't. None of it was overwritten, and no
1825
+ // later push will overwrite it — but the files are behind until a pull.
1826
+ if (notHeld.kept.length || notHeld.foreign.length) {
1827
+ const paths = [...new Set([...notHeld.kept, ...notHeld.foreign])].sort()
1828
+ note(`Changed on the backend by someone else, not in your files yet: ${paths.join(', ')}`)
1829
+ note('Take them with `uniweb refresh` (or `uniweb pull --merge`). Until then, pushing leaves them as they are.')
1830
+ }
1625
1831
  // ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
1626
1832
  // piece of sync state above is banked: the push itself happened, and the next one must
1627
1833
  // see it. What must not happen is a publish that goes live on top of it, which is why
@@ -102,7 +102,7 @@ function slugify(s) {
102
102
  // than imported because pull.js statically imports @uniweb/build).
103
103
  export function extractDocument(payload) {
104
104
  if (!payload || typeof payload !== 'object') return null
105
- if (payload.$model || payload.$id || payload.info) return payload
105
+ if (payload.$schema || payload.$uuid || payload.$id || payload.info) return payload
106
106
  return payload.document || payload.entity || null
107
107
  }
108
108
 
@@ -93,10 +93,6 @@ export async function deploy(args = []) {
93
93
  const dryRun = args.includes('--dry-run')
94
94
  const siteDir = await resolveSiteDir(args)
95
95
 
96
- // Advisory only — warns and ships. See utils/conformance.js for why this
97
- // is not a gate.
98
- await warnIfContentDoesNotConform(siteDir, { args, warn: say.warn, dim: say.dim })
99
-
100
96
  // Host dispatch. Resolution order:
101
97
  // 1. --target <name> picks a target from deploy.yml
102
98
  // 2. deploy.yml's `default:` target when no flag is given
@@ -184,6 +180,13 @@ export async function deploy(args = []) {
184
180
  }
185
181
  }
186
182
 
183
+ // A static host: advisory — warns and ships (utils/conformance.js). Uniweb Cloud is
184
+ // `publish`, below, which refuses content that does not conform: there the backend
185
+ // enforces the schemas, so the check is a gate, not advice (2026-09-24).
186
+ if (plan.kind !== 'uniweb') {
187
+ await warnIfContentDoesNotConform(siteDir, { args, warn: say.warn, dim: say.dim })
188
+ }
189
+
187
190
  // Uniweb Cloud is `publish`'s flow — delegate so deploy.yml stays one
188
191
  // actionable record and there's a single implementation of go-live.
189
192
  if (plan.kind === 'uniweb') {
@@ -224,9 +227,8 @@ export async function deploy(args = []) {
224
227
  // publish ignores deploy's --host/--target; --dry-run/--no-save pass straight
225
228
  // through.
226
229
  const { publish } = await import('./publish.js')
227
- // Conformance was already reported above, and publish runs the same check
228
- // — without this the user reads one warning twice and learns to skim it.
229
- const result = await publish([...args, '--no-validate'])
230
+ // publish runs the conformance gate itself; deploy checked nothing for this host.
231
+ const result = await publish(args)
230
232
  process.exit(result?.exitCode ?? 0)
231
233
  }
232
234
 
@@ -68,7 +68,7 @@ import {
68
68
  } from '../backend/client.js'
69
69
  import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
70
70
  import { resolveSiteDir } from './deploy.js'
71
- import { warnIfContentDoesNotConform } from '../utils/conformance.js'
71
+ import { refuseIfContentDoesNotConform } from '../utils/conformance.js'
72
72
  import { readFlagValue } from '../utils/args.js'
73
73
  import { checkFlags } from '../utils/flag-guard.js'
74
74
  import {
@@ -87,6 +87,7 @@ import {
87
87
  ensureItemUuids,
88
88
  readFolderItemUuids,
89
89
  ensureSiteExists,
90
+ refuseUnsendableRecords,
90
91
  clearRemoteSyncStateIfUnbound,
91
92
  dropSiteBoundValues,
92
93
  pushSyncPackages
@@ -255,9 +256,11 @@ export async function publish(args = []) {
255
256
 
256
257
  const siteDir = await resolveSiteDir(args, 'publish')
257
258
 
258
- // Advisory only — warns and ships. See utils/conformance.js for why this
259
- // is not a gate.
260
- await warnIfContentDoesNotConform(siteDir, { args })
259
+ // ⭐ A GATE, before anything is sent — the backend checks these records against the
260
+ // schemas this publish registers. See utils/conformance.js.
261
+ if (await refuseIfContentDoesNotConform(siteDir, { args, error: say.err, warn: say.warn, dim: say.dim })) {
262
+ return { exitCode: 1 }
263
+ }
261
264
  const siteYml = readSiteYml(join(siteDir, 'site.yml'))
262
265
  // ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
263
266
  // backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
@@ -568,6 +571,11 @@ export async function publish(args = []) {
568
571
  say.err(`Could not build the sync package: ${err.message}`)
569
572
  return { exitCode: 1 }
570
573
  }
574
+ // A record the backend would refuse stops the publish HERE — before the site is
575
+ // created or a byte uploaded, not merely before the send (`refuseUnsendableRecords`).
576
+ if (refuseUnsendableRecords(probe.refusals, { error: say.err, note: say.dim })) {
577
+ return { exitCode: 1 }
578
+ }
571
579
  const schemalessNames = (probe.schemaless || []).map((col) => col.name)
572
580
  // A product decision the author is usually making unknowingly — say it at warn
573
581
  // level, not dim among everything else. See the helper for what the old
@@ -964,6 +972,9 @@ export async function publish(args = []) {
964
972
  return { exitCode: 1 }
965
973
  }
966
974
  for (const w of pkg.warnings) say.dim(`! ${w}`)
975
+ if (refuseUnsendableRecords(pkg.refusals, { error: say.err, note: say.dim })) {
976
+ return { exitCode: 1 }
977
+ }
967
978
  const report = {
968
979
  info: (m) => say.info(m),
969
980
  note: (m) => say.dim(m),