uniweb 0.35.0 → 0.37.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
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/kit": "^0.15.1",
45
- "@uniweb/core": "^0.15.0",
46
- "@uniweb/runtime": "^0.13.4",
44
+ "@uniweb/core": "^0.16.0",
45
+ "@uniweb/kit": "^0.15.2",
46
+ "@uniweb/runtime": "^0.13.5",
47
47
  "@uniweb/semantic-parser": "^1.4.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.31.0",
51
- "@uniweb/semantic-parser": "^1.4.0",
52
- "@uniweb/content-reader": "^1.2.4"
50
+ "@uniweb/build": "^0.33.0",
51
+ "@uniweb/content-reader": "^1.2.4",
52
+ "@uniweb/semantic-parser": "^1.4.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -95,7 +95,10 @@ import {
95
95
  reportPaymentRefusal
96
96
  } from '../backend/payment-handoff.js'
97
97
  import { reportSchemalessQueries } from '../utils/schemaless-report.js'
98
- import { uploadSiteData } from '../utils/site-data-upload.js'
98
+ import {
99
+ uploadSiteData,
100
+ describeDataRefusal
101
+ } from '../utils/site-data-upload.js'
99
102
 
100
103
  const c = {
101
104
  reset: '\x1b[0m',
@@ -456,7 +459,12 @@ export async function publish(args = []) {
456
459
  // destructive — a placeholder file, created meaning to fill it in — so the count
457
460
  // is reported and confirmed before anything is sent.
458
461
  {
459
- const guard = await guardEmptyRecords({ siteDir, args, warn: say.warn, note: say.dim })
462
+ const guard = await guardEmptyRecords({
463
+ siteDir,
464
+ args,
465
+ warn: say.warn,
466
+ note: say.dim
467
+ })
460
468
  if (!guard.ok) return { exitCode: 1 }
461
469
  }
462
470
 
@@ -602,28 +610,56 @@ export async function publish(args = []) {
602
610
  // does not exist, which is the failure this work kept catching in others.
603
611
  // `client.discover()` is the mechanism if that changes — `DISCOVERY_DEFAULTS`
604
612
  // makes an absent key non-breaking by construction.
605
- if (ball) {
606
- say.info('Uploading schema-less record data…')
607
- try {
608
- const r = await uploadSiteData({
609
- apiBase: client.origin,
610
- token: await client.token(),
611
- siteUuid: site.uuid,
612
- ball,
613
- onProgress: (m) => say.dim(` ${m}`)
614
- })
615
- if (r.failed.length) {
616
- // A file whose bytes did not land must not be published: the site would
617
- // serve a stale copy or 404, and the only trace would be a warning.
618
- say.err(`${r.failed.length} data file(s) failed to upload not publishing.`)
619
- for (const f of r.failed) say.dim(` ${f.path} (HTTP ${f.status})`)
620
- return { exitCode: 1 }
621
- }
622
- say.dim(`Record data : ${r.uploaded.length} file(s) [${r.mode}]`)
623
- } catch (err) {
624
- say.err(`Record data upload failed: ${err.message}`)
613
+ //
614
+ // ⛔ UNCONDITIONAL — `if (ball)` was here until 2026-09-01 and it was the bug.
615
+ //
616
+ // `collectSchemalessData` returns null for an empty set, so a publish that
617
+ // carried no schema-less data sent NO PLAN AT ALL. The backend reconciles a
618
+ // site's data usage against this manifest, and a request that never arrives
619
+ // is not a manifest saying "none" — it is silence, indistinguishable from a
620
+ // publish that never happened. So deleting your LAST schema-less collection
621
+ // — the exact operation the reconcile exists to make free — was the one
622
+ // operation that could not be expressed, and the site kept paying for it
623
+ // until the whole site was deleted.
624
+ //
625
+ // The general shape, worth more than the fix: an EMPTY set and NO set are
626
+ // different statements, and an `if (x)` guard collapses them into one. The
627
+ // cost is always paid by whoever is downstream trying to tell them apart.
628
+ //
629
+ // Both halves agreed in channel backend-framework-82f2 (2026-09-01); the
630
+ // backend's route accepts an empty `files` array as of the same exchange.
631
+ say.info('Uploading schema-less record data…')
632
+ try {
633
+ const r = await uploadSiteData({
634
+ apiBase: client.origin,
635
+ token: await client.token(),
636
+ siteUuid: site.uuid,
637
+ ball,
638
+ onProgress: (m) => say.dim(` ${m}`)
639
+ })
640
+ if (r.failed.length) {
641
+ // A file whose bytes did not land must not be published: the site would
642
+ // serve a stale copy or 404, and the only trace would be a warning.
643
+ say.err(
644
+ `${r.failed.length} data file(s) failed to upload — not publishing.`
645
+ )
646
+ for (const f of r.failed) say.dim(` ${f.path} (HTTP ${f.status})`)
625
647
  return { exitCode: 1 }
626
648
  }
649
+ say.dim(`Record data : ${r.uploaded.length} file(s) [${r.mode}]`)
650
+ } catch (err) {
651
+ // A typed plan refusal gets its own account (quota, or whatever else the
652
+ // backend names); anything else falls through to the raw message. Same
653
+ // treatment the asset plan and the site create already get — this was the
654
+ // last door still printing the problem document at the user verbatim.
655
+ const refusal = describeDataRefusal(err)
656
+ if (refusal) {
657
+ say.err(refusal.headline)
658
+ for (const line of refusal.notes) say.dim(line)
659
+ } else {
660
+ say.err(`Record data upload failed: ${err.message}`)
661
+ }
662
+ return { exitCode: 1 }
627
663
  }
628
664
 
629
665
  // 5. Push the site (content + folder) over the send-only-changed cache —
@@ -680,9 +716,7 @@ export async function publish(args = []) {
680
716
  ? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir) }
681
717
  : {}),
682
718
  ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
683
- ...(Object.keys(ext.pins).length
684
- ? { injectExtensions: ext.pins }
685
- : {}),
719
+ ...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
686
720
  ...(assetRewrite ? { assetRewrite } : {}),
687
721
  ...(assetIds ? { assetIds } : {})
688
722
  })
@@ -257,7 +257,15 @@ export function printSurvey(
257
257
  statusText = `${colors.dim}aligned${colors.reset}`
258
258
  } else if (row.status === 'behind') {
259
259
  icon = `${colors.yellow}✗${colors.reset}`
260
- statusText = `${colors.yellow}behind${colors.reset}`
260
+ // `behind` alone was the whole message, in one colour, for every
261
+ // distance. In 0.x the minor slot is where our breaking changes live
262
+ // (`publish.js` derives it from a breaking marker and nothing else), so
263
+ // `^0.14.1 → ^0.16.0` is two releases a consumer must act on and
264
+ // `^0.15.0 → ^0.15.2` is not — and the table said the same thing about
265
+ // both. Naming the class is the difference between a list and a signal.
266
+ statusText = row.breaking
267
+ ? `${colors.red}${row.bump} · BREAKING${colors.reset}`
268
+ : `${colors.yellow}${row.bump}${colors.reset}`
261
269
  } else {
262
270
  icon = `${colors.cyan}↑${colors.reset}`
263
271
  statusText = `${colors.cyan}ahead of CLI${colors.reset}`
@@ -272,6 +280,35 @@ export function printSurvey(
272
280
  ` ${colors.dim}(${alignedCount} other${alignedCount === 1 ? '' : 's'} already aligned — ${colors.reset}${colors.cyan}--verbose${colors.reset}${colors.dim} to list)${colors.reset}`
273
281
  )
274
282
  }
283
+
284
+ // ⭐ **A summary, because the table scrolls and `--yes` does not stop.** The
285
+ // per-row label above is invisible to the case that matters most: a CI or an
286
+ // agent running `update --yes`, where nobody reads a table and the only
287
+ // artifact is a log. This block names the packages, so "what did that
288
+ // upgrade cross?" is answerable afterwards from the log alone.
289
+ //
290
+ // ⚖️ It reports; it does not gate. Crossing a 0.x minor IS the ordinary way
291
+ // to take a Uniweb update — gating it would gate nearly every real upgrade
292
+ // and make the verb useless. The decision stays the operator's; what changed
293
+ // is that they can now make it knowingly.
294
+ const breakingRows = report.rows.filter((r) => r.breaking)
295
+ if (breakingRows.length > 0) {
296
+ const names = [...new Set(breakingRows.map((r) => r.name))]
297
+ log('')
298
+ log(
299
+ `${colors.red}⚠${colors.reset} ${colors.bright}${names.length} package${names.length === 1 ? '' : 's'} cross${names.length === 1 ? 'es' : ''} a breaking boundary:${colors.reset}`
300
+ )
301
+ for (const name of names) {
302
+ const r = breakingRows.find((x) => x.name === name)
303
+ log(` ${colors.red}${name}${colors.reset} ${r.current} → ${r.target} ${colors.dim}(${r.bump})${colors.reset}`)
304
+ }
305
+ log(
306
+ ` ${colors.dim}In 0.x the minor slot is where breaking changes go, so these are releases${colors.reset}`
307
+ )
308
+ log(
309
+ ` ${colors.dim}you may need to act on. Read their changelogs before shipping.${colors.reset}`
310
+ )
311
+ }
275
312
  log('')
276
313
  }
