heyquilt 0.3.121 → 0.3.138
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/RELEASES.md +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
package/src/quilts/merge.js
DELETED
|
@@ -1,486 +0,0 @@
|
|
|
1
|
-
// Merge, rebase, cherry-pick, revert and resolve on the server (plans/quilt-source-control/07).
|
|
2
|
-
// A merge into a line rebases the square's head onto the tip as one commit and moves the line
|
|
3
|
-
// by compare-and-swap; a lost race is retried here, never by the client. A textual conflict
|
|
4
|
-
// never reaches a line: the merge is refused and, by default, the conflicted rebase becomes the
|
|
5
|
-
// square's head with the conflicts kept as data (conflict terms), so the square carries on.
|
|
6
|
-
import { recall, remember } from './resolutions.js'
|
|
7
|
-
import { fastMerge as mergeSmart } from './fastmerge.js' // mergeSmart (src/merge3.js) behind a fast clean path
|
|
8
|
-
import { cleanCommit, commitBytes, commitId, blobId, termBytes, termId, cleanPath } from './records.js'
|
|
9
|
-
import { sameEntry } from './tree.js'
|
|
10
|
-
import { isText } from './textdiff.js'
|
|
11
|
-
import { QuiltError, uuidv7, squareShort } from './service.js'
|
|
12
|
-
|
|
13
|
-
const MAX_ATTEMPTS = 5
|
|
14
|
-
// The highest ladder step that merges by itself (07: auto_combine, default 'whitespace'); words never do.
|
|
15
|
-
const AUTO = new Set(['lines', 'additive', 'whitespace', 'union'])
|
|
16
|
-
const MAX_SIDES = 8
|
|
17
|
-
|
|
18
|
-
// The line's merge lease (07 step 8): a merge that lost three races queues for the line's lease
|
|
19
|
-
// and makes its next attempts holding it, one holder at a time in arrival order; every merge
|
|
20
|
-
// that holds no lease waits while it is held before reading the tip. So a holder can lose only
|
|
21
|
-
// to attempts already running, and every merge lands. Two layers: an in-process queue keeps
|
|
22
|
-
// arrival order on one API machine, and the store's line lease (quilt_line_leases) is the lock
|
|
23
|
-
// across machines. A non-holder peeks at the table lease with ttl 0 (takes it only if free, and
|
|
24
|
-
// then it is already expired). A crashed holder's lease lapses after LEASE_TTL_MS.
|
|
25
|
-
const LEASE_TTL_MS = 10_000
|
|
26
|
-
const leases = new Map() // quilt + line → { held: boolean, queue: [resolve] }
|
|
27
|
-
|
|
28
|
-
function leaseOf (key) { let l = leases.get(key); if (!l) { l = { held: false, queue: [], idle: [] }; leases.set(key, l) } return l }
|
|
29
|
-
function takeLease (key) {
|
|
30
|
-
const l = leaseOf(key)
|
|
31
|
-
if (!l.held) { l.held = true; return Promise.resolve() }
|
|
32
|
-
return new Promise((resolve) => l.queue.push(resolve))
|
|
33
|
-
}
|
|
34
|
-
function giveLease (key) {
|
|
35
|
-
const l = leaseOf(key)
|
|
36
|
-
const next = l.queue.shift()
|
|
37
|
-
if (next) return next()
|
|
38
|
-
l.held = false
|
|
39
|
-
for (const wake of l.idle.splice(0)) wake()
|
|
40
|
-
}
|
|
41
|
-
async function waitLease (key) {
|
|
42
|
-
const l = leaseOf(key)
|
|
43
|
-
while (l.held) await new Promise((resolve) => l.idle.push(resolve))
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
47
|
-
|
|
48
|
-
export function mergeOps (svc) {
|
|
49
|
-
const { store, now, conflictsAt, quiltFor, squareByRef, resolve, getCommit, snapshot, entriesOf, treeGrower, reader, seal, readBlob, squareView, commitView, writable, once, getTerm, fromStore } = svc.internals
|
|
50
|
-
|
|
51
|
-
/** The entries of `paths` in a commit's tree (only the nodes on their way are read). */
|
|
52
|
-
const entryMap = async (q, id, paths) => {
|
|
53
|
-
const root = await snapshot(q, id)
|
|
54
|
-
const m = new Map()
|
|
55
|
-
for (const p of paths) { const e = await reader(q).lookup(root, p); if (e) m.set(p, e) }
|
|
56
|
-
return m
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Three-way combination of trees: for every path either side changed from `base`, the final
|
|
61
|
-
* entry (taken, merged by content, or a conflict term). Returns { finals: Map path → entry|null,
|
|
62
|
-
* blobs (new merged blobs), terms (term objects), combined, conflicts: [{ path, kind, term }] }.
|
|
63
|
-
* `sides` names who and what each side is, for the terms: { ours: {commit, square, by}, theirs }.
|
|
64
|
-
*/
|
|
65
|
-
async function combine (q, baseId, oursId, theirsId, sides, origin) {
|
|
66
|
-
const [B, O, T] = [await snapshot(q, baseId), await snapshot(q, oursId), await snapshot(q, theirsId)]
|
|
67
|
-
const r = reader(q)
|
|
68
|
-
const [dO, dT] = [await r.diff(B, O), await r.diff(B, T)]
|
|
69
|
-
const oursPaths = new Map(dO.map((d) => [d.path, d]))
|
|
70
|
-
const theirsPaths = new Map(dT.map((d) => [d.path, d]))
|
|
71
|
-
const finals = new Map()
|
|
72
|
-
const blobs = []; const terms = []; const combined = []; const conflicts = []
|
|
73
|
-
const overlap = []
|
|
74
|
-
for (const [p, d] of oursPaths) if (!theirsPaths.has(p)) finals.set(p, d.after || null)
|
|
75
|
-
for (const [p, d] of theirsPaths) if (!oursPaths.has(p)) finals.set(p, d.after || null)
|
|
76
|
-
for (const [p] of oursPaths) if (theirsPaths.has(p)) overlap.push(p)
|
|
77
|
-
// A path one side changed that is a directory of a path the other side changed (07 step 2).
|
|
78
|
-
const dirClash = []
|
|
79
|
-
// And two different paths that fold to one name on a case-insensitive disk (07 step 2).
|
|
80
|
-
const folded = new Map()
|
|
81
|
-
for (const t of theirsPaths.keys()) { const f = t.toLowerCase(); folded.set(f, [...(folded.get(f) || []), t]) }
|
|
82
|
-
for (const p of oursPaths.keys()) for (const t of folded.get(p.toLowerCase()) || []) if (p !== t && theirsPaths.get(t).after && oursPaths.get(p).after) dirClash.push([p, t])
|
|
83
|
-
for (const p of oursPaths.keys()) for (const t of theirsPaths.keys()) if (p !== t && (t.startsWith(p + '/') || p.startsWith(t + '/'))) dirClash.push([p, t])
|
|
84
|
-
|
|
85
|
-
const side = (e, s) => ({ ...(e && e.blob ? { blob: e.blob, mode: e.mode } : {}), from_commit: s.commit, ...(s.square ? { from_square: s.square } : {}), ...(s.by ? { by: s.by } : {}) })
|
|
86
|
-
const conflict = async (p, kind, o, a, b, suggestion = null, clash = null) => {
|
|
87
|
-
const term = { kind, adds: [side(a, sides.ours), side(b, sides.theirs)], removes: [side(o, sides.base || {})], origin, ...(suggestion ? { suggestion } : {}), ...(clash ? { clash } : {}) }
|
|
88
|
-
// The same conflict settled before (07, D12): apply the remembered answer and say so.
|
|
89
|
-
const known = !(a && a.mode === 'conflict') && !(b && b.mode === 'conflict') ? await recall(store, q, p, term) : null
|
|
90
|
-
if (known) {
|
|
91
|
-
finals.set(p, known.blob ? { path: p, mode: known.mode || 'file', blob: known.blob, size: known.size } : null)
|
|
92
|
-
combined.push({ path: p, how: 'reuse', by: known.by })
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
const id = termId(term)
|
|
96
|
-
terms.push({ kind: 'term', id, bytes: termBytes(term) })
|
|
97
|
-
finals.set(p, { path: p, mode: 'conflict', term: id })
|
|
98
|
-
conflicts.push({ path: p, kind, term: id, ...(suggestion ? { suggestion } : {}) })
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
for (const p of overlap) {
|
|
102
|
-
const o = oursPaths.get(p).before || null
|
|
103
|
-
const a = oursPaths.get(p).after || null
|
|
104
|
-
const b = theirsPaths.get(p).after || null
|
|
105
|
-
if (sameOrBoth(a, b)) { finals.set(p, a); continue }
|
|
106
|
-
if (!a || !b) { await conflict(p, 'edit_vs_delete', o, a, b); continue }
|
|
107
|
-
if (a.mode === 'conflict' || b.mode === 'conflict') { await conflict(p, 'content', o, a, b); continue }
|
|
108
|
-
// Modes: the side that changed it wins; both changed it differently is a mode term.
|
|
109
|
-
const mode = a.mode === b.mode ? a.mode : (o && a.mode === o.mode) ? b.mode : (o && b.mode === o.mode) ? a.mode : null
|
|
110
|
-
if (a.blob === b.blob) { if (mode) finals.set(p, { ...a, mode }); else await conflict(p, 'mode', o, a, b); continue }
|
|
111
|
-
const [ob, ab, bb] = [o && o.blob ? await readBlob(q, o.blob) : Buffer.alloc(0), await readBlob(q, a.blob), await readBlob(q, b.blob)]
|
|
112
|
-
if (!isText(ob) || !isText(ab) || !isText(bb)) { await conflict(p, 'content', o, a, b); continue }
|
|
113
|
-
const m = mergeSmart(ob.toString('utf8'), ab.toString('utf8'), bb.toString('utf8'))
|
|
114
|
-
if (!m.conflicts.length && m.how === 'words') {
|
|
115
|
-
// 07: a word merge is a suggestion only; the term carries the text it made.
|
|
116
|
-
const sb = Buffer.from(m.text, 'utf8')
|
|
117
|
-
const sid = blobId(sb)
|
|
118
|
-
blobs.push({ kind: 'blob', id: sid, bytes: sb })
|
|
119
|
-
await conflict(p, 'content', o, a, b, { how: 'words', blob: sid })
|
|
120
|
-
continue
|
|
121
|
-
}
|
|
122
|
-
// Both sides inserted at one place the base had nothing and no step settles it: `order`.
|
|
123
|
-
if (m.conflicts.length && o && m.conflicts.every((c) => c.base.length === 0)) { await conflict(p, 'order', o, a, b); continue }
|
|
124
|
-
if (m.conflicts.length || !AUTO.has(m.how)) { await conflict(p, o ? 'content' : 'name', o, a, b); continue }
|
|
125
|
-
if (!mode) { await conflict(p, 'mode', o, a, b); continue }
|
|
126
|
-
const bytes = Buffer.from(m.text, 'utf8')
|
|
127
|
-
const id = blobId(bytes)
|
|
128
|
-
blobs.push({ kind: 'blob', id, bytes })
|
|
129
|
-
finals.set(p, { path: p, mode, blob: id, size: bytes.length })
|
|
130
|
-
combined.push({ path: p, how: m.how })
|
|
131
|
-
}
|
|
132
|
-
const clashes = new Map()
|
|
133
|
-
for (const [p, t] of dirClash) clashes.set(p, [...(clashes.get(p) || []), t])
|
|
134
|
-
for (const [p, others] of clashes) {
|
|
135
|
-
const e = finals.get(p)
|
|
136
|
-
if (e && e.mode !== 'conflict') await conflict(p, 'name', oursPaths.get(p)?.before || theirsPaths.get(p)?.before || null, oursPaths.get(p)?.after || null, theirsPaths.get(p)?.after || null, null, others)
|
|
137
|
-
}
|
|
138
|
-
return { finals, blobs, terms, combined, conflicts, oursPaths, theirsPaths, overlap }
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** FileChanges that turn the tree `onto` (a Map) into it with `finals` applied. */
|
|
142
|
-
function changesOnto (onto, finals) {
|
|
143
|
-
const out = []
|
|
144
|
-
for (const [p, e] of finals) {
|
|
145
|
-
const cur = onto.get(p) || null
|
|
146
|
-
if (!e && !cur) continue
|
|
147
|
-
if (e && cur && sameEntry(e, cur)) continue
|
|
148
|
-
if (!e) out.push({ path: p, op: 'delete' })
|
|
149
|
-
else if (e.mode === 'conflict') out.push({ path: p, op: 'conflict', term: e.term })
|
|
150
|
-
else out.push({ path: p, op: 'put', blob: e.blob, mode: e.mode, size: e.size })
|
|
151
|
-
}
|
|
152
|
-
return out
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const sameOrBoth = (a, b) => (!a && !b) || (a && b && sameEntry(a, b))
|
|
156
|
-
|
|
157
|
-
/** A new commit record from parts; returns { record, id, objects } ready to seal. */
|
|
158
|
-
function makeCommit (q, sq, { parents, predecessor, author, onBehalfOf, message, changes, stack }) {
|
|
159
|
-
const c = cleanCommit({ quilt_id: q, square_id: sq.id, parents, ...(predecessor ? { predecessor } : {}), author, ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}), ...(sq.task_id ? { task_id: sq.task_id } : {}), message, authored_at: now(), changes, stack })
|
|
160
|
-
const id = commitId(c)
|
|
161
|
-
return { record: c, id, object: { kind: 'commit', id, bytes: commitBytes(c) } }
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Rebases a square's head onto `ontoId` as one commit: returns { commit, root, objects,
|
|
166
|
-
* combined, conflicts, fast } without writing anything but objects. The fast path (no path
|
|
167
|
-
* changed on both sides) re-parents the square's changes and reads no file (07 step 2).
|
|
168
|
-
*/
|
|
169
|
-
async function rebaseCommit (q, sq, head, ontoId, { message, reason = 'rebase' } = {}) {
|
|
170
|
-
const headCommit = await getCommit(q, head)
|
|
171
|
-
const sides = {
|
|
172
|
-
ours: { commit: head, square: sq.id, by: headCommit.author.member_id },
|
|
173
|
-
theirs: { commit: ontoId, by: 'line' },
|
|
174
|
-
base: { commit: sq.base }
|
|
175
|
-
}
|
|
176
|
-
const comb = await combine(q, sq.base, head, ontoId, sides, { reason, against: ontoId })
|
|
177
|
-
const ontoRoot = await snapshot(q, ontoId)
|
|
178
|
-
const onto = await entryMap(q, ontoId, comb.finals.keys())
|
|
179
|
-
const changes = changesOnto(onto, comb.finals)
|
|
180
|
-
const made = makeCommit(q, sq, { parents: [ontoId].filter(Boolean), predecessor: head, author: headCommit.author, onBehalfOf: headCommit.on_behalf_of, message: message ?? headCommit.message, changes, stack: headCommit.stack })
|
|
181
|
-
const tree = await treeGrower(q)(ontoRoot, changes)
|
|
182
|
-
return { made, root: tree.root, objects: [...comb.blobs, ...comb.terms, ...tree.objects, made.object], combined: comb.combined, conflicts: comb.conflicts, fast: !comb.overlap.length }
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** The patch message by default: the square's title and its commit messages (07). */
|
|
186
|
-
async function defaultMessage (q, sq) {
|
|
187
|
-
const commits = (await store.squareCommits(q, sq.id)).filter((c) => !c.hidden_seq)
|
|
188
|
-
const msgs = [...new Set(commits.map((c) => String(c.message || '').split('\n')[0]).filter(Boolean))]
|
|
189
|
-
return msgs.length === 1 && msgs[0] === sq.title ? sq.title : [sq.title, '', ...msgs.map((m) => `- ${m}`)].join('\n').trim()
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Merge a square into a line (07's MergeRequest): { square, expect_head, message?,
|
|
194
|
-
* on_conflict: 'rebase' | 'leave', dry_run, expect_tip? }.
|
|
195
|
-
*/
|
|
196
|
-
async function mergeIntoLine (me, ref, lineName, body, { reverts } = {}) {
|
|
197
|
-
const q = await quiltFor(me, ref, 'write')
|
|
198
|
-
let sq = await writable(me, q, body.square || body.square_id)
|
|
199
|
-
// A square left 'merging' by a server that died mid-merge goes back to 'open' once its lease lapsed (07).
|
|
200
|
-
if (sq.state === 'merging' && await sweepOne(sq)) sq = await store.getSquare(sq.id)
|
|
201
|
-
const line = lineName || sq.line
|
|
202
|
-
const expect = body.expect_head === undefined ? sq.head : body.expect_head
|
|
203
|
-
const run = async () => {
|
|
204
|
-
const ln = await store.getLine(q.id, line)
|
|
205
|
-
if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
|
|
206
|
-
if (ln.frozen) throw new QuiltError(423, 'line_frozen', `${line} is frozen`)
|
|
207
|
-
const parent = await openParent(sq)
|
|
208
|
-
if (parent) throw new QuiltError(409, 'parent_open', `this square is stacked on ${squareShort(parent.id)}, which has not merged: merge it first, or fold this square into it`, { parent: squareShort(parent.id) })
|
|
209
|
-
if (sq.state !== 'open') throw new QuiltError(409, sq.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${sq.state}`)
|
|
210
|
-
if ((sq.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: sq.head })
|
|
211
|
-
if (!sq.head) throw new QuiltError(400, 'bad_request', 'the square has no commits to merge')
|
|
212
|
-
// The line's review rule (13); an automatic revert of a red patch does not wait for approvals.
|
|
213
|
-
if (!reverts && svc.internals.guardReview) await svc.internals.guardReview(q.id, sq, line)
|
|
214
|
-
const held = await conflictsAt(q.id, sq.head)
|
|
215
|
-
if (held.length) throw new QuiltError(422, 'conflict', `the square holds ${held.length} conflicted path${held.length === 1 ? '' : 's'} (${held.map((e) => e.path).join(', ')}); resolve them first`, { head: sq.head, paths: held.map((e) => e.path) })
|
|
216
|
-
const message = body.message || await defaultMessage(q.id, sq)
|
|
217
|
-
const warnings = []
|
|
218
|
-
if (body.expect_tip && body.expect_tip !== ln.tip) warnings.push({ kind: 'behind_by', detail: 'patches merged since you looked' })
|
|
219
|
-
|
|
220
|
-
if (body.dry_run) {
|
|
221
|
-
const r = await rebaseCommit(q.id, sq, sq.head, ln.tip, { message })
|
|
222
|
-
return r.conflicts.length
|
|
223
|
-
? { ok: false, dry_run: true, refused: { code: 'conflict', detail: `would conflict in ${r.conflicts.map((c) => c.path).join(', ')}` }, terms: r.conflicts, warnings }
|
|
224
|
-
: { ok: true, dry_run: true, would_merge: { line, patch_no: ln.patch_count + 1, fast: r.fast }, combined: r.combined, warnings }
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (svc.internals.mergeWindow) await svc.internals.mergeWindow(q, line) // 429 merge_window above the line's window (07)
|
|
228
|
-
try { await store.beginMerge({ square_id: sq.id, expect_head: sq.head, actor: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
|
|
229
|
-
let retries = 0
|
|
230
|
-
const leaseKey = `${q.id}\u0000${line}`
|
|
231
|
-
const holder = `merge:${sq.id}:${uuidv7()}`
|
|
232
|
-
const tableLease = (ttl) => store.takeLease({ kind: 'line', quilt_id: q.id, line, holder, ttl_ms: ttl, at: now() })
|
|
233
|
-
let release = null
|
|
234
|
-
try {
|
|
235
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
236
|
-
if (attempt === 4) {
|
|
237
|
-
await takeLease(leaseKey)
|
|
238
|
-
release = async () => { giveLease(leaseKey); await store.giveLease({ kind: 'line', quilt_id: q.id, line, holder }).catch(() => {}) }
|
|
239
|
-
while (!(await tableLease(LEASE_TTL_MS)).held) await pause(10 + Math.random() * 15)
|
|
240
|
-
}
|
|
241
|
-
if (!release) {
|
|
242
|
-
await waitLease(leaseKey)
|
|
243
|
-
const until = Date.now() + LEASE_TTL_MS
|
|
244
|
-
while (!(await tableLease(0)).held && Date.now() < until) await pause(10 + Math.random() * 15)
|
|
245
|
-
}
|
|
246
|
-
const tip = (await store.getLine(q.id, line)).tip
|
|
247
|
-
const r = await rebaseCommit(q.id, sq, sq.head, tip, { message })
|
|
248
|
-
if (r.conflicts.length) {
|
|
249
|
-
// Refused (D10). With on_conflict 'rebase' the conflicted rebase is the new head.
|
|
250
|
-
let newHead = sq.head
|
|
251
|
-
await seal(q.id, r.objects, { by: me.member_id })
|
|
252
|
-
if ((body.on_conflict || 'rebase') === 'rebase') {
|
|
253
|
-
await store.stageFiles({ quilt_id: q.id, commit_id: r.made.id, files: r.made.record.changes })
|
|
254
|
-
await store.commit({ commit: { ...r.made.record, id: r.made.id, at: now() }, expect_head: sq.head, state: 'merging', actor: me.member_id, at: now(), payload: { rebased_onto: tip, reason: 'merge_conflict' }, square_patch: { base: tip } })
|
|
255
|
-
newHead = r.made.id
|
|
256
|
-
}
|
|
257
|
-
await store.endMerge({ square_id: sq.id, why: 'conflict', actor: me.member_id, at: now() })
|
|
258
|
-
const terms = await Promise.all(r.conflicts.map(async (c) => ({ ...c, sides: (await getTerm(q.id, c.term)).adds.map((s) => ({ by: s.by, commit: s.from_commit, square: s.from_square && squareShort(s.from_square) })) })))
|
|
259
|
-
return { ok: false, refused: { code: 'conflict', detail: `conflict in ${r.conflicts.map((c) => c.path).join(', ')}` }, head: newHead, terms, warnings, square: await squareView(await store.getSquare(sq.id)) }
|
|
260
|
-
}
|
|
261
|
-
await seal(q.id, r.objects, { by: me.member_id })
|
|
262
|
-
await store.stageFiles({ quilt_id: q.id, commit_id: r.made.id, files: r.made.record.changes })
|
|
263
|
-
try {
|
|
264
|
-
const m = await store.merge({ commit: { ...r.made.record, id: r.made.id, at: now() }, line, expected_tip: tip, expect_head: sq.head, snapshot_root: r.root, actor: me.member_id, at: now(), payload: { combined: r.combined, ...(reverts ? { reverts } : {}) } })
|
|
265
|
-
// 03's second short transaction; if it fails, readers still see the patch as pending.
|
|
266
|
-
await store.fillLinePaths({ quilt_id: q.id }).catch(() => {})
|
|
267
|
-
const restacked = await restackChildren(q, sq, r.made.id, me)
|
|
268
|
-
return { ok: true, seq: m.seq, merged: { line, patch_no: m.patch_no, commit: r.made.id, seq: m.seq }, retries, combined: r.combined, fast: r.fast, warnings, ...(restacked.length ? { restacked } : {}), square: await squareView(await store.getSquare(sq.id)) }
|
|
269
|
-
} catch (err) {
|
|
270
|
-
if (err.code !== 'tip_moved') throw err
|
|
271
|
-
retries++
|
|
272
|
-
await new Promise((resolve) => setTimeout(resolve, Math.random() * 20 * attempt))
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
await store.endMerge({ square_id: sq.id, why: 'tip_moved', actor: me.member_id, at: now() })
|
|
276
|
-
throw new QuiltError(409, 'tip_moved', `${line} kept moving; ${MAX_ATTEMPTS} attempts lost the race. Call again.`, { attempts: MAX_ATTEMPTS })
|
|
277
|
-
} catch (err) {
|
|
278
|
-
await store.endMerge({ square_id: sq.id, why: err.code || 'error', actor: me.member_id, at: now() }).catch(() => {})
|
|
279
|
-
throw fromStore(err)
|
|
280
|
-
} finally {
|
|
281
|
-
if (release) await release()
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
if (body.dry_run) return run()
|
|
285
|
-
return once('merge', sq.id, expect, { line, square: sq.id, message: body.message || null, on_conflict: body.on_conflict || 'rebase' }, run)
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ---- the merging sweeper (07): a merge whose server died leaves its square 'merging' ----
|
|
289
|
-
|
|
290
|
-
const lapsed = (sq) => sq.state === 'merging' && now() - (typeof sq.updated_at === 'number' ? sq.updated_at : Date.parse(sq.updated_at)) > LEASE_TTL_MS
|
|
291
|
-
async function sweepOne (sq) {
|
|
292
|
-
if (!lapsed(sq)) return false
|
|
293
|
-
return !!(await store.endMerge({ square_id: sq.id, why: 'lease_lapsed', actor: 'system', at: now() }))
|
|
294
|
-
}
|
|
295
|
-
/** Returns every lapsed 'merging' square of a quilt to 'open' with a merge_refused row (why: 'lease_lapsed'). */
|
|
296
|
-
async function sweepMerges (qid) {
|
|
297
|
-
let n = 0
|
|
298
|
-
for (const sq of await store.squares(qid, { state: 'merging' })) if (await sweepOne(sq)) n++
|
|
299
|
-
return n
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Rebase a square onto its line's tip, its open parent square's head when stacked, or `onto`: conflicts become terms, never a stop (07). */
|
|
303
|
-
async function rebase (me, ref, sqRef, body = {}) {
|
|
304
|
-
const q = await quiltFor(me, ref, 'write')
|
|
305
|
-
const sq = await writable(me, q, sqRef)
|
|
306
|
-
const expect = body.expect_head === undefined ? sq.head : body.expect_head
|
|
307
|
-
return once('rebase', sq.id, expect, body, async () => {
|
|
308
|
-
if (sq.state !== 'open') throw new QuiltError(409, sq.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${sq.state}`)
|
|
309
|
-
const parent = await openParent(sq)
|
|
310
|
-
const onto = body.onto ? await resolve(q.id, body.onto) : parent ? (parent.head || parent.base) : (await store.getLine(q.id, sq.line)).tip
|
|
311
|
-
return rebaseSquare(q, sq, onto, me, expect, {})
|
|
312
|
-
})
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** A stacked square's parent while it is still open or merging (06), else null. */
|
|
316
|
-
async function openParent (sq) {
|
|
317
|
-
if (!sq.parent_square) return null
|
|
318
|
-
const p = await store.getSquare(sq.parent_square)
|
|
319
|
-
return p && (p.state === 'open' || p.state === 'merging') ? p : null
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
async function rebaseSquare (q, sq, onto, me, expect, payload) {
|
|
323
|
-
if (onto === sq.base) return { ok: true, noop: true, square: await squareView(sq) }
|
|
324
|
-
if (!sq.head) {
|
|
325
|
-
const s = await store.setSquare({ square_id: sq.id, patch: { base: onto }, op: 'rebase', actor: me.member_id, at: now() })
|
|
326
|
-
return { ok: true, square: await squareView(s), conflicted: 0 }
|
|
327
|
-
}
|
|
328
|
-
const r = await rebaseCommit(q.id, sq, sq.head, onto, {})
|
|
329
|
-
await seal(q.id, r.objects, { by: me.member_id })
|
|
330
|
-
await store.stageFiles({ quilt_id: q.id, commit_id: r.made.id, files: r.made.record.changes })
|
|
331
|
-
try {
|
|
332
|
-
const row = await store.commit({ commit: { ...r.made.record, id: r.made.id, at: now() }, expect_head: expect, actor: me.member_id, at: now(), payload: { rebased_onto: onto, ...payload }, square_patch: { base: onto } })
|
|
333
|
-
return { ok: true, seq: row.seq, result: commitView({ ...row, changes: r.made.record.changes }), conflicted: r.conflicts.length, terms: r.conflicts, combined: r.combined, square: await squareView(await store.getSquare(sq.id)) }
|
|
334
|
-
} catch (err) { throw fromStore(err) }
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Restack (06, D14): when a square merges, the open squares stacked on it are rebased onto the
|
|
339
|
-
* line's new tip, conflicts kept as terms in their heads. A child whose head moved meanwhile is
|
|
340
|
-
* left for its holder to rebase; the answer lists what happened to each.
|
|
341
|
-
*/
|
|
342
|
-
async function restackChildren (q, parent, tip, me) {
|
|
343
|
-
const kids = (await store.squares(q.id, { state: 'open' })).filter((s) => s.parent_square === parent.id)
|
|
344
|
-
const out = []
|
|
345
|
-
for (const k of kids) {
|
|
346
|
-
try {
|
|
347
|
-
const r = await rebaseSquare(q, k, tip, me, k.head, { restacked: { parent_square: parent.id } })
|
|
348
|
-
out.push({ square: squareShort(k.id), holder: k.holder, conflicted: r.conflicted || 0, ...(r.terms && r.terms.length ? { terms: r.terms } : {}) })
|
|
349
|
-
} catch (err) {
|
|
350
|
-
out.push({ square: squareShort(k.id), holder: k.holder, error: err.code || 'failed' })
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
return out
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Applies one commit's change to a square as a single-parent commit (07): three-way with the
|
|
358
|
-
* picked commit's first parent as base. `invert` is revert: base and theirs swap.
|
|
359
|
-
*/
|
|
360
|
-
async function applyOne (q, sq, me, pickId, { invert = false, message, payload, open = false }) {
|
|
361
|
-
const x = await getCommit(q, pickId)
|
|
362
|
-
const parent = x.parents[0] || null
|
|
363
|
-
const [base, theirs] = invert ? [pickId, parent] : [parent, pickId]
|
|
364
|
-
const ours = sq.head || sq.base
|
|
365
|
-
const sides = { ours: { commit: ours, square: sq.id, by: me.member_id }, theirs: { commit: pickId, square: x.square_id, by: x.author.member_id }, base: { commit: base } }
|
|
366
|
-
const comb = await combine(q, base, ours, theirs, sides, { reason: 'merge', against: pickId })
|
|
367
|
-
// Only what the picked commit changed moves; paths only ours changed stay as they are.
|
|
368
|
-
const finals = new Map([...comb.finals].filter(([p]) => comb.theirsPaths.has(p)))
|
|
369
|
-
const onto = await entryMap(q, ours, finals.keys())
|
|
370
|
-
const changes = changesOnto(onto, finals)
|
|
371
|
-
if (!changes.length) throw new QuiltError(400, 'bad_request', invert ? 'reverting that changes nothing here' : 'that change is already here')
|
|
372
|
-
const made = makeCommit(q, sq, { parents: [ours].filter(Boolean), author: { member_id: me.member_id, kind: me.kind, name: me.name, ...(me.tool ? { tool: me.tool } : {}) }, onBehalfOf: me.on_behalf_of, message, changes })
|
|
373
|
-
await seal(q, [...comb.blobs, ...comb.terms, made.object], { by: me.member_id })
|
|
374
|
-
await store.stageFiles({ quilt_id: q, commit_id: made.id, files: made.record.changes })
|
|
375
|
-
// A revert's new square is opened in the same transaction as its commit (04).
|
|
376
|
-
const row = open
|
|
377
|
-
? await store.openAndCommit({ square: sq, at: now(), commit: { ...made.record, id: made.id, at: now() }, expect_head: null, actor: me.member_id, payload })
|
|
378
|
-
: await store.commit({ commit: { ...made.record, id: made.id, at: now() }, expect_head: sq.head || null, actor: me.member_id, at: now(), payload })
|
|
379
|
-
const conflicts = comb.conflicts.filter((c) => finals.has(c.path))
|
|
380
|
-
return { ok: true, seq: row.seq, result: commitView({ ...row, changes: made.record.changes }), conflicted: conflicts.length, terms: conflicts, square: await squareView(await store.getSquare(sq.id)) }
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
async function cherryPick (me, ref, sqRef, body) {
|
|
384
|
-
const q = await quiltFor(me, ref, 'write')
|
|
385
|
-
const sq = await writable(me, q, sqRef)
|
|
386
|
-
const expect = body.expect_head === undefined ? sq.head : body.expect_head
|
|
387
|
-
return once('cherry-pick', sq.id, expect, body, async () => {
|
|
388
|
-
if ((sq.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: sq.head })
|
|
389
|
-
if (sq.state !== 'open') throw new QuiltError(409, 'square_closed', `the square is ${sq.state}`)
|
|
390
|
-
const pick = await resolve(q.id, body.from)
|
|
391
|
-
if (!pick) throw new QuiltError(404, 'not_found', `${body.from} names no commit`)
|
|
392
|
-
const x = await getCommit(q.id, pick)
|
|
393
|
-
try {
|
|
394
|
-
return await applyOne(q.id, sq, me, pick, { message: body.message || `${x.message}\n\n(cherry picked from commit ${pick.slice(0, 12)})`, payload: { picked_from: pick } })
|
|
395
|
-
} catch (err) { throw fromStore(err) }
|
|
396
|
-
})
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Revert a patch (or any commit): a new square from the line tip whose one commit inverts it
|
|
401
|
-
* (06, 07). It merges like any square.
|
|
402
|
-
*/
|
|
403
|
-
async function revert (me, ref, body) {
|
|
404
|
-
const q = await quiltFor(me, ref, 'write')
|
|
405
|
-
const target = await resolve(q.id, body.rev)
|
|
406
|
-
if (!target) throw new QuiltError(404, 'not_found', `${body.rev} names no commit`)
|
|
407
|
-
const x = await getCommit(q.id, target)
|
|
408
|
-
const line = body.line || 'main'
|
|
409
|
-
const ln = await store.getLine(q.id, line)
|
|
410
|
-
const label = x.patch ? `patch ${x.patch.line}:${x.patch.patch_no}` : `commit ${target.slice(0, 12)}`
|
|
411
|
-
const title = `Revert ${label}: ${String(x.message).split('\n')[0]}`.slice(0, 200)
|
|
412
|
-
const square = { id: uuidv7(now()), quilt_id: q.id, title, line, base: ln.tip, holder: me.member_id, opened_by: me.member_id, writers: [] }
|
|
413
|
-
const sq = { ...square, head: null, state: 'open' }
|
|
414
|
-
try {
|
|
415
|
-
return await applyOne(q.id, sq, me, target, { open: true, invert: true, message: body.message || `${title}\n\nThis reverts ${label}.`, payload: { reverts: target, ...(x.patch ? { reverts_patch: x.patch } : {}) } })
|
|
416
|
-
} catch (err) { throw fromStore(err) }
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Resolve conflict terms by choice (07's ResolveRequest): { expect_head, entries: [{ path,
|
|
421
|
-
* choose: 'ours' | 'theirs' | 'base' | 'suggestion' | n | 'delete' | 'keep_edit' | 'keep_delete' | { blob } }] }.
|
|
422
|
-
*/
|
|
423
|
-
async function resolveTerms (me, ref, sqRef, body) {
|
|
424
|
-
const q = await quiltFor(me, ref, 'write')
|
|
425
|
-
const sq = await writable(me, q, sqRef)
|
|
426
|
-
const expect = body.expect_head === undefined ? sq.head : body.expect_head
|
|
427
|
-
return once('resolve', sq.id, expect, body, async () => {
|
|
428
|
-
if (!sq.head) throw new QuiltError(400, 'bad_request', 'nothing to resolve')
|
|
429
|
-
const cur = await entryMap(q.id, sq.head, (body.entries || []).map((e) => cleanPath(e.path)))
|
|
430
|
-
const changes = []
|
|
431
|
-
const resolved = []
|
|
432
|
-
const picks = []
|
|
433
|
-
for (const e of body.entries || []) {
|
|
434
|
-
const p = cleanPath(e.path)
|
|
435
|
-
const at = cur.get(p)
|
|
436
|
-
if (!at || at.mode !== 'conflict') throw new QuiltError(400, 'bad_request', `${p} is not conflicted`)
|
|
437
|
-
const t = await getTerm(q.id, at.term)
|
|
438
|
-
let pick
|
|
439
|
-
const c = e.choose
|
|
440
|
-
if (c === 'suggestion') {
|
|
441
|
-
if (!t.suggestion) throw new QuiltError(400, 'bad_request', `${p}: this conflict has no suggestion`)
|
|
442
|
-
const row = await store.getBlob(q.id, t.suggestion.blob)
|
|
443
|
-
pick = { blob: t.suggestion.blob, mode: t.adds[0].mode || 'file', size: row.size }
|
|
444
|
-
} else if (c && typeof c === 'object' && c.blob) {
|
|
445
|
-
const row = await store.getBlob(q.id, c.blob)
|
|
446
|
-
if (!row) throw new QuiltError(422, 'pack_unsealed', `blob ${c.blob.slice(0, 12)} is not uploaded`)
|
|
447
|
-
pick = { blob: c.blob, mode: t.adds[0].mode || 'file', size: row.size }
|
|
448
|
-
} else {
|
|
449
|
-
const s = c === 'ours' || c === 'keep_edit' ? (t.adds[0].blob ? t.adds[0] : t.adds[1]) : c === 'theirs' ? t.adds[1] : c === 'base' ? t.removes[0] : Number.isInteger(c) ? t.adds[c] : null
|
|
450
|
-
if (c === 'delete' || c === 'keep_delete' || (s && !s.blob)) pick = null
|
|
451
|
-
else if (!s) throw new QuiltError(400, 'bad_request', `${p}: choose ours, theirs, base, suggestion, a side number, delete, keep_edit, keep_delete or { blob }`)
|
|
452
|
-
else { const row = await store.getBlob(q.id, s.blob); pick = { blob: s.blob, mode: s.mode || 'file', size: row.size } }
|
|
453
|
-
}
|
|
454
|
-
changes.push(pick ? { path: p, op: 'put', ...pick } : { path: p, op: 'delete' })
|
|
455
|
-
// A name term kept on this side wins the name: the paths it clashed with go, and the answer says so.
|
|
456
|
-
const dropped = []
|
|
457
|
-
if (t.kind === 'name' && pick && t.clash) {
|
|
458
|
-
const root = await snapshot(q.id, sq.head)
|
|
459
|
-
for (const other of t.clash) if (await reader(q.id).lookup(root, other) && !changes.some((x) => x.path === other)) { changes.push({ path: other, op: 'delete' }); dropped.push(other) }
|
|
460
|
-
}
|
|
461
|
-
resolved.push({ path: p, term: at.term, how: typeof c === 'object' ? 'blob' : String(c), ...(dropped.length ? { dropped } : {}) })
|
|
462
|
-
picks.push({ path: p, term: at.term, pick, how: typeof c === 'object' ? 'blob' : String(c) })
|
|
463
|
-
}
|
|
464
|
-
if (!changes.length) throw new QuiltError(400, 'bad_request', 'name the paths to resolve')
|
|
465
|
-
const head = await getCommit(q.id, sq.head)
|
|
466
|
-
const made = makeCommit(q.id, sq, { parents: head.parents, predecessor: sq.head, author: { member_id: me.member_id, kind: me.kind, name: me.name }, onBehalfOf: me.on_behalf_of, message: head.message, changes: mergeChanges(head.changes, changes), stack: head.stack })
|
|
467
|
-
await seal(q.id, [made.object], { by: me.member_id })
|
|
468
|
-
await store.stageFiles({ quilt_id: q.id, commit_id: made.id, files: made.record.changes })
|
|
469
|
-
try {
|
|
470
|
-
const row = await store.commit({ commit: { ...made.record, id: made.id, at: now() }, expect_head: expect, actor: me.member_id, at: now(), payload: { resolved } })
|
|
471
|
-
// Settled once, settled for good (07, D12), unless the resolver says remember: false.
|
|
472
|
-
if (body.remember !== false) await remember(store, getTerm, q.id, picks, me, now())
|
|
473
|
-
return { ok: true, seq: row.seq, result: commitView({ ...row, changes: made.record.changes }), conflicted: (await conflictsAt(q.id, made.id)).length, square: await squareView(await store.getSquare(sq.id)) }
|
|
474
|
-
} catch (err) { throw fromStore(err) }
|
|
475
|
-
})
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
return { mergeIntoLine, sweepMerges, rebase, cherryPick, revert, resolve: resolveTerms, combine, mergeInternals: { combine, changesOnto, entryMap, makeCommit, rebaseCommit } }
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/** A commit's changes with some paths replaced (a resolution rewrites the head, D13). */
|
|
482
|
-
function mergeChanges (old, repl) {
|
|
483
|
-
const m = new Map(old.map((c) => [c.path, c]))
|
|
484
|
-
for (const c of repl) m.set(c.path, c)
|
|
485
|
-
return [...m.values()]
|
|
486
|
-
}
|
package/src/quilts/objects.js
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
// Object storage for quilts (plans/quilt-source-control/03, "Object storage layout"): written
|
|
2
|
-
// once, never overwritten, read by range. Packs live at q/<quilt_id>/p/<pack_id>, in a folder on
|
|
3
|
-
// disk (diskBlobs) or a private Supabase Storage bucket (bucket.js). A second write of the same
|
|
4
|
-
// key keeps the first bytes.
|
|
5
|
-
import fs from 'node:fs'
|
|
6
|
-
import path from 'node:path'
|
|
7
|
-
|
|
8
|
-
/** Files under `dir` on this machine: <dir>/<room>/<name>. */
|
|
9
|
-
export class DiskBlobs {
|
|
10
|
-
constructor (dir) { this.dir = dir }
|
|
11
|
-
file (room, name) { return path.join(this.dir, room, name) }
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const packRoom = (quiltId) => `q/${quiltId}/p`
|
|
15
|
-
|
|
16
|
-
/** Packs over DiskBlobs, or a bucket with uploadTarget, downloadTarget and bucket.list (bucket.js). */
|
|
17
|
-
export class BlobstoreObjects {
|
|
18
|
-
constructor (store, { fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
19
|
-
this.store = store
|
|
20
|
-
this.fetch = fetchImpl
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Writes a pack once; a pack already there is left as it is (same id, same bytes). */
|
|
24
|
-
async putPack (quiltId, packId, bytes) {
|
|
25
|
-
const room = packRoom(quiltId)
|
|
26
|
-
if (this.store instanceof DiskBlobs) {
|
|
27
|
-
const file = this.store.file(room, packId)
|
|
28
|
-
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
29
|
-
try { fs.writeFileSync(file, bytes, { flag: 'wx' }) } catch (err) { if (err.code !== 'EEXIST') throw err }
|
|
30
|
-
return
|
|
31
|
-
}
|
|
32
|
-
const target = await this.store.uploadTarget(room, packId, bytes.length)
|
|
33
|
-
if (target.exists) return
|
|
34
|
-
const res = await this.fetch(target.url, { method: target.method || 'PUT', headers: { 'content-type': 'application/octet-stream', 'x-upsert': 'false' }, body: bytes })
|
|
35
|
-
if (res.ok) { await res.body?.cancel().catch(() => {}); return }
|
|
36
|
-
// The pack is there already (Supabase says 409, or 400 "Duplicate"), which for
|
|
37
|
-
// content-addressed bytes is success.
|
|
38
|
-
const text = await res.text().catch(() => '')
|
|
39
|
-
if (res.status === 409 || /duplicate|already exists/i.test(text)) return
|
|
40
|
-
throw new Error(`storage: pack upload answered ${res.status}`)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** `len` bytes of a pack from `offset` (the whole pack when no range is given). */
|
|
44
|
-
async readPack (quiltId, packId, offset, len) {
|
|
45
|
-
const room = packRoom(quiltId)
|
|
46
|
-
if (this.store instanceof DiskBlobs) {
|
|
47
|
-
const file = this.store.file(room, packId)
|
|
48
|
-
if (offset == null) return fs.readFileSync(file)
|
|
49
|
-
const fd = fs.openSync(file, 'r')
|
|
50
|
-
try {
|
|
51
|
-
const buf = Buffer.alloc(len)
|
|
52
|
-
const n = fs.readSync(fd, buf, 0, len, offset)
|
|
53
|
-
if (n !== len) throw new Error(`storage: pack ${packId} is shorter than its index says`)
|
|
54
|
-
return buf
|
|
55
|
-
} finally { fs.closeSync(fd) }
|
|
56
|
-
}
|
|
57
|
-
const { url } = await this.store.downloadTarget(room, packId)
|
|
58
|
-
const headers = offset == null ? {} : { range: `bytes=${offset}-${offset + len - 1}` }
|
|
59
|
-
const res = await this.fetch(url, { headers })
|
|
60
|
-
if (!res.ok) throw new Error(`storage: pack read answered ${res.status}`)
|
|
61
|
-
const buf = Buffer.from(await res.arrayBuffer())
|
|
62
|
-
// A store that ignores Range sends the whole pack.
|
|
63
|
-
return offset != null && res.status === 200 && buf.length !== len ? buf.subarray(offset, offset + len) : buf
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Other written-once objects of a quilt (03's layout: `w` WAL segments, `j` journal groups):
|
|
67
|
-
// q/<quilt_id>/<kind>/<name>. Same write-once rule as packs.
|
|
68
|
-
async putFile (quiltId, kind, name, bytes) {
|
|
69
|
-
const room = `q/${quiltId}/${kind}`
|
|
70
|
-
if (this.store instanceof DiskBlobs) {
|
|
71
|
-
const file = this.store.file(room, name)
|
|
72
|
-
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
73
|
-
try { fs.writeFileSync(file, bytes, { flag: 'wx' }) } catch (err) { if (err.code !== 'EEXIST') throw err }
|
|
74
|
-
return
|
|
75
|
-
}
|
|
76
|
-
const target = await this.store.uploadTarget(room, name, bytes.length)
|
|
77
|
-
if (target.exists) return
|
|
78
|
-
const res = await this.fetch(target.url, { method: target.method || 'PUT', headers: { 'content-type': 'application/octet-stream', 'x-upsert': 'false' }, body: bytes })
|
|
79
|
-
if (res.ok) { await res.body?.cancel().catch(() => {}); return }
|
|
80
|
-
const text = await res.text().catch(() => '')
|
|
81
|
-
if (res.status === 409 || /duplicate|already exists/i.test(text)) return
|
|
82
|
-
throw new Error(`storage: ${kind}/${name} upload answered ${res.status}`)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async readFile (quiltId, kind, name) {
|
|
86
|
-
const room = `q/${quiltId}/${kind}`
|
|
87
|
-
if (this.store instanceof DiskBlobs) return fs.readFileSync(this.store.file(room, name))
|
|
88
|
-
const { url } = await this.store.downloadTarget(room, name)
|
|
89
|
-
const res = await this.fetch(url)
|
|
90
|
-
if (!res.ok) throw new Error(`storage: ${kind}/${name} read answered ${res.status}`)
|
|
91
|
-
return Buffer.from(await res.arrayBuffer())
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** The names under q/<quilt_id>/<kind>, sorted. */
|
|
95
|
-
async listFiles (quiltId, kind) {
|
|
96
|
-
const room = `q/${quiltId}/${kind}`
|
|
97
|
-
if (this.store instanceof DiskBlobs) {
|
|
98
|
-
try { return fs.readdirSync(path.join(this.store.dir, room)).sort() } catch (err) { if (err.code === 'ENOENT') return []; throw err }
|
|
99
|
-
}
|
|
100
|
-
const names = []
|
|
101
|
-
for (let offset = 0; ; offset += 1000) {
|
|
102
|
-
const { data, error } = await this.store.bucket.list(room, { limit: 1000, offset, sortBy: { column: 'name', order: 'asc' } })
|
|
103
|
-
if (error) throw new Error(`storage: ${error.message}`)
|
|
104
|
-
names.push(...data.map((f) => f.name))
|
|
105
|
-
if (data.length < 1000) return names.sort()
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Packs in memory, for tests. */
|
|
111
|
-
export class MemoryObjects {
|
|
112
|
-
constructor () { this.packs = new Map(); this.puts = 0 }
|
|
113
|
-
async putPack (quiltId, packId, bytes) {
|
|
114
|
-
const key = `${quiltId}/${packId}`
|
|
115
|
-
this.puts++
|
|
116
|
-
if (!this.packs.has(key)) this.packs.set(key, Buffer.from(bytes))
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async readPack (quiltId, packId, offset, len) {
|
|
120
|
-
const b = this.packs.get(`${quiltId}/${packId}`)
|
|
121
|
-
if (!b) throw new Error(`storage: no pack ${packId}`)
|
|
122
|
-
return offset == null ? Buffer.from(b) : Buffer.from(b.subarray(offset, offset + len))
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async putFile (quiltId, kind, name, bytes) {
|
|
126
|
-
const key = `${quiltId}/${kind}/${name}`
|
|
127
|
-
this.puts++
|
|
128
|
-
if (!this.packs.has(key)) this.packs.set(key, Buffer.from(bytes))
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async readFile (quiltId, kind, name) {
|
|
132
|
-
const b = this.packs.get(`${quiltId}/${kind}/${name}`)
|
|
133
|
-
if (!b) throw new Error(`storage: no ${kind}/${name}`)
|
|
134
|
-
return Buffer.from(b)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async listFiles (quiltId, kind) {
|
|
138
|
-
const pre = `${quiltId}/${kind}/`
|
|
139
|
-
return [...this.packs.keys()].filter((key) => key.startsWith(pre)).map((key) => key.slice(pre.length)).sort()
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** A disk-backed object store in `dir`. */
|
|
144
|
-
export const diskObjects = (dir) => new BlobstoreObjects(new DiskBlobs(dir))
|