uniweb 0.36.0 → 0.37.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.36.0",
3
+ "version": "0.37.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/kit": "^0.15.2",
45
- "@uniweb/semantic-parser": "^1.4.0",
44
+ "@uniweb/core": "^0.16.0",
45
+ "@uniweb/kit": "^0.15.3",
46
46
  "@uniweb/runtime": "^0.13.5",
47
- "@uniweb/core": "^0.16.0"
47
+ "@uniweb/semantic-parser": "^1.4.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.32.0",
51
- "@uniweb/content-reader": "^1.2.4",
52
- "@uniweb/semantic-parser": "^1.4.0"
50
+ "@uniweb/build": "^0.33.0",
51
+ "@uniweb/semantic-parser": "^1.4.0",
52
+ "@uniweb/content-reader": "^1.2.4"
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,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-02T03:17:32.722Z",
3
+ "generatedAt": "2026-09-02T17:42:38.924Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.2.2",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.32.0",
13
+ "version": "0.33.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -54,7 +54,7 @@
54
54
  ]
55
55
  },
56
56
  "@uniweb/kit": {
57
- "version": "0.15.2",
57
+ "version": "0.15.3",
58
58
  "path": "framework/kit",
59
59
  "deps": [
60
60
  "@uniweb/core",
@@ -119,7 +119,7 @@
119
119
  "deps": []
120
120
  },
121
121
  "@uniweb/unipress": {
122
- "version": "0.8.18",
122
+ "version": "0.8.20",
123
123
  "path": "framework/unipress",
124
124
  "deps": [
125
125
  "@uniweb/build",
@@ -8,6 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
8
8
  import { tmpdir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
  import * as tar from 'tar'
11
+ import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
11
12
 
12
13
  /**
13
14
  * Fetch a template from a GitHub repository
@@ -78,21 +79,9 @@ export async function fetchGitHubTemplate(owner, repo, options = {}) {
78
79
  }
79
80
  }
80
81
 
81
- /**
82
- * Fetch with retry and timeout
83
- */
84
- async function fetchWithRetry(url, options = {}, maxRetries = 3) {
85
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
86
- try {
87
- const response = await fetch(url, {
88
- ...options,
89
- signal: AbortSignal.timeout(60000) // 60s timeout for GitHub (can be slow)
90
- })
91
- return response
92
- } catch (err) {
93
- if (attempt === maxRetries) throw err
94
- const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
95
- await new Promise((r) => setTimeout(r, delay))
96
- }
97
- }
98
- }
82
+ // Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
83
+ // This file carried its own copy, as did the other two fetchers — three
84
+ // byte-identical implementations, and none reachable from the upload paths
85
+ // that had no retry at all. The wrapper keeps this caller's own timeout.
86
+ const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
87
+ sharedFetchWithRetry(url, { ...options }, { retries: maxRetries, timeoutMs: 60000 })
@@ -8,6 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
8
8
  import { tmpdir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
  import * as tar from 'tar'
11
+ import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
11
12
 
12
13
  /**
13
14
  * Fetch a template from npm registry
@@ -76,21 +77,9 @@ export async function fetchNpmTemplate(packageName, options = {}) {
76
77
  }
77
78
  }
78
79
 
79
- /**
80
- * Fetch with retry and timeout
81
- */
82
- async function fetchWithRetry(url, options = {}, maxRetries = 3) {
83
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
84
- try {
85
- const response = await fetch(url, {
86
- ...options,
87
- signal: AbortSignal.timeout(30000) // 30s timeout
88
- })
89
- return response
90
- } catch (err) {
91
- if (attempt === maxRetries) throw err
92
- const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
93
- await new Promise((r) => setTimeout(r, delay))
94
- }
95
- }
96
- }
80
+ // Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
81
+ // This file carried its own copy, as did the other two fetchers — three
82
+ // byte-identical implementations, and none reachable from the upload paths
83
+ // that had no retry at all. The wrapper keeps this caller's own timeout.
84
+ const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
85
+ sharedFetchWithRetry(url, { ...options }, { retries: maxRetries, timeoutMs: 30000 })
@@ -8,6 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
8
8
  import { tmpdir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
  import * as tar from 'tar'
11
+ import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
11
12
 
12
13
  // GitHub repository for official templates
13
14
  const TEMPLATES_REPO = 'uniweb/templates'
@@ -222,22 +223,9 @@ async function handleGitHubError(response) {
222
223
  throw new Error(`GitHub API error: ${response.status}`)
223
224
  }
224
225
 
225
- /**
226
- * Fetch with retry and timeout
227
- */
228
- async function fetchWithRetry(url, options = {}, maxRetries = 3) {
229
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
230
- try {
231
- const response = await fetch(url, {
232
- ...options,
233
- redirect: 'follow',
234
- signal: AbortSignal.timeout(60000) // 60s timeout
235
- })
236
- return response
237
- } catch (err) {
238
- if (attempt === maxRetries) throw err
239
- const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
240
- await new Promise((r) => setTimeout(r, delay))
241
- }
242
- }
243
- }
226
+ // Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
227
+ // This file carried its own copy, as did the other two fetchers — three
228
+ // byte-identical implementations, and none reachable from the upload paths
229
+ // that had no retry at all. The wrapper keeps this caller's own timeout.
230
+ const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
231
+ sharedFetchWithRetry(url, { redirect: 'follow', ...options }, { retries: maxRetries, timeoutMs: 60000 })
@@ -30,6 +30,7 @@ import { createHash } from 'node:crypto'
30
30
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
31
31
  import { join } from 'node:path'
32
32
  import { contentTypeFor } from './code-upload.js'
33
+ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
33
34
 
34
35
  /**
35
36
  * Walk a built site's `dist/assets/` and produce the upload file list. `path` is
@@ -202,11 +203,20 @@ export async function uploadSiteAssets({
202
203
  try {
203
204
  // The plan's url may be origin-relative (direct mode → the backend) or
204
205
  // absolute (presigned → storage); new URL() resolves both.
205
- putRes = await fetch(new URL(up.url, origin), {
206
+ // **Retried.** A single connection-level failure used to fail the whole
207
+ // verb after every other file had already gone up. The PUT is idempotent
208
+ // here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
209
+ // — so repeating it is safe by construction, which is why this opts in to
210
+ // `retryOnStatus` rather than inheriting it.
211
+ //
212
+ // ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
213
+ // to send a whole BODY, and aborting a legitimately slow upload only to
214
+ // retry it is worse than not timing out at all.
215
+ putRes = await fetchWithRetry(new URL(up.url, origin), {
206
216
  method: up.method || 'PUT',
207
217
  headers,
208
218
  body: src.bytes ?? readFileSync(src.diskPath)
209
- })
219
+ }, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
210
220
  } catch (err) {
211
221
  failed.push({ path: src.path, status: 0, detail: err.message })
212
222
  continue
@@ -41,6 +41,7 @@
41
41
  import { createHash } from 'node:crypto'
42
42
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
43
43
  import { join } from 'node:path'
44
+ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
44
45
 
45
46
  // Extension → declared content type. Extension-honest by construction (Vite
46
47
  // output); anything unknown ships as octet-stream.
@@ -287,7 +288,16 @@ export async function uploadFoundationCode({
287
288
  }
288
289
  const bytes = readFileSync(join(distDir, file.path))
289
290
  try {
290
- const res = await fetch(new URL(target.url, origin), {
291
+ // **Retried.** A single connection-level failure used to fail the whole
292
+ // verb after every other file had already gone up. The PUT is idempotent
293
+ // here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
294
+ // — so repeating it is safe by construction, which is why this opts in to
295
+ // `retryOnStatus` rather than inheriting it.
296
+ //
297
+ // ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
298
+ // to send a whole BODY, and aborting a legitimately slow upload only to
299
+ // retry it is worse than not timing out at all.
300
+ const res = await fetchWithRetry(new URL(target.url, origin), {
291
301
  method: target.method || 'PUT',
292
302
  // x-uniweb-sha256: optional integrity guard — direct mode verifies
293
303
  // the received bytes and 400s on mismatch (corruption-in-flight).
@@ -297,7 +307,7 @@ export async function uploadFoundationCode({
297
307
  'x-uniweb-sha256': file.sha256
298
308
  },
299
309
  body: bytes
300
- })
310
+ }, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
301
311
  if (res.ok) {
302
312
  uploaded.push(file.path)
303
313
  onProgress(`${file.path} (${file.size} bytes)`)
@@ -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
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `fetch` with bounded retry and a timeout. **The one implementation.**
3
+ *
4
+ * ## Why this file exists
5
+ *
6
+ * There were three, byte-identical apart from a timeout constant — one each in
7
+ * `templates/fetchers/{npm,release,github}.js` — and **none of them was reachable
8
+ * from the code that needed it most.** The three upload paths (`code-upload`,
9
+ * `asset-upload`, `site-data-upload`) had no retry at all, so a single
10
+ * connection-level failure on any one file failed the whole `register` / `push`
11
+ * / `publish` after every other file had already gone up.
12
+ *
13
+ * ⭐ **Measured by the `flows` lane, 2026-09-02:** roughly one red per full suite
14
+ * run, landing on a *different flow each time* and passing on re-run every time —
15
+ * the signature of an unretried transient, with the network as the variable
16
+ * rather than any flow. Their symptom was recorded as "not diagnosed, may be the
17
+ * manor, the CDN, or the connection"; it was none of those. It was us.
18
+ *
19
+ * ## ⚠️ What a retry does NOT do
20
+ *
21
+ * It masks whatever drops the connection. That is the right trade for a genuine
22
+ * transient and the wrong one for a systematic fault: if the failure rate ever
23
+ * climbs, a retry turns a fast red into a slow one and hides the cause. **Do not
24
+ * read a green run as evidence the network is healthy.**
25
+ *
26
+ * ## Retrying a PUT is safe HERE, and not in general
27
+ *
28
+ * These uploads are idempotent by construction — the same bytes to the same
29
+ * target, with an `x-uniweb-sha256` integrity guard the far side verifies. That
30
+ * is why `retryOnStatus` is **opt-in** rather than the default: a caller has to
31
+ * assert its own idempotence, because this helper cannot know it.
32
+ */
33
+
34
+ const DEFAULT_RETRIES = 3
35
+ const DEFAULT_TIMEOUT_MS = 30_000
36
+ const MAX_BACKOFF_MS = 10_000
37
+
38
+ /** Exponential backoff, capped. Attempt is 1-based. */
39
+ const backoffMs = (attempt) => Math.min(1000 * Math.pow(2, attempt), MAX_BACKOFF_MS)
40
+
41
+ /**
42
+ * @param {string|URL} url
43
+ * @param {RequestInit} [options] - passed to `fetch`; `signal` is supplied here.
44
+ * @param {Object} [config]
45
+ * @param {number} [config.retries=3] - total attempts, not extra ones.
46
+ * @param {number} [config.timeoutMs=30000] - per ATTEMPT, not for the whole call.
47
+ * ⚠️ For an upload this bounds the time to send a whole body, so a large file
48
+ * on a slow link needs a generous value — too short and a legitimately slow
49
+ * upload is aborted and then retried, which is worse than no timeout at all.
50
+ * @param {(status: number) => boolean} [config.retryOnStatus] - opt-in. Without
51
+ * it only a THROWN fetch is retried (a connection-level failure, which is what
52
+ * surfaces as `status: 0` in the upload paths). A caller passing this asserts
53
+ * its request is safe to repeat.
54
+ * @param {(info: {attempt: number, of: number, reason: string, delayMs: number}) => void} [config.onRetry]
55
+ * Called before each wait. ⭐ Worth passing: a retry that no one can see turns
56
+ * a visible failure into an invisible slowdown, which is its own defect.
57
+ * @returns {Promise<Response>}
58
+ */
59
+ export async function fetchWithRetry(url, options = {}, config = {}) {
60
+ const {
61
+ retries = DEFAULT_RETRIES,
62
+ timeoutMs = DEFAULT_TIMEOUT_MS,
63
+ retryOnStatus = null,
64
+ onRetry = null,
65
+ } = config
66
+
67
+ let lastErr = null
68
+
69
+ for (let attempt = 1; attempt <= retries; attempt++) {
70
+ try {
71
+ const res = await fetch(url, {
72
+ ...options,
73
+ signal: options.signal ?? AbortSignal.timeout(timeoutMs),
74
+ })
75
+
76
+ if (attempt < retries && retryOnStatus && retryOnStatus(res.status)) {
77
+ const delayMs = backoffMs(attempt)
78
+ onRetry?.({ attempt, of: retries, reason: `HTTP ${res.status}`, delayMs })
79
+ await new Promise((r) => setTimeout(r, delayMs))
80
+ continue
81
+ }
82
+
83
+ return res
84
+ } catch (err) {
85
+ lastErr = err
86
+ // The last attempt throws to the caller, which keeps this a drop-in for
87
+ // the three template fetchers whose contract was "throws like fetch".
88
+ if (attempt === retries) throw err
89
+ const delayMs = backoffMs(attempt)
90
+ onRetry?.({ attempt, of: retries, reason: err.message, delayMs })
91
+ await new Promise((r) => setTimeout(r, delayMs))
92
+ }
93
+ }
94
+
95
+ /* c8 ignore next 2 -- unreachable: the loop returns or throws on the last attempt */
96
+ throw lastErr
97
+ }
98
+
99
+ /**
100
+ * The statuses worth repeating an idempotent request for: a server that failed
101
+ * to answer this time, or asked us to slow down.
102
+ *
103
+ * ⛔ Never a 4xx other than 429 — a rejection does not improve on repetition,
104
+ * and retrying one turns a clear error into a delayed clear error.
105
+ *
106
+ * @param {number} status
107
+ * @returns {boolean}
108
+ */
109
+ export const isTransientStatus = (status) => status === 429 || status >= 500
@@ -51,6 +51,8 @@
51
51
  */
52
52
 
53
53
  import { createHash } from 'node:crypto'
54
+ import { humanBytes } from './bytes.js'
55
+ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
54
56
 
55
57
  /**
56
58
  * Plan + upload a site's static collection data files.
@@ -72,10 +74,23 @@ export async function uploadSiteData({
72
74
  ball,
73
75
  onProgress = () => {}
74
76
  }) {
77
+ // ⛔ An EMPTY set still posts a plan, and that is the whole point of this lane
78
+ // being a manifest rather than a stream of files.
79
+ //
80
+ // The backend reconciles a site's data usage against what this plan declares:
81
+ // whatever is not in the manifest is gone, so deleting a collection stops
82
+ // costing on the next publish. ⭐ A plan with zero files is a STATEMENT that
83
+ // there are none; the ABSENCE of a request says nothing at all. Returning
84
+ // early here — which this did until 2026-09-01 — made "the user deleted their
85
+ // last schema-less collection" unexpressible on the wire, so that site kept
86
+ // paying for bytes it no longer served until the whole site was deleted.
87
+ // Nothing looked wrong at either end: no error, no warning, just a request
88
+ // that was never sent.
89
+ //
90
+ // Agreed both sides in channel backend-framework-82f2; the backend's plan
91
+ // route accepted an empty `files` array in the same exchange (it was a 400
92
+ // before, which is what made the omission look like the only option).
75
93
  const entries = Object.entries(ball?.data || {})
76
- if (!entries.length) {
77
- return { mode: 'none', uploaded: [], failed: [], serveBase: null }
78
- }
79
94
 
80
95
  // One plan for the whole set. The per-request file cap counts a plan, so
81
96
  // splitting would evade it rather than respect it; if a set ever exceeds it,
@@ -129,9 +144,25 @@ export async function uploadSiteData({
129
144
  )
130
145
  if (!planRes.ok) {
131
146
  const body = await planRes.text().catch(() => '')
132
- throw new Error(
147
+ // The PARSED problem document has to survive the throw. Flattening it into
148
+ // the message is what left the other two doors unable to branch on `reason`
149
+ // and printing raw JSON at users; this lane was the last one still doing it.
150
+ // Callers read `err.problem`; `describeDataRefusal` turns it into lines.
151
+ let problem = null
152
+ if (body) {
153
+ try {
154
+ const parsed = JSON.parse(body)
155
+ if (parsed && typeof parsed === 'object') problem = parsed
156
+ } catch {
157
+ /* not a problem document — prose refusal, or an upstream error page */
158
+ }
159
+ }
160
+ const err = new Error(
133
161
  `site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
134
162
  )
163
+ err.status = planRes.status
164
+ err.problem = problem
165
+ throw err
135
166
  }
136
167
 
137
168
  const plan = await planRes.json()
@@ -149,13 +180,26 @@ export async function uploadSiteData({
149
180
  if (!target) {
150
181
  // A file the plan did not answer for is unaddressable. Report it; never
151
182
  // invent a location for it.
152
- failed.push({ path: f.path, status: 0, detail: 'no upload target in plan' })
183
+ failed.push({
184
+ path: f.path,
185
+ status: 0,
186
+ detail: 'no upload target in plan'
187
+ })
153
188
  continue
154
189
  }
155
190
  try {
156
191
  // Relative on the direct arm, absolute on presigned — `new URL` resolves
157
192
  // both against the origin.
158
- const res = await fetch(new URL(target.url, origin), {
193
+ // **Retried.** A single connection-level failure used to fail the whole
194
+ // verb after every other file had already gone up. The PUT is idempotent
195
+ // here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
196
+ // — so repeating it is safe by construction, which is why this opts in to
197
+ // `retryOnStatus` rather than inheriting it.
198
+ //
199
+ // ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
200
+ // to send a whole BODY, and aborting a legitimately slow upload only to
201
+ // retry it is worse than not timing out at all.
202
+ const res = await fetchWithRetry(new URL(target.url, origin), {
159
203
  method: target.method || 'PUT',
160
204
  headers: {
161
205
  'Content-Type': 'application/json',
@@ -163,7 +207,7 @@ export async function uploadSiteData({
163
207
  ...authHeaders
164
208
  },
165
209
  body: f.bytes
166
- })
210
+ }, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
167
211
  if (res.ok) {
168
212
  uploaded.push(f.path)
169
213
  onProgress(`${f.path}`)
@@ -182,3 +226,65 @@ export async function uploadSiteData({
182
226
  serveBase: plan.serve_base || null
183
227
  }
184
228
  }
229
+
230
+ /**
231
+ * Turn a typed data-uploads refusal into user-facing lines, or null when there is
232
+ * no typed `reason` (⇒ fall through to the generic message, degrading rather than
233
+ * swallowing).
234
+ *
235
+ * ⭐ The THIRD door, and the last to get one. `/dev/assets` has
236
+ * `describeAssetRefusal`, `POST /dev/site` has `describeCreateRefusal`, and this
237
+ * lane threw prose with the JSON inlined until 2026-09-01 — which is the failure
238
+ * the other two describers exist to prevent, so leaving it was just an untreated
239
+ * instance of a solved problem.
240
+ *
241
+ * ⛔ Branch on `reason`, never the status: `507` alone cannot be told from any
242
+ * other `507` and carries none of the numbers, and `detail` is prose the backend
243
+ * may reword.
244
+ *
245
+ * ⚖️ **The advice DIVERGES from the asset lane's, and that divergence is the whole
246
+ * reason this is a separate function rather than a reused one.** On the asset lane
247
+ * removing an image frees nothing — freeing is entity-deletion-granular. Here the
248
+ * publish declares the COMPLETE set of schema-less data files every time, so
249
+ * dropping a collection and re-publishing is a real way to stop paying for it.
250
+ * Telling a data user "editing content frees nothing" would be false, and telling
251
+ * an asset user "just remove it" would be worse.
252
+ *
253
+ * @param {Error & { problem?: object|null }} err
254
+ * @returns {{ headline: string, notes: string[] } | null}
255
+ */
256
+ export function describeDataRefusal(err) {
257
+ const p = err?.problem
258
+ const reason = p?.reason
259
+ if (typeof reason !== 'string') return null
260
+
261
+ if (reason === 'storage_quota_exceeded') {
262
+ const notes = []
263
+ const used = humanBytes(p.used_bytes)
264
+ const limit = humanBytes(p.limit_bytes)
265
+ const needed = humanBytes(p.needed_bytes)
266
+ if (used) notes.push(` Used: ${used}`)
267
+ if (limit) notes.push(` Limit: ${limit}`)
268
+ if (needed) notes.push(` This publish adds: ${needed}`)
269
+ notes.push(
270
+ 'Every publish declares the full set of schema-less data files, so removing'
271
+ )
272
+ notes.push(
273
+ 'a collection and re-publishing stops it counting. Deleting a site or entity'
274
+ )
275
+ notes.push('frees space too.')
276
+ return {
277
+ headline:
278
+ "Storage quota reached — the site owner's workspace cannot take on more record data.",
279
+ notes
280
+ }
281
+ }
282
+
283
+ // An unrecognised reason still beats a status dump: name it, and let the
284
+ // backend's own prose follow when it sent any.
285
+ const detail = typeof p.detail === 'string' ? p.detail : ''
286
+ return {
287
+ headline: `Record data upload refused by the backend (${reason}).`,
288
+ notes: detail ? [` ${detail}`] : []
289
+ }
290
+ }