uniweb 0.12.32 → 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 +3 -3
- package/partials/agents.md +2 -0
- package/src/backend/client.js +12 -5
- package/src/backend/data-bundle.js +43 -0
- package/src/backend/site-media.js +61 -0
- package/src/backend/site-sync.js +246 -0
- package/src/commands/deploy.js +137 -201
- package/src/commands/pull.js +108 -24
- package/src/commands/push.js +26 -212
- package/src/framework-index.json +4 -4
- package/src/utils/asset-upload.js +22 -8
package/src/commands/pull.js
CHANGED
|
@@ -32,18 +32,20 @@
|
|
|
32
32
|
* Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
|
|
33
33
|
*
|
|
34
34
|
* A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
|
|
35
|
-
* clear message.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
|
|
36
|
+
* `entities/<uuid>.json`); `readPullDocuments` reads the entity files out of it, with
|
|
37
|
+
* a tolerant JSON fallback (`extractDocument` / `splitCollectionsPull`). Verified live
|
|
38
|
+
* against the playground backend, 2026-06-17.
|
|
38
39
|
*/
|
|
39
40
|
|
|
40
|
-
import { readFileSync } from 'node:fs'
|
|
41
|
-
import { join } from 'node:path'
|
|
41
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
42
|
+
import { join, dirname } from 'node:path'
|
|
42
43
|
import yaml from 'js-yaml'
|
|
43
44
|
import {
|
|
44
45
|
siteContentDocumentToProject,
|
|
45
46
|
collectionsToProject,
|
|
46
47
|
resolveCollectionsConfig,
|
|
48
|
+
readZip,
|
|
47
49
|
} from '@uniweb/build/uwx'
|
|
48
50
|
import { makeModelResolver } from './push.js'
|
|
49
51
|
import { BackendClient } from '../backend/client.js'
|
|
@@ -76,6 +78,27 @@ function readYamlUuid(filePath) {
|
|
|
76
78
|
}
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
// Conditional-pull ETag cache (gitignored `.uniweb/pull-cache.json`): the last ETag
|
|
82
|
+
// seen per lane. The ETag is OPAQUE — cached and echoed verbatim in If-None-Match,
|
|
83
|
+
// never parsed or recomputed (the backend owns the hash; the client treats it as a
|
|
84
|
+
// token). A missing cache just means a full (unconditional) pull.
|
|
85
|
+
function pullCachePath(siteDir) {
|
|
86
|
+
return join(siteDir, '.uniweb', 'pull-cache.json')
|
|
87
|
+
}
|
|
88
|
+
function readPullCache(siteDir) {
|
|
89
|
+
try {
|
|
90
|
+
const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
|
|
91
|
+
return obj && typeof obj === 'object' ? obj : {}
|
|
92
|
+
} catch {
|
|
93
|
+
return {}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function writePullCache(siteDir, { content, folder }) {
|
|
97
|
+
const p = pullCachePath(siteDir)
|
|
98
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
99
|
+
writeFileSync(p, JSON.stringify({ version: 1, content, folder }, null, 2) + '\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
// Extract a single entity `$`-document from a pull response. Tolerant of a raw
|
|
80
103
|
// document, or a `{ document }` / `{ entity }` envelope. (Adjust at live e2e.)
|
|
81
104
|
export function extractDocument(payload) {
|
|
@@ -104,6 +127,46 @@ export function splitCollectionsPull(payload) {
|
|
|
104
127
|
}
|
|
105
128
|
}
|
|
106
129
|
|
|
130
|
+
// Read a pull lane's bytes into entity `$`-documents. The backend serves a `.uwx`
|
|
131
|
+
// (our Stored ZIP: `manifest.json` + `entities/<uuid>.json`); the entity files ARE the
|
|
132
|
+
// documents. Falls back to a JSON body (a raw doc, a `{document}`/`{entity}` envelope,
|
|
133
|
+
// or a list) so the lane survives a future envelope change. Returns an array (possibly
|
|
134
|
+
// empty).
|
|
135
|
+
export function readPullDocuments(buf) {
|
|
136
|
+
// `.uwx` ZIP — the local-file signature is "PK\x03\x04".
|
|
137
|
+
if (buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b) {
|
|
138
|
+
const docs = []
|
|
139
|
+
for (const [name, data] of readZip(buf)) {
|
|
140
|
+
if (name === 'manifest.json' || !name.endsWith('.json')) continue
|
|
141
|
+
try {
|
|
142
|
+
docs.push(JSON.parse(data.toString('utf8')))
|
|
143
|
+
} catch {
|
|
144
|
+
/* skip a non-document entry */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return docs
|
|
148
|
+
}
|
|
149
|
+
// JSON fallback — flatten any envelope splitCollectionsPull understands into a
|
|
150
|
+
// flat `$`-document list (a raw doc, a list, `{entities}`/`{documents}`, or
|
|
151
|
+
// `{folder, records}`).
|
|
152
|
+
let payload
|
|
153
|
+
try {
|
|
154
|
+
payload = JSON.parse(buf.toString('utf8'))
|
|
155
|
+
} catch {
|
|
156
|
+
return []
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(payload)) return payload.map(extractDocument).filter(Boolean)
|
|
159
|
+
if (payload?.folder) return [payload.folder, ...(payload.records || [])].filter(Boolean)
|
|
160
|
+
const list = Array.isArray(payload?.entities)
|
|
161
|
+
? payload.entities
|
|
162
|
+
: Array.isArray(payload?.documents)
|
|
163
|
+
? payload.documents
|
|
164
|
+
: null
|
|
165
|
+
if (list) return list.map(extractDocument).filter(Boolean)
|
|
166
|
+
const single = extractDocument(payload)
|
|
167
|
+
return single ? [single] : []
|
|
168
|
+
}
|
|
169
|
+
|
|
107
170
|
/**
|
|
108
171
|
* @param {string[]} args
|
|
109
172
|
* @param {object} [deps] - injectable seams for testing: `fetch` (default global
|
|
@@ -141,10 +204,12 @@ export async function pull(args = [], deps = {}) {
|
|
|
141
204
|
return { exitCode: 0 }
|
|
142
205
|
}
|
|
143
206
|
|
|
144
|
-
// GET a pull lane via the client and
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
207
|
+
// GET a pull lane via the client and return `{ docs, etag }` from its `.uwx` (ZIP)
|
|
208
|
+
// body — `readPullDocuments` reads the entity files out of it (JSON fallback). A
|
|
209
|
+
// conditional request whose ETag matches returns `{ notModified: true }` (304, empty
|
|
210
|
+
// body). `doRequest` is a thunk returning the client's Response promise. 404 / any
|
|
211
|
+
// failure → null (the lane is skipped, not fatal).
|
|
212
|
+
const getDocs = async (label, doRequest) => {
|
|
148
213
|
info(`Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`)
|
|
149
214
|
let res
|
|
150
215
|
try {
|
|
@@ -158,15 +223,21 @@ export async function pull(args = [], deps = {}) {
|
|
|
158
223
|
note(`${label}: not found (404) — it was deleted, or you lack access.`)
|
|
159
224
|
return null
|
|
160
225
|
}
|
|
226
|
+
if (res.status === 304) {
|
|
227
|
+
note(`${label}: unchanged (304)`)
|
|
228
|
+
return { notModified: true }
|
|
229
|
+
}
|
|
161
230
|
if (!res.ok) {
|
|
162
231
|
error(`${label} pull failed: HTTP ${res.status} ${res.statusText}`)
|
|
163
232
|
if (res.status === 401 || res.status === 403) note("Credentials weren't accepted — supply a bearer with --token <bearer>.")
|
|
164
233
|
return null
|
|
165
234
|
}
|
|
166
235
|
try {
|
|
167
|
-
|
|
236
|
+
const etag = res.headers?.get?.('etag') ?? null
|
|
237
|
+
const docs = readPullDocuments(Buffer.from(await res.arrayBuffer()))
|
|
238
|
+
return { docs, etag }
|
|
168
239
|
} catch (err) {
|
|
169
|
-
error(`Could not
|
|
240
|
+
error(`Could not read the ${label} response: ${err.message}`)
|
|
170
241
|
return null
|
|
171
242
|
}
|
|
172
243
|
}
|
|
@@ -176,25 +247,34 @@ export async function pull(args = [], deps = {}) {
|
|
|
176
247
|
let records = 0
|
|
177
248
|
let deleted = 0
|
|
178
249
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
250
|
+
// Conditional-pull cache: the last ETag seen per lane (opaque token — cached and
|
|
251
|
+
// echoed verbatim, never recomputed). Lives in the gitignored `.uniweb/`.
|
|
252
|
+
const cache = readPullCache(siteDir)
|
|
253
|
+
let etagContent = cache.content
|
|
254
|
+
let etagFolder = cache.folder
|
|
255
|
+
|
|
256
|
+
// Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
|
|
257
|
+
// entity (the site-content document). A 304 (unchanged) leaves local files as-is.
|
|
258
|
+
const content = await getDocs('content', () => client.pullSiteContent(siteContentUuid, { etag: etagContent }))
|
|
259
|
+
if (content && !content.notModified) {
|
|
260
|
+
const siteDoc = content.docs && (content.docs.find((d) => d?.info || d?.$model) || content.docs[0] || null)
|
|
261
|
+
if (siteDoc) {
|
|
262
|
+
const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
|
|
263
|
+
pages += report.pages.length
|
|
264
|
+
sections += report.sections.length
|
|
265
|
+
deleted += report.deleted.length
|
|
266
|
+
}
|
|
267
|
+
if (content.etag) etagContent = content.etag
|
|
188
268
|
}
|
|
189
269
|
|
|
190
270
|
// Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
|
|
191
271
|
// (the backend resolves the site's `@uniweb/folder` from it; the framework never
|
|
192
272
|
// holds a folder uuid). Models are resolved by name (async) up front, so
|
|
193
|
-
// collectionsToProject keeps its synchronous contract.
|
|
273
|
+
// collectionsToProject keeps its synchronous contract. A 304 leaves files as-is.
|
|
194
274
|
if (!noCollections) {
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
const { folderDoc, recordDocs } = splitCollectionsPull(
|
|
275
|
+
const folder = await getDocs('collections', () => client.pullFolder(siteContentUuid, { etag: etagFolder }))
|
|
276
|
+
if (folder && !folder.notModified && folder.docs?.length) {
|
|
277
|
+
const { folderDoc, recordDocs } = splitCollectionsPull(folder.docs)
|
|
198
278
|
const resolveModel = makeModelResolver({ client })
|
|
199
279
|
const declByModel = new Map()
|
|
200
280
|
for (const model of [...new Set(recordDocs.map((d) => d.$model).filter(Boolean))]) {
|
|
@@ -215,8 +295,12 @@ export async function pull(args = [], deps = {}) {
|
|
|
215
295
|
for (const s of report.skipped) note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
|
|
216
296
|
for (const w of report.warnings) note(`! ${w}`)
|
|
217
297
|
}
|
|
298
|
+
if (folder?.etag) etagFolder = folder.etag
|
|
218
299
|
}
|
|
219
300
|
|
|
301
|
+
// Persist the ETags so the next pull is conditional (304 when unchanged).
|
|
302
|
+
writePullCache(siteDir, { content: etagContent, folder: etagFolder })
|
|
303
|
+
|
|
220
304
|
success(
|
|
221
305
|
`Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` + (deleted ? `, ${deleted} deleted` : '')
|
|
222
306
|
)
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
|
40
|
-
import { resolve
|
|
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,
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
}
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-06-
|
|
3
|
+
"generatedAt": "2026-06-23T17:13:47.118Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.14.
|
|
6
|
+
"version": "0.14.17",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"deps": []
|
|
62
62
|
},
|
|
63
63
|
"@uniweb/runtime": {
|
|
64
|
-
"version": "0.8.
|
|
64
|
+
"version": "0.8.20",
|
|
65
65
|
"path": "framework/runtime",
|
|
66
66
|
"deps": [
|
|
67
67
|
"@uniweb/core",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"deps": []
|
|
100
100
|
},
|
|
101
101
|
"@uniweb/unipress": {
|
|
102
|
-
"version": "0.4.
|
|
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
|
|
77
|
-
* that the plan reports already `present`, so a partial failure
|
|
78
|
-
* broken serve URL into content. Throws only on a plan-level
|
|
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
|
+
}
|