277
314
 
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-02T02:47:52.336Z",
3
+ "generatedAt": "2026-09-02T16:36:26.382Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.2.1",
6
+ "version": "0.2.2",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.31.0",
13
+ "version": "0.33.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -34,7 +34,7 @@
34
34
  "deps": []
35
35
  },
36
36
  "@uniweb/core": {
37
- "version": "0.15.0",
37
+ "version": "0.16.0",
38
38
  "path": "framework/core",
39
39
  "deps": [
40
40
  "@uniweb/semantic-parser",
@@ -47,14 +47,14 @@
47
47
  "deps": []
48
48
  },
49
49
  "@uniweb/icons": {
50
- "version": "0.4.6",
50
+ "version": "0.4.7",
51
51
  "path": "framework/icons",
52
52
  "deps": [
53
53
  "@uniweb/core"
54
54
  ]
55
55
  },
56
56
  "@uniweb/kit": {
57
- "version": "0.15.1",
57
+ "version": "0.15.2",
58
58
  "path": "framework/kit",
59
59
  "deps": [
60
60
  "@uniweb/core",
@@ -73,7 +73,7 @@
73
73
  "deps": []
74
74
  },
75
75
  "@uniweb/projections": {
76
- "version": "0.5.3",
76
+ "version": "0.5.4",
77
77
  "path": "framework/projections",
78
78
  "deps": [
79
79
  "@uniweb/content-writer",
@@ -81,7 +81,7 @@
81
81
  ]
82
82
  },
83
83
  "@uniweb/runtime": {
84
- "version": "0.13.4",
84
+ "version": "0.13.5",
85
85
  "path": "framework/runtime",
86
86
  "deps": [
87
87
  "@uniweb/core",
@@ -119,7 +119,7 @@
119
119
  "deps": []
120
120
  },
121
121
  "@uniweb/unipress": {
122
- "version": "0.8.17",
122
+ "version": "0.8.19",
123
123
  "path": "framework/unipress",
124
124
  "deps": [
125
125
  "@uniweb/build",
@@ -33,6 +33,53 @@ export function stripVersionRange(spec) {
33
33
  )
34
34
  }
35
35
 
36
+ /**
37
+ * How far a bump moves, in the terms our versioning actually uses.
38
+ *
39
+ * ⭐ **In 0.x the MINOR slot is the breaking one, and it is the only channel we
40
+ * have.** `scripts/framework/publish.js` derives a package's bump from its own
41
+ * commits: a breaking marker means minor, everything else patch. So
42
+ * `^0.14.1 → ^0.16.0` is two breaking releases and `^0.15.0 → ^0.15.2` is not —
43
+ * and until 2026-09-02 `update` printed both as `behind`, in the same colour,
44
+ * and `--yes` applied them without a word.
45
+ *
46
+ * That is the one signal the version scheme exists to send, discarded by the
47
+ * command we tell every project to run — `AGENTS.md` ships that instruction
48
+ * into every scaffold. The `flows` lane crossed `@uniweb/core` `^0.14.1 →
49
+ * ^0.15.0` this way and learned it afterwards, from a changelog.
50
+ *
51
+ * @param {string} from - the currently declared range or version
52
+ * @param {string} to - the version the matrix carries
53
+ * @returns {'patch'|'minor'|'major'|'none'}
54
+ */
55
+ export function bumpClass(from, to) {
56
+ const [aMaj = 0, aMin = 0, aPat = 0] = stripVersionRange(from).split('.').map(Number)
57
+ const [bMaj = 0, bMin = 0, bPat = 0] = stripVersionRange(to).split('.').map(Number)
58
+ if (bMaj !== aMaj) return 'major'
59
+ if (bMin !== aMin) return 'minor'
60
+ if (bPat !== aPat) return 'patch'
61
+ return 'none'
62
+ }
63
+
64
+ /**
65
+ * Is this crossing one a consumer must act on?
66
+ *
67
+ * A major always is. A minor is **when the major is 0**, because that is where
68
+ * our scheme puts breaking changes — and npm agrees, which is the check that
69
+ * makes this more than our own convention: `^0.14.1` admits `0.14.x` and
70
+ * refuses `0.15.0`, so the range itself already treats the slot as a wall.
71
+ *
72
+ * @param {string} from
73
+ * @param {string} to
74
+ * @returns {boolean}
75
+ */
76
+ export function isBreakingBump(from, to) {
77
+ const cls = bumpClass(from, to)
78
+ if (cls === 'major') return true
79
+ if (cls !== 'minor') return false
80
+ return Number(stripVersionRange(to).split('.')[0]) === 0
81
+ }
82
+
36
83
  /**
37
84
  * Compare two version specs (range prefix tolerated). Returns 1 / -1 / 0.
38
85
  * @param {string} a
@@ -115,7 +162,12 @@ export async function surveyWorkspaceDeps(workspaceDir) {
115
162
  name,
116
163
  current,
117
164
  target,
118
- status
165
+ status,
166
+ // Classified here rather than at print time so every consumer of a
167
+ // survey row gets the same answer — the report, the summary, and any
168
+ // gate a caller applies.
169
+ bump: status === 'behind' ? bumpClass(current, target) : 'none',
170
+ breaking: status === 'behind' && isBreakingBump(current, target)
119
171
  })
120
172
  }
121
173
  }
@@ -51,6 +51,7 @@
51
51
  */
52
52
 
53
53
  import { createHash } from 'node:crypto'
54
+ import { humanBytes } from './bytes.js'
54
55
 
55
56
  /**
56
57
  * Plan + upload a site's static collection data files.
@@ -72,10 +73,23 @@ export async function uploadSiteData({
72
73
  ball,
73
74
  onProgress = () => {}
74
75
  }) {
76
+ // ⛔ An EMPTY set still posts a plan, and that is the whole point of this lane
77
+ // being a manifest rather than a stream of files.
78
+ //
79
+ // The backend reconciles a site's data usage against what this plan declares:
80
+ // whatever is not in the manifest is gone, so deleting a collection stops
81
+ // costing on the next publish. ⭐ A plan with zero files is a STATEMENT that
82
+ // there are none; the ABSENCE of a request says nothing at all. Returning
83
+ // early here — which this did until 2026-09-01 — made "the user deleted their
84
+ // last schema-less collection" unexpressible on the wire, so that site kept
85
+ // paying for bytes it no longer served until the whole site was deleted.
86
+ // Nothing looked wrong at either end: no error, no warning, just a request
87
+ // that was never sent.
88
+ //
89
+ // Agreed both sides in channel backend-framework-82f2; the backend's plan
90
+ // route accepted an empty `files` array in the same exchange (it was a 400
91
+ // before, which is what made the omission look like the only option).
75
92
  const entries = Object.entries(ball?.data || {})
76
- if (!entries.length) {
77
- return { mode: 'none', uploaded: [], failed: [], serveBase: null }
78
- }
79
93
 
80
94
  // One plan for the whole set. The per-request file cap counts a plan, so
81
95
  // splitting would evade it rather than respect it; if a set ever exceeds it,
@@ -129,9 +143,25 @@ export async function uploadSiteData({
129
143
  )
130
144
  if (!planRes.ok) {
131
145
  const body = await planRes.text().catch(() => '')
132
- throw new Error(
146
+ // The PARSED problem document has to survive the throw. Flattening it into
147
+ // the message is what left the other two doors unable to branch on `reason`
148
+ // and printing raw JSON at users; this lane was the last one still doing it.
149
+ // Callers read `err.problem`; `describeDataRefusal` turns it into lines.
150
+ let problem = null
151
+ if (body) {
152
+ try {
153
+ const parsed = JSON.parse(body)
154
+ if (parsed && typeof parsed === 'object') problem = parsed
155
+ } catch {
156
+ /* not a problem document — prose refusal, or an upstream error page */
157
+ }
158
+ }
159
+ const err = new Error(
133
160
  `site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
134
161
  )
162
+ err.status = planRes.status
163
+ err.problem = problem
164
+ throw err
135
165
  }
136
166
 
137
167
  const plan = await planRes.json()
@@ -149,7 +179,11 @@ export async function uploadSiteData({
149
179
  if (!target) {
150
180
  // A file the plan did not answer for is unaddressable. Report it; never
151
181
  // invent a location for it.
152
- failed.push({ path: f.path, status: 0, detail: 'no upload target in plan' })
182
+ failed.push({
183
+ path: f.path,
184
+ status: 0,
185
+ detail: 'no upload target in plan'
186
+ })
153
187
  continue
154
188
  }
155
189
  try {
@@ -182,3 +216,65 @@ export async function uploadSiteData({
182
216
  serveBase: plan.serve_base || null
183
217
  }
184
218
  }
219
+
220
+ /**
221
+ * Turn a typed data-uploads refusal into user-facing lines, or null when there is
222
+ * no typed `reason` (⇒ fall through to the generic message, degrading rather than
223
+ * swallowing).
224
+ *
225
+ * ⭐ The THIRD door, and the last to get one. `/dev/assets` has
226
+ * `describeAssetRefusal`, `POST /dev/site` has `describeCreateRefusal`, and this
227
+ * lane threw prose with the JSON inlined until 2026-09-01 — which is the failure
228
+ * the other two describers exist to prevent, so leaving it was just an untreated
229
+ * instance of a solved problem.
230
+ *
231
+ * ⛔ Branch on `reason`, never the status: `507` alone cannot be told from any
232
+ * other `507` and carries none of the numbers, and `detail` is prose the backend
233
+ * may reword.
234
+ *
235
+ * ⚖️ **The advice DIVERGES from the asset lane's, and that divergence is the whole
236
+ * reason this is a separate function rather than a reused one.** On the asset lane
237
+ * removing an image frees nothing — freeing is entity-deletion-granular. Here the
238
+ * publish declares the COMPLETE set of schema-less data files every time, so
239
+ * dropping a collection and re-publishing is a real way to stop paying for it.
240
+ * Telling a data user "editing content frees nothing" would be false, and telling
241
+ * an asset user "just remove it" would be worse.
242
+ *
243
+ * @param {Error & { problem?: object|null }} err
244
+ * @returns {{ headline: string, notes: string[] } | null}
245
+ */
246
+ export function describeDataRefusal(err) {
247
+ const p = err?.problem
248
+ const reason = p?.reason
249
+ if (typeof reason !== 'string') return null
250
+
251
+ if (reason === 'storage_quota_exceeded') {
252
+ const notes = []
253
+ const used = humanBytes(p.used_bytes)
254
+ const limit = humanBytes(p.limit_bytes)
255
+ const needed = humanBytes(p.needed_bytes)
256
+ if (used) notes.push(` Used: ${used}`)
257
+ if (limit) notes.push(` Limit: ${limit}`)
258
+ if (needed) notes.push(` This publish adds: ${needed}`)
259
+ notes.push(
260
+ 'Every publish declares the full set of schema-less data files, so removing'
261
+ )
262
+ notes.push(
263
+ 'a collection and re-publishing stops it counting. Deleting a site or entity'
264
+ )
265
+ notes.push('frees space too.')
266
+ return {
267
+ headline:
268
+ "Storage quota reached — the site owner's workspace cannot take on more record data.",
269
+ notes
270
+ }
271
+ }
272
+
273
+ // An unrecognised reason still beats a status dump: name it, and let the
274
+ // backend's own prose follow when it sent any.
275
+ const detail = typeof p.detail === 'string' ? p.detail : ''
276
+ return {
277
+ headline: `Record data upload refused by the backend (${reason}).`,
278
+ notes: detail ? [` ${detail}`] : []
279
+ }
280
+ }