uniweb 0.65.0 → 0.66.0
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 +6 -6
- package/partials/agents.md +3 -3
- package/src/backend/foundation-bring-along.js +154 -97
- package/src/backend/site-sync.js +220 -14
- package/src/commands/clone.js +1 -1
- package/src/commands/deploy.js +9 -7
- package/src/commands/publish.js +15 -4
- package/src/commands/pull.js +219 -67
- package/src/commands/push.js +15 -5
- package/src/commands/status.js +17 -3
- package/src/commands/validate.js +16 -12
- package/src/framework-index.json +4 -4
- package/src/index.js +20 -13
- package/src/utils/conformance.js +68 -10
- package/src/utils/flag-guard.js +5 -4
- package/src/utils/git.js +48 -1
- package/src/utils/pull-written.js +5 -0
- package/src/utils/semver-precedence.js +23 -0
package/src/backend/site-sync.js
CHANGED
|
@@ -15,6 +15,7 @@ import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from '
|
|
|
15
15
|
import { join, dirname, relative, isAbsolute } from 'node:path'
|
|
16
16
|
import yaml from 'js-yaml'
|
|
17
17
|
import { hasUncommittedContent } from '../utils/git.js'
|
|
18
|
+
import { recordWritten } from '../utils/pull-written.js'
|
|
18
19
|
import { humanBytes } from '../utils/bytes.js'
|
|
19
20
|
import { isAuthoredPreview } from '../utils/preview.js'
|
|
20
21
|
import {
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
diffSiteUnits,
|
|
34
35
|
describeSiteDiff,
|
|
35
36
|
computeUnitHashes,
|
|
37
|
+
collectSiteUnits,
|
|
36
38
|
collectUnitUuids,
|
|
37
39
|
collectFolderItemUuids,
|
|
38
40
|
collectQueryUuids,
|
|
@@ -91,6 +93,141 @@ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
|
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
// A unit in the form it is compared in across the two representations: keys sorted,
|
|
97
|
+
// `$`-keys dropped (`$uuid`, `$id` — identity and payload handles, not content).
|
|
98
|
+
const canonicalUnit = (v) =>
|
|
99
|
+
Array.isArray(v)
|
|
100
|
+
? v.map(canonicalUnit)
|
|
101
|
+
: v && typeof v === 'object'
|
|
102
|
+
? Object.fromEntries(
|
|
103
|
+
Object.keys(v)
|
|
104
|
+
.filter((k) => !k.startsWith('$'))
|
|
105
|
+
.sort()
|
|
106
|
+
.map((k) => [k, canonicalUnit(v[k])])
|
|
107
|
+
)
|
|
108
|
+
: v
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether the backend's post-write copy of a unit is the content we sent.
|
|
112
|
+
*
|
|
113
|
+
* Compared on OUR keys only. The two are not byte-comparable — the backend's copy
|
|
114
|
+
* carries fields we don't emit (`params`, `theme_override`, its own `$uuid`) and
|
|
115
|
+
* orders keys its own way, which is why `site-diff.js` never hashes one side against
|
|
116
|
+
* the other — but what we emit, it stores verbatim: measured 2026-09-23 against a
|
|
117
|
+
* live `uniwebd`, 15 of 15 units equal on our keys after a push.
|
|
118
|
+
*/
|
|
119
|
+
function writtenAsSent(sent, written) {
|
|
120
|
+
if (!sent || !written) return false
|
|
121
|
+
const onOurKeys = {}
|
|
122
|
+
for (const k of Object.keys(sent)) if (!k.startsWith('$')) onOurKeys[k] = written[k]
|
|
123
|
+
return JSON.stringify(canonicalUnit(sent)) === JSON.stringify(canonicalUnit(onOurKeys))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The post-write tokens a site-content push may bank: only those that describe
|
|
128
|
+
* content this copy HOLDS.
|
|
129
|
+
*
|
|
130
|
+
* ⛔ BANKING EVERY RETURNED TOKEN LOST OTHER WRITERS' WORK — measured 2026-09-23 on a
|
|
131
|
+
* live `uniwebd`, two copies of one site:
|
|
132
|
+
*
|
|
133
|
+
* 1. B edits section X and pushes.
|
|
134
|
+
* 2. A, which has not pulled, pushes an edit to section Y. The per-item gate reads
|
|
135
|
+
* A's X as untouched (`pkg == base`) and keeps B's X — correct. But the response
|
|
136
|
+
* carries X's token for B's content, and A banked it.
|
|
137
|
+
* 3. A's next push of X then reads as "only you changed it" (`host == base`) and
|
|
138
|
+
* overwrites B's edit, with no refusal.
|
|
139
|
+
*
|
|
140
|
+
* The entity token did the same to ADDITIONS: a section B added survived A's first
|
|
141
|
+
* push (created after A's base ⇒ KEEP), A banked a base newer than it, and A's next
|
|
142
|
+
* push deleted it (created at-or-before the base ⇒ "you dropped it").
|
|
143
|
+
*
|
|
144
|
+
* So a unit's token is banked only when its post-write copy is what we sent — our own
|
|
145
|
+
* write, or a unit nobody touched. A unit the backend kept for someone else keeps the
|
|
146
|
+
* token we already hold, which is the one for OUR content, so the next push that
|
|
147
|
+
* changes it is `pkg != base, host != base` and refused. And while the backend holds
|
|
148
|
+
* units we don't, the entity token stays where it was, so the next push keeps them.
|
|
149
|
+
*
|
|
150
|
+
* With either document missing there is nothing to compare, and the tokens are banked
|
|
151
|
+
* as the backend sent them — the contract is that `finalized[].document` is always
|
|
152
|
+
* there.
|
|
153
|
+
*
|
|
154
|
+
* @returns {{ version: string|null, itemVersions: Object<string,string>|null,
|
|
155
|
+
* held: string[], kept: string[], foreign: string[] }} `held`: units the backend
|
|
156
|
+
* holds as we sent them; `kept`: units it kept someone else's content for;
|
|
157
|
+
* `foreign`: units it holds that we did not send. All paths.
|
|
158
|
+
*/
|
|
159
|
+
export function heldTokens({ sent, written, version = null, itemVersions = null }) {
|
|
160
|
+
if (!sent || !written) return { version, itemVersions, held: [], kept: [], foreign: [] }
|
|
161
|
+
const ours = collectSiteUnits(sent)
|
|
162
|
+
const theirs = collectSiteUnits(written)
|
|
163
|
+
const uuidAt = collectUnitUuids(written)
|
|
164
|
+
const units = new Set(Object.values(uuidAt))
|
|
165
|
+
const held = []
|
|
166
|
+
const kept = []
|
|
167
|
+
const foreign = []
|
|
168
|
+
const items = {}
|
|
169
|
+
for (const [path, uuid] of Object.entries(uuidAt)) {
|
|
170
|
+
if (!ours.has(path)) {
|
|
171
|
+
foreign.push(path)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (!writtenAsSent(ours.get(path), theirs.get(path))) {
|
|
175
|
+
kept.push(path)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
held.push(path)
|
|
179
|
+
if (itemVersions?.[uuid]) items[uuid] = itemVersions[uuid]
|
|
180
|
+
}
|
|
181
|
+
// A token for an item that is not a unit (a `queries` declaration) is never sent
|
|
182
|
+
// back — the emit narrows preconditions to units (`withBaseVersion`) — so it is
|
|
183
|
+
// banked as before: it can neither arm nor disarm anything.
|
|
184
|
+
for (const [uuid, token] of Object.entries(itemVersions || {})) {
|
|
185
|
+
if (!units.has(uuid)) items[uuid] = token
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
version: foreign.length ? null : version,
|
|
189
|
+
itemVersions: itemVersions ? items : null,
|
|
190
|
+
held: held.sort(),
|
|
191
|
+
kept: kept.sort(),
|
|
192
|
+
foreign: foreign.sort()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The files a unit projects to, under the site's own roots — `site.yml::paths` can
|
|
197
|
+
// relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
|
|
198
|
+
// to three files.
|
|
199
|
+
function unitFilesOf(siteDir, unitPath) {
|
|
200
|
+
if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
|
|
201
|
+
let paths = {}
|
|
202
|
+
try {
|
|
203
|
+
paths = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths || {}
|
|
204
|
+
} catch {
|
|
205
|
+
/* no or unreadable site.yml — the defaults are right */
|
|
206
|
+
}
|
|
207
|
+
for (const root of ['pages', 'layout']) {
|
|
208
|
+
if (unitPath.startsWith(`${root}/`)) return [`${paths[root] || root}${unitPath.slice(root.length)}`]
|
|
209
|
+
}
|
|
210
|
+
return []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Record, as synced, the files of the units a push left on the backend as we sent them.
|
|
215
|
+
*
|
|
216
|
+
* ⭐ WHAT A PUSH DELIVERED IS THE NEXT MERGE'S ANCESTOR. `pull --merge` finds the
|
|
217
|
+
* version a file last synced at by the hash in `pull-written.json`. A pull records what
|
|
218
|
+
* it wrote; this records what a push delivered — the units `heldTokens` says the
|
|
219
|
+
* backend holds as ours, never one it kept for someone else. Without it, after a merge
|
|
220
|
+
* and a push the ancestor was the pull's raw output, which nobody commits: the next
|
|
221
|
+
* merge found no common version and put both whole files under conflict markers, for
|
|
222
|
+
* edits on different lines (measured 2026-09-23).
|
|
223
|
+
*/
|
|
224
|
+
function recordPushedUnits(siteDir, unitPaths) {
|
|
225
|
+
const files = new Set()
|
|
226
|
+
for (const p of unitPaths) for (const f of unitFilesOf(siteDir, p)) files.add(f)
|
|
227
|
+
const present = [...files].map((f) => join(siteDir, f)).filter((abs) => existsSync(abs))
|
|
228
|
+
if (present.length) recordWritten(siteDir, present)
|
|
229
|
+
}
|
|
230
|
+
|
|
94
231
|
// Pull the finalized entities out of the restore response. The backend returns
|
|
95
232
|
// `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
|
|
96
233
|
// carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
|
|
@@ -112,10 +249,10 @@ export function extractFinalized(payload) {
|
|
|
112
249
|
uuid: d?.uuid ?? d?.document?.$uuid ?? null,
|
|
113
250
|
changed: d?.changed,
|
|
114
251
|
// Post-write optimistic-concurrency token, read back from the stored row.
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
252
|
+
// Do not branch on `changed`: the backend pins "zero-write ⇒ version unmoved"
|
|
253
|
+
// with a test, so a no-op resubmit returns the value we already hold. ⛔ But a
|
|
254
|
+
// site-content push banks it only while the stored document holds nothing we
|
|
255
|
+
// don't — see `heldTokens`, and what banking it unconditionally deleted.
|
|
119
256
|
version: typeof d?.version === 'string' ? d.version : null,
|
|
120
257
|
// Per-item post-write tokens, `{ recordUuid: <opaque> }` (backend `d7e46335`).
|
|
121
258
|
// Same map shape and same key name the PULL manifest entry stamps, deliberately
|
|
@@ -548,8 +685,12 @@ export function readItemBaseVersions(siteDir, backend) {
|
|
|
548
685
|
}
|
|
549
686
|
export function mergeItemBaseVersions(siteDir, backend, versions) {
|
|
550
687
|
if (!versions || !Object.keys(versions).length) return
|
|
688
|
+
// ⛔ The read names the backend. Without it the read keyed nothing, came back `{}`,
|
|
689
|
+
// and this "merge" replaced the map with the last response — dropping the token
|
|
690
|
+
// `heldTokens` withholds for a unit the backend kept for someone else, and with it
|
|
691
|
+
// the precondition that refuses the next push of that unit (2026-09-23).
|
|
551
692
|
updateSyncCache(siteDir, backend, {
|
|
552
|
-
itemBaseVersions: { ...readItemBaseVersions(siteDir), ...versions }
|
|
693
|
+
itemBaseVersions: { ...readItemBaseVersions(siteDir, backend), ...versions }
|
|
553
694
|
})
|
|
554
695
|
}
|
|
555
696
|
export function mergeBaseVersions(siteDir, backend, versions) {
|
|
@@ -935,6 +1076,31 @@ async function recordAndDescribeOwner({ client, siteDir, payload, note }) {
|
|
|
935
1076
|
return org
|
|
936
1077
|
}
|
|
937
1078
|
|
|
1079
|
+
/**
|
|
1080
|
+
* Stop a push or publish before it sends anything, when a record cannot be sent as
|
|
1081
|
+
* written — the emit's `refusals` (`@uniweb/build`'s `recordsToEntities`).
|
|
1082
|
+
*
|
|
1083
|
+
* ⛔ Checked BEFORE the site is created or a byte uploaded, not only before the send:
|
|
1084
|
+
* both are writes of their own, which a refused records lane does not undo. (The lane
|
|
1085
|
+
* itself is one transaction on the backend since 2026-09-24, so a refusal there writes
|
|
1086
|
+
* nothing. Before that, a refused lane left the folder's entries and entities with no
|
|
1087
|
+
* data behind, and every later push of the site's records was refused — measured
|
|
1088
|
+
* 2026-09-23, from one record written by section.)
|
|
1089
|
+
*
|
|
1090
|
+
* @param {string[]|undefined} refusals
|
|
1091
|
+
* @param {{ error: (m: string) => void, note: (m: string) => void }} report
|
|
1092
|
+
* @returns {boolean} true when the caller must stop
|
|
1093
|
+
*/
|
|
1094
|
+
export function refuseUnsendableRecords(refusals, { error, note }) {
|
|
1095
|
+
if (!refusals?.length) return false
|
|
1096
|
+
const n = refusals.length
|
|
1097
|
+
error(
|
|
1098
|
+
`${n === 1 ? 'A record' : `${n} records`} cannot be pushed as written — no content was sent.`
|
|
1099
|
+
)
|
|
1100
|
+
for (const r of refusals) note(` ${r}`)
|
|
1101
|
+
return true
|
|
1102
|
+
}
|
|
1103
|
+
|
|
938
1104
|
/**
|
|
939
1105
|
* Guarantee we have per-item identity before an identity-bearing push.
|
|
940
1106
|
*
|
|
@@ -1440,11 +1606,11 @@ export async function pushSyncPackages({
|
|
|
1440
1606
|
const harvest = (finalized) => {
|
|
1441
1607
|
for (const f of finalized || []) {
|
|
1442
1608
|
if (f.uuid && f.version) newVersions[f.uuid] = f.version
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
// field entirely, which leaves the cached tokens
|
|
1447
|
-
// entity grain, exactly as before.
|
|
1609
|
+
// NOT gated on `changed` — same rule as the entity token: the backend pins
|
|
1610
|
+
// "zero-write ⇒ version unmoved", so a no-op resubmit hands back the value we
|
|
1611
|
+
// already hold. (The site-content lane filters first, `harvestSiteContent`.)
|
|
1612
|
+
// An older backend omits the field entirely, which leaves the cached tokens
|
|
1613
|
+
// alone and degrades to the entity grain, exactly as before.
|
|
1448
1614
|
if (f.itemVersions) Object.assign(newItemVersions, f.itemVersions)
|
|
1449
1615
|
}
|
|
1450
1616
|
}
|
|
@@ -1453,6 +1619,27 @@ export async function pushSyncPackages({
|
|
|
1453
1619
|
mergeBaseVersions(siteDir, client.origin, newVersions)
|
|
1454
1620
|
mergeItemBaseVersions(siteDir, client.origin, newItemVersions)
|
|
1455
1621
|
}
|
|
1622
|
+
// ⛔ The site-content lane banks only the tokens for content this copy holds — the
|
|
1623
|
+
// document we sent against the one the backend stored (`heldTokens`, and the two
|
|
1624
|
+
// losses banking everything caused). What the backend kept for someone else is
|
|
1625
|
+
// collected here, to re-base attribution and to say so.
|
|
1626
|
+
const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
|
|
1627
|
+
const notHeld = { kept: [], foreign: [] }
|
|
1628
|
+
const heldUnits = []
|
|
1629
|
+
const harvestSiteContent = (finalized) => {
|
|
1630
|
+
for (const f of finalized || []) {
|
|
1631
|
+
const held = heldTokens({
|
|
1632
|
+
sent: sentSiteDoc,
|
|
1633
|
+
written: f.document,
|
|
1634
|
+
version: f.version,
|
|
1635
|
+
itemVersions: f.itemVersions
|
|
1636
|
+
})
|
|
1637
|
+
harvest([{ ...f, version: held.version, itemVersions: held.itemVersions }])
|
|
1638
|
+
heldUnits.push(...held.held)
|
|
1639
|
+
notHeld.kept.push(...held.kept)
|
|
1640
|
+
notHeld.foreign.push(...held.foreign)
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1456
1643
|
// The backend's post-write copy of the site-content document, kept for the
|
|
1457
1644
|
// remote-side unit base (see writeUnitBases).
|
|
1458
1645
|
let siteFinalizedDoc = null
|
|
@@ -1475,7 +1662,7 @@ export async function pushSyncPackages({
|
|
|
1475
1662
|
mergeHarvested()
|
|
1476
1663
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
1477
1664
|
}
|
|
1478
|
-
|
|
1665
|
+
harvestSiteContent(finalized)
|
|
1479
1666
|
siteFinalizedDoc = finalized[0]?.document || null
|
|
1480
1667
|
// ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
|
|
1481
1668
|
// placements below. These items have no file to back-fill into — they all
|
|
@@ -1519,7 +1706,7 @@ export async function pushSyncPackages({
|
|
|
1519
1706
|
if (createdOrg)
|
|
1520
1707
|
wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
|
|
1521
1708
|
const createdFinalized = extractFinalized(payload)
|
|
1522
|
-
|
|
1709
|
+
harvestSiteContent(createdFinalized)
|
|
1523
1710
|
siteFinalizedDoc = createdFinalized?.[0]?.document || null
|
|
1524
1711
|
finalizedTotal += createdFinalized?.length ?? 1
|
|
1525
1712
|
}
|
|
@@ -1589,12 +1776,21 @@ export async function pushSyncPackages({
|
|
|
1589
1776
|
// actually shipped — a push that skipped it left that state where it was.
|
|
1590
1777
|
if (siteContent) {
|
|
1591
1778
|
const patch = {}
|
|
1592
|
-
const ours =
|
|
1779
|
+
const ours = sentSiteDoc
|
|
1593
1780
|
if (ours) patch.local = computeUnitHashes(ours)
|
|
1594
1781
|
// `finalized[].document` is the backend's own representation, read back from
|
|
1595
1782
|
// the stored row — the only remote-side base a push can produce.
|
|
1596
1783
|
const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
|
|
1597
|
-
if (theirs)
|
|
1784
|
+
if (theirs) {
|
|
1785
|
+
patch.remote = computeUnitHashes(theirs)
|
|
1786
|
+
// A unit the backend kept for someone else is not agreed state: its remote base
|
|
1787
|
+
// stays where it was, so a refusal names it as changed upstream.
|
|
1788
|
+
const before = readUnitBases(siteDir, client.origin).remote
|
|
1789
|
+
for (const path of notHeld.kept) {
|
|
1790
|
+
if (before[path]) patch.remote[path] = before[path]
|
|
1791
|
+
else delete patch.remote[path]
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1598
1794
|
if (Object.keys(patch).length) writeUnitBases(siteDir, client.origin, patch)
|
|
1599
1795
|
// The backend's post-write document carries `$uuid` per item at every nesting
|
|
1600
1796
|
// level, so the push that just landed also re-arms identity for the next one.
|
|
@@ -1622,6 +1818,16 @@ export async function pushSyncPackages({
|
|
|
1622
1818
|
)
|
|
1623
1819
|
}
|
|
1624
1820
|
}
|
|
1821
|
+
// The files of the units the backend now holds as ours are the synced version — the
|
|
1822
|
+
// ancestor the next `pull --merge` merges from.
|
|
1823
|
+
if (heldUnits.length) recordPushedUnits(siteDir, heldUnits)
|
|
1824
|
+
// What the backend holds that this copy doesn't. None of it was overwritten, and no
|
|
1825
|
+
// later push will overwrite it — but the files are behind until a pull.
|
|
1826
|
+
if (notHeld.kept.length || notHeld.foreign.length) {
|
|
1827
|
+
const paths = [...new Set([...notHeld.kept, ...notHeld.foreign])].sort()
|
|
1828
|
+
note(`Changed on the backend by someone else, not in your files yet: ${paths.join(', ')}`)
|
|
1829
|
+
note('Take them with `uniweb refresh` (or `uniweb pull --merge`). Until then, pushing leaves them as they are.')
|
|
1830
|
+
}
|
|
1625
1831
|
// ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
|
|
1626
1832
|
// piece of sync state above is banked: the push itself happened, and the next one must
|
|
1627
1833
|
// see it. What must not happen is a publish that goes live on top of it, which is why
|
package/src/commands/clone.js
CHANGED
|
@@ -102,7 +102,7 @@ function slugify(s) {
|
|
|
102
102
|
// than imported because pull.js statically imports @uniweb/build).
|
|
103
103
|
export function extractDocument(payload) {
|
|
104
104
|
if (!payload || typeof payload !== 'object') return null
|
|
105
|
-
if (payload.$
|
|
105
|
+
if (payload.$schema || payload.$uuid || payload.$id || payload.info) return payload
|
|
106
106
|
return payload.document || payload.entity || null
|
|
107
107
|
}
|
|
108
108
|
|
package/src/commands/deploy.js
CHANGED
|
@@ -93,10 +93,6 @@ export async function deploy(args = []) {
|
|
|
93
93
|
const dryRun = args.includes('--dry-run')
|
|
94
94
|
const siteDir = await resolveSiteDir(args)
|
|
95
95
|
|
|
96
|
-
// Advisory only — warns and ships. See utils/conformance.js for why this
|
|
97
|
-
// is not a gate.
|
|
98
|
-
await warnIfContentDoesNotConform(siteDir, { args, warn: say.warn, dim: say.dim })
|
|
99
|
-
|
|
100
96
|
// Host dispatch. Resolution order:
|
|
101
97
|
// 1. --target <name> picks a target from deploy.yml
|
|
102
98
|
// 2. deploy.yml's `default:` target when no flag is given
|
|
@@ -184,6 +180,13 @@ export async function deploy(args = []) {
|
|
|
184
180
|
}
|
|
185
181
|
}
|
|
186
182
|
|
|
183
|
+
// A static host: advisory — warns and ships (utils/conformance.js). Uniweb Cloud is
|
|
184
|
+
// `publish`, below, which refuses content that does not conform: there the backend
|
|
185
|
+
// enforces the schemas, so the check is a gate, not advice (2026-09-24).
|
|
186
|
+
if (plan.kind !== 'uniweb') {
|
|
187
|
+
await warnIfContentDoesNotConform(siteDir, { args, warn: say.warn, dim: say.dim })
|
|
188
|
+
}
|
|
189
|
+
|
|
187
190
|
// Uniweb Cloud is `publish`'s flow — delegate so deploy.yml stays one
|
|
188
191
|
// actionable record and there's a single implementation of go-live.
|
|
189
192
|
if (plan.kind === 'uniweb') {
|
|
@@ -224,9 +227,8 @@ export async function deploy(args = []) {
|
|
|
224
227
|
// publish ignores deploy's --host/--target; --dry-run/--no-save pass straight
|
|
225
228
|
// through.
|
|
226
229
|
const { publish } = await import('./publish.js')
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
const result = await publish([...args, '--no-validate'])
|
|
230
|
+
// publish runs the conformance gate itself; deploy checked nothing for this host.
|
|
231
|
+
const result = await publish(args)
|
|
230
232
|
process.exit(result?.exitCode ?? 0)
|
|
231
233
|
}
|
|
232
234
|
|
package/src/commands/publish.js
CHANGED
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
} from '../backend/client.js'
|
|
69
69
|
import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
|
|
70
70
|
import { resolveSiteDir } from './deploy.js'
|
|
71
|
-
import {
|
|
71
|
+
import { refuseIfContentDoesNotConform } from '../utils/conformance.js'
|
|
72
72
|
import { readFlagValue } from '../utils/args.js'
|
|
73
73
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
74
74
|
import {
|
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
ensureItemUuids,
|
|
88
88
|
readFolderItemUuids,
|
|
89
89
|
ensureSiteExists,
|
|
90
|
+
refuseUnsendableRecords,
|
|
90
91
|
clearRemoteSyncStateIfUnbound,
|
|
91
92
|
dropSiteBoundValues,
|
|
92
93
|
pushSyncPackages
|
|
@@ -255,9 +256,11 @@ export async function publish(args = []) {
|
|
|
255
256
|
|
|
256
257
|
const siteDir = await resolveSiteDir(args, 'publish')
|
|
257
258
|
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
await
|
|
259
|
+
// ⭐ A GATE, before anything is sent — the backend checks these records against the
|
|
260
|
+
// schemas this publish registers. See utils/conformance.js.
|
|
261
|
+
if (await refuseIfContentDoesNotConform(siteDir, { args, error: say.err, warn: say.warn, dim: say.dim })) {
|
|
262
|
+
return { exitCode: 1 }
|
|
263
|
+
}
|
|
261
264
|
const siteYml = readSiteYml(join(siteDir, 'site.yml'))
|
|
262
265
|
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
263
266
|
// backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
|
|
@@ -568,6 +571,11 @@ export async function publish(args = []) {
|
|
|
568
571
|
say.err(`Could not build the sync package: ${err.message}`)
|
|
569
572
|
return { exitCode: 1 }
|
|
570
573
|
}
|
|
574
|
+
// A record the backend would refuse stops the publish HERE — before the site is
|
|
575
|
+
// created or a byte uploaded, not merely before the send (`refuseUnsendableRecords`).
|
|
576
|
+
if (refuseUnsendableRecords(probe.refusals, { error: say.err, note: say.dim })) {
|
|
577
|
+
return { exitCode: 1 }
|
|
578
|
+
}
|
|
571
579
|
const schemalessNames = (probe.schemaless || []).map((col) => col.name)
|
|
572
580
|
// A product decision the author is usually making unknowingly — say it at warn
|
|
573
581
|
// level, not dim among everything else. See the helper for what the old
|
|
@@ -964,6 +972,9 @@ export async function publish(args = []) {
|
|
|
964
972
|
return { exitCode: 1 }
|
|
965
973
|
}
|
|
966
974
|
for (const w of pkg.warnings) say.dim(`! ${w}`)
|
|
975
|
+
if (refuseUnsendableRecords(pkg.refusals, { error: say.err, note: say.dim })) {
|
|
976
|
+
return { exitCode: 1 }
|
|
977
|
+
}
|
|
967
978
|
const report = {
|
|
968
979
|
info: (m) => say.info(m),
|
|
969
980
|
note: (m) => say.dim(m),
|