uniweb 0.68.0 → 0.69.1
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/partials/agents.md +1 -1
- package/src/backend/site-sync.js +174 -84
- package/src/commands/publish.js +38 -36
- package/src/commands/pull.js +46 -3
- package/src/commands/push.js +53 -49
- package/src/framework-index.json +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.1",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
45
44
|
"@uniweb/core": "^0.29.8",
|
|
46
45
|
"@uniweb/runtime": "^0.27.4",
|
|
47
|
-
"@uniweb/kit": "^0.19.4",
|
|
48
46
|
"@uniweb/schemas": "^0.7.0",
|
|
49
|
-
"@uniweb/semantic-parser": "^1.4.1"
|
|
47
|
+
"@uniweb/semantic-parser": "^1.4.1",
|
|
48
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
49
|
+
"@uniweb/kit": "^0.19.4"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@uniweb/build": "^0.
|
|
52
|
+
"@uniweb/build": "^0.63.1",
|
|
53
53
|
"@uniweb/content-reader": "^1.2.5",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.4.1"
|
|
55
55
|
},
|
package/partials/agents.md
CHANGED
|
@@ -896,7 +896,7 @@ The body — the value of the schema's content field, `article_body.content`.
|
|
|
896
896
|
|
|
897
897
|
**A component receives it differently from how the file writes it**, the same way on a static site and a hosted one: the brief's fields at the top (`article.title`), every other section under its name (`article.article_body.content`, `article.article_body.author`), and `$name`, the handle. Lists carry the brief alone unless the query says otherwise (`deferred:`, below). Full rules: `reference/entity-content.md`.
|
|
898
898
|
|
|
899
|
-
**A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name, or its `slug:`) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (
|
|
899
|
+
**A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name, or its `slug:`) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (in the same push when that record is new too), and a pull writes the name back.
|
|
900
900
|
|
|
901
901
|
**`records/folder.yml` — optional, and only for folders.** It is the one file in `records/` that is not a record, it moves with the directory (`paths.records`), and like a pages folder's `folder.yml` it describes the folder it sits in. Every record sits at the top of the site's records folder unless `folder.yml` places it in a sub-folder. Add one only when a query needs to ask for a *slice* of the records rather than all of them — most sites never do:
|
|
902
902
|
|
package/src/backend/site-sync.js
CHANGED
|
@@ -42,7 +42,10 @@ import {
|
|
|
42
42
|
updateBackendMap,
|
|
43
43
|
clearBackendSections,
|
|
44
44
|
normalizeBackendOrigin,
|
|
45
|
-
removeYamlScalar
|
|
45
|
+
removeYamlScalar,
|
|
46
|
+
harvestRecordItems,
|
|
47
|
+
storedRecordItems,
|
|
48
|
+
reprintRecordItems
|
|
46
49
|
} from '@uniweb/build/uwx'
|
|
47
50
|
|
|
48
51
|
// First entity `$`-document out of a `.uwx` we produced or the backend served.
|
|
@@ -62,6 +65,21 @@ function entityDocFromUwx(buf) {
|
|
|
62
65
|
return null
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
// Every entity `$`-document of a `.uwx` — the folder lane holds the folder and its records.
|
|
69
|
+
function entityDocsFromUwx(buf) {
|
|
70
|
+
const docs = []
|
|
71
|
+
if (!buf || buf.length < 2 || buf[0] !== 0x50 || buf[1] !== 0x4b) return docs
|
|
72
|
+
for (const [name, data] of readZip(buf)) {
|
|
73
|
+
if (name === 'manifest.json' || !name.endsWith('.json')) continue
|
|
74
|
+
try {
|
|
75
|
+
docs.push(JSON.parse(data.toString('utf8')))
|
|
76
|
+
} catch {
|
|
77
|
+
// not a document
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return docs
|
|
81
|
+
}
|
|
82
|
+
|
|
65
83
|
/**
|
|
66
84
|
* Turn an entity-grained staleness refusal into a file-level account.
|
|
67
85
|
*
|
|
@@ -793,6 +811,73 @@ export function writeItemUuids(siteDir, backend, map) {
|
|
|
793
811
|
updateBackendMap(siteDir, backend, 'items', map)
|
|
794
812
|
}
|
|
795
813
|
|
|
814
|
+
/**
|
|
815
|
+
* The identity of each record's list items:
|
|
816
|
+
* `{ <record $uuid>: { <place>: "<item $uuid> <fingerprint>" } }`.
|
|
817
|
+
*
|
|
818
|
+
* ⛔ THE FOURTH MAP, FOR THE SAME REASON AS THE OTHER THREE. An item of a record's `many`
|
|
819
|
+
* section — or of a list nested in one, or of a tree's `$children` — is a `multi` row with no
|
|
820
|
+
* file and no name, so neither a path-keyed map nor the record's own file can hold it. Sent
|
|
821
|
+
* without it, the record is refused whole (`identity_required`), since applying it would
|
|
822
|
+
* replace every stored item: measured 2026-09-25 on an edit of a talk's title. Banked per
|
|
823
|
+
* record from a push's finalized documents (paired by place with what was sent) and from a
|
|
824
|
+
* pull; matched on the next send by content, then by place (`@uniweb/build`'s
|
|
825
|
+
* `record-items.js`). Each record's entry is replaced whole.
|
|
826
|
+
*/
|
|
827
|
+
export function readRecordItemUuids(siteDir, backend) {
|
|
828
|
+
return readBackendState(siteDir, backend).recordItems || {}
|
|
829
|
+
}
|
|
830
|
+
export function writeRecordItemUuids(siteDir, backend, map) {
|
|
831
|
+
if (!map || !Object.keys(map).length) return
|
|
832
|
+
updateBackendMap(siteDir, backend, 'recordItems', map)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// The records' list items, banked from the documents the backend returned for them —
|
|
836
|
+
// `returned` by `$uuid` — paired by place with the lists as sent, which ride on each record's
|
|
837
|
+
// index entry (`lists`). A record keeps an entry only while it has list items, or had one.
|
|
838
|
+
function bankRecordItems(siteDir, backend, pairs) {
|
|
839
|
+
const prior = readRecordItemUuids(siteDir, backend)
|
|
840
|
+
const bank = {}
|
|
841
|
+
for (const { uuid, lists, returned } of pairs) {
|
|
842
|
+
if (!uuid || !lists || !returned) continue
|
|
843
|
+
const items = harvestRecordItems(lists, returned)
|
|
844
|
+
if (Object.keys(items).length || prior[uuid]) bank[uuid] = items
|
|
845
|
+
}
|
|
846
|
+
writeRecordItemUuids(siteDir, backend, bank)
|
|
847
|
+
return Object.keys(bank).length
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Recover the list-item identity of records the backend holds and this copy never banked —
|
|
852
|
+
* `uuids`, the emit's `recordItemIdentity.unbanked` — from the backend's own documents: one read
|
|
853
|
+
* of the folder lane, and nothing written but `sync.json`. The send that follows matches their
|
|
854
|
+
* items by place alone (`storedRecordItems`): what was sent before is unknown here.
|
|
855
|
+
*
|
|
856
|
+
* Best-effort, like `ensureItemUuids`: a failure leaves them unbanked, and the backend refuses
|
|
857
|
+
* the send rather than replace their items.
|
|
858
|
+
*
|
|
859
|
+
* @returns {Promise<number>} how many records were recovered
|
|
860
|
+
*/
|
|
861
|
+
export async function recoverRecordItemUuids({ client, siteDir, uuids, note }) {
|
|
862
|
+
const siteUuid = readBackendState(siteDir, client.origin).site?.uuid || null
|
|
863
|
+
if (!siteUuid || !uuids?.length) return 0
|
|
864
|
+
try {
|
|
865
|
+
const res = await client.pullFolder(siteUuid)
|
|
866
|
+
if (!res?.ok) return 0
|
|
867
|
+
const wanted = new Set(uuids)
|
|
868
|
+
const bank = {}
|
|
869
|
+
for (const doc of entityDocsFromUwx(Buffer.from(await res.arrayBuffer()))) {
|
|
870
|
+
if (wanted.has(doc?.$uuid)) bank[doc.$uuid] = storedRecordItems(doc)
|
|
871
|
+
}
|
|
872
|
+
writeRecordItemUuids(siteDir, client.origin, bank)
|
|
873
|
+
const n = Object.keys(bank).length
|
|
874
|
+
if (n) note?.(`Recovered the identity of the list items of ${n} record(s) from the backend.`)
|
|
875
|
+
return n
|
|
876
|
+
} catch {
|
|
877
|
+
return 0
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
796
881
|
/**
|
|
797
882
|
* The workspace this site was created in on `backend` — its owner, as the create
|
|
798
883
|
* answered it — in the header's form: `@handle`, a unit's bare uuid when that unit has
|
|
@@ -1186,12 +1271,9 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
|
|
|
1186
1271
|
// ⚠️ Until 2026-09-23 this said the site's recorded ORG resolves it and must be
|
|
1187
1272
|
// passed in — true before 2026-09-22, and read as a reason to pass it.
|
|
1188
1273
|
const pkg = await comparisonEmit(siteDir, { backend, priorHashes, sendAll })
|
|
1189
|
-
// A record held back for a reference (`pushInPasses`) is not in either lane, and is
|
|
1190
|
-
// still unpushed.
|
|
1191
1274
|
const changed =
|
|
1192
1275
|
(pkg.siteContent?.entityCount || 0) +
|
|
1193
|
-
(pkg.records?.entityCount || 0)
|
|
1194
|
-
(pkg.waiting || []).filter((w) => w.held).length
|
|
1276
|
+
(pkg.records?.entityCount || 0)
|
|
1195
1277
|
return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
|
|
1196
1278
|
}
|
|
1197
1279
|
|
|
@@ -1222,7 +1304,7 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
|
|
|
1222
1304
|
*/
|
|
1223
1305
|
async function comparisonEmit(
|
|
1224
1306
|
siteDir,
|
|
1225
|
-
{ backend = null, priorHashes = {}, sendAll = false } = {}
|
|
1307
|
+
{ backend = null, priorHashes = {}, sendAll = false, declarations = null } = {}
|
|
1226
1308
|
) {
|
|
1227
1309
|
const applied = readAppliedInjections(siteDir, backend)
|
|
1228
1310
|
// ⛔ Per backend: an asset id is minted by one and means nothing to another, so a
|
|
@@ -1232,7 +1314,13 @@ async function comparisonEmit(
|
|
|
1232
1314
|
const queryUuids = readQueryUuids(siteDir, backend)
|
|
1233
1315
|
return emitSyncPackages(siteDir, {
|
|
1234
1316
|
backend,
|
|
1235
|
-
|
|
1317
|
+
// Offline, always. A caller that has just read the Models from the backend — a pull —
|
|
1318
|
+
// may hand them in: the declarations a push would resolve, so the hashes still compare.
|
|
1319
|
+
// Without them a site whose foundation is not in the project (a clone's) resolves none
|
|
1320
|
+
// of its Models here, and nothing is banked.
|
|
1321
|
+
resolveModel: declarations
|
|
1322
|
+
? async (name) => declarations.get(name) ?? null
|
|
1323
|
+
: makeModelResolver({ client: null, offline: true }),
|
|
1236
1324
|
priorHashes,
|
|
1237
1325
|
sendAll,
|
|
1238
1326
|
...applied,
|
|
@@ -1263,13 +1351,69 @@ async function comparisonEmit(
|
|
|
1263
1351
|
* ⭐ Re-banking is the correct answer rather than clearing, because after a pull the
|
|
1264
1352
|
* on-disk state IS the agreed state: it came from the backend. A push with no edits
|
|
1265
1353
|
* in between should send nothing, and clearing would make it send everything.
|
|
1354
|
+
*
|
|
1355
|
+
* @param {object} [opts]
|
|
1356
|
+
* @param {object[]} [opts.recordDocs] - the record documents the pull took; their list items'
|
|
1357
|
+
* identity is banked against the files just written (`readRecordItemUuids`)
|
|
1358
|
+
* @param {Map<string,object>} [opts.declarations] - the Models the pull read, by name — so a
|
|
1359
|
+
* site whose foundation is not in the project (a clone's) can be re-banked offline
|
|
1266
1360
|
*/
|
|
1267
|
-
export async function rebankSyncHashes(siteDir, backend = null) {
|
|
1268
|
-
const pkg = await comparisonEmit(siteDir, { backend, sendAll: true })
|
|
1361
|
+
export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, declarations } = {}) {
|
|
1362
|
+
const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations })
|
|
1269
1363
|
writeSyncCache(siteDir, backend, pkg.hashes || {}, pkg.applied || {})
|
|
1364
|
+
// ⭐ And the identity of the records' list items, from the documents the pull just took:
|
|
1365
|
+
// the files it wrote hold the stored items in stored order, so the emit over them pairs
|
|
1366
|
+
// with each document item for item.
|
|
1367
|
+
if (recordDocs?.length) {
|
|
1368
|
+
const byUuid = new Map(recordDocs.filter((d) => d?.$uuid).map((d) => [d.$uuid, d]))
|
|
1369
|
+
const recordUuids = readBackendState(siteDir, backend).records || {}
|
|
1370
|
+
bankRecordItems(
|
|
1371
|
+
siteDir,
|
|
1372
|
+
backend,
|
|
1373
|
+
(pkg.records?.index || []).map((entry) => {
|
|
1374
|
+
const uuid = entry.ownId ? recordUuids[entry.ownId] : null
|
|
1375
|
+
return { uuid, lists: entry.lists, returned: uuid ? byUuid.get(uuid) : null }
|
|
1376
|
+
})
|
|
1377
|
+
)
|
|
1378
|
+
}
|
|
1270
1379
|
return Object.keys(pkg.hashes || {}).length
|
|
1271
1380
|
}
|
|
1272
1381
|
|
|
1382
|
+
/**
|
|
1383
|
+
* Re-bank the hashes of the records a push sent naming a record it created — `keys`,
|
|
1384
|
+
* the emit's `namesNew` — as the next push will hash them, and fingerprint their list
|
|
1385
|
+
* items the same way.
|
|
1386
|
+
*
|
|
1387
|
+
* ⭐ Such a record names its new record by `$ref`, and the backend stores the uuid it
|
|
1388
|
+
* mints. The next push names it by that uuid: the same reference, and another hash — so
|
|
1389
|
+
* the hash the push banked describes a document no later emit builds, and the next push
|
|
1390
|
+
* would send every one of these records again. Re-sending is not harmless: a record's
|
|
1391
|
+
* list-section items go without the identity a re-send needs (`identity_required`).
|
|
1392
|
+
*
|
|
1393
|
+
* ⚖️ Only these keys, over the hashes the push just banked: the site-content lane and
|
|
1394
|
+
* the folder are banked as that push sent them, and a caller's own emit options (a
|
|
1395
|
+
* publish that does not declare services) are not the comparison emit's.
|
|
1396
|
+
*/
|
|
1397
|
+
export async function rebankRecordHashes(siteDir, backend, keys) {
|
|
1398
|
+
if (!keys?.length) return
|
|
1399
|
+
const pkg = await comparisonEmit(siteDir, { backend, sendAll: true })
|
|
1400
|
+
const hashes = { ...readSyncCache(siteDir, backend) }
|
|
1401
|
+
for (const key of keys) if (pkg.hashes?.[key]) hashes[key] = pkg.hashes[key]
|
|
1402
|
+
writeSyncCache(siteDir, backend, hashes, readAppliedInjections(siteDir, backend))
|
|
1403
|
+
// Their list items too: an item that named a new record is fingerprinted anew, each uuid kept
|
|
1404
|
+
// at its place (`reprintRecordItems`) — or the next send finds it by place alone.
|
|
1405
|
+
const wanted = new Set(keys)
|
|
1406
|
+
const recordUuids = readBackendState(siteDir, backend).records || {}
|
|
1407
|
+
const bank = readRecordItemUuids(siteDir, backend)
|
|
1408
|
+
const reprinted = {}
|
|
1409
|
+
for (const entry of pkg.records?.index || []) {
|
|
1410
|
+
if (!wanted.has(`${entry.model} ${entry.id}`)) continue
|
|
1411
|
+
const uuid = entry.ownId ? recordUuids[entry.ownId] : null
|
|
1412
|
+
if (uuid && bank[uuid]) reprinted[uuid] = reprintRecordItems(bank[uuid], entry.lists)
|
|
1413
|
+
}
|
|
1414
|
+
writeRecordItemUuids(siteDir, backend, reprinted)
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1273
1417
|
/**
|
|
1274
1418
|
* What a push did with the site's `template:` intent — read from the backend's own
|
|
1275
1419
|
* reply, never inferred from what we sent.
|
|
@@ -1747,6 +1891,18 @@ export async function pushSyncPackages({
|
|
|
1747
1891
|
}
|
|
1748
1892
|
for (const w of bf.warnings) note(`! ${w}`)
|
|
1749
1893
|
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
1894
|
+
// ⭐ BANK THE RECORDS' LIST ITEMS: each item's `$uuid` in the document the backend returned,
|
|
1895
|
+
// paired by place with the lists as sent — so the next send of a record updates its items
|
|
1896
|
+
// rather than being refused for replacing them (`readRecordItemUuids`).
|
|
1897
|
+
bankRecordItems(
|
|
1898
|
+
siteDir,
|
|
1899
|
+
client.origin,
|
|
1900
|
+
finalized.map((f) => ({
|
|
1901
|
+
uuid: f?.uuid,
|
|
1902
|
+
lists: Number.isInteger(f?.index) ? records.index?.[f.index]?.lists : null,
|
|
1903
|
+
returned: f?.document
|
|
1904
|
+
}))
|
|
1905
|
+
)
|
|
1750
1906
|
draftsNotKept = bf.notKeptAsDrafts || []
|
|
1751
1907
|
if (bf.updated.length)
|
|
1752
1908
|
wrote.push(`wrote ${bf.updated.length} record file(s)`)
|
|
@@ -1774,6 +1930,15 @@ export async function pushSyncPackages({
|
|
|
1774
1930
|
// Entities absent from finalized[] (skipped, or not editable) keep their cached
|
|
1775
1931
|
// value — absence is not invalidation.
|
|
1776
1932
|
writeSyncCache(siteDir, client.origin, hashes, applied)
|
|
1933
|
+
// A record that named a record this push created is banked as the next push will send
|
|
1934
|
+
// it — by that record's uuid, which the back-fill above just recorded.
|
|
1935
|
+
if (records && pkg.namesNew?.length) {
|
|
1936
|
+
try {
|
|
1937
|
+
await rebankRecordHashes(siteDir, client.origin, pkg.namesNew)
|
|
1938
|
+
} catch (err) {
|
|
1939
|
+
note(`! Could not re-bank ${pkg.namesNew.length} record(s) that name a new record: ${err.message}. The next push sends them again.`)
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1777
1942
|
mergeHarvested()
|
|
1778
1943
|
// Re-base the page attribution: our emitted document and the backend's post-write
|
|
1779
1944
|
// copy of it are the two sides' new agreed state. Only when the site-content lane
|
|
@@ -1850,78 +2015,3 @@ export async function pushSyncPackages({
|
|
|
1850
2015
|
}
|
|
1851
2016
|
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
|
|
1852
2017
|
}
|
|
1853
|
-
|
|
1854
|
-
/**
|
|
1855
|
-
* Push, then push again while records WAIT for a reference's record — the verbs' way to
|
|
1856
|
-
* send a package (`push`, `publish`).
|
|
1857
|
-
*
|
|
1858
|
-
* ⭐ A reference is sent as the uuid the backend minted for the record it names, so a
|
|
1859
|
-
* record that points at one created by the same push cannot be completed in that push
|
|
1860
|
-
* (`@uniweb/build`'s `emitSyncPackages` → `waiting`). The first pass creates what it can —
|
|
1861
|
-
* records whose waiting references are not required go without them — and each further
|
|
1862
|
-
* pass, emitted afresh from the state the last one banked, sends what has become
|
|
1863
|
-
* nameable. A chain of required references takes one pass per link; two records that
|
|
1864
|
-
* each require the other can never be completed, and the push stops, naming them.
|
|
1865
|
-
*
|
|
1866
|
-
* @param {object} params
|
|
1867
|
-
* @param {object} params.pkg - the first pass's package (`emitSyncPackages`)
|
|
1868
|
-
* @param {() => Promise<object>} params.reemit - the next pass's package, emitted afresh
|
|
1869
|
-
* @param {Function} [params.push] - sends one package; `pushSyncPackages` (a test's seam)
|
|
1870
|
-
* @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[], passes: number }>}
|
|
1871
|
-
*/
|
|
1872
|
-
export async function pushInPasses({ client, siteDir, pkg, report, reemit, maxPasses = 12, push = pushSyncPackages }) {
|
|
1873
|
-
const { note, error } = report
|
|
1874
|
-
let current = pkg
|
|
1875
|
-
let passes = 1
|
|
1876
|
-
let result = await push({ client, siteDir, pkg: current, report })
|
|
1877
|
-
let finalizedTotal = result.finalizedTotal || 0
|
|
1878
|
-
const wrote = [...(result.wrote || [])]
|
|
1879
|
-
while (result.exitCode === 0 && current.waiting?.length) {
|
|
1880
|
-
const n = current.waiting.length
|
|
1881
|
-
note(
|
|
1882
|
-
`${n} record${n === 1 ? '' : 's'} name${n === 1 ? 's' : ''} a record this push created — ` +
|
|
1883
|
-
`pushing again to complete ${n === 1 ? 'its references' : 'their references'}.`
|
|
1884
|
-
)
|
|
1885
|
-
let next
|
|
1886
|
-
try {
|
|
1887
|
-
next = await reemit()
|
|
1888
|
-
} catch (err) {
|
|
1889
|
-
error(`Could not build the next pass: ${err.message}`)
|
|
1890
|
-
return { ...result, exitCode: 1, finalizedTotal, wrote, passes }
|
|
1891
|
-
}
|
|
1892
|
-
if (refuseUnsendableRecords(next.refusals, report)) {
|
|
1893
|
-
return { ...result, exitCode: 1, finalizedTotal, wrote, passes }
|
|
1894
|
-
}
|
|
1895
|
-
// Nothing became nameable: every record still waiting waits on one that waits too.
|
|
1896
|
-
if (waitingKey(next.waiting) === waitingKey(current.waiting) || passes >= maxPasses) {
|
|
1897
|
-
error(
|
|
1898
|
-
'These records could not be completed — each requires a reference to a record that ' +
|
|
1899
|
-
'is itself waiting to be created:'
|
|
1900
|
-
)
|
|
1901
|
-
for (const w of next.waiting || []) {
|
|
1902
|
-
const refs = w.pending.map((p) => `${p.path} → "${p.name}"`).join(', ')
|
|
1903
|
-
note(` ${w.id} (${w.model}): ${refs}`)
|
|
1904
|
-
}
|
|
1905
|
-
note(
|
|
1906
|
-
'Push one of them without that reference first — or make it optional in the schema — ' +
|
|
1907
|
-
'then add it back and push again.'
|
|
1908
|
-
)
|
|
1909
|
-
return { ...result, exitCode: 1, finalizedTotal, wrote, passes }
|
|
1910
|
-
}
|
|
1911
|
-
current = next
|
|
1912
|
-
passes++
|
|
1913
|
-
result = await push({ client, siteDir, pkg: current, report })
|
|
1914
|
-
finalizedTotal += result.finalizedTotal || 0
|
|
1915
|
-
for (const w of result.wrote || []) if (!wrote.includes(w)) wrote.push(w)
|
|
1916
|
-
}
|
|
1917
|
-
return { ...result, finalizedTotal, wrote, passes }
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
// Which records wait, and on what — two passes that leave it the same made no progress.
|
|
1921
|
-
function waitingKey(waiting) {
|
|
1922
|
-
return JSON.stringify(
|
|
1923
|
-
(waiting || [])
|
|
1924
|
-
.map((w) => [w.model, w.id, w.pending.map((p) => `${p.path}:${p.name}`).sort()])
|
|
1925
|
-
.sort()
|
|
1926
|
-
)
|
|
1927
|
-
}
|
package/src/commands/publish.js
CHANGED
|
@@ -86,12 +86,13 @@ import {
|
|
|
86
86
|
readItemBaseVersions,
|
|
87
87
|
ensureItemUuids,
|
|
88
88
|
readFolderItemUuids,
|
|
89
|
+
readRecordItemUuids,
|
|
90
|
+
recoverRecordItemUuids,
|
|
89
91
|
ensureSiteExists,
|
|
90
92
|
refuseUnsendableRecords,
|
|
91
93
|
clearRemoteSyncStateIfUnbound,
|
|
92
94
|
dropSiteBoundValues,
|
|
93
|
-
|
|
94
|
-
pushInPasses
|
|
95
|
+
pushSyncPackages
|
|
95
96
|
} from '../backend/site-sync.js'
|
|
96
97
|
import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
97
98
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
@@ -943,36 +944,45 @@ export async function publish(args = []) {
|
|
|
943
944
|
say.dim('Services unchanged.')
|
|
944
945
|
}
|
|
945
946
|
|
|
946
|
-
// The emit's options, read afresh for each pass (`pushInPasses`): a pass banks
|
|
947
|
-
// hashes, identity and base versions that the next one must build on.
|
|
948
|
-
//
|
|
949
947
|
// publish rides the same gated push as `uniweb push`: if an app author has
|
|
950
948
|
// edited since this clone last synced, the push is refused rather than
|
|
951
949
|
// overwriting them, and nothing goes live. `--force` drops the precondition.
|
|
952
950
|
const forced = args.includes('--force')
|
|
953
|
-
const emitOptions =
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
951
|
+
const emitOptions = {
|
|
952
|
+
backend: client.origin,
|
|
953
|
+
...(declaration.declare ? {} : { declareServices: false }),
|
|
954
|
+
// Placement identity for the folder — see writeFolderItemUuids.
|
|
955
|
+
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
956
|
+
// Identity for the records' list items — see readRecordItemUuids.
|
|
957
|
+
recordItemUuids: readRecordItemUuids(siteDir, client.origin),
|
|
958
|
+
...(foundationDir ? { foundationDir } : {}),
|
|
959
|
+
resolveModel,
|
|
960
|
+
priorHashes,
|
|
961
|
+
itemUuids,
|
|
962
|
+
...(forced
|
|
963
|
+
? {}
|
|
964
|
+
: {
|
|
965
|
+
baseVersions: readBaseVersions(siteDir, client.origin),
|
|
966
|
+
itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
|
|
967
|
+
}),
|
|
968
|
+
...(Object.keys(injectInfo).length ? { injectInfo } : {}),
|
|
969
|
+
...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
|
|
970
|
+
...(assetRewrite ? { assetRewrite } : {}),
|
|
971
|
+
...(assetIds ? { assetIds } : {})
|
|
972
|
+
}
|
|
973
973
|
let pkg
|
|
974
974
|
try {
|
|
975
|
-
pkg = await emitSyncPackages(siteDir, emitOptions
|
|
975
|
+
pkg = await emitSyncPackages(siteDir, emitOptions)
|
|
976
|
+
// Records the backend holds whose list items this copy never banked — see `uniweb push`.
|
|
977
|
+
const unbanked = pkg.recordItemIdentity?.unbanked
|
|
978
|
+
if (unbanked?.length) {
|
|
979
|
+
if (await recoverRecordItemUuids({ client, siteDir, uuids: unbanked, note: (m) => say.dim(m) })) {
|
|
980
|
+
pkg = await emitSyncPackages(siteDir, {
|
|
981
|
+
...emitOptions,
|
|
982
|
+
recordItemUuids: readRecordItemUuids(siteDir, client.origin)
|
|
983
|
+
})
|
|
984
|
+
}
|
|
985
|
+
}
|
|
976
986
|
} catch (err) {
|
|
977
987
|
say.err(`Could not build the sync package: ${err.message}`)
|
|
978
988
|
return { exitCode: 1 }
|
|
@@ -987,19 +997,11 @@ export async function publish(args = []) {
|
|
|
987
997
|
error: (m) => say.err(m),
|
|
988
998
|
dim: (s) => `${c.dim}${s}${c.reset}`
|
|
989
999
|
}
|
|
990
|
-
const pushResult = await
|
|
1000
|
+
const pushResult = await pushSyncPackages({
|
|
991
1001
|
client,
|
|
992
1002
|
siteDir,
|
|
993
1003
|
pkg,
|
|
994
|
-
report
|
|
995
|
-
reemit: () =>
|
|
996
|
-
emitSyncPackages(
|
|
997
|
-
siteDir,
|
|
998
|
-
emitOptions({
|
|
999
|
-
priorHashes: readSyncCache(siteDir, client.origin),
|
|
1000
|
-
itemUuids: readItemUuids(siteDir, client.origin)
|
|
1001
|
-
})
|
|
1002
|
-
)
|
|
1004
|
+
report
|
|
1003
1005
|
})
|
|
1004
1006
|
if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
|
|
1005
1007
|
const siteUuid = pushResult.boundSiteUuid
|
package/src/commands/pull.js
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
computeUnitHashes,
|
|
78
78
|
collectUnitUuids,
|
|
79
79
|
collectQueryUuids,
|
|
80
|
+
collectFolderItemUuids,
|
|
80
81
|
documentSchema
|
|
81
82
|
} from '@uniweb/build/uwx'
|
|
82
83
|
import {
|
|
@@ -91,7 +92,8 @@ import {
|
|
|
91
92
|
mergeBaseVersions,
|
|
92
93
|
mergeItemBaseVersions,
|
|
93
94
|
writeUnitBases,
|
|
94
|
-
writeItemUuids
|
|
95
|
+
writeItemUuids,
|
|
96
|
+
writeFolderItemUuids
|
|
95
97
|
} from '../backend/site-sync.js'
|
|
96
98
|
import { resolveWorkspace } from '../backend/workspace.js'
|
|
97
99
|
import {
|
|
@@ -849,6 +851,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
849
851
|
client.pullSiteContent(siteContentUuid, { etag: conditional ? etagContent : undefined })
|
|
850
852
|
)
|
|
851
853
|
if (content?.refused) return { exitCode: 1 }
|
|
854
|
+
// The Models the pulled site's queries name, as the push qualified them — the re-bank
|
|
855
|
+
// below resolves with them too (see there).
|
|
856
|
+
let pulledQueryModels = []
|
|
852
857
|
if (content && !content.notModified) {
|
|
853
858
|
const siteDoc =
|
|
854
859
|
content.docs &&
|
|
@@ -856,6 +861,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
856
861
|
content.docs[0] ||
|
|
857
862
|
null)
|
|
858
863
|
if (siteDoc) {
|
|
864
|
+
pulledQueryModels = (Array.isArray(siteDoc.queries) ? siteDoc.queries : [])
|
|
865
|
+
.map((q) => q?.schema)
|
|
866
|
+
.filter((s) => typeof s === 'string' && /^@[^/]+\//.test(s))
|
|
859
867
|
// Re-base the page attribution. The pulled document IS the backend's own
|
|
860
868
|
// representation, so it becomes the remote base directly. We deliberately
|
|
861
869
|
// CLEAR the local base rather than reuse it: our source files were just
|
|
@@ -962,6 +970,12 @@ export async function pull(args = [], deps = {}) {
|
|
|
962
970
|
// holds a folder uuid). Models are resolved by name (async) up front, so
|
|
963
971
|
// recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
|
|
964
972
|
let folderLane = null
|
|
973
|
+
// The record documents this pull took — the re-bank below pairs their list items with the
|
|
974
|
+
// files just written.
|
|
975
|
+
let pulledRecordDocs = []
|
|
976
|
+
// The Models this pull read from the backend, by name — the re-bank below resolves with
|
|
977
|
+
// them, since a clone's foundation is not in the project to resolve them offline.
|
|
978
|
+
let pulledDeclarations = null
|
|
965
979
|
if (!noRecords) {
|
|
966
980
|
const folder = await getDocs('records', () =>
|
|
967
981
|
client.pullFolder(siteContentUuid, { etag: conditional ? etagFolder : undefined })
|
|
@@ -970,8 +984,10 @@ export async function pull(args = [], deps = {}) {
|
|
|
970
984
|
if (folder?.refused) return { exitCode: 1 }
|
|
971
985
|
if (folder && !folder.notModified && folder.docs?.length) {
|
|
972
986
|
const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
|
|
987
|
+
pulledRecordDocs = recordDocs
|
|
973
988
|
const resolveModel = makeModelResolver({ client })
|
|
974
989
|
const declByModel = new Map()
|
|
990
|
+
pulledDeclarations = declByModel
|
|
975
991
|
for (const model of [
|
|
976
992
|
...new Set(recordDocs.map((d) => documentSchema(d)).filter(Boolean))
|
|
977
993
|
]) {
|
|
@@ -998,6 +1014,12 @@ export async function pull(args = [], deps = {}) {
|
|
|
998
1014
|
scope: await scopeFor(null)
|
|
999
1015
|
}
|
|
1000
1016
|
})
|
|
1017
|
+
// ⭐ THE FOLDER'S PLACEMENT IDENTITY, from the folder this pull took — as a push banks
|
|
1018
|
+
// it from the folder it sent. ⛔ Only a push banked it until 2026-09-25, so a copy that
|
|
1019
|
+
// had never pushed (every clone) sent its first folder with its placements uuid-less,
|
|
1020
|
+
// and the backend refused that push (`identity_required`, section `contents`) —
|
|
1021
|
+
// measured on a template clone's first edit.
|
|
1022
|
+
if (folderDoc) writeFolderItemUuids(siteDir, client.origin, collectFolderItemUuids(folderDoc))
|
|
1001
1023
|
// The folder's organization, `folder.yml` in the records directory — named as
|
|
1002
1024
|
// the author would write it (`records/folder.yml`, or under `paths.records`).
|
|
1003
1025
|
if (report.records === 'updated') {
|
|
@@ -1069,9 +1091,30 @@ export async function pull(args = [], deps = {}) {
|
|
|
1069
1091
|
//
|
|
1070
1092
|
// Best-effort: a failure here costs an unnecessary re-send on the next push, never
|
|
1071
1093
|
// wrong content, and must not fail a pull whose files are already written.
|
|
1072
|
-
|
|
1094
|
+
// Nothing projected — both lanes unchanged (304) — leaves the banked state describing the
|
|
1095
|
+
// files as they are, so there is nothing to re-bank. Re-banking anyway only failed, noisily,
|
|
1096
|
+
// where the Models cannot be resolved offline (a clone's).
|
|
1097
|
+
const projected = Boolean(content && !content.notModified) || Boolean(folderLane && !folderLane.notModified)
|
|
1098
|
+
if (!dryRun && projected) {
|
|
1073
1099
|
try {
|
|
1074
|
-
|
|
1100
|
+
// ⭐ With the Models this pull read, and those its queries name — a query may name a
|
|
1101
|
+
// schema no record uses yet. The re-bank is offline, and a site whose foundation is not
|
|
1102
|
+
// in the project (a clone's) resolves none of them otherwise: every template clone
|
|
1103
|
+
// said "could not re-bank" until 2026-09-25, and banked nothing.
|
|
1104
|
+
const declarations = pulledDeclarations || new Map()
|
|
1105
|
+
const readModel = makeModelResolver({ client })
|
|
1106
|
+
for (const model of pulledQueryModels) {
|
|
1107
|
+
if (declarations.has(model)) continue
|
|
1108
|
+
try {
|
|
1109
|
+
declarations.set(model, await readModel(model))
|
|
1110
|
+
} catch {
|
|
1111
|
+
// Unreadable here: the re-bank says so if it needs it.
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
await rebankSyncHashes(siteDir, client.origin, {
|
|
1115
|
+
recordDocs: pulledRecordDocs,
|
|
1116
|
+
...(declarations.size ? { declarations } : {})
|
|
1117
|
+
})
|
|
1075
1118
|
} catch (err) {
|
|
1076
1119
|
note(`! could not re-bank the sync cache: ${err.message}`)
|
|
1077
1120
|
note(' The next push will re-send content that is already current.')
|
package/src/commands/push.js
CHANGED
|
@@ -82,12 +82,14 @@ import {
|
|
|
82
82
|
readItemUuids,
|
|
83
83
|
readFolderItemUuids,
|
|
84
84
|
readQueryUuids,
|
|
85
|
+
readRecordItemUuids,
|
|
86
|
+
recoverRecordItemUuids,
|
|
85
87
|
ensureItemUuids,
|
|
86
88
|
refuseUnsendableRecords,
|
|
87
89
|
ensureSiteExists,
|
|
88
90
|
clearRemoteSyncStateIfUnbound,
|
|
89
91
|
dropSiteBoundValues,
|
|
90
|
-
|
|
92
|
+
pushSyncPackages
|
|
91
93
|
} from '../backend/site-sync.js'
|
|
92
94
|
import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
93
95
|
|
|
@@ -496,43 +498,55 @@ export async function push(args = [], deps = {}) {
|
|
|
496
498
|
output || dryRun
|
|
497
499
|
? readItemUuids(siteDir, client.origin)
|
|
498
500
|
: await ensureItemUuids({ client, siteDir, note })
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}
|
|
501
|
+
const emitOptions = {
|
|
502
|
+
backend: client.origin,
|
|
503
|
+
// Placement identity for the folder — see writeFolderItemUuids.
|
|
504
|
+
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
505
|
+
// Identity for the `queries` section — see readQueryUuids. Keyed by
|
|
506
|
+
// name, because a declaration has no file for a path-keyed map to hold.
|
|
507
|
+
queryUuids: readQueryUuids(siteDir, client.origin),
|
|
508
|
+
// Identity for the records' list items — see readRecordItemUuids.
|
|
509
|
+
recordItemUuids: readRecordItemUuids(siteDir, client.origin),
|
|
510
|
+
...(foundationDir ? { foundationDir } : {}),
|
|
511
|
+
resolveModel: makeModelResolver({
|
|
512
|
+
client,
|
|
513
|
+
offline: Boolean(output) || dryRun
|
|
514
|
+
}),
|
|
515
|
+
priorHashes,
|
|
516
|
+
sendAll,
|
|
517
|
+
itemUuids,
|
|
518
|
+
// The PINNED foundation ref from the bring-along above, stamped over the
|
|
519
|
+
// authored `site.yml` string. Delivery is version-pinned end to end, so an
|
|
520
|
+
// unpinned local name on the wire names code no host can serve. Absent when
|
|
521
|
+
// the site already references a registry ref or URL — then site.yml's own
|
|
522
|
+
// value rides verbatim.
|
|
523
|
+
...(fnd.ref ? { injectInfo: { foundation: fnd.ref } } : {}),
|
|
524
|
+
// Both grains are dropped together by --force: one flag, one meaning,
|
|
525
|
+
// no partial-force mode.
|
|
526
|
+
...(force
|
|
527
|
+
? {}
|
|
528
|
+
: {
|
|
529
|
+
baseVersions: readBaseVersions(siteDir, client.origin),
|
|
530
|
+
itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
|
|
531
|
+
}),
|
|
532
|
+
...(assetRewrite ? { assetRewrite } : {}),
|
|
533
|
+
...(assetIds ? { assetIds } : {})
|
|
534
|
+
}
|
|
533
535
|
let pkg
|
|
534
536
|
try {
|
|
535
|
-
pkg = await emitSyncPackages(siteDir, emitOptions
|
|
537
|
+
pkg = await emitSyncPackages(siteDir, emitOptions)
|
|
538
|
+
// A record the backend holds whose list items this copy never banked — pushed before the
|
|
539
|
+
// bank existed, say — is refused if sent without them. Recover them from the backend, and
|
|
540
|
+
// build the package again with them.
|
|
541
|
+
const unbanked = pkg.recordItemIdentity?.unbanked
|
|
542
|
+
if (unbanked?.length && !output && !dryRun) {
|
|
543
|
+
if (await recoverRecordItemUuids({ client, siteDir, uuids: unbanked, note })) {
|
|
544
|
+
pkg = await emitSyncPackages(siteDir, {
|
|
545
|
+
...emitOptions,
|
|
546
|
+
recordItemUuids: readRecordItemUuids(siteDir, client.origin)
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
}
|
|
536
550
|
} catch (err) {
|
|
537
551
|
error(`Could not build the sync package: ${err.message}`)
|
|
538
552
|
return { exitCode: 2 }
|
|
@@ -598,9 +612,8 @@ export async function push(args = [], deps = {}) {
|
|
|
598
612
|
}
|
|
599
613
|
|
|
600
614
|
// Submit both lanes, back-fill the minted uuids, and persist the send-only-changed
|
|
601
|
-
// cache
|
|
602
|
-
|
|
603
|
-
const result = await pushInPasses({
|
|
615
|
+
// cache. Shared with `uniweb publish` via ../backend/site-sync.js.
|
|
616
|
+
const result = await pushSyncPackages({
|
|
604
617
|
client,
|
|
605
618
|
siteDir,
|
|
606
619
|
pkg,
|
|
@@ -609,16 +622,7 @@ export async function push(args = [], deps = {}) {
|
|
|
609
622
|
note,
|
|
610
623
|
error,
|
|
611
624
|
dim: (s) => `${colors.dim}${s}${colors.reset}`
|
|
612
|
-
}
|
|
613
|
-
reemit: () =>
|
|
614
|
-
emitSyncPackages(
|
|
615
|
-
siteDir,
|
|
616
|
-
emitOptions({
|
|
617
|
-
priorHashes: readSyncCache(siteDir, client.origin),
|
|
618
|
-
sendAll: false,
|
|
619
|
-
itemUuids: readItemUuids(siteDir, client.origin)
|
|
620
|
-
})
|
|
621
|
-
)
|
|
625
|
+
}
|
|
622
626
|
})
|
|
623
627
|
if (result.exitCode !== 0) return { exitCode: result.exitCode }
|
|
624
628
|
success(
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-25T13:32:50.848Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "0.6.2",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"@uniweb/build": {
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.63.1",
|
|
14
14
|
"path": "framework/build",
|
|
15
15
|
"deps": [
|
|
16
16
|
"@uniweb/content-reader",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"deps": []
|
|
117
117
|
},
|
|
118
118
|
"@uniweb/templates": {
|
|
119
|
-
"version": "0.17.
|
|
119
|
+
"version": "0.17.1",
|
|
120
120
|
"path": "framework/templates",
|
|
121
121
|
"deps": []
|
|
122
122
|
},
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"deps": []
|
|
127
127
|
},
|
|
128
128
|
"@uniweb/unipress": {
|
|
129
|
-
"version": "0.10.
|
|
129
|
+
"version": "0.10.6",
|
|
130
130
|
"path": "framework/unipress",
|
|
131
131
|
"deps": [
|
|
132
132
|
"@uniweb/build",
|