uniweb 0.12.33 → 0.12.34

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.12.33",
3
+ "version": "0.12.34",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/kit": "0.9.18",
45
44
  "@uniweb/core": "0.7.14",
45
+ "@uniweb/kit": "0.9.18",
46
46
  "@uniweb/runtime": "0.8.20"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/semantic-parser": "1.1.17",
50
- "@uniweb/build": "0.14.16",
51
- "@uniweb/content-reader": "1.1.12"
49
+ "@uniweb/build": "0.14.17",
50
+ "@uniweb/content-reader": "1.1.12",
51
+ "@uniweb/semantic-parser": "1.1.17"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -1762,6 +1762,8 @@ export default {
1762
1762
 
1763
1763
  The content handler receives `block.parsedContent.data` and reads raw ProseMirror from `block.rawContent`. It returns a new ProseMirror document — the framework re-parses it through the semantic parser. Returning `null` or the same reference as `block.rawContent` signals no change.
1764
1764
 
1765
+ > **`instantiateContent` resolves `{placeholders}` in text nodes only** — not in link `href`s or other node/mark attributes. So `[{email}](mailto:{email})` fills the visible label but leaves the `mailto:` URL literal. For dynamic URLs, emit the value as plain text and let the component linkify it, or build the href in the handler yourself.
1766
+
1765
1767
  ### Reserved frontmatter fields
1766
1768
 
1767
1769
  `source` and `where` are convention-level reserved fields — they flow through to both `block.properties` (for handler access) and `params` (visible to components). Components can ignore them. This is consistent with how `background` and `theme` work. List them in `meta.js` params with descriptions so the editor and schema recognize them.
@@ -0,0 +1,43 @@
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
+
14
+ import { createHash } from 'node:crypto'
15
+ import { buildAssetUrl } from '../utils/asset-upload.js'
16
+
17
+ /**
18
+ * @param {object} client - BackendClient (origin + uploadSiteAssets + discover)
19
+ * @param {{ data: object, search: object }} ball - the assembled data ball
20
+ * @param {{ onProgress?: (m: string) => void }} [opts]
21
+ * @returns {Promise<string>} the content-addressed serve URL (→ `info.data_bundle`)
22
+ */
23
+ export async function uploadDataBundle(client, ball, { onProgress } = {}) {
24
+ const bytes = Buffer.from(JSON.stringify(ball))
25
+ const sha256 = createHash('sha256').update(bytes).digest('hex')
26
+ const localUrl = '/data-bundle/base.json' // bookkeeping key into assetsByLocalUrl
27
+
28
+ const result = await client.uploadSiteAssets({
29
+ files: [
30
+ { path: 'data-bundle/base.json', content_type: 'application/json', size: bytes.length, sha256, localUrl, bytes },
31
+ ],
32
+ onProgress,
33
+ })
34
+ if (result.failed?.length) {
35
+ const f = result.failed[0]
36
+ throw new Error(`data-bundle upload failed: HTTP ${f.status} ${f.detail}`)
37
+ }
38
+ const entry = result.assetsByLocalUrl[localUrl]
39
+ if (!entry) throw new Error('data-bundle upload returned no asset id')
40
+
41
+ const config = await client.discover()
42
+ return buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
43
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Upload a site's local media to the backend's content-addressed asset store via the
3
+ * SAME asset lane the data bundle rides, and return a `{ ref → serveUrl }` map for the
4
+ * deploy's second emit (`assetRewrite`) to swap the entity content refs for.
5
+ *
6
+ * Input is the site-root asset refs the producer surfaced in
7
+ * `emitSyncPackages().localAssets` (`/images/hero.png`); `resolveAssetPath` finds the
8
+ * file under the site's `public/` (or `assets/`). A ref whose file is missing is
9
+ * skipped (warned), never a broken serve URL. The serve URL is the backend's canonical
10
+ * `serve_url` when present, else reconstructed from `id`+`assetBase` (the dev fallback).
11
+ * Content-addressed like every asset: identical bytes → same id → a re-deploy of
12
+ * unchanged media is a cheap no-op PUT (the lane's `present` skip-list).
13
+ */
14
+
15
+ import { createHash } from 'node:crypto'
16
+ import { existsSync, readFileSync } from 'node:fs'
17
+ import { basename } from 'node:path'
18
+ import { resolveAssetPath } from '@uniweb/build/site'
19
+ import { buildAssetUrl } from '../utils/asset-upload.js'
20
+ import { contentTypeFor } from '../utils/code-upload.js'
21
+
22
+ /**
23
+ * @param {object} client - BackendClient (origin + uploadSiteAssets + discover)
24
+ * @param {string} siteDir - the site root (site-root refs resolve under public/)
25
+ * @param {string[]} refs - site-root local asset refs (`/images/x.png`)
26
+ * @param {{ onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
27
+ * @returns {Promise<Record<string,string>>} ref → serve URL (only resolved + uploaded refs)
28
+ */
29
+ export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn } = {}) {
30
+ if (!refs?.length) return {}
31
+
32
+ const files = []
33
+ for (const ref of refs) {
34
+ const { resolved } = resolveAssetPath(ref, siteDir, siteDir)
35
+ if (!resolved || !existsSync(resolved)) {
36
+ warn?.(`local-media: ${ref} not found under the site (skipped)`)
37
+ continue
38
+ }
39
+ const bytes = readFileSync(resolved)
40
+ files.push({
41
+ path: ref.replace(/^\/+/, ''), // bookkeeping key into the plan (must be unique)
42
+ content_type: contentTypeFor(basename(resolved)),
43
+ size: bytes.length,
44
+ sha256: createHash('sha256').update(bytes).digest('hex'),
45
+ localUrl: ref, // the rewrite key — the original content ref
46
+ diskPath: resolved,
47
+ })
48
+ }
49
+ if (!files.length) return {}
50
+
51
+ const result = await client.uploadSiteAssets({ files, onProgress })
52
+ for (const f of result.failed || []) warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
53
+
54
+ const config = await client.discover()
55
+ const map = {}
56
+ for (const ref of refs) {
57
+ const entry = result.assetsByLocalUrl[ref]
58
+ if (entry) map[ref] = entry.serveUrl || buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
59
+ }
60
+ return map
61
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * site-sync — the reusable core of `uniweb push`: given a site's emitted sync
3
+ * packages, submit them over the two directional lanes (site-content first, then the
4
+ * folder keyed by the site's uuid), back-fill the minted uuids into the source files,
5
+ * and persist the send-only-changed cache. Extracted from the push command so
6
+ * `uniweb deploy` (the composite path) reuses the exact same lane submission.
7
+ *
8
+ * The command keeps flag parsing, the emit, and the `-o`/`--dry-run` preview;
9
+ * everything from "the packages are built, now POST them" lives here. Logging is
10
+ * injected via `report` ({ info, note, error, dim }) so each caller styles output its
11
+ * own way.
12
+ */
13
+
14
+ import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
15
+ import { join, dirname } from 'node:path'
16
+ import { backfillEntityUuids, writeSiteEntityUuid } from '@uniweb/build/uwx'
17
+
18
+ // Pull the finalized entities out of the restore response. The backend returns
19
+ // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
20
+ // carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
21
+ // is not echoed), the minted entity `uuid`, a `changed` flag, and the full `document`
22
+ // (verbatim stored content with every `$uuid` filled in). A couple of shapes are
23
+ // tolerated; only entries with a valid `index` + `uuid` are usable.
24
+ export function extractFinalized(payload) {
25
+ const list = Array.isArray(payload?.report?.finalized)
26
+ ? payload.report.finalized
27
+ : Array.isArray(payload?.finalized)
28
+ ? payload.finalized
29
+ : Array.isArray(payload)
30
+ ? payload
31
+ : null
32
+ if (!list) return null
33
+ return list
34
+ .map((d) => ({
35
+ index: d?.index,
36
+ uuid: d?.uuid ?? d?.document?.$uuid ?? null,
37
+ changed: d?.changed,
38
+ document: d?.document ?? null,
39
+ }))
40
+ .filter((e) => Number.isInteger(e.index) && e.uuid)
41
+ }
42
+
43
+ // Pull the minted site-content uuid out of a CREATE response. The exact shape is an
44
+ // open backend item, so the extractor is deliberately tolerant of a bare
45
+ // `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
46
+ // envelope the update/folder lanes return (the site entity is submitted alone, so its
47
+ // minted uuid is the first finalized entry). Returns null if none is present.
48
+ export function extractMintedSiteUuid(payload) {
49
+ if (typeof payload?.siteContentUuid === 'string') return payload.siteContentUuid
50
+ if (typeof payload?.$uuid === 'string') return payload.$uuid
51
+ if (typeof payload?.uuid === 'string') return payload.uuid
52
+ const finalized = extractFinalized(payload)
53
+ if (finalized && finalized.length) {
54
+ const site = finalized.find((f) => f.index === 0) || finalized[0]
55
+ return site?.uuid ?? null
56
+ }
57
+ return null
58
+ }
59
+
60
+ // One-line summary from the authoritative per-entity `changed` flag (`false` = a true
61
+ // no-op). Falls back silently when the backend omits it.
62
+ function changedSummary(finalized) {
63
+ const changed = finalized.filter((f) => f.changed === true).length
64
+ const unchanged = finalized.filter((f) => f.changed === false).length
65
+ const parts = []
66
+ if (changed) parts.push(`${changed} changed`)
67
+ if (unchanged) parts.push(`${unchanged} unchanged`)
68
+ return parts.length ? parts.join(', ') : null
69
+ }
70
+
71
+ // Resolve a Model NOT defined by the local foundation by reading its declaration (the
72
+ // `@uniweb/data-schema` form) from the backend via the client. Cached per run; HTTP
73
+ // 404 → null (the emitter then says "register it first"). The bearer is acquired lazily
74
+ // by the client, so a fully-local sync never authenticates.
75
+ //
76
+ // `offline` (set for `-o` / `--dry-run`) forces every non-local Model to null WITHOUT
77
+ // touching the backend — an offline emit must never authenticate.
78
+ export function makeModelResolver({ client, offline = false }) {
79
+ const cache = new Map()
80
+ return async (modelName) => {
81
+ if (cache.has(modelName)) return cache.get(modelName)
82
+ const decl = offline ? null : await client.readDataSchema(modelName)
83
+ cache.set(modelName, decl)
84
+ return decl
85
+ }
86
+ }
87
+
88
+ // "Send only changed" cache: content hashes from the last successful sync, keyed
89
+ // `<model> <id>`. Gitignored, per-clone, deletable (a deleted cache just means one full
90
+ // re-sync, which the backend then no-ops). NOT identity — the minted `$uuid` lives in
91
+ // the source files; this is a pure wire-efficiency cache.
92
+ function syncCachePath(siteDir) {
93
+ return join(siteDir, '.uniweb', 'sync-cache.json')
94
+ }
95
+ export function readSyncCache(siteDir) {
96
+ try {
97
+ const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
98
+ return obj && typeof obj.hashes === 'object' && obj.hashes ? obj.hashes : {}
99
+ } catch {
100
+ return {} // missing / unreadable → treat everything as changed
101
+ }
102
+ }
103
+ export function writeSyncCache(siteDir, hashes) {
104
+ const p = syncCachePath(siteDir)
105
+ mkdirSync(dirname(p), { recursive: true })
106
+ writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
107
+ }
108
+
109
+ /**
110
+ * Submit a site's emitted sync packages over both directional lanes, back-fill the
111
+ * minted uuids, and persist the send-only-changed cache. The HTTP + file-write-back
112
+ * half that `emitSyncPackages` (producer-pure) deliberately omits.
113
+ *
114
+ * @param {object} params
115
+ * @param {object} params.client - BackendClient (carries the origin + the lane methods)
116
+ * @param {string} params.siteDir - the site root (for $uuid write-back + the cache)
117
+ * @param {object} params.pkg - the `emitSyncPackages` result
118
+ * ({ siteContent, collections, siteContentUuid, hashes })
119
+ * @param {string|null} [params.asOrg] - act-as org (membership-gated), forwarded to each lane
120
+ * @param {{info,note,error,dim?:Function}} params.report - injected logging
121
+ * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
122
+ * exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
123
+ */
124
+ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report }) {
125
+ const { siteContent, collections, siteContentUuid, hashes } = pkg
126
+ const { info, note, error } = report
127
+ const dim = report.dim || ((s) => s)
128
+
129
+ const wrote = []
130
+ let finalizedTotal = 0
131
+
132
+ // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
133
+ // returning the client's Response promise (so the "Pushing …" line prints before the
134
+ // request fires). The client carries `collision=force` (last-push-wins) + the optional
135
+ // `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
136
+ // (already reported).
137
+ const postLane = async (label, doRequest) => {
138
+ info(`Pushing ${label} to ${dim(client.origin)} …`)
139
+ let res
140
+ try {
141
+ res = await doRequest()
142
+ } catch (err) {
143
+ error(`Could not reach the backend at ${client.origin}: ${err.message}`)
144
+ note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
145
+ return null
146
+ }
147
+ if (!res.ok) {
148
+ error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
149
+ if (res.status === 401 || res.status === 403) {
150
+ note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
151
+ } else if (res.status === 409) {
152
+ // The site's @uniweb/folder is genesis-owned: its structure is fixed on first
153
+ // deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
154
+ note(
155
+ "This site's collection structure is already established on the backend and can't be changed " +
156
+ 'in place — e.g. adding or removing a schema-backed collection, or switching one between ' +
157
+ 'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
158
+ 'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
159
+ )
160
+ }
161
+ const body = await res.text().catch(() => '')
162
+ if (body) note(body.slice(0, 800))
163
+ return null
164
+ }
165
+ try {
166
+ return await res.json()
167
+ } catch (err) {
168
+ error(`Could not parse the ${label} response as JSON: ${err.message}`)
169
+ return null
170
+ }
171
+ }
172
+
173
+ // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
174
+ // finalized list (for record back-fill + the changed summary). Returns the finalized
175
+ // array, or null on failure (already reported).
176
+ const pushLane = async (label, doRequest) => {
177
+ const payload = await postLane(label, doRequest)
178
+ if (payload === null) return null
179
+ const finalized = extractFinalized(payload)
180
+ if (!finalized) {
181
+ error(`The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`)
182
+ note(JSON.stringify(payload).slice(0, 800))
183
+ return null
184
+ }
185
+ const summary = changedSummary(finalized)
186
+ if (summary) note(`${label}: ${summary}`)
187
+ return finalized
188
+ }
189
+
190
+ // Lane 1 — site-content (the site is born here; it must exist before its folder). A
191
+ // known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the site
192
+ // and returns its uuid, which we record into site.yml). `boundSiteUuid` carries the
193
+ // minted/known uuid forward to key the folder push.
194
+ let boundSiteUuid = siteContentUuid
195
+ if (siteContent) {
196
+ if (siteContentUuid) {
197
+ const finalized = await pushLane(
198
+ 'site-content',
199
+ () => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg })
200
+ )
201
+ if (!finalized) return { exitCode: 1, finalizedTotal, wrote }
202
+ finalizedTotal += finalized.length
203
+ } else {
204
+ const payload = await postLane(
205
+ 'site-content',
206
+ () => client.createSiteContent(siteContent.buffer, { asOrg })
207
+ )
208
+ if (payload === null) return { exitCode: 1, finalizedTotal, wrote }
209
+ const minted = extractMintedSiteUuid(payload)
210
+ if (!minted) {
211
+ error('The create response carried no minted site-content uuid — cannot record the site identity or push its folder.')
212
+ note(JSON.stringify(payload).slice(0, 800))
213
+ return { exitCode: 1, finalizedTotal, wrote }
214
+ }
215
+ writeSiteEntityUuid(siteDir, minted)
216
+ boundSiteUuid = minted
217
+ wrote.push('recorded site $uuid in site.yml')
218
+ finalizedTotal += extractFinalized(payload)?.length ?? 1
219
+ }
220
+ }
221
+
222
+ // Lane 2 — collections (the @uniweb/folder + the records it references), keyed by the
223
+ // site-content uuid. On a brand-new site the backend creates the folder on this first
224
+ // push. Records round-trip their own $uuid (back-filled into source files); the folder
225
+ // itself has no uuid (the backend owns it).
226
+ if (collections) {
227
+ if (!boundSiteUuid) {
228
+ error('Cannot push collections — the site has no uuid yet. Push the site-content lane first.')
229
+ return { exitCode: 1, finalizedTotal, wrote }
230
+ }
231
+ const finalized = await pushLane(
232
+ 'collections',
233
+ () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
234
+ )
235
+ if (!finalized) return { exitCode: 1, finalizedTotal, wrote }
236
+ const bf = backfillEntityUuids({ index: collections.index, finalized })
237
+ for (const w of bf.warnings) note(`! ${w}`)
238
+ for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
239
+ if (bf.updated.length) wrote.push(`wrote ${bf.updated.length} record file(s)`)
240
+ finalizedTotal += finalized.length
241
+ }
242
+
243
+ // Persist the full content-hash map so the next push skips unchanged entities.
244
+ writeSyncCache(siteDir, hashes)
245
+ return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
246
+ }
@@ -16,19 +16,22 @@
16
16
  * For static-host artifacts WITHOUT upload, see `uniweb export`. To publish a
