uniweb 0.25.2 → 0.25.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,13 +43,13 @@
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "^0.10.1",
45
45
  "@uniweb/kit": "^0.12.3",
46
- "@uniweb/semantic-parser": "^1.2.3",
47
- "@uniweb/runtime": "^0.12.2"
46
+ "@uniweb/runtime": "^0.12.2",
47
+ "@uniweb/semantic-parser": "^1.2.3"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/semantic-parser": "^1.2.3",
50
+ "@uniweb/build": "^0.24.3",
51
51
  "@uniweb/content-reader": "^1.2.3",
52
- "@uniweb/build": "^0.24.2"
52
+ "@uniweb/semantic-parser": "^1.2.3"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -53,9 +53,9 @@ import {
53
53
  loadDeployYml,
54
54
  resolveTarget,
55
55
  recordLastDeploy,
56
- assembleDataBall,
57
- collectBallAssets,
58
- rewriteBallAssets
56
+ collectSchemalessData,
57
+ collectSchemalessDataAssets,
58
+ rewriteSchemalessDataAssets
59
59
  } from '@uniweb/build/site'
60
60
  import { emitSyncPackages } from '@uniweb/build/uwx'
61
61
  import { isSiteRelativeExtensionUrl } from '@uniweb/build'
@@ -79,7 +79,6 @@ import {
79
79
  pushSyncPackages,
80
80
  resolveSiteOrgForCreate
81
81
  } from '../backend/site-sync.js'
82
- import { uploadDataBundle } from '../backend/data-bundle.js'
83
82
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
84
83
  import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
