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/commands/pull.js
CHANGED
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
* three-way merge local work with what the backend sends, instead of choosing
|
|
29
29
|
* between them. Most "conflicts" are two people editing different paragraphs of one
|
|
30
30
|
* section, which merges silently; only genuine overlaps get conflict markers. The
|
|
31
|
-
* common ancestor is the
|
|
32
|
-
* per-version history and does not need to — so `--merge`
|
|
33
|
-
* merge itself is `git merge-file`.
|
|
31
|
+
* common ancestor is the version the last pull wrote, found in the repo's history —
|
|
32
|
+
* the backend keeps no per-version history and does not need to — so `--merge`
|
|
33
|
+
* requires a repo, and the merge itself is `git merge-file`.
|
|
34
34
|
*
|
|
35
35
|
* `uniweb login && uniweb pull`. Run from a site, or a workspace with one site.
|
|
36
36
|
*
|
|
@@ -76,7 +76,8 @@ import {
|
|
|
76
76
|
readZip,
|
|
77
77
|
computeUnitHashes,
|
|
78
78
|
collectUnitUuids,
|
|
79
|
-
collectQueryUuids
|
|
79
|
+
collectQueryUuids,
|
|
80
|
+
documentSchema
|
|
80
81
|
} from '@uniweb/build/uwx'
|
|
81
82
|
import {
|
|
82
83
|
readWritten,
|
|
@@ -97,6 +98,7 @@ import {
|
|
|
97
98
|
uncommittedUnder,
|
|
98
99
|
siteContentRoots,
|
|
99
100
|
showAtHead,
|
|
101
|
+
findCommitted,
|
|
100
102
|
mergeFile
|
|
101
103
|
} from '../utils/git.js'
|
|
102
104
|
import { isNonInteractive } from '../utils/interactive.js'
|
|
@@ -203,10 +205,15 @@ function readYamlUuid(filePath) {
|
|
|
203
205
|
function pullCachePath(siteDir) {
|
|
204
206
|
return join(siteDir, '.uniweb', 'pull-cache.json')
|
|
205
207
|
}
|
|
208
|
+
// ⛔ VERSION 2 DISOWNS EVERY ETAG A VERSION-1 PULL WROTE. Before 2026-09-23 a
|
|
209
|
+
// `--merge` that kept a local file whole — every one, in the default layout (see
|
|
210
|
+
// `showAt`) — still cached the ETag of content it never took. Every later pull then
|
|
211
|
+
// answered 304 and the file never caught up. Ignoring those once costs one full pull.
|
|
212
|
+
const PULL_CACHE_VERSION = 2
|
|
206
213
|
function readPullCache(siteDir) {
|
|
207
214
|
try {
|
|
208
215
|
const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
|
|
209
|
-
return obj && typeof obj === 'object' ? obj : {}
|
|
216
|
+
return obj && typeof obj === 'object' && obj.version >= PULL_CACHE_VERSION ? obj : {}
|
|
210
217
|
} catch {
|
|
211
218
|
return {}
|
|
212
219
|
}
|
|
@@ -216,7 +223,7 @@ function writePullCache(siteDir, { content, folder }) {
|
|
|
216
223
|
mkdirSync(dirname(p), { recursive: true })
|
|
217
224
|
writeFileSync(
|
|
218
225
|
p,
|
|
219
|
-
JSON.stringify({ version:
|
|
226
|
+
JSON.stringify({ version: PULL_CACHE_VERSION, content, folder }, null, 2) + '\n'
|
|
220
227
|
)
|
|
221
228
|
}
|
|
222
229
|
|
|
@@ -224,7 +231,10 @@ function writePullCache(siteDir, { content, folder }) {
|
|
|
224
231
|
// document, or a `{ document }` / `{ entity }` envelope. (Adjust at live e2e.)
|
|
225
232
|
export function extractDocument(payload) {
|
|
226
233
|
if (!payload || typeof payload !== 'object') return null
|
|
227
|
-
|
|
234
|
+
// ⛔ `$uuid` counts: every stored entity has one. Without it, a document naming no
|
|
235
|
+
// `$schema` stopped being a document at all — it vanished from the pull, which then
|
|
236
|
+
// reported "0 record(s)" and succeeded (2026-09-24, when `$model` left this test).
|
|
237
|
+
if (payload.$schema || payload.$uuid || payload.$id || payload.info) return payload
|
|
228
238
|
return payload.document || payload.entity || null
|
|
229
239
|
}
|
|
230
240
|
|
|
@@ -244,8 +254,59 @@ export function splitRecordsPull(payload) {
|
|
|
244
254
|
if (!list) return { folderDoc: null, recordDocs: [] }
|
|
245
255
|
const docs = list.map(extractDocument).filter(Boolean)
|
|
246
256
|
return {
|
|
247
|
-
folderDoc: docs.find((d) => d
|
|
248
|
-
recordDocs: docs.filter((d) => d
|
|
257
|
+
folderDoc: docs.find((d) => documentSchema(d) === FOLDER_MODEL) || null,
|
|
258
|
+
recordDocs: docs.filter((d) => documentSchema(d) !== FOLDER_MODEL)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Say why a pull did not place every record it was sent: a headline for an error
|
|
266
|
+
* and the lines under it.
|
|
267
|
+
*
|
|
268
|
+
* ⛔ A data schema named by id, or not named at all, is said first, and instead of the
|
|
269
|
+
* per-record reasons. The folder is found by its data schema's scoped name
|
|
270
|
+
* (`splitRecordsPull`) and a record is placed in `records/<schema>/` by its data
|
|
271
|
+
* schema's scoped name, so a response naming them by id — or naming none — places
|
|
272
|
+
* nothing, and every record is then skipped as "not in the folder", which hides the
|
|
273
|
+
* cause. Measured 2026-09-23: a backend answered the folder lane with ids for the
|
|
274
|
+
* folder and every record, under the key `$schema` replaced (`$model`).
|
|
275
|
+
*
|
|
276
|
+
* @param {{ docs: object[], taken: number, skipped: object[] }} lane - every document
|
|
277
|
+
* the lane carried, how many records were written or already current, and the
|
|
278
|
+
* projection's skips
|
|
279
|
+
* @returns {{ headline: string, lines: string[] }}
|
|
280
|
+
*/
|
|
281
|
+
export function describeUnplacedRecords({ docs, taken, skipped }) {
|
|
282
|
+
const none = taken === 0
|
|
283
|
+
const lead = none ? 'No record' : 'Not every record'
|
|
284
|
+
const placedBy =
|
|
285
|
+
"A record is placed in records/<schema>/ by its data schema's scoped name, so these cannot be placed."
|
|
286
|
+
const byId = [
|
|
287
|
+
...new Set((docs || []).map((d) => documentSchema(d)).filter((m) => m && UUID_RE.test(m)))
|
|
288
|
+
]
|
|
289
|
+
if (byId.length) {
|
|
290
|
+
return {
|
|
291
|
+
headline: `${lead} could be placed — the backend named their data schemas by id, not by scoped name.`,
|
|
292
|
+
lines: [
|
|
293
|
+
`Named by id: ${byId.slice(0, 3).join(', ')}${byId.length > 3 ? ', …' : ''}`,
|
|
294
|
+
placedBy,
|
|
295
|
+
'Your record files are unchanged.'
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const unnamed = (docs || []).filter((d) => !documentSchema(d)).length
|
|
300
|
+
if (unnamed) {
|
|
301
|
+
return {
|
|
302
|
+
headline: `${lead} could be placed — ${unnamed} of the backend's documents name no data schema (\`$schema\`).`,
|
|
303
|
+
lines: [placedBy, 'Your record files are unchanged.']
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const n = skipped.length
|
|
307
|
+
return {
|
|
308
|
+
headline: `${n} record${n === 1 ? '' : 's'} could not be placed — ${n === 1 ? 'its file is' : 'their files are'} unchanged.`,
|
|
309
|
+
lines: skipped.map((s) => `↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
|
|
249
310
|
}
|
|
250
311
|
}
|
|
251
312
|
|
|
@@ -438,39 +499,66 @@ async function checkWorkingTree(siteDir, args) {
|
|
|
438
499
|
* merging afterwards — so the projector stays a plain writer and needs no hook, and
|
|
439
500
|
* nothing is lost in between because "mine" is already in memory.
|
|
440
501
|
*
|
|
441
|
-
* Requires a repo, and that is not a mandate creeping in: the ancestor
|
|
502
|
+
* Requires a repo, and that is not a mandate creeping in: the ancestor is a
|
|
442
503
|
* committed version, and the merge is git's. There is nothing to merge against
|
|
443
504
|
* without one.
|
|
444
505
|
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
506
|
+
* ⭐ THE ANCESTOR IS THE VERSION THE LAST PULL WROTE — found in history by the hash
|
|
507
|
+
* the pull recorded (`findCommitted`), HEAD's only for a file no pull ever wrote.
|
|
508
|
+
* HEAD stops being that version the moment someone commits an edit on top of it,
|
|
509
|
+
* and a merge against it read their edit as the starting point: the backend's
|
|
510
|
+
* version won whole, reverting the committed edit (measured 2026-09-23 — commit,
|
|
511
|
+
* push refused, `uniweb refresh`, the edit gone from the working tree). The same
|
|
512
|
+
* reason brings in work that is COMMITTED but was never pushed: clean to git, so a
|
|
513
|
+
* dirty scan cannot see it, yet the projection would overwrite it.
|
|
514
|
+
*
|
|
515
|
+
* @returns {Map<string, {content: Buffer, base: Buffer|null}>|null} captured work,
|
|
516
|
+
* or null when merging isn't possible here (the caller reports and stops).
|
|
447
517
|
*/
|
|
448
518
|
function captureLocalWork(siteDir) {
|
|
449
519
|
const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
|
|
450
520
|
if (dirty === null) return null
|
|
451
521
|
const written = readWritten(siteDir)
|
|
522
|
+
const ancestorOf = (rel) => {
|
|
523
|
+
const recorded = written.files[rel]
|
|
524
|
+
return (recorded && findCommitted(siteDir, rel, recorded)) || showAtHead(siteDir, rel)
|
|
525
|
+
}
|
|
452
526
|
const out = new Map()
|
|
453
527
|
for (const rel of dirty) {
|
|
454
528
|
if (isPullOutput(siteDir, rel, written)) continue // pull's own output, not work
|
|
455
529
|
try {
|
|
456
|
-
out.set(rel, {
|
|
457
|
-
content: readFileSync(join(siteDir, rel)),
|
|
458
|
-
inHead: showAtHead(siteDir, rel) !== null
|
|
459
|
-
})
|
|
530
|
+
out.set(rel, { content: readFileSync(join(siteDir, rel)), base: ancestorOf(rel) })
|
|
460
531
|
} catch {
|
|
461
532
|
// Locally deleted. Pull will restore the backend's copy, which is the
|
|
462
533
|
// sensible reading of "I removed this and then asked for their version".
|
|
463
534
|
}
|
|
464
535
|
}
|
|
536
|
+
// Committed work: a file a pull wrote, edited and committed since. Its ancestor is
|
|
537
|
+
// only ever the version the pull wrote — HEAD is the edit itself — and when that is
|
|
538
|
+
// not in history there is none, which the merge treats as such.
|
|
539
|
+
for (const [rel, recorded] of Object.entries(written.files)) {
|
|
540
|
+
if (out.has(rel) || dirty.includes(rel)) continue
|
|
541
|
+
let content
|
|
542
|
+
try {
|
|
543
|
+
content = readFileSync(join(siteDir, rel))
|
|
544
|
+
} catch {
|
|
545
|
+
continue
|
|
546
|
+
}
|
|
547
|
+
if (createHash('sha256').update(content).digest('hex') === recorded) continue // untouched
|
|
548
|
+
out.set(rel, { content, base: findCommitted(siteDir, rel, recorded) })
|
|
549
|
+
}
|
|
465
550
|
return out
|
|
466
551
|
}
|
|
467
552
|
|
|
468
|
-
// Merge captured work back over what the projection just wrote. Returns a report
|
|
553
|
+
// Merge captured work back over what the projection just wrote. Returns a report;
|
|
554
|
+
// `unavailable` lists files whose merge could not run at all — they did NOT take the
|
|
555
|
+
// backend's version, so the caller must not record this pull as taken.
|
|
469
556
|
function mergeLocalWork(siteDir, captured) {
|
|
470
557
|
const clean = []
|
|
471
558
|
const conflicted = []
|
|
472
559
|
const kept = []
|
|
473
|
-
|
|
560
|
+
const unavailable = []
|
|
561
|
+
for (const [rel, { content: mine, base }] of captured) {
|
|
474
562
|
const abs = join(siteDir, rel)
|
|
475
563
|
let theirs = null
|
|
476
564
|
try {
|
|
@@ -490,27 +578,23 @@ function mergeLocalWork(siteDir, captured) {
|
|
|
490
578
|
}
|
|
491
579
|
if (theirs.equals(mine)) continue // both sides agree already
|
|
492
580
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
writeFileSync(abs, mine)
|
|
499
|
-
kept.push(`${rel} (no committed ancestor, kept yours)`)
|
|
500
|
-
continue
|
|
501
|
-
}
|
|
502
|
-
|
|
581
|
+
// ⛔ NO ANCESTOR IS A TWO-WAY MERGE, NOT "KEEP OURS". Both sides hold the file and
|
|
582
|
+
// no common version is on record — added on both sides, or a pull's version that
|
|
583
|
+
// was never committed. Keeping ours hid the backend's version entirely, and the
|
|
584
|
+
// pull then recorded it as taken. An empty ancestor is git's add/add: both
|
|
585
|
+
// versions, under conflict markers, for a person to settle.
|
|
503
586
|
const dir = mkdtempSync(join(tmpdir(), 'uniweb-merge-'))
|
|
504
587
|
try {
|
|
505
588
|
const basePath = join(dir, 'base')
|
|
506
589
|
const theirsPath = join(dir, 'theirs')
|
|
507
|
-
writeFileSync(basePath, base)
|
|
590
|
+
writeFileSync(basePath, base || '')
|
|
508
591
|
writeFileSync(theirsPath, theirs)
|
|
509
592
|
writeFileSync(abs, mine) // merge-file rewrites this in place
|
|
510
593
|
const r = mergeFile(siteDir, abs, basePath, theirsPath)
|
|
511
594
|
if (!r.merged) {
|
|
512
595
|
writeFileSync(abs, mine) // merge couldn't run — never leave a half-state
|
|
513
596
|
kept.push(`${rel} (merge unavailable, kept yours)`)
|
|
597
|
+
unavailable.push(rel)
|
|
514
598
|
} else if (r.conflicted) {
|
|
515
599
|
conflicted.push(rel)
|
|
516
600
|
} else {
|
|
@@ -520,7 +604,7 @@ function mergeLocalWork(siteDir, captured) {
|
|
|
520
604
|
rmSync(dir, { recursive: true, force: true })
|
|
521
605
|
}
|
|
522
606
|
}
|
|
523
|
-
return { clean, conflicted, kept }
|
|
607
|
+
return { clean, conflicted, kept, unavailable }
|
|
524
608
|
}
|
|
525
609
|
|
|
526
610
|
// Minimal yes/no prompt; defaults to no, because the default must not destroy.
|
|
@@ -704,12 +788,16 @@ export async function pull(args = [], deps = {}) {
|
|
|
704
788
|
const etag = res.headers?.get?.('etag') ?? null
|
|
705
789
|
const buf = Buffer.from(await res.arrayBuffer())
|
|
706
790
|
const docs = readPullDocuments(buf)
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
791
|
+
// The staleness tokens this lane carried, so the next push is gated against
|
|
792
|
+
// the state we took. Banked by the caller once local work is merged back — a
|
|
793
|
+
// file the merge did not take must not have its token recorded as taken. A 304
|
|
794
|
+
// carries none, correctly: the tokens we hold are still current.
|
|
795
|
+
return {
|
|
796
|
+
docs,
|
|
797
|
+
etag,
|
|
798
|
+
versions: readPullVersions(buf),
|
|
799
|
+
itemVersions: readPullItemVersions(buf)
|
|
800
|
+
}
|
|
713
801
|
} catch (err) {
|
|
714
802
|
error(`Could not read the ${label} response: ${err.message}`)
|
|
715
803
|
return null
|
|
@@ -724,6 +812,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
724
812
|
let sections = 0
|
|
725
813
|
let records = 0
|
|
726
814
|
let deleted = 0
|
|
815
|
+
// Set when the folder lane carried records this pull did not place — see the ⛔
|
|
816
|
+
// where it is set.
|
|
817
|
+
let recordsNotPlaced = null
|
|
727
818
|
|
|
728
819
|
// Conditional-pull cache: the last ETag seen per lane (opaque token — cached and
|
|
729
820
|
// echoed verbatim, never recomputed). Lives in the gitignored `.uniweb/`.
|
|
@@ -747,16 +838,21 @@ export async function pull(args = [], deps = {}) {
|
|
|
747
838
|
return pullScope
|
|
748
839
|
}
|
|
749
840
|
|
|
841
|
+
// ⛔ `--force` IS UNCONDITIONAL. It means "take the backend's version over mine", and
|
|
842
|
+
// an ETag answers a different question — whether the BACKEND moved, never whether
|
|
843
|
+
// the files still hold what it sent. Echoed here, a `git checkout` followed by
|
|
844
|
+
// `pull --force` got a 304 and left the checked-out files in place (2026-09-23).
|
|
845
|
+
const conditional = !force
|
|
750
846
|
// Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
|
|
751
847
|
// entity (the site-content document). A 304 (unchanged) leaves local files as-is.
|
|
752
848
|
const content = await getDocs('content', () =>
|
|
753
|
-
client.pullSiteContent(siteContentUuid, { etag: etagContent })
|
|
849
|
+
client.pullSiteContent(siteContentUuid, { etag: conditional ? etagContent : undefined })
|
|
754
850
|
)
|
|
755
851
|
if (content?.refused) return { exitCode: 1 }
|
|
756
852
|
if (content && !content.notModified) {
|
|
757
853
|
const siteDoc =
|
|
758
854
|
content.docs &&
|
|
759
|
-
(content.docs.find((d) => d?.info || d
|
|
855
|
+
(content.docs.find((d) => d?.info || documentSchema(d)) ||
|
|
760
856
|
content.docs[0] ||
|
|
761
857
|
null)
|
|
762
858
|
if (siteDoc) {
|
|
@@ -865,17 +961,19 @@ export async function pull(args = [], deps = {}) {
|
|
|
865
961
|
// (the backend resolves the site's `@uniweb/folder` from it; the framework never
|
|
866
962
|
// holds a folder uuid). Models are resolved by name (async) up front, so
|
|
867
963
|
// recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
|
|
964
|
+
let folderLane = null
|
|
868
965
|
if (!noRecords) {
|
|
869
966
|
const folder = await getDocs('records', () =>
|
|
870
|
-
client.pullFolder(siteContentUuid, { etag: etagFolder })
|
|
967
|
+
client.pullFolder(siteContentUuid, { etag: conditional ? etagFolder : undefined })
|
|
871
968
|
)
|
|
969
|
+
folderLane = folder
|
|
872
970
|
if (folder?.refused) return { exitCode: 1 }
|
|
873
971
|
if (folder && !folder.notModified && folder.docs?.length) {
|
|
874
972
|
const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
|
|
875
973
|
const resolveModel = makeModelResolver({ client })
|
|
876
974
|
const declByModel = new Map()
|
|
877
975
|
for (const model of [
|
|
878
|
-
...new Set(recordDocs.map((d) => d
|
|
976
|
+
...new Set(recordDocs.map((d) => documentSchema(d)).filter(Boolean))
|
|
879
977
|
]) {
|
|
880
978
|
try {
|
|
881
979
|
declByModel.set(model, await resolveModel(model))
|
|
@@ -883,8 +981,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
883
981
|
note(`! could not resolve model ${model}: ${err.message}`)
|
|
884
982
|
}
|
|
885
983
|
}
|
|
886
|
-
// ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
|
|
887
|
-
//
|
|
984
|
+
// ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its data
|
|
985
|
+
// schema (`documentSchema`) names the pool folder — not by any query that happens to select
|
|
888
986
|
// it. Handed the foundation's scope (`scopeFor`), `recordsToProject` places a
|
|
889
987
|
// `@/x` model the producer resolved to `@acme/x` back where the author wrote it.
|
|
890
988
|
const report = recordsToProject({
|
|
@@ -911,34 +1009,36 @@ export async function pull(args = [], deps = {}) {
|
|
|
911
1009
|
removed.push(resolve(siteDir, report.recordsFile))
|
|
912
1010
|
}
|
|
913
1011
|
records += report.placed.length + report.updated.length
|
|
914
|
-
|
|
915
|
-
|
|
1012
|
+
// ⛔ A PULL THAT DID NOT PLACE A RECORD HAS NOT TAKEN THE BACKEND'S RECORDS.
|
|
1013
|
+
// Until 2026-09-23 each skip was a dim note, the pull said "✓ Pulled … 0
|
|
1014
|
+
// record(s)" and exited 0, and it banked the lane's ETag and tokens. So the next
|
|
1015
|
+
// pull would hear 304 and never retry, and a `refresh && push` would go on to
|
|
1016
|
+
// push — and a push sends the folder whole (`utils/records-guard.js`), so the
|
|
1017
|
+
// skipped records would leave it. It is an error now, the lane is not recorded
|
|
1018
|
+
// as taken (below), and the exit is non-zero (the end).
|
|
1019
|
+
if (report.skipped.length) {
|
|
1020
|
+
recordsNotPlaced = describeUnplacedRecords({
|
|
1021
|
+
docs: folder.docs,
|
|
1022
|
+
taken: report.placed.length + report.updated.length + report.unchanged.length,
|
|
1023
|
+
skipped: report.skipped
|
|
1024
|
+
})
|
|
1025
|
+
error(recordsNotPlaced.headline)
|
|
1026
|
+
for (const line of recordsNotPlaced.lines) note(` ${line}`)
|
|
1027
|
+
}
|
|
916
1028
|
for (const w of report.warnings) note(`! ${w}`)
|
|
917
1029
|
}
|
|
918
1030
|
if (folder?.etag) etagFolder = folder.etag
|
|
919
1031
|
}
|
|
920
1032
|
|
|
921
|
-
//
|
|
922
|
-
|
|
1033
|
+
// ⛔ WHAT THIS PULL WROTE, AND THE HASHES A PUSH DIFFS AGAINST, DESCRIBE THE
|
|
1034
|
+
// BACKEND'S CONTENT — so both are taken over the projection, BEFORE local work is
|
|
1035
|
+
// merged back. Taken after it, as they were until 2026-09-23, they recorded the
|
|
1036
|
+
// merged-in local edits as pull output and as already pushed: the next push said
|
|
1037
|
+
// "Nothing to push", `status` said in sync, and the edit never left this machine —
|
|
1038
|
+
// while a later plain pull would have overwritten it as its own output.
|
|
1039
|
+
//
|
|
923
1040
|
// Config files are rewritten every pull whether or not they changed; include the
|
|
924
1041
|
// ones the projector owns so a pull-then-pull doesn't trip on them either.
|
|
925
|
-
// Merge captured local work back over what the projection just wrote. Runs after
|
|
926
|
-
// BOTH lanes, so a file either lane produced is merged the same way.
|
|
927
|
-
if (captured && captured.size) {
|
|
928
|
-
mergeReport = mergeLocalWork(siteDir, captured)
|
|
929
|
-
const r = mergeReport
|
|
930
|
-
if (r.clean.length) {
|
|
931
|
-
info(`Merged your changes into ${r.clean.length} file(s):`)
|
|
932
|
-
for (const f of r.clean) note(` ${f}`)
|
|
933
|
-
}
|
|
934
|
-
for (const f of r.kept) note(`\u21b7 ${f}`)
|
|
935
|
-
if (r.conflicted.length) {
|
|
936
|
-
error(`${r.conflicted.length} file(s) have conflicts to resolve:`)
|
|
937
|
-
for (const f of r.conflicted) note(` ${f}`)
|
|
938
|
-
note('Conflict markers are in place. Resolve them, then commit and push.')
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
|
|
942
1042
|
recordWritten(
|
|
943
1043
|
siteDir,
|
|
944
1044
|
[
|
|
@@ -978,14 +1078,66 @@ export async function pull(args = [], deps = {}) {
|
|
|
978
1078
|
}
|
|
979
1079
|
}
|
|
980
1080
|
|
|
981
|
-
|
|
1081
|
+
// Merge captured local work back over what the projection just wrote. Runs after
|
|
1082
|
+
// BOTH lanes, so a file either lane produced is merged the same way.
|
|
1083
|
+
if (captured && captured.size) {
|
|
1084
|
+
mergeReport = mergeLocalWork(siteDir, captured)
|
|
1085
|
+
const r = mergeReport
|
|
1086
|
+
if (r.clean.length) {
|
|
1087
|
+
info(`Merged your changes into ${r.clean.length} file(s):`)
|
|
1088
|
+
for (const f of r.clean) note(` ${f}`)
|
|
1089
|
+
}
|
|
1090
|
+
for (const f of r.kept) note(`\u21b7 ${f}`)
|
|
1091
|
+
if (r.conflicted.length) {
|
|
1092
|
+
error(`${r.conflicted.length} file(s) have conflicts to resolve:`)
|
|
1093
|
+
for (const f of r.conflicted) note(` ${f}`)
|
|
1094
|
+
note('Conflict markers are in place. Resolve them, then commit and push.')
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ⛔ THE TOKENS AND ETAGS SAY "THIS COPY HOLDS THE BACKEND'S VERSION" — so they are
|
|
1099
|
+
// banked only once every file has taken it, merged or in conflict. A token banked
|
|
1100
|
+
// for a file that kept ours makes the next push of it read as "only you changed it"
|
|
1101
|
+
// and overwrite the backend's version; an ETag banked for it makes every later pull
|
|
1102
|
+
// answer 304, so the file never catches up. Measured 2026-09-23, both: a refused
|
|
1103
|
+
// push, `uniweb refresh`, and a push that quietly put back a heading another writer
|
|
1104
|
+
// had replaced. When a merge could not run, nothing of this pull is recorded as
|
|
1105
|
+
// taken, and the next pull fetches it again.
|
|
1106
|
+
const unavailable = mergeReport?.unavailable || []
|
|
1107
|
+
if (unavailable.length) {
|
|
1108
|
+
note("! Not recorded as taken — a merge could not run, so those files keep yours.")
|
|
1109
|
+
note(' Pushing them is refused until a pull merges them; fix git, then `uniweb pull --merge` again.')
|
|
1110
|
+
} else {
|
|
1111
|
+
for (const lane of [content, folderLane]) {
|
|
1112
|
+
if (!lane || lane.notModified || lane.refused) continue
|
|
1113
|
+
// Records this pull did not place were not taken, so neither is their lane.
|
|
1114
|
+
if (lane === folderLane && recordsNotPlaced) continue
|
|
1115
|
+
mergeBaseVersions(siteDir, client.origin, lane.versions)
|
|
1116
|
+
mergeItemBaseVersions(siteDir, client.origin, lane.itemVersions)
|
|
1117
|
+
}
|
|
1118
|
+
// Persist the ETags so the next pull is conditional (304 when unchanged). The
|
|
1119
|
+
// folder's is DROPPED when records were not placed, so the next pull fetches
|
|
1120
|
+
// them again rather than hearing 304 about a state it never took.
|
|
1121
|
+
writePullCache(siteDir, {
|
|
1122
|
+
content: etagContent,
|
|
1123
|
+
folder: recordsNotPlaced ? null : etagFolder
|
|
1124
|
+
})
|
|
1125
|
+
}
|
|
1126
|
+
if (recordsNotPlaced) {
|
|
1127
|
+
note('Records not recorded as taken — the next pull fetches them again.')
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const summary =
|
|
982
1131
|
`Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
|
|
983
|
-
|
|
984
|
-
)
|
|
1132
|
+
(deleted ? `, ${deleted} deleted` : '')
|
|
1133
|
+
if (recordsNotPlaced) info(summary)
|
|
1134
|
+
else success(summary)
|
|
985
1135
|
// Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
|
|
986
1136
|
// The pull itself worked; there is work left for a human. This is what makes a
|
|
987
1137
|
// chained `uniweb pull --merge && uniweb push` safe by construction — without it,
|
|
988
1138
|
// the obvious one-liner pushes conflict markers into live content.
|
|
1139
|
+
// Records it could not place are the same: the pull ran, and what it was sent is not
|
|
1140
|
+
// all in the files — a chained push must not go on as if it were.
|
|
989
1141
|
const conflicts = mergeReport?.conflicted?.length ?? 0
|
|
990
|
-
return { exitCode: conflicts ? 1 : 0, merge: mergeReport }
|
|
1142
|
+
return { exitCode: conflicts || recordsNotPlaced ? 1 : 0, merge: mergeReport }
|
|
991
1143
|
}
|
package/src/commands/push.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - folder lane → one `@uniweb/folder` + the collection-record entities it
|
|
6
6
|
* references (the dynamic half; the `$ref` closure rides together).
|
|
7
7
|
*
|
|
8
|
-
* Each entity is an entity-content document (`$id` + `$
|
|
8
|
+
* Each entity is an entity-content document (`$id` + `$schema` + sections). The site's
|
|
9
9
|
* identity on a backend is its site-content uuid, in that backend's section of
|
|
10
10
|
* `sync.json`. A first push to a backend has none — it CREATEs the site (uuid-less),
|
|
11
11
|
* the backend mints + adopts it and returns the new uuid, which `push` records in
|
|
@@ -64,7 +64,7 @@ import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
|
64
64
|
import { updateBackendMap, carryServed, SYNC_STORE_FILE } from '@uniweb/build/uwx'
|
|
65
65
|
import { BackendClient } from '../backend/client.js'
|
|
66
66
|
import { resolveSiteDir } from './deploy.js'
|
|
67
|
-
import {
|
|
67
|
+
import { refuseIfContentDoesNotConform } from '../utils/conformance.js'
|
|
68
68
|
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
69
69
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
70
70
|
import {
|
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
readFolderItemUuids,
|
|
84
84
|
readQueryUuids,
|
|
85
85
|
ensureItemUuids,
|
|
86
|
+
refuseUnsendableRecords,
|
|
86
87
|
ensureSiteExists,
|
|
87
88
|
clearRemoteSyncStateIfUnbound,
|
|
88
89
|
dropSiteBoundValues,
|
|
@@ -148,9 +149,10 @@ export async function push(args = [], deps = {}) {
|
|
|
148
149
|
|
|
149
150
|
const siteDir = await resolveSiteDir(args, 'push')
|
|
150
151
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
152
|
+
// ⭐ A GATE, before anything is sent: the backend checks these records against the
|
|
153
|
+
// schemas this push registers, so one that does not conform would be refused on
|
|
154
|
+
// arrival or lose a value on the way. See utils/conformance.js.
|
|
155
|
+
if (await refuseIfContentDoesNotConform(siteDir, { args })) return { exitCode: 1 }
|
|
154
156
|
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
155
157
|
// backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
|
|
156
158
|
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
@@ -394,6 +396,7 @@ export async function push(args = [], deps = {}) {
|
|
|
394
396
|
let assetIds = null
|
|
395
397
|
if (!output && !dryRun) {
|
|
396
398
|
let mediaRefs = []
|
|
399
|
+
let refusals = []
|
|
397
400
|
try {
|
|
398
401
|
const probe = await emitSyncPackages(siteDir, {
|
|
399
402
|
backend: client.origin,
|
|
@@ -403,10 +406,14 @@ export async function push(args = [], deps = {}) {
|
|
|
403
406
|
resolveModel: makeModelResolver({ client, offline: false })
|
|
404
407
|
})
|
|
405
408
|
mediaRefs = probe.localAssets || []
|
|
409
|
+
refusals = probe.refusals || []
|
|
406
410
|
} catch (err) {
|
|
407
411
|
error(`Could not scan the site for local media: ${err.message}`)
|
|
408
412
|
return { exitCode: 2 }
|
|
409
413
|
}
|
|
414
|
+
// A record the backend would refuse stops the push HERE — before the site is
|
|
415
|
+
// created or a byte uploaded, not merely before the send (`refuseUnsendableRecords`).
|
|
416
|
+
if (refuseUnsendableRecords(refusals, { error, note })) return { exitCode: 1 }
|
|
410
417
|
if (mediaRefs.length) {
|
|
411
418
|
// The site has to exist before its bytes do — an upload with no owning
|
|
412
419
|
// entity is charged and cannot be freed, because freeing means deleting the
|
|
@@ -530,6 +537,9 @@ export async function push(args = [], deps = {}) {
|
|
|
530
537
|
const { siteContent, records, siteContentUuid, warnings, skipped } = pkg
|
|
531
538
|
log('')
|
|
532
539
|
for (const w of warnings) note(`! ${w}`)
|
|
540
|
+
// The same stop for the paths the probe above does not run on — `-o` and
|
|
541
|
+
// `--dry-run` say what a push would do, and a push would stop here.
|
|
542
|
+
if (refuseUnsendableRecords(pkg.refusals, { error, note })) return { exitCode: 1 }
|
|
533
543
|
// Warn level, not dim: this is the author choosing entities vs static files.
|
|
534
544
|
reportSchemalessQueries(pkg.schemaless, { warn, dim: note })
|
|
535
545
|
|
package/src/commands/status.js
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
foundationScopedName
|
|
39
39
|
} from '../backend/foundation-bring-along.js'
|
|
40
40
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
41
|
+
import { compareSemverPrecedence } from '../utils/semver-precedence.js'
|
|
41
42
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
42
43
|
|
|
43
44
|
const c = {
|
|
@@ -120,6 +121,7 @@ export async function status(args = []) {
|
|
|
120
121
|
let site = null
|
|
121
122
|
let fdnLatest = null
|
|
122
123
|
let foundationFresh = null // true/false when both digests are known; else null
|
|
124
|
+
let localFoundationVersion = null
|
|
123
125
|
let remoteError = null // a refusal about the workspace — the one remote failure worth saying
|
|
124
126
|
if (remote) {
|
|
125
127
|
try {
|
|
@@ -138,6 +140,7 @@ export async function status(args = []) {
|
|
|
138
140
|
// site.yml ref. The digest compare is read-only — it never builds, so it
|
|
139
141
|
// only fires when the local foundation is already built (dist present).
|
|
140
142
|
const local = resolveLocalFoundation(siteDir, siteYml)
|
|
143
|
+
localFoundationVersion = local?.version ?? null
|
|
141
144
|
const lookupName =
|
|
142
145
|
(local && (await foundationScopedName(local.dir))) || fndScope
|
|
143
146
|
if (lookupName) fdnLatest = await client.readFoundationLatest(lookupName)
|
|
@@ -240,10 +243,21 @@ export async function status(args = []) {
|
|
|
240
243
|
`A newer foundation version (${fdnLatest.latest_version}) is registered than the site pins (${fndVersion}).`
|
|
241
244
|
)
|
|
242
245
|
}
|
|
246
|
+
// What the next push or publish does with it (`backend/foundation-bring-along.js`):
|
|
247
|
+
// release changed code — under the next version when its own is taken — unless the
|
|
248
|
+
// registry holds a NEWER version, which stops them. ⛔ This pointed at `uniweb
|
|
249
|
+
// register` until 2026-09-24 — which cannot release a change under a version
|
|
250
|
+
// already registered, since a registered version is immutable.
|
|
243
251
|
if (foundationFresh === false) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
252
|
+
if (compareSemverPrecedence(localFoundationVersion, fdnLatest.latest_version) === -1) {
|
|
253
|
+
say.info(
|
|
254
|
+
`The registry holds foundation ${fdnLatest.latest_version}, newer than your ${localFoundationVersion} — a push or publish stops until you pull that change, or pass \`--bump\` to release yours above it.`
|
|
255
|
+
)
|
|
256
|
+
} else {
|
|
257
|
+
say.info(
|
|
258
|
+
'Local foundation differs from the registered version — the next `uniweb push` or `uniweb publish` releases it.'
|
|
259
|
+
)
|
|
260
|
+
}
|
|
247
261
|
} else if (foundationFresh === true) {
|
|
248
262
|
say.ok('Local foundation matches the registered version.')
|
|
249
263
|
}
|
package/src/commands/validate.js
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
*
|
|
5
5
|
* For each section that consumes file-based data, this resolves the schema the
|
|
6
6
|
* foundation bound to that input (via `meta.js` `data:`) and checks the data
|
|
7
|
-
* items against it
|
|
8
|
-
*
|
|
7
|
+
* items against it — and every record file in the records directory against the
|
|
8
|
+
* schema its folder names, whether or not a section reads it. It answers "does my
|
|
9
|
+
* data match what I promised?" — distinct from `doctor`, which checks your project
|
|
10
|
+
* against framework conventions.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* ⭐ A violation fails it (exit 1); `--lax` reports without failing. It warned by
|
|
13
|
+
* default until 2026-09-24, with `--strict` to fail — a gate that passes is one
|
|
14
|
+
* nobody reads, and `push` / `publish` now refuse the same findings. The live render
|
|
15
|
+
* path stays tolerant. Dynamic (remote) inputs and entity references can't be
|
|
16
|
+
* resolved without a running backend, so they're reported as deferred, never
|
|
17
|
+
* silently skipped.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import { existsSync, readFileSync } from 'node:fs'
|
|
@@ -201,7 +204,9 @@ function dedupeUsers(users) {
|
|
|
201
204
|
|
|
202
205
|
export async function validate(args = []) {
|
|
203
206
|
const asJson = args.includes('--json')
|
|
204
|
-
|
|
207
|
+
// A violation fails the run unless `--lax` says to only report it. (`--strict`, the
|
|
208
|
+
// flag that turned failing on until 2026-09-24, is simply the default now.)
|
|
209
|
+
const strict = !args.includes('--lax')
|
|
205
210
|
const siteFilter = flagValue(args, '--site')
|
|
206
211
|
const positional = args.find(
|
|
207
212
|
(a, i) => !a.startsWith('--') && args[i - 1] !== '--site'
|
|
@@ -353,7 +358,7 @@ export async function validate(args = []) {
|
|
|
353
358
|
? `${colors.red}error${colors.reset}`
|
|
354
359
|
: `${colors.yellow}warning${colors.reset}`
|
|
355
360
|
log(
|
|
356
|
-
`${totalViolations} violation(s) — reported as ${mode}${strict ? '' : ` ${colors.dim}(
|
|
361
|
+
`${totalViolations} violation(s) — reported as ${mode}${strict ? '' : ` ${colors.dim}(--lax: without it, this fails)${colors.reset}`}`
|
|
357
362
|
)
|
|
358
363
|
}
|
|
359
364
|
if (hadError)
|
|
@@ -362,9 +367,8 @@ export async function validate(args = []) {
|
|
|
362
367
|
}
|
|
363
368
|
}
|
|
364
369
|
|
|
365
|
-
// Exit semantics: 2 = couldn't run; 1 = violations
|
|
366
|
-
//
|
|
367
|
-
// default). Setup/read failures are surfaced but don't fail the build.
|
|
370
|
+
// Exit semantics: 2 = couldn't run; 1 = violations (unless --lax); 0 = clean, or
|
|
371
|
+
// violations reported under --lax. Setup/read failures are surfaced but don't fail.
|
|
368
372
|
if (hadError) return { exitCode: 2 }
|
|
369
373
|
if (totalViolations > 0 && strict) return { exitCode: 1 }
|
|
370
374
|
return { exitCode: 0 }
|