17
17
  * site that already lives on the backend as a synced draft, see `uniweb release`.
18
18
  *
19
- * Uniweb-host flow (deployToUniwebBackend):
20
- * 1. Resolve the site dir + the deploy.yml target.
21
- * 2. discover() the backend's anonymous capability handshake (GET /dev/config):
22
- * delivery support + the installed-runtime list.
23
- * 3. Resolve the runtime: `site.yml::runtime` if pinned, else the highest
24
- * version the backend reports installed (fail closed if neither resolves).
25
- * 4. Build the site data (link mode): site-content.json (+ per-locale variants),
26
- * collection data, search indexes.
27
- * 5. Assemble the deploy payload (foundation, runtimeVersion, theme, languages,
28
- * defaultLanguage, locales, optional dataFiles/searchFiles).
29
- * 6. POST /dev/deploy via BackendClient the login bearer authorizes; on first
30
- * deploy the backend mints a delivery uuid (round-tripped through
31
- * deploy.yml::lastDeploy) and returns the serve URL.
19
+ * Uniweb-host flow (deployToUniwebBackend) — the composite deploy = build → ball →
20
+ * media push publish:
21
+ * 1. Resolve the site dir + deploy.yml target; discover() the backend (GET
22
+ * /dev/config) and resolve the runtime (`site.yml::runtime` if pinned, else the
23
+ * backend's highest installed; fail closed if neither resolves).
24
+ * 2. Build the site data (link mode): site-content.json (+ per-locale variants),
25
+ * collection data, search indexes, processed assets.
26
+ * 3. Partition collections by schema presence: schema-less → the static-data ball;
27
+ * schema-backed typed folder entities on the push lane.
28
+ * 4. Upload the site's local media (entity refs + the ball's refs, one deduped set)
29
+ * each site-root ref's backend serve URL; rewrite the ball with it, then upload the
30
+ * rewritten ball (content-addressed → `info.data_bundle`).
31
+ * 5. Push the SAME two-lane sync `uniweb push` uses (site-content with
32
+ * `info.data_bundle` stamped + media refs rewritten, then the folder + records) —
33
+ * over the send-only-changed cache; the backend mints/round-trips the site uuid.
34
+ * 6. Publish — make the just-pushed composite live; the backend returns the serve URL.
32
35
  *