85
84
  import {
@@ -88,6 +87,7 @@ import {
88
87
  } from '../backend/foundation-bring-along.js'
89
88
  import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
90
89
  import { reportSchemalessCollections } from '../utils/schemaless-report.js'
90
+ import { uploadSiteData } from '../utils/site-data-upload.js'
91
91
 
92
92
  const c = {
93
93
  reset: '\x1b[0m',
@@ -527,8 +527,8 @@ export async function publish(args = []) {
527
527
 
528
528
  // 4. Assemble the static-data ball (schema-less data + search index) BEFORE
529
529
  // uploading, since its records can carry local media too.
530
- let ball = await assembleDataBall(distDir, schemalessNames)
531
- const ballAssets = collectBallAssets(ball)
530
+ let ball = await collectSchemalessData(distDir, schemalessNames)
531
+ const ballAssets = collectSchemalessDataAssets(ball)
532
532
 
533
533
  // 4b. Upload ALL local media (entity refs + ball refs) on one asset lane →
534
534
  // the ref→serveUrl map; rewrite the entity content AND the ball with it.
@@ -567,7 +567,7 @@ export async function publish(args = []) {
567
567
  `${ASSET_MAP_FILE} : ${rec.added.length} added, ${rec.changed.length} changed — commit it`
568
568
  )
569
569
  }
570
- if (ballAssets.length) ball = rewriteBallAssets(ball, map)
570
+ if (ballAssets.length) ball = rewriteSchemalessDataAssets(ball, map)
571
571
  say.dim(
572
572
  `Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`
573
573
  )
@@ -586,34 +586,56 @@ export async function publish(args = []) {
586
586
  }
587
587
  }
588
588
 
589
- // 4c. Upload the (media-rewritten) ball its content-addressed serve URL.
590
- let dataBundle
589
+ // 4c. Deliver the schema-less collection data one object per file.
590
+ //
591
+ // Each `dist/data/**` file is PUT to the target `data-uploads` returns,
592
+ // landing at its serving tail. Hosting intercepts `/data/**.json` and
593
+ // reads `_data/{tail}` from the site bucket, so the file is served from
594
+ // where it lands: **nothing records where anything went**, and no `info`
595
+ // field is stamped.
596
+ //
597
+ // ⛔ NO BALL. A CLI sends separates or a ball, never both — presence of a
598
+ // ball is the backend's signal for which CLI it is talking to, and
599
+ // sending both destroys it along with their ability to know when the
600
+ // unwrap can be deleted. Released CLIs keep sending one and their unwrap
601
+ // keeps serving them; this one does not.
602
+ //
603
+ // 📌 No capability gate, deliberately. A backend predating this lane
604
+ // answers 404 and the publish fails — accepted while pre-prod [Diego,
605
+ // 2026-08-18: "we are pre-prod. I'm not concerned about old cli vs new"].
606
+ // A gate was written and removed: it is machinery for a population that
607
+ // does not exist, which is the failure this work kept catching in others.
608
+ // `client.discover()` is the mechanism if that changes — `DISCOVERY_DEFAULTS`
609
+ // makes an absent key non-breaking by construction.
591
610
  if (ball) {
592
- say.info('Uploading data bundle…')
611
+ say.info('Uploading collection data…')
593
612
  try {
594
- dataBundle = await uploadDataBundle(client, ball, {
613
+ const r = await uploadSiteData({
614
+ apiBase: client.origin,
615
+ token: await client.token(),
595
616
  siteUuid: site.uuid,
617
+ ball,
596
618
  onProgress: (m) => say.dim(` ${m}`)
597
619
  })
598
- } catch (err) {
599
- // The ball rides the same asset lane, so it hits the same typed refusals.
600
- const refusal = describeAssetRefusal(err)
601
- if (refusal) {
602
- say.err(refusal.headline)
603
- for (const line of refusal.notes) say.dim(line)
604
- } else {
605
- say.err(`Data bundle upload failed: ${err.message}`)
620
+ if (r.failed.length) {
621
+ // A file whose bytes did not land must not be published: the site would
622
+ // serve a stale copy or 404, and the only trace would be a warning.
623
+ say.err(`${r.failed.length} data file(s) failed to upload — not publishing.`)
624
+ for (const f of r.failed) say.dim(` ${f.path} (HTTP ${f.status})`)
625
+ return { exitCode: 1 }
606
626
  }
627
+ say.dim(`Collection data : ${r.uploaded.length} file(s) [${r.mode}]`)
628
+ } catch (err) {
629
+ say.err(`Collection data upload failed: ${err.message}`)
607
630
  return { exitCode: 1 }
608
631
  }
609
- say.dim(
610
- `Data bundle : ${Object.keys(ball.data).length} data + ${Object.keys(ball.search).length} search file(s)`
611
- )
612
632
  }
613
633
 
614
634
  // 5. Push the site (content + folder) over the send-only-changed cache —
615
635
  // the SAME two-lane submission `uniweb push` uses — stamping
616
- // info.data_bundle and rewriting local media refs to backend serve URLs.
636
+ // the pinned foundation ref and rewriting local media refs to serve URLs.
637
+ // (It stamped `info.data_bundle` until 2026-08-18; the ball is gone and
638
+ // collection data now lands at its serving tail, so nothing records it.)
617
639
  const priorHashes = readSyncCache(siteDir)
618
640
  // publish rides the same gated push as `uniweb push`: if an app author has
619
641
  // edited since this clone last synced, the push is refused rather than
@@ -635,8 +657,17 @@ export async function publish(args = []) {
635
657
  // released version on the wire is required when site.yml uses an unversioned
636
658
  // local ref; injectInfo overrides info.foundation. A registry/URL ref → fnd.ref
637
659
  // is null → the site.yml ref is forwarded verbatim (already pinned).
660
+ // ⛔ DO NOT STAMP `info.data` HERE. The name is TAKEN: `uwx/site.js` already
661
+ // emits `info.data` from `site.yml`'s top-level `data:`/`fetch:` block, and
662
+ // `injectInfo` WINS the merge (`sync-package.js`: `{...siteDoc.info,
663
+ // ...injectInfo}`), so stamping a file map here silently replaces the author's
664
+ // fetch config on the wire. Both are `type: json`, so the store validator
665
+ // accepts either and nothing errors at any layer.
666
+ //
667
+ // A file map needs a name nothing else claims (`static_data` / `data_files`
668
+ // were proposed) AND a consumer that reads it — neither settled. See
669
+ // `kb/framework/build/data-ball-retirement.md`.
638
670
  const injectInfo = {
639
- ...(dataBundle ? { data_bundle: dataBundle } : {}),
640
671
  ...(fnd.ref ? { foundation: fnd.ref } : {})
641
672
  }
642
673
  let pkg
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-18T16:05:00.154Z",
3
+ "generatedAt": "2026-08-18T22:18:28.572Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.24.2",
6
+ "version": "0.24.3",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Static collection data — the passthrough lane.
3
+ *
4
+ * A site's **schema-less** collections have no entity model, so their compiled
5
+ * `dist/data/**` JSON is delivered as files rather than synced as entities.
6
+ * This plans and uploads that set: one plan call, one object per file, each
7
+ * PUT to the target the backend returns.
8
+ *
9
+ * 1. PLAN — POST {apiBase}/dev/site/data-uploads/{site} with the file list
10
+ * ({ path, content_type, size, sha256 }). Response carries
11
+ * `mode` ('presigned' | 'direct'), `expires_in`, `serve_base`,
12
+ * and one `uploads` entry per file ({ path, method, url, headers }).
13
+ * 2. UPLOAD — PUT each file's bytes to its URL. Order is irrelevant; there is
14
+ * no entry file and no server confirm step.
15
+ *
16
+ * ⭐ **`path` is the SERVING TAIL** (`data/articles.json`), not a bookkeeping
17
+ * key. The file lands where it is served — which is the whole point of this
18
+ * lane, and why it returns no map: nothing has to record where anything went.
19
+ *
20
+ * ## Why this is not the asset lane
21
+ *
22
+ * Assets are **global and content-addressed** — identical bytes dedup across
23
+ * sites — so the relation from a site's path to an object is many-to-one and a
24
+ * serve path cannot be a property of the object. This lane is **per-site and
25
+ * path-addressed**. Sending static data through the asset store is what forced
26
+ * the old "data ball": one bundled asset the backend had to fetch, parse and
27
+ * fan out, which is the only place these bytes ever transited it.
28
+ *
29
+ * ⇒ **The backend is a PASSTHROUGH here** — it brokers the authorization, not
30
+ * the bytes. Hosting static content is the hosting platform's mission where an
31
+ * edge exists; where there is none (edgeless), the backend serves them itself,
32
+ * and that is the posture working rather than a fallback.
33
+ *
34
+ * ⛔ **Do not compose paths onto `serve_base`.** It is `null` on the presigned
35
+ * arm (prod serving is the delivery tier's URL shape, not the backend's to
36
+ * mint) and the gateway's data route on the direct arm. Nothing in a build
37
+ * needs it — the site's fetcher asks for a site-relative `/data/{path}` at
38
+ * runtime — so it is returned for parity and for a caller that wants to verify
39
+ * an upload landed.
40
+ *
41
+ * Contract ratified 2026-08-18; see `kb/framework/build/data-ball-retirement.md`.
42
+ * ⚠️ **The endpoint is not built yet** — this is unwired until it is.
43
+ */
44
+
45
+ import { createHash } from 'node:crypto'
46
+
47
+ /**
48
+ * Plan + upload a site's static collection data files.
49
+ *
50
+ * @param {object} opts
51
+ * @param {string} opts.apiBase - backend origin
52
+ * @param {string} opts.token - bearer, used on the direct arm only
53
+ * @param {string} opts.siteUuid - the site these files belong to (path segment)
54
+ * @param {{ data: Record<string, unknown> }|null} opts.ball - source of the set:
55
+ * `{ "<relpath under dist/data>": <json> }`, media refs already rewritten
56
+ * @param {(m: string) => void} [opts.onProgress]
57
+ * @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path:string,status:number,detail?:string}>, serveBase: string|null }>}
58
+ * **No map** — by design. The files are at their serving paths.
59
+ */
60
+ export async function uploadSiteData({
61
+ apiBase,
62
+ token,
63
+ siteUuid,
64
+ ball,
65
+ onProgress = () => {}
66
+ }) {
67
+ const entries = Object.entries(ball?.data || {})
68
+ if (!entries.length) {
69
+ return { mode: 'none', uploaded: [], failed: [], serveBase: null }
70
+ }
71
+
72
+ // One plan for the whole set. The per-request file cap counts a plan, so
73
+ // splitting would evade it rather than respect it; if a set ever exceeds it,
74
+ // the refusal is the correct outcome and the cap is the thing to fix.
75
+ const files = entries.map(([relPath, value]) => {
76
+ const bytes = Buffer.from(JSON.stringify(value))
77
+ return {
78
+ // ⚠️ The tail RELATIVE TO THE DATA ROOT — no `data/` prefix.
79
+ //
80
+ // The plan's `serve_base` already ends in `/data/`
81
+ // (`/gateway/site/{site}/data/`), and the runtime asks for
82
+ // `{config.base}/data/{name}.json` (`DATA_URL_PREFIX`). Sending
83
+ // `data/articles.json` would store one level too deep and serve at
84
+ // `…/data/data/articles.json`, which the runtime never requests.
85
+ //
86
+ // ⛔ And the failure is INVISIBLE from here: the plan succeeds, the PUT
87
+ // succeeds, and only a visitor's fetch 404s. Confirmed against the
88
+ // shipped contract's own example (`collections/articles.json` with a
89
+ // `/data/`-bearing `serve_base`), which supersedes an earlier
90
+ // parenthetical that showed the prefix.
91
+ path: relPath,
92
+ content_type: 'application/json',
93
+ size: bytes.length,
94
+ sha256: createHash('sha256').update(bytes).digest('hex'),
95
+ bytes
96
+ }
97
+ })
98
+
99
+ const origin = apiBase.replace(/\/$/, '')
100
+ // `{site}` is a PATH segment, not a body field — every sibling on this
101
+ // controller addresses the site that way (`content/push/{site}`,
102
+ // `publish/{site}`, `status/{site}`). The asset lane's body-borne owner is
103
+ // the wrong precedent: that lane is global, this one is per-site.
104
+ const planRes = await fetch(
105
+ `${origin}/dev/site/data-uploads/${encodeURIComponent(siteUuid)}`,
106
+ {
107
+ method: 'POST',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ Authorization: `Bearer ${token}`
111
+ },
112
+ body: JSON.stringify({
113
+ files: files.map(({ path, content_type, size, sha256 }) => ({
114
+ path,
115
+ content_type,
116
+ size,
117
+ sha256
118
+ }))
119
+ })
120
+ }
121
+ )
122
+ if (!planRes.ok) {
123
+ const body = await planRes.text().catch(() => '')
124
+ throw new Error(
125
+ `site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
126
+ )
127
+ }
128
+
129
+ const plan = await planRes.json()
130
+ const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
131
+ // The one mode-aware bit, same rule as the code and runtime lanes: a direct
132
+ // PUT is a bearer-authed backend route; a presigned URL is self-authorizing
133
+ // and must NOT carry a foreign bearer, which can break signature validation.
134
+ const authHeaders =
135
+ plan.mode === 'presigned' ? {} : { Authorization: `Bearer ${token}` }
136
+
137
+ const uploaded = []
138
+ const failed = []
139
+ for (const f of files) {
140
+ const target = targets.get(f.path)
141
+ if (!target) {
142
+ // A file the plan did not answer for is unaddressable. Report it; never
143
+ // invent a location for it.
144
+ failed.push({ path: f.path, status: 0, detail: 'no upload target in plan' })
145
+ continue
146
+ }
147
+ try {
148
+ // Relative on the direct arm, absolute on presigned — `new URL` resolves
149
+ // both against the origin.
150
+ const res = await fetch(new URL(target.url, origin), {
151
+ method: target.method || 'PUT',
152
+ headers: {
153
+ 'Content-Type': 'application/json',
154
+ ...(target.headers || {}),
155
+ ...authHeaders
156
+ },
157
+ body: f.bytes
158
+ })
159
+ if (res.ok) {
160
+ uploaded.push(f.path)
161
+ onProgress(`${f.path}`)
162
+ } else {
163
+ failed.push({ path: f.path, status: res.status })
164
+ }
165
+ } catch (err) {
166
+ failed.push({ path: f.path, status: 0, detail: err.message })
167
+ }
168
+ }
169
+
170
+ return {
171
+ mode: plan.mode || 'direct',
172
+ uploaded,
173
+ failed,
174
+ serveBase: plan.serve_base || null
175
+ }
176
+ }
@@ -1,85 +0,0 @@
1
- /**
2
- * Upload the static-data ball (assembleDataBall's `{ data, search }` doc) to the
3
- * backend's content-addressed asset store via the SAME asset lane deploy uses for
4
- * media, and return its durable serve URL — the `info.data_bundle` the composite push
5
- * stamps on the site-content entity. The backend unwraps the ball into the `/data/*`
6
- * + `/_search/*` bytes the gateway serves.
7
- *
8
- * The ball is in-memory (not a built file on disk), so it rides as `bytes` on the
9
- * single upload entry — `uploadSiteAssets` PUTs `bytes` when present, else reads a
10
- * `diskPath` (its media path). Content-addressed like every asset: identical ball →
11
- * same id → a re-deploy of unchanged data is a cheap no-op PUT.
12
- *
13
- * The returned URL is the plan entry's `serve_url`, read verbatim. See the note at
14
- * the return for why the origin-relative form is safe to store.
15
- */
16
-
17
- import { createHash } from 'node:crypto'
18
-
19
- /**
20
- * @param {object} client - BackendClient (origin + uploadSiteAssets)
21
- * @param {{ data: object, search: object }} ball - the assembled data ball
22
- * @param {{ onProgress?: (m: string) => void }} [opts]
23
- * @returns {Promise<string>} the content-addressed serve URL (→ `info.data_bundle`)
24
- */
25
- export async function uploadDataBundle(
26
- client,
27
- ball,
28
- { siteUuid = null, onProgress } = {}
29
- ) {
30
- const bytes = Buffer.from(JSON.stringify(ball))
31
- const sha256 = createHash('sha256').update(bytes).digest('hex')
32
- const localUrl = '/data-bundle/base.json' // bookkeeping key into assetsByLocalUrl
33
-
34
- const result = await client.uploadSiteAssets({
35
- files: [
36
- {
37
- path: 'data-bundle/base.json',
38
- content_type: 'application/json',
39
- size: bytes.length,
40
- sha256,
41
- localUrl,
42
- bytes
43
- }
44
- ],
45
- siteUuid,
46
- onProgress
47
- })
48
- if (result.failed?.length) {
49
- const f = result.failed[0]
50
- throw new Error(`data-bundle upload failed: HTTP ${f.status} ${f.detail}`)
51
- }
52
- const entry = result.assetsByLocalUrl[localUrl]
53
- if (!entry) throw new Error('data-bundle upload returned no asset id')
54
-
55
- // The backend's canonical serve URL, READ — never composed. Same rule and the
56
- // same code path as `site-media.js`; until 2026-08-17 this composed from a
57
- // discovered `assetBase` unconditionally, which made it the one place a
58
- // discovery failure could bake the historical production CDN host into content
59
- // we push.
60
- //
61
- // Storing it verbatim is safe even though `serve_url` is ORIGIN-RELATIVE in the
62
- // backend's `direct` mode — the only mode any deployment has ever run.
63
- // `info.data_bundle` is never fetched over HTTP: the backend resolves it to a
64
- // blob-store key, discarding everything before the final `dist/`, so absolute
65
- // and relative forms resolve to the same key (pinned both ways on their side).
66
- //
67
- // ⚠️ That argument is scoped to a serve URL CONTAINING `dist/`, and the scope is
68
- // load-bearing rather than incidental: the key is recovered by splitting on that
69
- // segment, so a serve URL without one cannot be resolved to a key at all — the
70
- // failure is on the READING side, and this push looks entirely successful.
71
- // Reported by the backend 2026-08-17 as a defect on their side, not yet fixed;
72
- // not verified here, and not ours to fix. It is unreached only because no
73
- // deployment yet mints URLs of the other shape. ⇒ We keep reading `serve_url`
74
- // verbatim — composing one would be the worse answer, and it is the very coupling
75
- // deleted above. What we must NOT do is infer from "this has always worked" that
76
- // any serve URL round-trips; that holds for the shape, not for the field.
77
- //
78
- // Absent is an error, not a cue to invent a location: an unaddressable bundle
79
- // must stop the publish, never ship a URL nobody claimed. (Confirmed with the
80
- // backend 2026-08-17; `serve_url` is contractually on every plan entry.)
81
- if (!entry.serveUrl)
82
- throw new Error('data-bundle upload returned no serve_url')
83
-
84
- return entry.serveUrl
85
- }