uniweb 0.13.4 → 0.13.6

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.
@@ -12,9 +12,25 @@
12
12
  * pages/**, layout/**), and
13
13
  * - folder lane → `collectionsToProject` (the folder + record files).
14
14
  *
15
- * Pull is git-pull-like: it reconciles the working tree to the backend, DELETING
16
- * pages/sections that no longer exist there (toggle off with `--no-delete`). The
17
- * deletion is guarded so an empty/partial payload never wipes the tree.
15
+ * Pull is a CHECKOUT, not a merge — the "git-pull-like" it used to claim here was
16
+ * misleading. It reconciles the working tree to the backend: section bodies are
17
+ * rewritten from the fetched document, and pages/sections the backend no longer has
18
+ * are DELETED (toggle off with `--no-delete`). The deletion is guarded so an
19
+ * empty/partial payload never wipes the tree.
20
+ *
21
+ * Because it overwrites rather than merges, it REFUSES when there is uncommitted
22
+ * work under the directories it owns — that was the one remaining way the CLI could
23
+ * destroy a user's work without them agreeing to it. Commit or stash first, or pass
24
+ * `--force`. Outside a git repository there is nothing to fall back on, so it asks
25
+ * instead (and refuses when it cannot).
26
+ *
27
+ * `--merge` is the third option, and usually the one you want after a refused push:
28
+ * three-way merge local work with what the backend sends, instead of choosing
29
+ * between them. Most "conflicts" are two people editing different paragraphs of one
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`.
18
34
  *
19
35
  * `uniweb login && uniweb pull`. Run from a site, or a workspace with one site.
20
36
  *
@@ -22,6 +38,8 @@
22
38
  * uniweb pull GET both lanes, project to files, prune orphans
23
39
  * uniweb pull --no-collections Pull pages only; skip the folder (collections) lane
24
40
  * uniweb pull --no-delete Project, but keep files with no backend item
41
+ * uniweb pull --merge Three-way merge local changes with the backend's
42
+ * uniweb pull --force Pull over uncommitted local changes (discards them)
25
43
  * uniweb pull --dry-run Report what it would GET; write nothing
26
44
  * uniweb pull --registry <url> Override the backend origin
27
45
  * uniweb pull --token <bearer> Read with this bearer; skips `uniweb login`
@@ -38,16 +56,29 @@
38
56
  * against the playground backend, 2026-06-17.
39
57
  */
40
58
 
41
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
42
- import { join, dirname } from 'node:path'
59
+ import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
60
+ import { tmpdir } from 'node:os'
61
+ import { createHash } from 'node:crypto'
62
+ import { createInterface } from 'node:readline/promises'
63
+ import { join, dirname, relative } from 'node:path'
43
64
  import yaml from 'js-yaml'
44
65
  import {
45
66
  siteContentDocumentToProject,
46
67
  collectionsToProject,
47
68
  resolveCollectionsConfig,
48
69
  readZip,
70
+ computeUnitHashes,
71
+ collectUnitUuids,
49
72
  } from '@uniweb/build/uwx'
50
73
  import { makeModelResolver } from './push.js'
74
+ import {
75
+ mergeBaseVersions,
76
+ mergeItemBaseVersions,
77
+ writeUnitBases,
78
+ writeItemUuids,
79
+ } from '../backend/site-sync.js'
80
+ import { uncommittedUnder, siteContentRoots, showAtHead, mergeFile } from '../utils/git.js'
81
+ import { isNonInteractive } from '../utils/interactive.js'
51
82
  import { BackendClient } from '../backend/client.js'
52
83
  import { resolveSiteDir as defaultResolveSiteDir, resolveSiteBackend } from './deploy.js'
53
84
 
@@ -167,11 +198,280 @@ export function readPullDocuments(buf) {
167
198
  return single ? [single] : []
168
199
  }
169
200
 