33
36
  * Usage:
34
37
  * uniweb deploy Build + deploy to the resolved target
@@ -45,17 +48,20 @@
45
48
  */
46
49
 
47
50
  import { existsSync } from 'node:fs'
48
- import { readFile, readdir } from 'node:fs/promises'
49
- import { resolve, join, relative } from 'node:path'
51
+ import { readFile } from 'node:fs/promises'
52
+ import { resolve, join } from 'node:path'
50
53
  import { execSync } from 'node:child_process'
51
54
  import yaml from 'js-yaml'
52
55
 
53
- import { loadDeployYml, resolveTarget, recordLastDeploy, rewriteSiteContentPaths } from '@uniweb/build/site'
56
+ import { loadDeployYml, resolveTarget, recordLastDeploy, assembleDataBall, collectBallAssets, rewriteBallAssets } from '@uniweb/build/site'
54
57
  import { promptForHost } from '../utils/host-prompt.js'
55
58
  import { readFlagValue } from '../utils/args.js'
56
59
  import { parseBoolEnv } from '../utils/env.js'
57
60
  import { BackendClient } from '../backend/client.js'
58
- import { collectSiteAssets } from '../utils/asset-upload.js'
61
+ import { emitSyncPackages } from '@uniweb/build/uwx'
62
+ import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
63
+ import { uploadDataBundle } from '../backend/data-bundle.js'
64
+ import { uploadSiteMedia } from '../backend/site-media.js'
59
65
 
