uniweb 0.65.0 → 0.67.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.
@@ -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 COMMITTED version of each file — the backend keeps no
32
- * per-version history and does not need to — so `--merge` requires a repo, and the
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: 1, content, folder }, null, 2) + '\n'
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
- if (payload.$model || payload.$id || payload.info) return payload
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.$model === FOLDER_MODEL) || null,
248
- recordDocs: docs.filter((d) => d.$model !== FOLDER_MODEL)
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 IS the
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
- * @returns {Map<string, {content: Buffer, inHead: boolean}>|null} captured work, or
446
- * null when merging isn't possible here (the caller reports and stops).
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
- for (const [rel, { content: mine, inHead }] of captured) {
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
- const base = inHead ? showAtHead(siteDir, rel) : null
494
- if (!base) {
495
- // No committed ancestor — a file added locally and never committed. There is
496
- // no third input, so a three-way merge is not defined. Keep ours rather than
497
- // guess, and say so.
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
- // Bank the per-entity staleness tokens this lane carried, so the next push
708
- // is gated against the state we just took. A 304 skips this — correctly:
709
- // unchanged upstream means the token we already hold is still current.
710
- mergeBaseVersions(siteDir, client.origin, readPullVersions(buf))
711
- mergeItemBaseVersions(siteDir, client.origin, readPullItemVersions(buf))
712
- return { docs, etag }
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?.$model) ||
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.$model).filter(Boolean))
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
- // `$model` names the pool folder — not by any query that happens to select
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
- for (const s of report.skipped)
915
- note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
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
- // Persist the ETags so the next pull is conditional (304 when unchanged).
922
- writePullCache(siteDir, { content: etagContent, folder: etagFolder })
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
- success(
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
- (deleted ? `, ${deleted} deleted` : '')
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
  }
@@ -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` + `$model` + sections). The site's
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 { warnIfContentDoesNotConform } from '../utils/conformance.js'
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
- // Advisory only — warns and pushes. A malformed data block otherwise rides
152
- // the sync wire unchecked; see utils/conformance.js.
153
- await warnIfContentDoesNotConform(siteDir, { args })
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
 
@@ -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
- say.info(
245
- 'Local foundation differs from the registered version — `uniweb register` (or `uniweb publish`) to release the change.'
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
  }
@@ -4,14 +4,18 @@
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. It answers "does my data match what I promised?" — distinct
8
- * from `doctor`, which checks your project against framework conventions.
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
- * It warns by default; `--strict` turns findings into a non-zero exit for CI.
11
- * The live render path stays tolerant — this gate runs before a site is live,
12
- * by choice. Dynamic (remote) inputs and entity references can't be resolved
13
- * without a running backend, so they're reported as deferred, never silently
14
- * skipped.
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. Remote (`url:`) inputs are not in the project, so they're
16
+ * reported as deferred, never silently skipped. The value of an entity reference is
17
+ * not checked against the entity it names. ⛔ This said references were "reported as
18
+ * deferred" until 2026-09-24; no check ever reported them.
15
19
  */
16
20
 
17
21
  import { existsSync, readFileSync } from 'node:fs'
@@ -168,7 +172,7 @@ function printSiteHuman(result) {
168
172
  }
169
173
 
170
174
  if (deferred.length > 0) {
171
- log(` ${colors.dim}↪ deferred (not statically checkable):${colors.reset}`)
175
+ log(` ${colors.dim}↪ deferred (not checked):${colors.reset}`)
172
176
  for (const d of deferred) {
173
177
  const extra = d.url
174
178
  ? ` ${colors.dim}(${d.url})${colors.reset}`
@@ -201,7 +205,9 @@ function dedupeUsers(users) {
201
205
 
202
206
  export async function validate(args = []) {
203
207
  const asJson = args.includes('--json')
204
- const strict = args.includes('--strict')
208
+ // A violation fails the run unless `--lax` says to only report it. (`--strict`, the
209
+ // flag that turned failing on until 2026-09-24, is simply the default now.)
210
+ const strict = !args.includes('--lax')
205
211
  const siteFilter = flagValue(args, '--site')
206
212
  const positional = args.find(
207
213
  (a, i) => !a.startsWith('--') && args[i - 1] !== '--site'
@@ -353,7 +359,7 @@ export async function validate(args = []) {
353
359
  ? `${colors.red}error${colors.reset}`
354
360
  : `${colors.yellow}warning${colors.reset}`
355
361
  log(
356
- `${totalViolations} violation(s) — reported as ${mode}${strict ? '' : ` ${colors.dim}(pass --strict to fail CI)${colors.reset}`}`
362
+ `${totalViolations} violation(s) — reported as ${mode}${strict ? '' : ` ${colors.dim}(--lax: without it, this fails)${colors.reset}`}`
357
363
  )
358
364
  }
359
365
  if (hadError)
@@ -362,9 +368,8 @@ export async function validate(args = []) {
362
368
  }
363
369
  }
364
370
 
365
- // Exit semantics: 2 = couldn't run; 1 = violations under --strict; 0 = clean
366
- // or warn-only (the live path stays tolerant, so findings don't fail by
367
- // default). Setup/read failures are surfaced but don't fail the build.
371
+ // Exit semantics: 2 = couldn't run; 1 = violations (unless --lax); 0 = clean, or
372
+ // violations reported under --lax. Setup/read failures are surfaced but don't fail.
368
373
  if (hadError) return { exitCode: 2 }
369
374
  if (totalViolations > 0 && strict) return { exitCode: 1 }
370
375
  return { exitCode: 0 }