uniweb 0.25.2 → 0.25.4

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.4",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,14 +42,14 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "^0.10.1",
45
- "@uniweb/kit": "^0.12.3",
46
45
  "@uniweb/semantic-parser": "^1.2.3",
47
- "@uniweb/runtime": "^0.12.2"
46
+ "@uniweb/runtime": "^0.12.2",
47
+ "@uniweb/kit": "^0.12.3"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/semantic-parser": "^1.2.3",
50
+ "@uniweb/build": "^0.24.4",
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": {
@@ -24,7 +24,8 @@ import {
24
24
  diffSiteUnits,
25
25
  describeSiteDiff,
26
26
  computeUnitHashes,
27
- collectUnitUuids
27
+ collectUnitUuids,
28
+ readAssetMap
28
29
  } from '@uniweb/build/uwx'
29
30
 
30
31
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -181,11 +182,11 @@ function readSyncCacheFile(siteDir) {
181
182
  return {} // missing / unreadable → treat everything as changed
182
183
  }
183
184
  }
184
- // The cache holds three maps written on DIFFERENT events — content hashes on a
185
- // successful push, base versions on push AND pull, unit hashes on push and pull —
186
- // so every writer must preserve the ones it isn't touching. One merge point rather
187
- // than three hand-rolled preserves, because getting that wrong silently disarms
188
- // whichever map got clobbered.
185
+ // The cache holds several maps written on DIFFERENT events — content hashes and the
186
+ // injections that produced them on a successful push, base versions on push AND
187
+ // pull, unit hashes on push and pull — so every writer must preserve the ones it
188
+ // isn't touching. One merge point rather than a hand-rolled preserve per writer,
189
+ // because getting that wrong silently disarms whichever map got clobbered.
189
190
  function updateSyncCache(siteDir, patch) {
190
191
  const p = syncCachePath(siteDir)
191
192
  mkdirSync(dirname(p), { recursive: true })
@@ -203,9 +204,10 @@ const readMap = (siteDir, key) => {
203
204
  * Drop every cache map that describes a BACKEND site, when this clone is bound to
204
205
  * none. Returns the names dropped (empty when there was nothing to do).
205
206
  *
206
- * `.uniweb/sync-cache.json` holds four maps keyed by *unit path* — item uuids,
207
- * content hashes, entity base versions, unit bases and a unit path (`site.yml`,
208
- * `pages/about/about.md`) is the same string for every site. So the cache does not
207
+ * `.uniweb/sync-cache.json` holds five maps keyed by *unit path* — item uuids,
208
+ * content hashes, the injections those hashes were taken over, entity base versions,
209
+ * unit bases — and a unit path (`site.yml`, `pages/about/about.md`) is the same
210
+ * string for every site. So the cache does not
209
211
  * self-invalidate when `site.yml::$uuid` goes away: it keeps describing the site
210
212
  * this folder used to be.
211
213
  *
@@ -228,21 +230,29 @@ const readMap = (siteDir, key) => {
228
230
  * the clone look bound before the check runs.
229
231
  */
230
232
  /**
231
- * Drop the four remote-derived maps unconditionally, stamping the site they now
233
+ * Drop the five remote-derived maps unconditionally, stamping the site they now
232
234
  * describe (or clearing the stamp when there is none). Shared by the pre-flight
233
235
  * guard and the `item_uuid_conflict` recovery — the guard decides WHETHER, this
234
236
  * decides WHAT, and they must not drift.
235
237
  */
236
238
  export function clearRemoteSyncState(siteDir, siteUuid = null) {
237
239
  const prior = readSyncCacheFile(siteDir)
238
- const dropped = ['itemUuids', 'hashes', 'baseVersions', 'unitBases'].filter(
239
- (k) => prior[k] && Object.keys(prior[k]).length
240
- )
240
+ const dropped = [
241
+ 'itemUuids',
242
+ 'hashes',
243
+ 'baseVersions',
244
+ 'unitBases',
245
+ 'applied'
246
+ ].filter((k) => prior[k] && Object.keys(prior[k]).length)
241
247
  updateSyncCache(siteDir, {
242
248
  itemUuids: {},
243
249
  hashes: {},
244
250
  baseVersions: {},
245
251
  unitBases: {},
252
+ // Remote-derived like the rest, and doubly so: it holds the OLD site's asset
253
+ // serve URLs. Surviving the drop, it would rewrite the new site's media to
254
+ // bytes owned by the site this folder used to be.
255
+ applied: {},
246
256
  siteUuid: siteUuid || null
247
257
  })
248
258
  return dropped
@@ -258,7 +268,13 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
258
268
  }
259
269
 
260
270
  const prior = readSyncCacheFile(siteDir)
261
- const REMOTE_MAPS = ['itemUuids', 'hashes', 'baseVersions', 'unitBases']
271
+ const REMOTE_MAPS = [
272
+ 'itemUuids',
273
+ 'hashes',
274
+ 'baseVersions',
275
+ 'unitBases',
276
+ 'applied'
277
+ ]
262
278
  const populated = REMOTE_MAPS.filter(
263
279
  (k) => prior[k] && Object.keys(prior[k]).length
264
280
  )
@@ -302,8 +318,53 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
302
318
  export function readSyncCache(siteDir) {
303
319
  return readMap(siteDir, 'hashes')
304
320
  }
305
- export function writeSyncCache(siteDir, hashes) {
306
- updateSyncCache(siteDir, { hashes })
321
+
322
+ /**
323
+ * The hash-affecting injections the emit that produced those hashes applied, in the
324
+ * exact shape `emitSyncPackages` takes back as opts.
325
+ *
326
+ * ⛔ **Only the ones nothing else records.** `assetIds` is deliberately NOT banked
327
+ * here even though the emit applies it: `assets.json` is COMMITTED project state
328
+ * holding exactly that map (local ref → `{id, ext}`), written by the same push, and
329
+ * a gitignored second copy would be a second thing to disagree — the reason that
330
+ * file itself refuses to hold a serve URL. It also has the worse lifetime of the
331
+ * two: `clearRemoteSyncState` wipes this cache, and the committed map correctly
332
+ * survives. ⇒ Bank what a reader cannot re-derive; re-derive the rest.
333
+ *
334
+ * ⛔ Read this whenever you re-emit to COMPARE against `hashes`. A push hashes the
335
+ * DELIVERED document — local `/images/x.png` rewritten to the backend serve URL it
336
+ * just uploaded to, `info.foundation` replaced by the version-pinned ref — and banks
337
+ * that. An offline re-emit produces the AUTHORED document, which is a different
338
+ * document, so it matches nothing and every entity reads as changed forever. That is
339
+ * not hypothetical: it is what `uniweb status` did on any site with one local image
340
+ * (backend-framework-787e, 2026-08-19) — `push` said "1 entity unchanged" and
341
+ * `status --json` said `changed: 1`, from the same cache, seconds apart.
342
+ *
343
+ * ⭐ What is left is what only a backend round-trip produces — an asset **serve URL**
344
+ * and a released foundation version — and `status` is offline by design (measured at
345
+ * zero HTTP requests, a property the cross-client flows rely on). ⚠️ Note the serve
346
+ * URL is REPLAYED, never composed: we re-use the string the host handed us, which is
347
+ * a different act from reconstructing one, and the distinction is the same one that
348
+ * keeps `assets.json` id-only. An asset genuinely new to the site has no recorded
349
+ * mapping and still reads as changed, which is correct.
350
+ */
351
+ export function readAppliedInjections(siteDir) {
352
+ return readMap(siteDir, 'applied')
353
+ }
354
+
355
+ /**
356
+ * Bank the content hashes and the injections that produced them. ⛔ ONE call, both
357
+ * maps: they describe the same document, so writing either alone leaves the cache
358
+ * self-inconsistent — and the failure is silent, since a hash never says which
359
+ * document it is of. `applied` is written even when empty, so it can never be a
360
+ * leftover from an earlier push describing hashes it no longer matches.
361
+ *
362
+ * `assetIds` is dropped rather than stored — see readAppliedInjections: it has a
363
+ * committed source of truth in `assets.json`, and the reader re-derives it there.
364
+ */
365
+ export function writeSyncCache(siteDir, hashes, applied) {
366
+ const { assetIds: _inAssetsJson, ...bankable } = applied || {}
367
+ updateSyncCache(siteDir, { hashes, applied: bankable })
307
368
  }
308
369
 
309
370
  /**
@@ -835,10 +896,24 @@ export function writeUnitBases(siteDir, patch) {
835
896
  */
836
897
  export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
837
898
  const priorHashes = readSyncCache(siteDir)
899
+ // Re-emit the document the last push HASHED, not the one the author wrote — see
900
+ // readAppliedInjections. Two sources, on purpose:
901
+ // · BANKED — the serve URLs and pinned refs only a round-trip produces. Empty
902
+ // for a cache written before this was banked (and for a never-pushed site),
903
+ // which is the pre-fix behaviour and self-heals on the next push. It can never
904
+ // point at the wrong document: it is written with the hashes it belongs to.
905
+ // · RE-DERIVED — asset identity, from the COMMITTED `assets.json` the same push
906
+ // wrote. Reading the live file rather than a snapshot is what makes a moved
907
+ // map (a teammate's push, a pull) read as changed instead of matching a copy
908
+ // of itself.
909
+ const applied = readAppliedInjections(siteDir)
910
+ const assetIds = readAssetMap(siteDir)
838
911
  const pkg = await emitSyncPackages(siteDir, {
839
912
  resolveModel: makeModelResolver({ client: null, offline: true }),
840
913
  priorHashes,
841
- sendAll
914
+ sendAll,
915
+ ...applied,
916
+ ...(Object.keys(assetIds).length ? { assetIds } : {})
842
917
  })
843
918
  const changed =
844
919
  (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
@@ -867,7 +942,7 @@ export async function pushSyncPackages({
867
942
  asOrg,
868
943
  report
869
944
  }) {
870
- const { siteContent, collections, siteContentUuid, hashes } = pkg
945
+ const { siteContent, collections, siteContentUuid, hashes, applied } = pkg
871
946
  const { info, note, error } = report
872
947
  const dim = report.dim || ((s) => s)
873
948
 
@@ -1200,7 +1275,7 @@ export async function pushSyncPackages({
1200
1275
  // then bank the post-write tokens so the NEXT push carries a current base.
1201
1276
  // Entities absent from finalized[] (skipped, or not editable) keep their cached
1202
1277
  // value — absence is not invalidation.
1203
- writeSyncCache(siteDir, hashes)
1278
+ writeSyncCache(siteDir, hashes, applied)
1204
1279
  mergeHarvested()
1205
1280
  // Re-base the page attribution: our emitted document and the backend's post-write
1206
1281
  // copy of it are the two sides' new agreed state. Only when the site-content lane
@@ -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-19T03:59:24.190Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.24.2",
6
+ "version": "0.24.4",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -0,0 +1,184 @@
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
+ * **Wired into `publish.js` and shipped in `uniweb` 0.25.3.** Both arms of the
43
+ * endpoint exist (presigned landed 2026-08-18, per Diego).
44
+ *
45
+ * ⚠️ **The presigned arm has never been exercised against a real backend from
46
+ * here.** The branch is one line and unit-tested both ways, but a stub is not a
47
+ * presigning deployment — three claims diverged that way in a single day while
48
+ * this lane was being built. A local `uniwebd` answers `direct`, so proving it
49
+ * needs a presigning deployment and one real publish. **Believed correct, not
50
+ * demonstrated.**
51
+ */
52
+
53
+ import { createHash } from 'node:crypto'
54
+
55
+ /**
56
+ * Plan + upload a site's static collection data files.
57
+ *
58
+ * @param {object} opts
59
+ * @param {string} opts.apiBase - backend origin
60
+ * @param {string} opts.token - bearer, used on the direct arm only
61
+ * @param {string} opts.siteUuid - the site these files belong to (path segment)
62
+ * @param {{ data: Record<string, unknown> }|null} opts.ball - source of the set:
63
+ * `{ "<relpath under dist/data>": <json> }`, media refs already rewritten
64
+ * @param {(m: string) => void} [opts.onProgress]
65
+ * @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path:string,status:number,detail?:string}>, serveBase: string|null }>}
66
+ * **No map** — by design. The files are at their serving paths.
67
+ */
68
+ export async function uploadSiteData({
69
+ apiBase,
70
+ token,
71
+ siteUuid,
72
+ ball,
73
+ onProgress = () => {}
74
+ }) {
75
+ const entries = Object.entries(ball?.data || {})
76
+ if (!entries.length) {
77
+ return { mode: 'none', uploaded: [], failed: [], serveBase: null }
78
+ }
79
+
80
+ // One plan for the whole set. The per-request file cap counts a plan, so
81
+ // splitting would evade it rather than respect it; if a set ever exceeds it,
82
+ // the refusal is the correct outcome and the cap is the thing to fix.
83
+ const files = entries.map(([relPath, value]) => {
84
+ const bytes = Buffer.from(JSON.stringify(value))
85
+ return {
86
+ // ⚠️ The tail RELATIVE TO THE DATA ROOT — no `data/` prefix.
87
+ //
88
+ // The plan's `serve_base` already ends in `/data/`
89
+ // (`/gateway/site/{site}/data/`), and the runtime asks for
90
+ // `{config.base}/data/{name}.json` (`DATA_URL_PREFIX`). Sending
91
+ // `data/articles.json` would store one level too deep and serve at
92
+ // `…/data/data/articles.json`, which the runtime never requests.
93
+ //
94
+ // ⛔ And the failure is INVISIBLE from here: the plan succeeds, the PUT
95
+ // succeeds, and only a visitor's fetch 404s. Confirmed against the
96
+ // shipped contract's own example (`collections/articles.json` with a
97
+ // `/data/`-bearing `serve_base`), which supersedes an earlier
98
+ // parenthetical that showed the prefix.
99
+ path: relPath,
100
+ content_type: 'application/json',
101
+ size: bytes.length,
102
+ sha256: createHash('sha256').update(bytes).digest('hex'),
103
+ bytes
104
+ }
105
+ })
106
+
107
+ const origin = apiBase.replace(/\/$/, '')
108
+ // `{site}` is a PATH segment, not a body field — every sibling on this
109
+ // controller addresses the site that way (`content/push/{site}`,
110
+ // `publish/{site}`, `status/{site}`). The asset lane's body-borne owner is
111
+ // the wrong precedent: that lane is global, this one is per-site.
112
+ const planRes = await fetch(
113
+ `${origin}/dev/site/data-uploads/${encodeURIComponent(siteUuid)}`,
114
+ {
115
+ method: 'POST',
116
+ headers: {
117
+ 'Content-Type': 'application/json',
118
+ Authorization: `Bearer ${token}`
119
+ },
120
+ body: JSON.stringify({
121
+ files: files.map(({ path, content_type, size, sha256 }) => ({
122
+ path,
123
+ content_type,
124
+ size,
125
+ sha256
126
+ }))
127
+ })
128
+ }
129
+ )
130
+ if (!planRes.ok) {
131
+ const body = await planRes.text().catch(() => '')
132
+ throw new Error(
133
+ `site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
134
+ )
135
+ }
136
+
137
+ const plan = await planRes.json()
138
+ const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
139
+ // The one mode-aware bit, same rule as the code and runtime lanes: a direct
140
+ // PUT is a bearer-authed backend route; a presigned URL is self-authorizing
141
+ // and must NOT carry a foreign bearer, which can break signature validation.
142
+ const authHeaders =
143
+ plan.mode === 'presigned' ? {} : { Authorization: `Bearer ${token}` }
144
+
145
+ const uploaded = []
146
+ const failed = []
147
+ for (const f of files) {
148
+ const target = targets.get(f.path)
149
+ if (!target) {
150
+ // A file the plan did not answer for is unaddressable. Report it; never
151
+ // invent a location for it.
152
+ failed.push({ path: f.path, status: 0, detail: 'no upload target in plan' })
153
+ continue
154
+ }
155
+ try {
156
+ // Relative on the direct arm, absolute on presigned — `new URL` resolves
157
+ // both against the origin.
158
+ const res = await fetch(new URL(target.url, origin), {
159
+ method: target.method || 'PUT',
160
+ headers: {
161
+ 'Content-Type': 'application/json',
162
+ ...(target.headers || {}),
163
+ ...authHeaders
164
+ },
165
+ body: f.bytes
166
+ })
167
+ if (res.ok) {
168
+ uploaded.push(f.path)
169
+ onProgress(`${f.path}`)
170
+ } else {
171
+ failed.push({ path: f.path, status: res.status })
172
+ }
173
+ } catch (err) {
174
+ failed.push({ path: f.path, status: 0, detail: err.message })
175
+ }
176
+ }
177
+
178
+ return {
179
+ mode: plan.mode || 'direct',
180
+ uploaded,
181
+ failed,
182
+ serveBase: plan.serve_base || null
183
+ }
184
+ }
@@ -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
- }