60
66
  import {
61
67
  findWorkspaceRoot,
@@ -262,27 +268,20 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
262
268
  command: 'Deploying',
263
269
  })
264
270
 
265
- // Steer-hint (not enforced): a site that's been synced (has site.yml::$uuid) is
266
- // CMS-managed `uniweb release` publishes its current backend state, including
267
- // app-side edits. `deploy` still works: it hosts the LOCAL file-built payload as
268
- // a separate delivery (deploy ⊥ publish — alternative lifecycles, your choice).
269
- if (siteYml.$uuid) {
270
- say.warn('This site is synced (site.yml::$uuid) — CMS-managed.')
271
- say.dim('`uniweb release` publishes its current backend state; `deploy` hosts your local files as a separate delivery.')
272
- }
271
+ const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
272
+ const asOrg = readFlagValue(args, '--as-org')
273
273
 
274
- // Anonymous capability handshake (cached). Confirms the deploy lane is offered
275
- // and supplies the installed-runtime list (the /dev/config replacement for the
276
- // retired Worker /runtime/latest).
274
+ // Anonymous capability handshake (cached). The composite deploy ends in a publish,
275
+ // so confirm that lane is offered (the push/sync lanes are the backend's baseline).
277
276
  const config = await client.discover()
278
- if (config?.delivery && config.delivery.deploy === false) {
279
- say.err(`Backend at ${client.origin} does not offer the deploy lane (delivery.deploy=false).`)
277
+ if (config?.delivery && config.delivery.publish === false) {
278
+ say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
280
279
  process.exit(1)
281
280
  }
282
281
 
283
282
  // Runtime resolution: an explicit site.yml::runtime pin wins; else the highest
284
283
  // version the backend reports installed; else fail closed with a clear
285
- // precondition error (better than serving a site with no runtime — §9.4).
284
+ // precondition error (better than serving a site with no runtime).
286
285
  const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
287
286
  if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
288
287
  say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
@@ -296,23 +295,18 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
296
295
  process.exit(1)
297
296
  }
298
297
 
299
- // deploy.yml uuid round-trip: a prior deploy recorded the minted delivery uuid
300
- // under lastDeploy.<target>.siteUuid; resend it so /gateway/site/{uuid}/ stays
301
- // stable. First deploy has none → the backend mints one and we write it back.
302
- const priorUuid = readDeployedSiteUuid(deployYml, resolved.targetName)
303
-
304
298
  if (dryRun) {
305
- say.info('Dry run — would deploy to the Uniweb backend:')
299
+ say.info('Dry run — would deploy to the Uniweb backend as a composite (ball → push → publish):')
306
300
  say.dim(`Backend : ${client.origin}`)
307
301
  say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
308
302
  say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
309
- say.dim(`site_uuid : ${priorUuid || '(none — backend will mint)'}`)
303
+ say.dim(`site_uuid : ${siteYml.$uuid || '(none — the first push mints it)'}`)
310
304
  return
311
305
  }
312
306
 
313
- // Build (link mode): emits dist/site-content.json (+ per-locale variants under
314
- // dist/<lang>/), dist/data/*, dist/_search/*. Spawn the SAME CLI binary that's
315
- // running so the inner build can't resolve to a different installed version.
307
+ // Build (link mode): emits dist/data/*, dist/_search/*, dist/assets/*, and
308
+ // dist/site-content.json. Spawn the SAME CLI binary so the inner build can't resolve
309
+ // to a different installed version.
316
310
  say.info('Building site…')
317
311
  console.log('')
318
312
  execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
@@ -329,105 +323,125 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
329
323
  process.exit(1)
330
324
  }
331
325
 
332
- const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
333
- const languages = extractLanguages(siteContent)
334
- const defaultLanguage = siteContent?.config?.defaultLanguage || languages[0] || 'en'
335
- const theme = await readTheme(siteDir, siteContent)
336
-
337
- // Per-locale content: default + each non-default dist/<lang>/site-content.json.
338
- const localeContents = { [defaultLanguage]: siteContent }
339
- for (const lang of languages) {
340
- if (lang === defaultLanguage) continue
341
- const p = join(distDir, lang, 'site-content.json')
342
- if (existsSync(p)) localeContents[lang] = JSON.parse(await readFile(p, 'utf8'))
343
- else say.warn(`Locale "${lang}" listed in site config but no dist/${lang}/site-content.json — skipping.`)
344
- }
345
-
346
- // Collection JSON ( /data/<key>) and search indexes (→ /_search/<key>) two
347
- // distinct serve namespaces on the backend.
348
- const dataFiles = await collectDataFiles(distDir)
349
- if (Object.keys(dataFiles).length) say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
350
- const searchFiles = await collectSearchFiles(distDir)
351
- if (Object.keys(searchFiles).length) say.dim(`Search indexes : ${Object.keys(searchFiles).length} (_search/ JSON)`)
352
-
353
- // Assets: when the backend advertises the lane (config.assets.supported), upload
354
- // the site's processed media (dist/assets/*) to the content-addressed store and
355
- // rewrite the content's local refs to durable serve URLs. Image-free sites
356
- // collect nothing and deploy unchanged; an un-advertised lane is skipped.
357
- // Contract: kb/framework/build/delivery-lane.md §Assets (channel f90d).
358
- if (config?.assets && config.assets.supported === false) {
359
- say.dim('Asset lane not yet available on this backend skipping upload (image-free deploy).')
360
- } else {
361
- const assetFiles = collectSiteAssets(distDir)
362
- if (assetFiles.length) {
363
- say.info(`Uploading ${assetFiles.length} asset(s)…`)
364
- let assetResult
365
- try {
366
- assetResult = await client.uploadSiteAssets({ distDir, files: assetFiles, onProgress: (m) => say.dim(` ${m}`) })
367
- } catch (err) {
368
- say.err(`Asset upload failed: ${err.message}`)
369
- process.exit(1)
370
- }
371
- if (assetResult.failed.length) {
372
- say.err(`${assetResult.failed.length} asset(s) failed to upload:`)
373
- for (const f of assetResult.failed) say.dim(` ${f.path} HTTP ${f.status} ${f.detail}`)
374
- process.exit(1)
375
- }
376
- // Build localUrl → durable serve URL, then re-run the build's own rewrite
377
- // over each locale's content (image nodes, marks, dataBlocks, param fields).
378
- // assetBase comes from /dev/config (origin-relative in dev → prepend origin;
379
- // absolute CDN in prod → used verbatim).
380
- const urlMapping = {}
381
- for (const [localUrl, { id, ext }] of Object.entries(assetResult.assetsByLocalUrl)) {
382
- urlMapping[localUrl] = buildAssetUrl(client.origin, config.assetBase, id, ext)
383
- }
384
- for (const lang of Object.keys(localeContents)) {
385
- localeContents[lang] = rewriteSiteContentPaths(localeContents[lang], urlMapping)
386
- }
387
- const skippedNote = assetResult.skipped?.length ? `, ${assetResult.skipped.length} already present` : ''
388
- say.dim(`Assets : ${assetResult.uploaded.length} uploaded${skippedNote} (${assetResult.mode}) → ${config.assetBase}`)
326
+ // Non-local @std/registry Model schemas resolve through the backend (same as push).
327
+ const resolveModel = makeModelResolver({ client, offline: false })
328
+
329
+ // 1. Partition the collections by schema presence. A first emit reads `schemaless`
330
+ // — the collections with no data schema, delivered statically via the ball. Its
331
+ // packages are discarded (deploy is not a hot path; the cheap clarity beats a
332
+ // schemaless-only fast path, a later optimization).
333
+ let probe
334
+ try {
335
+ probe = await emitSyncPackages(siteDir, { ...(foundationDir ? { foundationDir } : {}), resolveModel })
336
+ } catch (err) {
337
+ say.err(`Could not build the sync package: ${err.message}`)
338
+ process.exit(1)
339
+ }
340
+ const schemalessNames = (probe.schemaless || []).map((col) => col.name)
341
+ const localAssets = probe.localAssets || [] // entity-content site-root media refs
342
+
343
+ // 2. Assemble the static-data ball (schema-less collection data + the search index)
344
+ // BEFORE uploading it, because its schema-less records can carry local media too,
345
+ // which we upload + rewrite to serve URLs exactly like entity content (the backend
346
+ // serves a serve_url in the ball identically — it unwraps the ball verbatim).
347
+ let ball = await assembleDataBall(distDir, schemalessNames)
348
+ const ballAssets = collectBallAssets(ball)
349
+
350
+ // 2b. Upload ALL local media (entity refs + ball refs) on one asset lane the
351
+ // ref→serveUrl map. The same map rewrites the entity content (assetRewrite, real
352
+ // emit below) AND the ball (here, before it's uploaded). Co-located refs were
353
+ // warned + skipped by the producer; a missing file is skipped here (warned).
354
+ let assetRewrite = null
355
+ const mediaRefs = [...new Set([...localAssets, ...ballAssets])]
356
+ if (mediaRefs.length) {
357
+ say.info('Uploading media…')
358
+ try {
359
+ const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
360
+ onProgress: (m) => say.dim(` ${m}`),
361
+ warn: (m) => say.dim(`! ${m}`),
362
+ })
363
+ if (Object.keys(map).length) assetRewrite = map
364
+ if (ballAssets.length) ball = rewriteBallAssets(ball, map) // swap the ball's local refs → serve URLs
365
+ say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
366
+ } catch (err) {
367
+ say.err(`Media upload failed: ${err.message}`)
368
+ process.exit(1)
389
369
  }
390
370
  }