201
+ // Read the per-entity optimistic-concurrency tokens out of a pull lane's manifest.
202
+ // The backend stamps each entity entry with a top-level `version` (opaque RFC3339);
203
+ // we cache it and echo it back as `base_version` on the next push, so a push
204
+ // against a base the backend has moved past is refused instead of silently
205
+ // overwriting. Returns a `{ <backend-uuid>: <version> }` map — empty for a JSON
206
+ // fallback body (no manifest ⇒ no tokens ⇒ that lane simply stays unconditional).
207
+ //
208
+ // TOP-LEVEL on the entry. The backend's `Entry::extra` is `#[serde(flatten)]`, so
209
+ // it never appears on the wire; reading `extra.version` finds nothing and silently
210
+ // leaves the gate disarmed (which is exactly what it did before 2026-07-26).
211
+ //
212
+ // Keyed by `entries[].uuid`, which on a PULL manifest is the real backend uuid.
213
+ // Note that is NOT symmetric with what we EMIT: on the push side that field is our
214
+ // `$id` handle, and the backend correlates our `base_version` via the body's
215
+ // `$uuid` instead. Same field name, different meaning by direction.
216
+ //
217
+ // This reads the SAME manifest.json that readPullDocuments deliberately skips:
218
+ // there the entity files are the payload, here the manifest is the index we were
219
+ // previously discarding.
220
+ export function readPullVersions(buf) {
221
+ return readManifestTokens(buf).entity
222
+ }
223
+
224
+ /**
225
+ * Per-ITEM staleness tokens: `{ <record $uuid>: <opaque version> }`, from the
226
+ * entry's `item_versions`.
227
+ *
228
+ * The entity token can only say "something in this document moved"; these say
229
+ * which records did, which is what lets two people editing different sections
230
+ * both land instead of one being refused. Echoed back as `item_base_versions`.
231
+ *
232
+ * Keyed by record uuid rather than by our file path deliberately: the token then
233
+ * joins to the body by the same identity the backend's reconcile uses, and a file
234
+ * rename cannot silently re-target it. Absent for an entity with no items, and
235
+ * absent from an older backend — both mean "no per-item precondition", fall back
236
+ * to the entity grain.
237
+ */
238
+ export function readPullItemVersions(buf) {
239
+ return readManifestTokens(buf).item
240
+ }
241
+
242
+ // One manifest read for both grains — they arrive on the same entry and must not
243
+ // drift apart by being parsed in two places.
244
+ function readManifestTokens(buf) {
245
+ const out = { entity: {}, item: {} }
246
+ if (!(buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b)) return out
247
+ for (const [name, data] of readZip(buf)) {
248
+ if (name !== 'manifest.json') continue
249
+ try {
250
+ const manifest = JSON.parse(data.toString('utf8'))
251
+ for (const entry of manifest?.entries || []) {
252
+ if (entry?.uuid && typeof entry.version === 'string') out.entity[entry.uuid] = entry.version
253
+ const items = entry?.item_versions
254
+ if (items && typeof items === 'object') {
255
+ for (const [uuid, v] of Object.entries(items)) {
256
+ if (typeof v === 'string') out.item[uuid] = v
257
+ }
258
+ }
259
+ }
260
+ } catch {
261
+ /* an unreadable manifest just means no tokens this pull */
262
+ }
263
+ break
264
+ }
265
+ return out
266
+ }
267
+
170
268
  /**
171
269
  * @param {string[]} args
172
270
  * @param {object} [deps] - injectable seams for testing: `fetch` (default global
173
271
  * fetch), `resolveSiteDir`, `getToken` (skip auth).
174
272
  */
