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 +5 -5
- package/src/backend/site-sync.js +94 -19
- package/src/commands/publish.js +55 -24
- package/src/framework-index.json +2 -2
- package/src/utils/site-data-upload.js +184 -0
- package/src/backend/data-bundle.js +0 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.25.
|
|
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/
|
|
50
|
+
"@uniweb/build": "^0.24.4",
|
|
51
51
|
"@uniweb/content-reader": "^1.2.3",
|
|
52
|
-
"@uniweb/
|
|
52
|
+
"@uniweb/semantic-parser": "^1.2.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
|
185
|
-
//
|
|
186
|
-
// so every writer must preserve the ones it
|
|
187
|
-
// than
|
|
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
|
|
207
|
-
* content hashes,
|
|
208
|
-
* `pages/about/about.md`) is the same
|
|
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
|
|
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 = [
|
|
239
|
-
|
|
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 = [
|
|
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
|
-
|
|
306
|
-
|
|
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
|
package/src/commands/publish.js
CHANGED
|
@@ -53,9 +53,9 @@ import {
|
|
|
53
53
|
loadDeployYml,
|
|
54
54
|
resolveTarget,
|
|
55
55
|
recordLastDeploy,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
531
|
-
const ballAssets =
|
|
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 =
|
|
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.
|
|
590
|
-
|
|
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
|
|
611
|
+
say.info('Uploading collection data…')
|
|
593
612
|
try {
|
|
594
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
say.
|
|
603
|
-
|
|
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
|
-
//
|
|
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
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-19T03:59:24.190Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.24.
|
|
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
|
-
}
|