391
371
 
392
- const payload = {
393
- foundation,
394
- runtimeVersion,
395
- theme,
396
- languages,
397
- defaultLanguage,
398
- ...(Object.keys(dataFiles).length ? { dataFiles } : {}),
399
- ...(Object.keys(searchFiles).length ? { searchFiles } : {}),
400
- // One entry per language — single-locale sites end up with { [default]: content };
401
- // multi-locale carry per-locale translated content. Same shape as Editor publish.
402
- locales: localeContents,
372
+ // 2c. Upload the (media-rewritten) ball. `data_bundle` is its content-addressed serve
373
+ // URL; omitted when there is nothing static to deliver.
374
+ let dataBundle
375
+ if (ball) {
376
+ say.info('Uploading data bundle…')
377
+ try {
378
+ dataBundle = await uploadDataBundle(client, ball, { onProgress: (m) => say.dim(` ${m}`) })
379
+ } catch (err) {
380
+ say.err(`Data bundle upload failed: ${err.message}`)
381
+ process.exit(1)
382
+ }
383
+ say.dim(`Data bundle : ${Object.keys(ball.data).length} data + ${Object.keys(ball.search).length} search file(s)`)
384
+ }
385
+
386
+ // 3. Push the site (content + folder) over the send-only-changed cache — the SAME
387
+ // two-lane submission `uniweb push` uses — stamping info.data_bundle on the
388
+ // site-content entity and rewriting local media refs to their backend serve URLs.
389
+ const priorHashes = readSyncCache(siteDir)
390
+ let pkg
391
+ try {
392
+ pkg = await emitSyncPackages(siteDir, {
393
+ ...(foundationDir ? { foundationDir } : {}),
394
+ resolveModel,
395
+ priorHashes,
396
+ ...(dataBundle ? { injectInfo: { data_bundle: dataBundle } } : {}),
397
+ ...(assetRewrite ? { assetRewrite } : {}),
398
+ })
399
+ } catch (err) {
400
+ say.err(`Could not build the sync package: ${err.message}`)
401
+ process.exit(1)
402
+ }
403
+ for (const w of pkg.warnings) say.dim(`! ${w}`)
404
+ const report = {
405
+ info: (m) => say.info(m),
406
+ note: (m) => say.dim(m),
407
+ error: (m) => say.err(m),
408
+ dim: (s) => `${c.dim}${s}${c.reset}`,
409
+ }
410
+ const pushResult = await pushSyncPackages({ client, siteDir, pkg, asOrg, report })
411
+ if (pushResult.exitCode !== 0) process.exit(pushResult.exitCode)
412
+ const siteUuid = pushResult.boundSiteUuid
413
+ if (!siteUuid) {
414
+ say.err('Push did not yield a site uuid — cannot publish.')
415
+ process.exit(1)
403
416
  }
404
417
 
405
- say.info(`Deploying to ${c.dim}${client.origin}${c.reset} …`)
406
- let res
418
+ // 4. Publish: make the just-pushed composite live (its current backend state).
419
+ const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
420
+ const languages = extractLanguages(siteContent)
421
+ say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
422
+ let pubRes
407
423
  try {
408
- res = await client.deploy(payload, { siteUuid: priorUuid || undefined })
424
+ pubRes = await client.publishSite(siteUuid, { runtimeVersion, ...(languages ? { languages } : {}) })
409
425
  } catch (err) {
410
426
  say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
411
427
  say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
412
428
  process.exit(1)
413
429
  }
414
- if (!res.ok) {
415
- say.err(`Deploy rejected: HTTP ${res.status} ${res.statusText}`)
416
- if (res.status === 401 || res.status === 403) {
430
+ if (!pubRes.ok) {
431
+ say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
432
+ if (pubRes.status === 401 || pubRes.status === 403) {
417
433
  say.dim("Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>).")
418
434
  }
419
- const body = await res.text().catch(() => '')
435
+ const body = await pubRes.text().catch(() => '')
420
436
  if (body) say.dim(body.slice(0, 800))
421
437
  process.exit(1)
422
438
  }
423
439
  let result
424
- try { result = await res.json() } catch { result = {} }
425
-
426
- const mintedUuid = result.site_uuid || priorUuid || null
440
+ try { result = await pubRes.json() } catch { result = {} }
427
441
  const serveUrl = absolutizeServeUrl(client.origin, result.url)
428
442
 
429
- // Persist deploy memory + the minted uuid for the next round-trip. recordLastDeploy
430
- // touches only lastDeploy.<target>, so siteUuid rides there safely.
443
+ // Persist deploy memory. One identity: site.yml::$uuid (the push uuid) no separate
444
+ // deploy uuid. recordLastDeploy touches only lastDeploy.<target>.
431
445
  await persistLastDeploy(siteDir, {
432
446
  targetName: resolved.targetName,
433
447
  targetConfig: resolved.fromFile ? null : { host: 'uniweb' },
@@ -436,7 +450,7 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
436
450
  at: new Date().toISOString(),
437
451
  host: 'uniweb',
438
452
  backend: client.origin,
439
- siteUuid: mintedUuid,
453
+ siteUuid,
440
454
  url: serveUrl,
441
455
  foundation: { ref: typeof foundation === 'string' ? foundation : foundation?.ref },
442
456
  runtime: runtimeVersion,
@@ -445,7 +459,7 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
445
459
  })
446
460
 
447
461
  console.log('')
448
- say.ok(`Deployed ${c.bold}${mintedUuid || 'site'}${c.reset}`)
462
+ say.ok(`Deployed ${c.bold}${siteUuid}${c.reset}`)
449
463
  if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
450
464
  }
451
465
 
@@ -457,12 +471,6 @@ function pickHighestRuntime(installed) {
457
471
  return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
458
472
  }
459
473
 
460
- // The previously-minted delivery uuid for `targetName` (lastDeploy.<target>.siteUuid
461
- // in a loaded deploy.yml), or null on a first deploy / absent file.
462
- function readDeployedSiteUuid(deployYml, targetName) {
463
- return deployYml?.lastDeploy?.[targetName]?.siteUuid || null
464
- }
465
-
466
474
  // The deploy response `url` is the serve path. When origin-relative (the self-serve
467
475
  // default, e.g. /gateway/site/<uuid>/) prefix the BackendClient origin so the printed
468
476
  // link is clickable; absolute URLs pass through unchanged.
@@ -472,15 +480,6 @@ function absolutizeServeUrl(origin, url) {
472
480
  return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
473
481
  }
474
482
 
475
- // Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
476
- // (`/gateway/asset/` in dev) → prepend the backend origin; absolute (a prod CDN)
477
- // → used verbatim. Shape: {assetBase}dist/{id}/base.{ext} — basename literally
478
- // `base`, {ext} the source extension the plan echoed.
479
- function buildAssetUrl(origin, assetBase, id, ext) {
480
- const base = /^https?:\/\//.test(assetBase) ? assetBase : `${origin}${assetBase}`
481
- return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
482
- }
483
-
484
483
  // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
485
484
  //
486
485
  // Distinct from the uniweb-edge flow above. Picked when the resolved
@@ -679,66 +678,3 @@ function extractLanguages(siteContent) {
679
678
  // Three accepted shapes: plain `'en'`, Editor `{ value, label }`, site.yml `{ code, label }`.
680
679
  return langs.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
681
680
  }
682
-
683
- // Collect compiled collection JSON files from dist/data/ recursively.
684
- // Returns `{ '<relPath>': '<utf8-content>' }` keyed by the path under data/
685
- // so the worker can write each to `${sitePrefix}/data/<relPath>` in R2.
686
- // Empty object when the site has no `collection:` data sources.
687
- async function collectDataFiles(distDir) {
688
- const dataDir = join(distDir, 'data')
689
- if (!existsSync(dataDir)) return {}
690
- const files = {}
691
- const entries = await readdir(dataDir, { withFileTypes: true, recursive: true })
692
- for (const entry of entries) {
693
- if (!entry.isFile()) continue
694
- if (!entry.name.endsWith('.json')) continue
695
- const fullPath = join(entry.parentPath || entry.path, entry.name)
696
- const relPath = relative(dataDir, fullPath)
697
- files[relPath] = await readFile(fullPath, 'utf8')
698
- }
699
- return files
700
- }
701
-
702
- // Collect search index files from dist/_search/ recursively.
703
- // Returns `{ '<locale>/<name>.json': '<utf8-content>' }` so the worker can
704
- // write each to `${sitePrefix}/_search/<key>` in R2, gated by searchEnabled.
705
- // Empty object when the build emitted no search indexes.
706
- async function collectSearchFiles(distDir) {
707
- const searchDir = join(distDir, '_search')
708
- if (!existsSync(searchDir)) return {}
709
- const files = {}
710
- const entries = await readdir(searchDir, { withFileTypes: true, recursive: true })
711
- for (const entry of entries) {
712
- if (!entry.isFile()) continue
713
- if (!entry.name.endsWith('.json')) continue
714
- const fullPath = join(entry.parentPath || entry.path, entry.name)
715
- const relPath = relative(searchDir, fullPath)
716
- files[relPath] = await readFile(fullPath, 'utf8')
717
- }
718
- return files
719
- }
720
-
721
- /**
722
- * Resolve theme config.
723
- *
724
- * The build pipeline does not (today) emit a separate theme.json, so we read
725
- * the developer-authored theme.yml from the site root. The Worker's
726
- * `buildTheme()` tolerates an empty config — sites with no theme.yml still
727
- * publish, they just get default tokens.
728
- */
729
- async function readTheme(siteDir, siteContent) {
730
- const themePath = join(siteDir, 'theme.yml')
731
- if (existsSync(themePath)) {
732
- try {
733
- const parsed = yaml.load(await readFile(themePath, 'utf8'))
734
- if (parsed && typeof parsed === 'object') return parsed
735
- } catch {
736
- // fall through to site-content.json fallback
737
- }
738
- }
739
- // site-content sometimes carries a `theme` key produced by collectors.
740
- if (siteContent?.theme && typeof siteContent.theme === 'object') {
741
- return siteContent.theme
742
- }
743
- return {}
744
- }
@@ -34,17 +34,23 @@
34
34
  * Backend: via BackendClient (the content + folder sync lanes). Origin from
35
35
  * --registry > UNIWEB_REGISTER_URL > the local default.
36
36
  * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
37
+ *
38
+ * The two-lane SUBMISSION (POST both lanes, back-fill uuids, persist the
39
+ * send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb deploy`
40
+ * (the composite path) reuses the exact same logic. This command owns flag parsing,
41
+ * the emit, and the `-o`/`--dry-run` preview.
37
42
  */
38
43
 
39
- import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
40
- import { resolve, join, dirname } from 'node:path'
41
- import {
42
- emitSyncPackages,
43
- backfillEntityUuids,
44
- writeSiteEntityUuid,
45
- } from '@uniweb/build/uwx'
44
+ import { writeFileSync } from 'node:fs'
45
+ import { resolve } from 'node:path'
46
+ import { emitSyncPackages } from '@uniweb/build/uwx'
46
47
  import { BackendClient } from '../backend/client.js'
47
48
  import { resolveSiteDir } from './deploy.js'
49
+ import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
50
+
51
+ // Re-exported for downstream importers (pull.js, push.test.js) that read these
52
+ // helpers from this module — their canonical home is now ../backend/site-sync.js.
53
+ export { extractMintedSiteUuid, makeModelResolver } from '../backend/site-sync.js'
48
54
 
49
55
  const colors = {
50
56
  reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
@@ -64,101 +70,6 @@ function flagValue(args, name) {
64
70
  return null
65
71
  }
66
72
 
67
- // Pull the finalized entities out of the restore response. The backend returns
68
- // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each
69
- // entry carries its position in the SUBMITTED sequence (`index`, the correlation
70
- // key — `$id` is not echoed), the minted entity `uuid`, a `changed` flag, and the
71
- // full `document` (verbatim stored content with every `$uuid` filled in). A couple
72
- // of shapes are tolerated; only entries with a valid `index` + `uuid` are usable.
73
- function extractFinalized(payload) {
74
- const list = Array.isArray(payload?.report?.finalized)
75
- ? payload.report.finalized
76
- : Array.isArray(payload?.finalized)
77
- ? payload.finalized
78
- : Array.isArray(payload)
79
- ? payload
80
- : null
81
- if (!list) return null
82
- return list
83
- .map((d) => ({
84
- index: d?.index,
85
- uuid: d?.uuid ?? d?.document?.$uuid ?? null,
86
- changed: d?.changed,
87
- document: d?.document ?? null,
88
- }))
89
- .filter((e) => Number.isInteger(e.index) && e.uuid)
90
- }
91
-
92
- // Pull the minted site-content uuid out of a CREATE
93
- // response. The exact shape is an open backend item — tolerant of a bare
94
- // `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
95
- // envelope the update/folder lanes return (the site entity is submitted alone, so its
96
- // minted uuid is the first finalized entry). Returns null if none is present. (Single
97
- // adjust-point to pin at the first live CREATE.)
98
- export function extractMintedSiteUuid(payload) {
99
- if (typeof payload?.siteContentUuid === 'string') return payload.siteContentUuid
100
- if (typeof payload?.$uuid === 'string') return payload.$uuid
101
- if (typeof payload?.uuid === 'string') return payload.uuid
102
- const finalized = extractFinalized(payload)
103
- if (finalized && finalized.length) {
104
- const site = finalized.find((f) => f.index === 0) || finalized[0]
105
- return site?.uuid ?? null
106
- }
107
- return null
108
- }
109
-
110
- // One-line summary from the authoritative per-entity `changed` flag (`false` = a
111
- // true no-op). Falls back silently when the backend omits it.
112
- function changedSummary(finalized) {
113
- const changed = finalized.filter((f) => f.changed === true).length
114
- const unchanged = finalized.filter((f) => f.changed === false).length
115
- const parts = []
116
- if (changed) parts.push(`${changed} changed`)
117
- if (unchanged) parts.push(`${unchanged} unchanged`)
118
- return parts.length ? parts.join(', ') : null
119
- }
120
-
121
- // Resolve a Model NOT defined by the local foundation by reading its declaration
122
- // (the `@uniweb/data-schema` form) from the backend via the client. Cached per run;
123
- // HTTP 404 → null (the emitter then says "register it first"). The bearer is acquired
124
- // lazily by the client, so a fully-local sync never authenticates.
125
- //
126
- // `offline` (set for `-o` / `--dry-run`) forces every non-local Model to null WITHOUT
127
- // touching the backend — an offline emit must never authenticate. The collections
128
- // emitter then soft-skips a convention-defaulted schema with a warning ("not synced")
129
- // and still emits the site-content lane; an EXPLICIT non-local schema surfaces as a
130
- // clear "could not be resolved" error rather than an auth prompt.
131
- export function makeModelResolver({ client, offline = false }) {
132
- const cache = new Map()
133
- return async (modelName) => {
134
- if (cache.has(modelName)) return cache.get(modelName)
135
- const decl = offline ? null : await client.readDataSchema(modelName)
136
- cache.set(modelName, decl)
137
- return decl
138
- }
139
- }
140
-
141
- // "Send only changed" cache: content hashes from the last successful sync, keyed
142
- // `<model> <id>`. Gitignored, per-clone, deletable (a deleted cache just means one
143
- // full re-sync, which the backend then no-ops). NOT identity — the minted `$uuid`
144
- // lives in the source files; this is a pure wire-efficiency cache.
145
- function syncCachePath(siteDir) {
146
- return join(siteDir, '.uniweb', 'sync-cache.json')
147
- }
148
- function readSyncCache(siteDir) {
149
- try {
150
- const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
151
- return obj && typeof obj.hashes === 'object' && obj.hashes ? obj.hashes : {}
152
- } catch {
153
- return {} // missing / unreadable → treat everything as changed
154
- }
155
- }
156
- function writeSyncCache(siteDir, hashes) {
157
- const p = syncCachePath(siteDir)
158
- mkdirSync(dirname(p), { recursive: true })
159
- writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
160
- }
161
-
162
73
  export async function push(args = []) {
163
74
  const dryRun = args.includes('--dry-run')
164
75
  const output = flagValue(args, '-o') || flagValue(args, '--output')
@@ -197,7 +108,7 @@ export async function push(args = []) {
197
108
  error(`Could not build the sync package: ${err.message}`)
198
109
  return { exitCode: 2 }
199
110
  }
200
- const { siteContent, collections, siteContentUuid, warnings, hashes, skipped } = pkg
111
+ const { siteContent, collections, siteContentUuid, warnings, skipped } = pkg
201
112
  log('')
202
113
  for (const w of warnings) note(`! ${w}`)
203
114
 
@@ -235,116 +146,19 @@ export async function push(args = []) {
235
146
  return { exitCode: 0 }
236
147
  }
237
148
 
238
- const wrote = []
239
- let finalizedTotal = 0
240
-
241
- // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
242
- // returning the client's Response promise (so the "Pushing …" line prints before the
243
- // request fires). The client carries `collision=force` (last-push-wins) + the optional
244
- // `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
245
- // (already reported).
246
- const postLane = async (label, doRequest) => {
247
- info(`Pushing ${label} to ${colors.dim}${client.origin}${colors.reset} …`)
248
- let res
249
- try {
250
- res = await doRequest()
251
- } catch (err) {
252
- error(`Could not reach the backend at ${client.origin}: ${err.message}`)
253
- note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
254
- return null
255
- }
256
- if (!res.ok) {
257
- error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
258
- if (res.status === 401 || res.status === 403) {
259
- note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
260
- }
261
- const body = await res.text().catch(() => '')
262
- if (body) note(body.slice(0, 800))
263
- return null
264
- }
265
- try {
266
- return await res.json()
267
- } catch (err) {
268
- error(`Could not parse the ${label} response as JSON: ${err.message}`)
269
- return null
270
- }
271
- }
272
-
273
- // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
274
- // finalized list (for record back-fill + the changed summary). Returns the finalized
275
- // array, or null on failure (already reported).
276
- const pushLane = async (label, doRequest) => {
277
- const payload = await postLane(label, doRequest)
278
- if (payload === null) return null
279
- const finalized = extractFinalized(payload)
280
- if (!finalized) {
281
- error(`The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`)
282
- note(JSON.stringify(payload).slice(0, 800))
283
- return null
284
- }
285
- const summary = changedSummary(finalized)
286
- if (summary) note(`${label}: ${summary}`)
287
- return finalized
288
- }
289
-
290
- // Lane 1 — site-content (the site is born here; it must exist before its folder). A
291
- // known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the
292
- // site and returns its uuid, which we record into site.yml). `boundSiteUuid` carries
293
- // the minted/known uuid forward to key the folder push.
294
- let boundSiteUuid = siteContentUuid
295
- if (siteContent) {
296
- if (siteContentUuid) {
297
- const finalized = await pushLane(
298
- 'site-content',
299
- () => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg })
300
- )
301
- if (!finalized) return { exitCode: 1 }
302
- finalizedTotal += finalized.length
303
- } else {
304
- const payload = await postLane(
305
- 'site-content',
306
- () => client.createSiteContent(siteContent.buffer, { asOrg })
307
- )
308
- if (payload === null) return { exitCode: 1 }
309
- const minted = extractMintedSiteUuid(payload)
310
- if (!minted) {
311
- error('The create response carried no minted site-content uuid — cannot record the site identity or push its folder.')
312
- note(JSON.stringify(payload).slice(0, 800))
313
- return { exitCode: 1 }
314
- }
315
- writeSiteEntityUuid(siteDir, minted)
316
- boundSiteUuid = minted
317
- wrote.push('recorded site $uuid in site.yml')
318
- finalizedTotal += extractFinalized(payload)?.length ?? 1
319
- }
320
- }
321
-
322
- // Lane 2 — collections (the @uniweb/folder + the records it references), keyed by the
323
- // site-content uuid. On a brand-new site the backend creates the folder on this first
324
- // push. Records round-trip their own $uuid (back-filled into source files); the folder
325
- // itself has no uuid (the backend owns it).
326
- if (collections) {
327
- if (!boundSiteUuid) {
328
- error('Cannot push collections — the site has no uuid yet. Push the site-content lane first.')
329
- return { exitCode: 1 }
330
- }
331
- const finalized = await pushLane(
332
- 'collections',
333
- () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
334
- )
335
- if (!finalized) return { exitCode: 1 }
336
- const bf = backfillEntityUuids({ index: collections.index, finalized })
337
- for (const w of bf.warnings) note(`! ${w}`)
338
- for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
339
- if (bf.updated.length) wrote.push(`wrote ${bf.updated.length} record file(s)`)
340
- finalizedTotal += finalized.length
341
- }
342
-
343
- // Persist the full content-hash map so the next push skips unchanged entities.
344
- writeSyncCache(siteDir, hashes)
149
+ // Submit both lanes, back-fill the minted uuids, and persist the send-only-changed
150
+ // cache. Shared with `uniweb deploy` via ../backend/site-sync.js.
151
+ const result = await pushSyncPackages({
152
+ client,
153
+ siteDir,
154
+ pkg,
155
+ asOrg,
156
+ report: { info, note, error, dim: (s) => `${colors.dim}${s}${colors.reset}` },
157
+ })
158
+ if (result.exitCode !== 0) return { exitCode: result.exitCode }
345
159
  success(
346
- `Pushed ${finalizedTotal} entit${finalizedTotal === 1 ? 'y' : 'ies'}` +
347
- (wrote.length ? ` — ${wrote.join(', ')}` : '')
160
+ `Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
161
+ (result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
348
162
  )
349
163
  return { exitCode: 0 }
350
164
  }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-18T03:42:23.549Z",
3
+ "generatedAt": "2026-06-23T17:13:47.118Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.16",
6
+ "version": "0.14.17",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -99,7 +99,7 @@
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/unipress": {
102
- "version": "0.4.22",
102
+ "version": "0.4.23",
103
103
  "path": "framework/unipress",
104
104
  "deps": [
105
105
  "@uniweb/build",
@@ -73,10 +73,15 @@ export function collectSiteAssets(distDir) {
73
73
 
74
74
  /**
75
75
  * Deliver a site's assets: plan, then PUT each NEW file. Returns the rewrite map
76
- * (`localUrl → { id, ext }`) — populated for files that uploaded successfully OR
77
- * that the plan reports already `present`, so a partial failure never injects a
78
- * broken serve URL into content. Throws only on a plan-level failure; per-file
79
- * PUT failures surface in `failed`.
76
+ * (`localUrl → { id, ext, serveUrl }`) — populated for files that uploaded
77
+ * successfully OR that the plan reports already `present`, so a partial failure
78
+ * never injects a broken serve URL into content. Throws only on a plan-level
79
+ * failure; per-file PUT failures surface in `failed`.
80
+ *
81
+ * `serveUrl` is the backend's canonical, ready-built serve URL for the asset (the
82
+ * plan entry's `serve_url`; content-addressed, lane-independent). Callers embed it
83
+ * verbatim; `buildAssetUrl(origin, assetBase, id, ext)` reconstructs the same
84
+ * string and stays as a fallback for an older backend that omits `serve_url`.
80
85
  *
81
86
  * Skip-list (content-addressed dedup): a plan entry with `present: true` is
82
87
  * already in the global store — we don't re-PUT it, but we DO record its id+ext
@@ -89,7 +94,7 @@ export function collectSiteAssets(distDir) {
89
94
  * @param {string} opts.distDir - the site's built dist/ directory
90
95
  * @param {Array} [opts.files] - pre-collected list (default: collectSiteAssets)
91
96
  * @param {(msg: string) => void} [opts.onProgress]
92
- * @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string }> }>}
97
+ * @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string, serveUrl?: string }> }>}
93
98
  */
94
99
  export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgress = () => {} }) {
95
100
  const list = files || collectSiteAssets(distDir)
@@ -130,7 +135,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
130
135
  // ⇒ false (older backend) → falls through to the upload path below.
131
136
  if (up.present) {
132
137
  skipped.push(src.path)
133
- assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
138
+ assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
134
139
  continue
135
140
  }
136
141
 
@@ -144,7 +149,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
144
149
  try {
145
150
  // The plan's url may be origin-relative (direct mode → uniwebd) or
146
151
  // absolute (presigned → storage); new URL() resolves both.
147
- putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: readFileSync(src.diskPath) })
152
+ putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: src.bytes ?? readFileSync(src.diskPath) })
148
153
  } catch (err) {
149
154
  failed.push({ path: src.path, status: 0, detail: err.message })
150
155
  continue
@@ -152,7 +157,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
152
157
  if (putRes.ok) {
153
158
  uploaded.push(src.path)
154
159
  // Authoritative id + ext from the plan; mapped only on a successful PUT.
155
- assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
160
+ assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
156
161
  } else {
157
162
  failed.push({ path: src.path, status: putRes.status, detail: await putRes.text().catch(() => '') })
158
163
  }
@@ -160,3 +165,12 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
160
165
 
161
166
  return { mode, uploaded, skipped, failed, assetsByLocalUrl }
162
167
  }
168
+
169
+ // Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
170
+ // (`/gateway/asset/` in dev) → prepend the backend origin; absolute (a prod CDN)
171
+ // → used verbatim. Shape: {assetBase}dist/{id}/base.{ext} — basename literally
172
+ // `base`, {ext} the source extension the plan echoed.
173
+ export function buildAssetUrl(origin, assetBase, id, ext) {
174
+ const base = /^https?:\/\//.test(assetBase) ? assetBase : `${origin}${assetBase}`
175
+ return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
176
+ }