273
+ /**
274
+ * Refuse a pull that would overwrite unsaved work. Returns a result object to
275
+ * return from `pull`, or null to proceed.
276
+ *
277
+ * With a repo: uncommitted work under pull's roots blocks, naming the files.
278
+ * Without one: there is nothing standing behind the tree, so ask — and in a
279
+ * non-interactive context refuse rather than assume consent.
280
+ */
281
+ async function checkWorkingTree(siteDir, args) {
282
+ const roots = siteContentRoots(siteDir)
283
+ let dirty = uncommittedUnder(siteDir, roots)
284
+
285
+ if (dirty === null) {
286
+ // Not a git work tree. Nothing to fall back on if this goes wrong.
287
+ if (isNonInteractive(args)) {
288
+ error('Refusing to pull: this site is not in a git repository.')
289
+ note('Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.')
290
+ note('Without version control there is no way back, and this session cannot ask.')
291
+ note('Re-run with --force to accept that, or put the site under git first.')
292
+ return { exitCode: 1 }
293
+ }
294
+ const ok = await confirm(`Pull will overwrite ${roots.length} content location(s) and this site is not in git. Continue?`)
295
+ if (!ok) {
296
+ info('Nothing pulled.')
297
+ return { exitCode: 0 }
298
+ }
299
+ return null
300
+ }
301
+
302
+ const written = readWritten(siteDir)
303
+ dirty = dirty.filter((f) => !isPullOutput(siteDir, f, written))
304
+ if (!dirty.length) return null
305
+
306
+ error(`Refusing to pull: ${dirty.length} uncommitted change(s) under the files pull rewrites.`)
307
+ for (const f of dirty.slice(0, 12)) note(` ${f}`)
308
+ if (dirty.length > 12) note(` … and ${dirty.length - 12} more`)
309
+ note('Pull reconciles the working tree to the backend, so these would be overwritten or deleted.')
310
+ note('Commit or stash them first — then pull, and your work is still in git either way.')
311
+ note('To discard them and take the backend version, re-run with --force.')
312
+ return { exitCode: 1 }
313
+ }
314
+
315
+ // What the last pull wrote: `{ <repo-relative path>: <sha256 of the bytes it wrote> }`.
316
+ //
317
+ // Without this the guard cries wolf: pull rewrites the tree, so the very next pull
318
+ // sees its OWN output as uncommitted work and refuses, listing files the user never
319
+ // touched. A guard that fires on nothing teaches people to reach for --force, which
320
+ // is the destructive option — the same failure mode as a gate that refuses disjoint
321
+ // edits. A file still byte-identical to what pull last wrote is not user work.
322
+ function writtenCachePath(siteDir) {
323
+ return join(siteDir, '.uniweb', 'pull-written.json')
324
+ }
325
+ function readWritten(siteDir) {
326
+ try {
327
+ const o = JSON.parse(readFileSync(writtenCachePath(siteDir), 'utf8'))
328
+ return {
329
+ files: o && typeof o.files === 'object' ? o.files : {},
330
+ deleted: Array.isArray(o?.deleted) ? o.deleted : [],
331
+ }
332
+ } catch {
333
+ return { files: {}, deleted: [] }
334
+ }
335
+ }
336
+ function recordWritten(siteDir, absPaths, deletedAbs = []) {
337
+ // MERGE, don't replace. A conditional pull that 304s writes nothing, and a
338
+ // partial pull writes only some lanes — in both cases the previous record is
339
+ // still "the last thing pull wrote there". Replacing would forget those paths and
340
+ // the next pull would see them as the user's work again, which is the false alarm
341
+ // this cache exists to prevent. A stale entry for a file that no longer exists is
342
+ // harmless: the hash read fails and it counts as a local change.
343
+ const prior = readWritten(siteDir)
344
+ const files = prior.files
345
+ for (const abs of absPaths) {
346
+ try {
347
+ files[relative(siteDir, abs)] = createHash('sha256').update(readFileSync(abs)).digest('hex')
348
+ } catch { /* deleted or unreadable — nothing to remember */ }
349
+ }
350
+ // Pull PRUNES too, and a deletion is a dirty path git reports just like an edit.
351
+ // Without recording them, pull's own pruning reads as the user having deleted
352
+ // files — the same false alarm as its writes, arriving by the other door.
353
+ const deleted = [...new Set([...prior.deleted, ...deletedAbs.map((a) => relative(siteDir, a))])]
354
+ try {
355
+ mkdirSync(dirname(writtenCachePath(siteDir)), { recursive: true })
356
+ writeFileSync(writtenCachePath(siteDir), JSON.stringify({ version: 1, files, deleted }, null, 2) + '\n')
357
+ } catch { /* best-effort: losing it only costs a spurious refusal */ }
358
+ }
359
+ // Is this dirty path just pull's own untouched output?
360
+ function isPullOutput(siteDir, relPath, written) {
361
+ let exists = true
362
+ let hash = null
363
+ try {
364
+ hash = createHash('sha256').update(readFileSync(join(siteDir, relPath))).digest('hex')
365
+ } catch {
366
+ exists = false
367
+ }
368
+ // Absent because pull pruned it — not because the user deleted it.
369
+ if (!exists) return written.deleted.includes(relPath)
370
+ return written.files[relPath] === hash
371
+ }
372
+
373
+ /**
374
+ * `--merge`: keep local work instead of refusing, by three-way merging it with what
375
+ * the backend sends.
376
+ *
377
+ * Without this the recovery from a same-section conflict is commit → pull (which
378
+ * overwrites your version) → re-apply by hand → push. Yet most such "conflicts"
379
+ * aren't: two people edited different paragraphs of one section, and a real merge
380
+ * takes both. Only genuine overlaps need a human.
381
+ *
382
+ * Works by capturing local content BEFORE the projection overwrites it, then
383
+ * merging afterwards — so the projector stays a plain writer and needs no hook, and
384
+ * nothing is lost in between because "mine" is already in memory.
385
+ *
386
+ * Requires a repo, and that is not a mandate creeping in: the ancestor IS the
387
+ * committed version, and the merge is git's. There is nothing to merge against
388
+ * without one.
389
+ *
390
+ * @returns {Map<string, {content: Buffer, inHead: boolean}>|null} captured work, or
391
+ * null when merging isn't possible here (the caller reports and stops).
392
+ */
393
+ function captureLocalWork(siteDir) {
394
+ const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
395
+ if (dirty === null) return null
396
+ const written = readWritten(siteDir)
397
+ const out = new Map()
398
+ for (const rel of dirty) {
399
+ if (isPullOutput(siteDir, rel, written)) continue // pull's own output, not work
400
+ try {
401
+ out.set(rel, { content: readFileSync(join(siteDir, rel)), inHead: showAtHead(siteDir, rel) !== null })
402
+ } catch {
403
+ // Locally deleted. Pull will restore the backend's copy, which is the
404
+ // sensible reading of "I removed this and then asked for their version".
405
+ }
406
+ }
407
+ return out
408
+ }
409
+
410
+ // Merge captured work back over what the projection just wrote. Returns a report.
411
+ function mergeLocalWork(siteDir, captured) {
412
+ const clean = []
413
+ const conflicted = []
414
+ const kept = []
415
+ for (const [rel, { content: mine, inHead }] of captured) {
416
+ const abs = join(siteDir, rel)
417
+ let theirs = null
418
+ try { theirs = readFileSync(abs) } catch { /* pruned by the pull */ }
419
+
420
+ if (theirs === null) {
421
+ // The backend no longer has it, but we changed it. Restoring is the
422
+ // non-destructive reading; the alternative silently discards local work to
423
+ // honour a deletion the user never saw.
424
+ mkdirSync(dirname(abs), { recursive: true })
425
+ writeFileSync(abs, mine)
426
+ kept.push(`${rel} (removed upstream, kept yours)`)
427
+ continue
428
+ }
429
+ if (theirs.equals(mine)) continue // both sides agree already
430
+
431
+ const base = inHead ? showAtHead(siteDir, rel) : null
432
+ if (!base) {
433
+ // No committed ancestor — a file added locally and never committed. There is
434
+ // no third input, so a three-way merge is not defined. Keep ours rather than
435
+ // guess, and say so.
436
+ writeFileSync(abs, mine)
437
+ kept.push(`${rel} (no committed ancestor, kept yours)`)
438
+ continue
439
+ }
440
+
441
+ const dir = mkdtempSync(join(tmpdir(), 'uniweb-merge-'))
442
+ try {
443
+ const basePath = join(dir, 'base')
444
+ const theirsPath = join(dir, 'theirs')
445
+ writeFileSync(basePath, base)
446
+ writeFileSync(theirsPath, theirs)
447
+ writeFileSync(abs, mine) // merge-file rewrites this in place
448
+ const r = mergeFile(siteDir, abs, basePath, theirsPath)
449
+ if (!r.merged) {
450
+ writeFileSync(abs, mine) // merge couldn't run — never leave a half-state
451
+ kept.push(`${rel} (merge unavailable, kept yours)`)
452
+ } else if (r.conflicted) {
453
+ conflicted.push(rel)
454
+ } else {
455
+ clean.push(rel)
456
+ }
457
+ } finally {
458
+ rmSync(dir, { recursive: true, force: true })
459
+ }
460
+ }
461
+ return { clean, conflicted, kept }
462
+ }
463
+
464
+ // Minimal yes/no prompt; defaults to no, because the default must not destroy.
465
+ async function confirm(question) {
466
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
467
+ try {
468
+ const a = (await rl.question(`${question} [y/N] `)).trim().toLowerCase()
469
+ return a === 'y' || a === 'yes'
470
+ } finally {
471
+ rl.close()
472
+ }
473
+ }
474
+
175
475
  export async function pull(args = [], deps = {}) {
176
476
  const resolveSiteDir = deps.resolveSiteDir || defaultResolveSiteDir
177
477
 
@@ -179,8 +479,36 @@ export async function pull(args = [], deps = {}) {
179
479
  const tokenFlag = flagValue(args, '--token')
180
480
  const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
181
481
  const noCollections = args.includes('--no-collections') || args.includes('--content-only')
482
+ const force = args.includes('--force')
483
+ const mergeMode = args.includes('--merge')
182
484
 
183
485
  const siteDir = await resolveSiteDir(args, 'pull')
486
+
487
+ // Don't overwrite work that isn't saved anywhere. Pull reconciles the working
488
+ // tree to the backend — it rewrites section bodies from the fetched document and
489
+ // prunes what the backend doesn't have — so uncommitted work under the
490
+ // directories it owns is destroyed with no way back. That is the one place the
491
+ // CLI could still lose a user's work without them agreeing to it.
492
+ //
493
+ // Checked coarsely, by root, on purpose: pull rewrites essentially every file
494
+ // under those roots, so an exact per-file intersection would refuse the same
495
+ // cases while being able to miss one.
496
+ // `--merge` keeps local work instead of refusing it: capture what is dirty now,
497
+ // let the projection write the backend's version, then merge ours back over it.
498
+ let captured = null
499
+ let mergeReport = null
500
+ if (!dryRun && mergeMode) {
501
+ captured = captureLocalWork(siteDir)
502
+ if (captured === null) {
503
+ error('Cannot merge: this site is not in a git repository.')
504
+ note('The common ancestor a merge needs is the committed version of each file.')
505
+ note('Without a repo there is nothing to merge against — use `uniweb pull --force` to take the backend version.')
506
+ return { exitCode: 1 }
507
+ }
508
+ } else if (!dryRun && !force) {
509
+ const blocked = await checkWorkingTree(siteDir, args)
510
+ if (blocked) return blocked
511
+ }
184
512
  const siteBackend = await resolveSiteBackend(siteDir)
185
513
  const client = new BackendClient({
186
514
  originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
@@ -236,7 +564,13 @@ export async function pull(args = [], deps = {}) {
236
564
  }
237
565
  try {
238
566
  const etag = res.headers?.get?.('etag') ?? null
239
- const docs = readPullDocuments(Buffer.from(await res.arrayBuffer()))
567
+ const buf = Buffer.from(await res.arrayBuffer())
568
+ const docs = readPullDocuments(buf)
569
+ // Bank the per-entity staleness tokens this lane carried, so the next push
570
+ // is gated against the state we just took. A 304 skips this — correctly:
571
+ // unchanged upstream means the token we already hold is still current.
572
+ mergeBaseVersions(siteDir, readPullVersions(buf))
573
+ mergeItemBaseVersions(siteDir, readPullItemVersions(buf))
240
574
  return { docs, etag }
241
575
  } catch (err) {
242
576
  error(`Could not read the ${label} response: ${err.message}`)
@@ -244,6 +578,10 @@ export async function pull(args = [], deps = {}) {
244
578
  }
245
579
  }
246
580
 
581
+ // Every file the projection writes, so the next pull can tell its own output
582
+ // apart from the user's work (see readWritten).
583
+ const wrote = []
584
+ const removed = []
247
585
  let pages = 0
248
586
  let sections = 0
249
587
  let records = 0
@@ -261,7 +599,20 @@ export async function pull(args = [], deps = {}) {
261
599
  if (content && !content.notModified) {
262
600
  const siteDoc = content.docs && (content.docs.find((d) => d?.info || d?.$model) || content.docs[0] || null)
263
601
  if (siteDoc) {
602
+ // Re-base the page attribution. The pulled document IS the backend's own
603
+ // representation, so it becomes the remote base directly. We deliberately
604
+ // CLEAR the local base rather than reuse it: our source files were just
605
+ // rewritten from this document, and what we would emit from them is not
606
+ // byte-identical to it, so the old local base no longer describes anything.
607
+ // The next push re-establishes it; until then our side reports as unknown,
608
+ // which is honest rather than wrong.
609
+ writeUnitBases(siteDir, { remote: computeUnitHashes(siteDoc), local: {} })
610
+ // Per-item identity for the next push. Without it the backend reads our
611
+ // records as new and re-mints every page and section row.
612
+ writeItemUuids(siteDir, collectUnitUuids(siteDoc))
264
613
  const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
614
+ wrote.push(...report.pages, ...report.sections, ...report.layout)
615
+ removed.push(...(report.deleted || []), ...(report.renamed || []).map((r) => r.from))
265
616
  pages += report.pages.length
266
617
  sections += report.sections.length
267
618
  deleted += report.deleted.length
@@ -302,9 +653,38 @@ export async function pull(args = [], deps = {}) {
302
653
 
303
654
  // Persist the ETags so the next pull is conditional (304 when unchanged).
304
655
  writePullCache(siteDir, { content: etagContent, folder: etagFolder })
656
+ // Config files are rewritten every pull whether or not they changed; include the
657
+ // ones the projector owns so a pull-then-pull doesn't trip on them either.
658
+ // Merge captured local work back over what the projection just wrote. Runs after
659
+ // BOTH lanes, so a file either lane produced is merged the same way.
660
+ if (captured && captured.size) {
661
+ mergeReport = mergeLocalWork(siteDir, captured)
662
+ const r = mergeReport
663
+ if (r.clean.length) {
664
+ info(`Merged your changes into ${r.clean.length} file(s):`)
665
+ for (const f of r.clean) note(` ${f}`)
666
+ }
667
+ for (const f of r.kept) note(`\u21b7 ${f}`)
668
+ if (r.conflicted.length) {
669
+ error(`${r.conflicted.length} file(s) have conflicts to resolve:`)
670
+ for (const f of r.conflicted) note(` ${f}`)
671
+ note('Conflict markers are in place. Resolve them, then commit and push.')
672
+ }
673
+ }
674
+
675
+ recordWritten(
676
+ siteDir,
677
+ [...wrote, ...['site.yml', 'theme.yml', 'head.html', 'collections.yml'].map((f) => join(siteDir, f))],
678
+ removed
679
+ )
305
680
 
306
681
  success(
307
682
  `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` + (deleted ? `, ${deleted} deleted` : '')
308
683
  )
309
- return { exitCode: 0 }
684
+ // Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
685
+ // The pull itself worked; there is work left for a human. This is what makes a
686
+ // chained `uniweb pull --merge && uniweb push` safe by construction — without it,
687
+ // the obvious one-liner pushes conflict markers into live content.
688
+ const conflicts = mergeReport?.conflicted?.length ?? 0
689
+ return { exitCode: conflicts ? 1 : 0, merge: mergeReport }
310
690
  }
@@ -12,8 +12,11 @@
12
12
  * UPDATE by that uuid. The folder lane is keyed by the SAME site-content uuid —
13
13
  * the backend owns the site's `@uniweb/folder`, so the framework never holds a
14
14
  * folder uuid. Records still round-trip their own `$uuid`
15
- * (back-filled into their source files). site-content is pushed wholesale (no per-item
16
- * uuids on the wire). Push-only, last-push-wins (`collision=force`) in v1.
15
+ * (back-filled into their source files). site-content items carry a per-item `$uuid`
16
+ * too, stamped at emit from the identity cache rather than from author files — without
17
+ * it the backend reads every record as new and recreates every page and section row.
18
+ * Push-only, and gated on the backend's per-entity `version`
19
+ * (see "Pushes are GATED by default" below); `--force` restores last-push-wins.
17
20
  *
18
21
  * Order: content first (CREATE or UPDATE — the site must exist before its folder),
19
22
  * then the folder, keyed by the site's uuid. On a brand-new site the backend creates
@@ -30,6 +33,14 @@
30
33
  * uniweb push --token <bearer> Submit with this bearer; skips `uniweb login`
31
34
  * uniweb push --foundation <dir> Use this local foundation for the Model schema
32
35
  * uniweb push --all Send every record (bypass the changed-only cache)
36
+ * uniweb push --force Overwrite upstream changes (drop the staleness gate)
37
+ *
38
+ * Pushes are GATED by default: each entity carries the backend `version` this clone
39
+ * last saw (a top-level `base_version` on the manifest entry), and the backend refuses the whole package
40
+ * atomically — before any write — if its stored version has moved. That prevents a
41
+ * developer who hasn't pulled from silently destroying an app author's edits (the
42
+ * backend's reconcile deletes items absent from the package, so an author's NEW page
43
+ * would be hard-deleted). `--force` omits the token and restores last-push-wins.
33
44
  *
34
45
  * Backend: via BackendClient (the content + folder sync lanes). Origin from
35
46
  * --registry > UNIWEB_REGISTER_URL > the local default.
@@ -46,7 +57,15 @@ import { resolve } from 'node:path'
46
57
  import { emitSyncPackages } from '@uniweb/build/uwx'
47
58
  import { BackendClient } from '../backend/client.js'
48
59
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
49
- import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
60
+ import {
61
+ makeModelResolver,
62
+ readSyncCache,
63
+ readBaseVersions,
64
+ readItemBaseVersions,
65
+ readItemUuids,
66
+ ensureItemUuids,
67
+ pushSyncPackages,
68
+ } from '../backend/site-sync.js'
50
69
 
51
70
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
52
71
  // helpers from this module — their canonical home is now ../backend/site-sync.js.
@@ -77,6 +96,12 @@ export async function push(args = []) {
77
96
  const asOrg = flagValue(args, '--as-org')
78
97
  const foundationDir = flagValue(args, '--foundation')
79
98
  const sendAll = args.includes('--all') // bypass the send-only-changed cache
99
+ // --force drops the optimistic-concurrency precondition, making the push
100
+ // unconditional (the backend then falls back to its `collision` policy). It is
101
+ // deliberately NOT "send collision=force": when a base_version is present the
102
+ // backend consults it and never looks at `collision`, so forcing has to mean
103
+ // OMITTING the token — the HTTP If-Match idiom.
104
+ const force = args.includes('--force')
80
105
 
81
106
  const siteDir = await resolveSiteDir(args, 'push')
82
107
  const siteBackend = await resolveSiteBackend(siteDir)
@@ -98,6 +123,17 @@ export async function push(args = []) {
98
123
  // position. Non-local Models are fetched from the registry on demand. `priorHashes`
99
124
  // (the .uniweb push-cache) drives "send only changed" across both lanes; --all bypasses.
100
125
  const priorHashes = readSyncCache(siteDir)
126
+ // Per-item identity, without which the backend reads every record as new and
127
+ // recreates every page and section row.
128
+ //
129
+ // An offline preview (`-o` / `--dry-run`) still stamps from the CACHE — that is a
130
+ // local file read, so it stays offline, and it keeps the emitted `.uwx` faithful
131
+ // to what a real push would send. Only the network RECOVERY is skipped, so a
132
+ // preview never reaches the backend. (Emitting `{}` here instead would make the
133
+ // preview quietly unrepresentative, which is the one thing `-o` exists to avoid.)
134
+ const itemUuids = (output || dryRun)
135
+ ? readItemUuids(siteDir)
136
+ : await ensureItemUuids({ client, siteDir, note })
101
137
  let pkg
102
138
  try {
103
139
  pkg = await emitSyncPackages(siteDir, {
@@ -105,6 +141,10 @@ export async function push(args = []) {
105
141
  resolveModel: makeModelResolver({ client, offline: Boolean(output) || dryRun }),
106
142
  priorHashes,
107
143
  sendAll,
144
+ itemUuids,
145
+ // Both grains are dropped together by --force: one flag, one meaning,
146
+ // no partial-force mode.
147
+ ...(force ? {} : { baseVersions: readBaseVersions(siteDir), itemBaseVersions: readItemBaseVersions(siteDir) }),
108
148
  })
109
149
  } catch (err) {
110
150
  error(`Could not build the sync package: ${err.message}`)