uniweb 0.64.1 → 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 +7 -7
- package/partials/agents.md +19 -19
- package/src/backend/client.js +61 -51
- package/src/backend/foundation-bring-along.js +154 -97
- package/src/backend/site-sync.js +237 -243
- package/src/backend/workspace.js +199 -0
- package/src/commands/clone.js +20 -30
- package/src/commands/deploy.js +9 -7
- package/src/commands/publish.js +37 -43
- package/src/commands/pull.js +232 -81
- package/src/commands/push.js +36 -45
- package/src/commands/status.js +24 -15
- package/src/commands/validate.js +16 -12
- package/src/framework-index.json +4 -4
- package/src/index.js +39 -27
- package/src/utils/conformance.js +68 -10
- package/src/utils/flag-guard.js +12 -9
- package/src/utils/git.js +48 -1
- package/src/utils/pull-written.js +5 -0
- package/src/utils/registry-auth.js +75 -16
- package/src/utils/registry-orgs.js +2 -2
- 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,
|
|
@@ -90,15 +91,14 @@ import {
|
|
|
90
91
|
mergeBaseVersions,
|
|
91
92
|
mergeItemBaseVersions,
|
|
92
93
|
writeUnitBases,
|
|
93
|
-
writeItemUuids
|
|
94
|
-
nameSiteWorkspace,
|
|
95
|
-
readSiteWorkspace
|
|
94
|
+
writeItemUuids
|
|
96
95
|
} from '../backend/site-sync.js'
|
|
97
|
-
import {
|
|
96
|
+
import { resolveWorkspace } from '../backend/workspace.js'
|
|
98
97
|
import {
|
|
99
98
|
uncommittedUnder,
|
|
100
99
|
siteContentRoots,
|
|
101
100
|
showAtHead,
|
|
101
|
+
findCommitted,
|
|
102
102
|
mergeFile
|
|
103
103
|
} from '../utils/git.js'
|
|
104
104
|
import { isNonInteractive } from '../utils/interactive.js'
|
|
@@ -106,7 +106,6 @@ import {
|
|
|
106
106
|
BackendClient,
|
|
107
107
|
describeRequestError,
|
|
108
108
|
refusalDetail,
|
|
109
|
-
workspaceHandle,
|
|
110
109
|
WorkspaceMismatchError
|
|
111
110
|
} from '../backend/client.js'
|
|
112
111
|
import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
|
|
@@ -206,10 +205,15 @@ function readYamlUuid(filePath) {
|
|
|
206
205
|
function pullCachePath(siteDir) {
|
|
207
206
|
return join(siteDir, '.uniweb', 'pull-cache.json')
|
|
208
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
|
|
209
213
|
function readPullCache(siteDir) {
|
|
210
214
|
try {
|
|
211
215
|
const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
|
|
212
|
-
return obj && typeof obj === 'object' ? obj : {}
|
|
216
|
+
return obj && typeof obj === 'object' && obj.version >= PULL_CACHE_VERSION ? obj : {}
|
|
213
217
|
} catch {
|
|
214
218
|
return {}
|
|
215
219
|
}
|
|
@@ -219,7 +223,7 @@ function writePullCache(siteDir, { content, folder }) {
|
|
|
219
223
|
mkdirSync(dirname(p), { recursive: true })
|
|
220
224
|
writeFileSync(
|
|
221
225
|
p,
|
|
222
|
-
JSON.stringify({ version:
|
|
226
|
+
JSON.stringify({ version: PULL_CACHE_VERSION, content, folder }, null, 2) + '\n'
|
|
223
227
|
)
|
|
224
228
|
}
|
|
225
229
|
|
|
@@ -227,7 +231,10 @@ function writePullCache(siteDir, { content, folder }) {
|
|
|
227
231
|
// document, or a `{ document }` / `{ entity }` envelope. (Adjust at live e2e.)
|
|
228
232
|
export function extractDocument(payload) {
|
|
229
233
|
if (!payload || typeof payload !== 'object') return null
|
|
230
|
-
|
|
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
|
|
231
238
|
return payload.document || payload.entity || null
|
|
232
239
|
}
|
|
233
240
|
|
|
@@ -247,8 +254,59 @@ export function splitRecordsPull(payload) {
|
|
|
247
254
|
if (!list) return { folderDoc: null, recordDocs: [] }
|
|
248
255
|
const docs = list.map(extractDocument).filter(Boolean)
|
|
249
256
|
return {
|
|
250
|
-
folderDoc: docs.find((d) => d
|
|
251
|
-
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}`)
|
|
252
310
|
}
|
|
253
311
|
}
|
|
254
312
|
|
|
@@ -441,39 +499,66 @@ async function checkWorkingTree(siteDir, args) {
|
|
|
441
499
|
* merging afterwards — so the projector stays a plain writer and needs no hook, and
|
|
442
500
|
* nothing is lost in between because "mine" is already in memory.
|
|
443
501
|
*
|
|
444
|
-
* 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
|
|
445
503
|
* committed version, and the merge is git's. There is nothing to merge against
|
|
446
504
|
* without one.
|
|
447
505
|
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
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).
|
|
450
517
|
*/
|
|
451
518
|
function captureLocalWork(siteDir) {
|
|
452
519
|
const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
|
|
453
520
|
if (dirty === null) return null
|
|
454
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
|
+
}
|
|
455
526
|
const out = new Map()
|
|
456
527
|
for (const rel of dirty) {
|
|
457
528
|
if (isPullOutput(siteDir, rel, written)) continue // pull's own output, not work
|
|
458
529
|
try {
|
|
459
|
-
out.set(rel, {
|
|
460
|
-
content: readFileSync(join(siteDir, rel)),
|
|
461
|
-
inHead: showAtHead(siteDir, rel) !== null
|
|
462
|
-
})
|
|
530
|
+
out.set(rel, { content: readFileSync(join(siteDir, rel)), base: ancestorOf(rel) })
|
|
463
531
|
} catch {
|
|
464
532
|
// Locally deleted. Pull will restore the backend's copy, which is the
|
|
465
533
|
// sensible reading of "I removed this and then asked for their version".
|
|
466
534
|
}
|
|
467
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
|
+
}
|
|
468
550
|
return out
|
|
469
551
|
}
|
|
470
552
|
|
|
471
|
-
// 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.
|
|
472
556
|
function mergeLocalWork(siteDir, captured) {
|
|
473
557
|
const clean = []
|
|
474
558
|
const conflicted = []
|
|
475
559
|
const kept = []
|
|
476
|
-
|
|
560
|
+
const unavailable = []
|
|
561
|
+
for (const [rel, { content: mine, base }] of captured) {
|
|
477
562
|
const abs = join(siteDir, rel)
|
|
478
563
|
let theirs = null
|
|
479
564
|
try {
|
|
@@ -493,27 +578,23 @@ function mergeLocalWork(siteDir, captured) {
|
|
|
493
578
|
}
|
|
494
579
|
if (theirs.equals(mine)) continue // both sides agree already
|
|
495
580
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
writeFileSync(abs, mine)
|
|
502
|
-
kept.push(`${rel} (no committed ancestor, kept yours)`)
|
|
503
|
-
continue
|
|
504
|
-
}
|
|
505
|
-
|
|
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.
|
|
506
586
|
const dir = mkdtempSync(join(tmpdir(), 'uniweb-merge-'))
|
|
507
587
|
try {
|
|
508
588
|
const basePath = join(dir, 'base')
|
|
509
589
|
const theirsPath = join(dir, 'theirs')
|
|
510
|
-
writeFileSync(basePath, base)
|
|
590
|
+
writeFileSync(basePath, base || '')
|
|
511
591
|
writeFileSync(theirsPath, theirs)
|
|
512
592
|
writeFileSync(abs, mine) // merge-file rewrites this in place
|
|
513
593
|
const r = mergeFile(siteDir, abs, basePath, theirsPath)
|
|
514
594
|
if (!r.merged) {
|
|
515
595
|
writeFileSync(abs, mine) // merge couldn't run — never leave a half-state
|
|
516
596
|
kept.push(`${rel} (merge unavailable, kept yours)`)
|
|
597
|
+
unavailable.push(rel)
|
|
517
598
|
} else if (r.conflicted) {
|
|
518
599
|
conflicted.push(rel)
|
|
519
600
|
} else {
|
|
@@ -523,7 +604,7 @@ function mergeLocalWork(siteDir, captured) {
|
|
|
523
604
|
rmSync(dir, { recursive: true, force: true })
|
|
524
605
|
}
|
|
525
606
|
}
|
|
526
|
-
return { clean, conflicted, kept }
|
|
607
|
+
return { clean, conflicted, kept, unavailable }
|
|
527
608
|
}
|
|
528
609
|
|
|
529
610
|
// Minimal yes/no prompt; defaults to no, because the default must not destroy.
|
|
@@ -601,15 +682,6 @@ export async function pull(args = [], deps = {}) {
|
|
|
601
682
|
args,
|
|
602
683
|
command: 'Pulling'
|
|
603
684
|
})
|
|
604
|
-
// The workspace these requests name: `--org` explicitly, else the one this project
|
|
605
|
-
// recorded for this backend — adopted afresh from the backend's answer when stale.
|
|
606
|
-
const orgFlag = readOrgFlag(args)
|
|
607
|
-
nameSiteWorkspace(client, {
|
|
608
|
-
siteDir,
|
|
609
|
-
workspace: orgFlag ? workspaceHandle(orgFlag) : readSiteWorkspace(siteDir, client.origin),
|
|
610
|
-
explicit: Boolean(orgFlag),
|
|
611
|
-
note
|
|
612
|
-
})
|
|
613
685
|
|
|
614
686
|
// ⭐ No scope check any more — and none is needed. This project's identity on
|
|
615
687
|
// `client.origin` is read from that origin's own section of sync.json, so a
|
|
@@ -664,6 +736,17 @@ export async function pull(args = [], deps = {}) {
|
|
|
664
736
|
return { exitCode: 0 }
|
|
665
737
|
}
|
|
666
738
|
|
|
739
|
+
// The workspace this pull works in — the one chosen with the login, unless this
|
|
740
|
+
// command names another (`workspace.js`). Resolved at the first request, not before:
|
|
741
|
+
// a pull with nothing to pull never needs one. A site kept elsewhere stops it.
|
|
742
|
+
const ws = await resolveWorkspace({ client, args })
|
|
743
|
+
if (ws.refused) {
|
|
744
|
+
error('This pull works in one workspace, and none is chosen.')
|
|
745
|
+
note(ws.reason)
|
|
746
|
+
return { exitCode: 2 }
|
|
747
|
+
}
|
|
748
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
749
|
+
|
|
667
750
|
// GET a pull lane via the client and return `{ docs, etag }` from its `.uwx` (ZIP)
|
|
668
751
|
// body — `readPullDocuments` reads the entity files out of it (JSON fallback). A
|
|
669
752
|
// conditional request whose ETag matches returns `{ notModified: true }` (304, empty
|
|
@@ -705,12 +788,16 @@ export async function pull(args = [], deps = {}) {
|
|
|
705
788
|
const etag = res.headers?.get?.('etag') ?? null
|
|
706
789
|
const buf = Buffer.from(await res.arrayBuffer())
|
|
707
790
|
const docs = readPullDocuments(buf)
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
+
}
|
|
714
801
|
} catch (err) {
|
|
715
802
|
error(`Could not read the ${label} response: ${err.message}`)
|
|
716
803
|
return null
|
|
@@ -725,6 +812,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
725
812
|
let sections = 0
|
|
726
813
|
let records = 0
|
|
727
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
|
|
728
818
|
|
|
729
819
|
// Conditional-pull cache: the last ETag seen per lane (opaque token — cached and
|
|
730
820
|
// echoed verbatim, never recomputed). Lives in the gitignored `.uniweb/`.
|
|
@@ -748,16 +838,21 @@ export async function pull(args = [], deps = {}) {
|
|
|
748
838
|
return pullScope
|
|
749
839
|
}
|
|
750
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
|
|
751
846
|
// Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
|
|
752
847
|
// entity (the site-content document). A 304 (unchanged) leaves local files as-is.
|
|
753
848
|
const content = await getDocs('content', () =>
|
|
754
|
-
client.pullSiteContent(siteContentUuid, { etag: etagContent })
|
|
849
|
+
client.pullSiteContent(siteContentUuid, { etag: conditional ? etagContent : undefined })
|
|
755
850
|
)
|
|
756
851
|
if (content?.refused) return { exitCode: 1 }
|
|
757
852
|
if (content && !content.notModified) {
|
|
758
853
|
const siteDoc =
|
|
759
854
|
content.docs &&
|
|
760
|
-
(content.docs.find((d) => d?.info || d
|
|
855
|
+
(content.docs.find((d) => d?.info || documentSchema(d)) ||
|
|
761
856
|
content.docs[0] ||
|
|
762
857
|
null)
|
|
763
858
|
if (siteDoc) {
|
|
@@ -866,17 +961,19 @@ export async function pull(args = [], deps = {}) {
|
|
|
866
961
|
// (the backend resolves the site's `@uniweb/folder` from it; the framework never
|
|
867
962
|
// holds a folder uuid). Models are resolved by name (async) up front, so
|
|
868
963
|
// recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
|
|
964
|
+
let folderLane = null
|
|
869
965
|
if (!noRecords) {
|
|
870
966
|
const folder = await getDocs('records', () =>
|
|
871
|
-
client.pullFolder(siteContentUuid, { etag: etagFolder })
|
|
967
|
+
client.pullFolder(siteContentUuid, { etag: conditional ? etagFolder : undefined })
|
|
872
968
|
)
|
|
969
|
+
folderLane = folder
|
|
873
970
|
if (folder?.refused) return { exitCode: 1 }
|
|
874
971
|
if (folder && !folder.notModified && folder.docs?.length) {
|
|
875
972
|
const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
|
|
876
973
|
const resolveModel = makeModelResolver({ client })
|
|
877
974
|
const declByModel = new Map()
|
|
878
975
|
for (const model of [
|
|
879
|
-
...new Set(recordDocs.map((d) => d
|
|
976
|
+
...new Set(recordDocs.map((d) => documentSchema(d)).filter(Boolean))
|
|
880
977
|
]) {
|
|
881
978
|
try {
|
|
882
979
|
declByModel.set(model, await resolveModel(model))
|
|
@@ -884,8 +981,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
884
981
|
note(`! could not resolve model ${model}: ${err.message}`)
|
|
885
982
|
}
|
|
886
983
|
}
|
|
887
|
-
// ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
|
|
888
|
-
//
|
|
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
|
|
889
986
|
// it. Handed the foundation's scope (`scopeFor`), `recordsToProject` places a
|
|
890
987
|
// `@/x` model the producer resolved to `@acme/x` back where the author wrote it.
|
|
891
988
|
const report = recordsToProject({
|
|
@@ -912,34 +1009,36 @@ export async function pull(args = [], deps = {}) {
|
|
|
912
1009
|
removed.push(resolve(siteDir, report.recordsFile))
|
|
913
1010
|
}
|
|
914
1011
|
records += report.placed.length + report.updated.length
|
|
915
|
-
|
|
916
|
-
|
|
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
|
+
}
|
|
917
1028
|
for (const w of report.warnings) note(`! ${w}`)
|
|
918
1029
|
}
|
|
919
1030
|
if (folder?.etag) etagFolder = folder.etag
|
|
920
1031
|
}
|
|
921
1032
|
|
|
922
|
-
//
|
|
923
|
-
|
|
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
|
+
//
|
|
924
1040
|
// Config files are rewritten every pull whether or not they changed; include the
|
|
925
1041
|
// ones the projector owns so a pull-then-pull doesn't trip on them either.
|
|
926
|
-
// Merge captured local work back over what the projection just wrote. Runs after
|
|
927
|
-
// BOTH lanes, so a file either lane produced is merged the same way.
|
|
928
|
-
if (captured && captured.size) {
|
|
929
|
-
mergeReport = mergeLocalWork(siteDir, captured)
|
|
930
|
-
const r = mergeReport
|
|
931
|
-
if (r.clean.length) {
|
|
932
|
-
info(`Merged your changes into ${r.clean.length} file(s):`)
|
|
933
|
-
for (const f of r.clean) note(` ${f}`)
|
|
934
|
-
}
|
|
935
|
-
for (const f of r.kept) note(`\u21b7 ${f}`)
|
|
936
|
-
if (r.conflicted.length) {
|
|
937
|
-
error(`${r.conflicted.length} file(s) have conflicts to resolve:`)
|
|
938
|
-
for (const f of r.conflicted) note(` ${f}`)
|
|
939
|
-
note('Conflict markers are in place. Resolve them, then commit and push.')
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
|
|
943
1042
|
recordWritten(
|
|
944
1043
|
siteDir,
|
|
945
1044
|
[
|
|
@@ -979,14 +1078,66 @@ export async function pull(args = [], deps = {}) {
|
|
|
979
1078
|
}
|
|
980
1079
|
}
|
|
981
1080
|
|
|
982
|
-
|
|
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 =
|
|
983
1131
|
`Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
|
|
984
|
-
|
|
985
|
-
)
|
|
1132
|
+
(deleted ? `, ${deleted} deleted` : '')
|
|
1133
|
+
if (recordsNotPlaced) info(summary)
|
|
1134
|
+
else success(summary)
|
|
986
1135
|
// Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
|
|
987
1136
|
// The pull itself worked; there is work left for a human. This is what makes a
|
|
988
1137
|
// chained `uniweb pull --merge && uniweb push` safe by construction — without it,
|
|
989
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.
|
|
990
1141
|
const conflicts = mergeReport?.conflicted?.length ?? 0
|
|
991
|
-
return { exitCode: conflicts ? 1 : 0, merge: mergeReport }
|
|
1142
|
+
return { exitCode: conflicts || recordsNotPlaced ? 1 : 0, merge: mergeReport }
|
|
992
1143
|
}
|