heyquilt 0.3.122 → 0.3.140

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.
Files changed (166) hide show
  1. package/RELEASES.md +279 -17
  2. package/bin/quilt.js +62 -152
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/account.js +0 -29
  6. package/src/agent-task-workflow.js +2 -2
  7. package/src/agents/common.js +13 -0
  8. package/src/ai-vm.js +9 -1
  9. package/src/api/activity.js +5 -0
  10. package/src/api/memory-store.js +16 -38
  11. package/src/api/notify.js +134 -0
  12. package/src/api/routes/browsers.js +29 -0
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/routes/teams.js +0 -23
  17. package/src/api/server.js +17 -25
  18. package/src/api/supabase-store.js +43 -56
  19. package/src/autowork.js +182 -16
  20. package/src/branch-view.js +6 -131
  21. package/src/connection.js +36 -9
  22. package/src/control.js +131 -48
  23. package/src/duties.js +1 -1
  24. package/src/force-date.js +27 -0
  25. package/src/fsutil.js +13 -3
  26. package/src/github-commit.js +3 -315
  27. package/src/history.js +13 -3
  28. package/src/mcp.js +93 -167
  29. package/src/merge3.js +11 -3
  30. package/src/now-summary.js +113 -0
  31. package/src/pathrules.js +12 -0
  32. package/src/protocol.js +10 -2
  33. package/src/quilt-default.js +84 -0
  34. package/src/quilt-history.js +106 -0
  35. package/src/quilt-remote.js +78 -0
  36. package/src/relay-ask.js +3 -66
  37. package/src/relay-cluster.js +3 -5
  38. package/src/relay-lease.js +6 -2
  39. package/src/relay-mcp.js +154 -432
  40. package/src/relay-prune.js +6 -145
  41. package/src/relay-turn-sync.js +1 -1
  42. package/src/release-words.js +2 -2
  43. package/src/runner.js +20 -7
  44. package/src/seam-access.js +97 -0
  45. package/src/seam-claims.js +53 -0
  46. package/src/seam-url.js +15 -0
  47. package/src/server.js +1516 -1383
  48. package/src/session-follow.js +461 -27
  49. package/src/session-layout3.js +410 -4
  50. package/src/session-migrate.js +15 -11
  51. package/src/session-quilt.js +1 -1
  52. package/src/session-seam.js +233 -20
  53. package/src/session.js +745 -3438
  54. package/src/source-live/bring-in.js +283 -0
  55. package/src/source-live/live-block.js +504 -33
  56. package/src/source-live/live-square.js +16 -5
  57. package/src/status.js +7 -58
  58. package/src/turn-sync.js +15 -2
  59. package/src/ui/agent-guide.js +1 -1
  60. package/src/ui/app.css +85 -20
  61. package/src/ui/app.js +4 -1
  62. package/src/ui/changes.js +5 -14
  63. package/src/ui/chat.js +9 -4
  64. package/src/ui/cloud-ai-model.js +13 -0
  65. package/src/ui/cloud-ai.js +10 -2
  66. package/src/ui/common.js +6 -4
  67. package/src/ui/fileview.js +20 -7
  68. package/src/ui/force-words.js +19 -0
  69. package/src/ui/git-rule.js +9 -28
  70. package/src/ui/home.js +3 -223
  71. package/src/ui/loom-model.js +15 -1
  72. package/src/ui/quiltview.js +213 -0
  73. package/src/ui/seam-model.js +251 -0
  74. package/src/ui/session.js +377 -489
  75. package/src/ui/tree.js +2 -8
  76. package/src/ui/voice-call.js +83 -14
  77. package/src/ui/voice-model.js +39 -0
  78. package/src/ui/voice.js +167 -17
  79. package/src/ui/workspaces.js +2 -122
  80. package/src/ui-server.js +81 -66
  81. package/src/update-check.js +25 -1
  82. package/src/voice.js +13 -6
  83. package/src/api/routes/github.js +0 -393
  84. package/src/branches.js +0 -194
  85. package/src/clash.js +0 -128
  86. package/src/commit-request.js +0 -59
  87. package/src/commit.js +0 -213
  88. package/src/git-hook.js +0 -48
  89. package/src/git.js +0 -147
  90. package/src/gitignore.js +0 -49
  91. package/src/gitstate.js +0 -1033
  92. package/src/quilts/AGENTS.md +0 -1
  93. package/src/quilts/CLAUDE.md +0 -1
  94. package/src/quilts/accounts.js +0 -136
  95. package/src/quilts/affected.js +0 -132
  96. package/src/quilts/archiver.js +0 -175
  97. package/src/quilts/autorevert.js +0 -49
  98. package/src/quilts/bisect-run.js +0 -31
  99. package/src/quilts/blame.js +0 -103
  100. package/src/quilts/block.js +0 -347
  101. package/src/quilts/bucket.js +0 -25
  102. package/src/quilts/cbor.js +0 -92
  103. package/src/quilts/checks.js +0 -308
  104. package/src/quilts/claims.js +0 -144
  105. package/src/quilts/cli.js +0 -536
  106. package/src/quilts/client.js +0 -137
  107. package/src/quilts/conflict-format.js +0 -190
  108. package/src/quilts/describe.js +0 -296
  109. package/src/quilts/dogfood.js +0 -82
  110. package/src/quilts/errors.js +0 -8
  111. package/src/quilts/events.js +0 -171
  112. package/src/quilts/export-git.js +0 -130
  113. package/src/quilts/export-remote.js +0 -156
  114. package/src/quilts/export.js +0 -81
  115. package/src/quilts/fastmerge.js +0 -41
  116. package/src/quilts/hash.js +0 -44
  117. package/src/quilts/history.js +0 -266
  118. package/src/quilts/ids.js +0 -12
  119. package/src/quilts/import-follow.js +0 -236
  120. package/src/quilts/import-git.js +0 -294
  121. package/src/quilts/import.js +0 -196
  122. package/src/quilts/index.js +0 -94
  123. package/src/quilts/keys.js +0 -186
  124. package/src/quilts/limits.js +0 -67
  125. package/src/quilts/lines.js +0 -49
  126. package/src/quilts/live.js +0 -50
  127. package/src/quilts/merge-smart.js +0 -114
  128. package/src/quilts/merge.js +0 -501
  129. package/src/quilts/oauth.js +0 -202
  130. package/src/quilts/objects.js +0 -144
  131. package/src/quilts/pack.js +0 -118
  132. package/src/quilts/records.js +0 -211
  133. package/src/quilts/resolutions.js +0 -69
  134. package/src/quilts/reviews.js +0 -208
  135. package/src/quilts/routes.js +0 -186
  136. package/src/quilts/runner.js +0 -95
  137. package/src/quilts/server.js +0 -144
  138. package/src/quilts/service.js +0 -963
  139. package/src/quilts/setup.js +0 -41
  140. package/src/quilts/signing.js +0 -149
  141. package/src/quilts/squares.js +0 -239
  142. package/src/quilts/store-memory.js +0 -844
  143. package/src/quilts/store-rpc.js +0 -90
  144. package/src/quilts/store-supabase.js +0 -13
  145. package/src/quilts/tags.js +0 -64
  146. package/src/quilts/textdiff.js +0 -96
  147. package/src/quilts/tokens.js +0 -66
  148. package/src/quilts/tools.js +0 -403
  149. package/src/quilts/tree.js +0 -351
  150. package/src/quilts/webhook.js +0 -116
  151. package/src/quilts/window.js +0 -121
  152. package/src/quilts/xdiff.js +0 -435
  153. package/src/relay-ci.js +0 -111
  154. package/src/relay-clash.js +0 -214
  155. package/src/relay-commit.js +0 -918
  156. package/src/relay-filekey.js +0 -46
  157. package/src/relay-git.js +0 -274
  158. package/src/relay-githttp.js +0 -166
  159. package/src/relay-github.js +0 -109
  160. package/src/relay-outbound.js +0 -1295
  161. package/src/relay-repo-moves.js +0 -43
  162. package/src/relay-sync.js +0 -795
  163. package/src/relay-upstream.js +0 -889
  164. package/src/sync-words.js +0 -42
  165. package/src/ui/branches.js +0 -111
  166. package/src/upstream.js +0 -192
@@ -1,963 +0,0 @@
1
- // The server of Quilt source control (plans/quilt-source-control/04): every commit, merge,
2
- // diff, blame and history query runs here, behind one API that the CLI, both MCPs and plain
3
- // HTTP share. It holds no state of its own: rows are in the store (store-memory.js, or
4
- // Postgres through the migration's functions), bytes in packs (objects.js).
5
- //
6
- // A caller (`me`) is { member_id: 'person:<id>' | 'agent:<id>' | 'token:<id>', kind: 'person' | 'agent', name, tool? }
7
- // (a quilt token, tokens.js, calls as an agent and is a member of its one quilt).
8
- import { uuidv7 } from './ids.js'
9
- import { buildPack, readEntry, verifyPack, KIND } from './pack.js'
10
- import { cleanCommit, cleanChanges, commitBytes, commitId, blobId, cleanPath, cleanPrefix, under, termBytes, termId, decodeTerm, decodeCommit } from './records.js'
11
- import { buildTree, updateTree, TreeReader, EMPTY_TREE } from './tree.js'
12
- import { isText, unified, lineStat } from './textdiff.js'
13
- import { idOf, isId, keyOf, shardOf } from './hash.js'
14
- import { StoreError } from './store-memory.js'
15
- import { guardClaims } from './claims.js'
16
- import { remember } from './resolutions.js'
17
- import { QuiltError, squareShort } from './errors.js'
18
-
19
- export { QuiltError, squareShort }
20
-
21
- const LEVELS = { read: 1, write: 2, admin: 3 }
22
- const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/
23
- const IDEM_MS = 24 * 60 * 60 * 1000
24
- /** sq:<id>:live, a square's shared uncommitted work (02). */
25
- const LIVE = /^sq:[^:~^]+:live$/
26
- const MAX_ATTEMPTS = 5
27
-
28
- export const MAX_STACK = 10
29
-
30
- export const shortId = (id, n = 12) => (id ? id.slice(0, n) : null)
31
-
32
- const statusOf = {
33
- not_found: 404, head_moved: 409, tip_moved: 409, square_merging: 409, square_closed: 409, holder_moved: 409, square_state: 409, exists: 409, slug_taken: 409,
34
- conflict: 422, line_frozen: 423, line_exists: 409, tag_exists: 409, claim_refused: 412, claim_taken: 409, policy: 428, own_square: 403, parent_open: 409, bad_parent: 422, bad_request: 400, forbidden: 403, not_holder: 403, too_many_files: 422, pack_corrupt: 422, key_exists: 409, moved: 409, too_many_agents: 409, not_claimable: 403
35
- }
36
-
37
- /** A store error as a QuiltError with its wire status. */
38
- function fromStore (err) {
39
- if (err instanceof QuiltError) return err
40
- if (err instanceof StoreError || err?.code in statusOf) {
41
- const { code, message, ...extra } = err
42
- return new QuiltError(statusOf[err.code] || 500, err.code, message, extra)
43
- }
44
- return err
45
- }
46
-
47
- export function createQuiltService ({ store, objects, now = Date.now }) {
48
- // Immutable objects are cached by id, forever (decision 30): bytes never change under an id.
49
- const objCache = new Map()
50
- const cacheObj = (id, bytes) => {
51
- objCache.set(id, bytes)
52
- if (objCache.size > 5000) objCache.delete(objCache.keys().next().value)
53
- }
54
- const entriesCache = new Map() // tree root → sorted entries
55
- const readers = new Map() // quilt id → TreeReader
56
-
57
- // ---- access ----
58
-
59
- async function quiltFor (me, ref, need = 'read') {
60
- const q = await findQuilt(me, ref)
61
- const level = await store.memberLevel(q.id, me.member_id)
62
- if (!level) throw new QuiltError(404, 'not_found', 'no such quilt')
63
- if (LEVELS[level] < LEVELS[need]) throw new QuiltError(403, 'forbidden', `you need ${need} on this quilt`)
64
- return { ...q, level }
65
- }
66
-
67
- async function findQuilt (me, ref) {
68
- const s = String(ref || '')
69
- const byId = /^[0-9a-f-]{36}$/i.test(s) ? await store.getQuilt(s) : null
70
- let q = byId || await store.quiltBySlug(me.member_id, s)
71
- if (!q) {
72
- // A quilt someone else owns, by its slug, when only one of the caller's quilts has it.
73
- const mine = (await store.quiltsFor(me.member_id)).filter((x) => x.slug === s)
74
- if (mine.length > 1) throw new QuiltError(400, 'bad_request', `${s} names ${mine.length} of your quilts; use its id`)
75
- q = mine[0] || null
76
- }
77
- if (!q) throw new QuiltError(404, 'not_found', 'no such quilt')
78
- return q
79
- }
80
-
81
- // ---- objects ----
82
-
83
- async function readObject (q, id, { blob = false } = {}) {
84
- const hit = objCache.get(id)
85
- if (hit) return hit
86
- const row = blob ? await store.getBlob(q, id) : await store.getObject(q, id)
87
- if (!row) throw new QuiltError(404, 'not_found', `no object ${id.slice(0, 12)} in this quilt`)
88
- // One more read when the store misses a pack it acked (read-after-write lag) or returns bytes
89
- // that fail their checksum (15); a second failure is real and goes to the caller.
90
- let bytes
91
- for (let attempt = 1; ; attempt++) {
92
- try {
93
- bytes = readEntry({ kind: row.kind, id, flags: row.flags }, await objects.readPack(q, row.pack_id, row.offset, row.len))
94
- break
95
- } catch (err) {
96
- if (attempt > 1 || !(err.code === 'pack_corrupt' || err.code === 'not_found' || err.status === 404)) throw err
97
- }
98
- }
99
- cacheObj(id, bytes)
100
- return bytes
101
- }
102
- const readBlob = (q, id) => readObject(q, id, { blob: true })
103
-
104
- /**
105
- * Seals objects into one server pack (03: before any row names them) and writes their rows.
106
- * Objects already in the quilt are left out; nothing is written when none are new.
107
- */
108
- async function seal (q, list, { origin = 'server', by = 'system' } = {}) {
109
- const uniq = [...new Map(list.map((o) => [o.id, o])).values()]
110
- const blobIds = uniq.filter((o) => o.kind === 'blob').map((o) => o.id)
111
- const have = new Set([...(blobIds.length ? await store.hasBlobs(q, blobIds) : []), ...(await hasObjects(q, uniq.filter((o) => o.kind !== 'blob').map((o) => o.id)))])
112
- const fresh = uniq.filter((o) => !have.has(o.id))
113
- if (!fresh.length) return null
114
- const pack = buildPack(fresh)
115
- await objects.putPack(q, pack.id, pack.bytes)
116
- await sealRows(q, pack, fresh.map((o) => o.bytes.length), origin, by)
117
- for (const o of fresh) cacheObj(o.id, Buffer.from(o.bytes))
118
- return pack.id
119
- }
120
-
121
- async function hasObjects (q, ids) {
122
- if (!ids.length) return []
123
- if (store.hasObjects) return store.hasObjects(q, ids)
124
- const out = []
125
- for (const id of ids) if (await store.getObject(q, id)) out.push(id)
126
- return out
127
- }
128
-
129
- async function sealRows (q, pack, sizes, origin, by) {
130
- const blobs = []; const objs = []
131
- pack.index.forEach((e, i) => {
132
- const row = { id: e.id, kind: e.kind, offset: e.offset, len: e.len, flags: e.flags, size: sizes[i] }
133
- if (e.kind === 'blob') blobs.push(row); else objs.push(row)
134
- })
135
- await store.sealPack({ quilt_id: q, pack: { id: pack.id, size: pack.bytes.length, object_count: pack.index.length, origin, uploaded_by: by }, blobs, objects: objs, at: now() })
136
- }
137
-
138
- // ---- trees and snapshots ----
139
-
140
- function reader (q) {
141
- let r = readers.get(q)
142
- if (!r) { r = new TreeReader((id) => readObject(q, id)); readers.set(q, r) }
143
- return r
144
- }
145
-
146
- async function entriesOf (q, root) {
147
- if (!root || root === EMPTY_TREE) return []
148
- let e = entriesCache.get(root)
149
- if (!e) {
150
- e = await reader(q).entries(root)
151
- entriesCache.set(root, e)
152
- if (entriesCache.size > 64) entriesCache.delete(entriesCache.keys().next().value)
153
- }
154
- return e
155
- }
156
-
157
- /** Builds a tree from entries and returns { root, objects } with the nodes to seal. */
158
- function treeObjects (entries) {
159
- const { root, nodes } = buildTree(entries)
160
- entriesCache.set(root, entries)
161
- if (entriesCache.size > 64) entriesCache.delete(entriesCache.keys().next().value)
162
- return { root, objects: [...nodes].map(([id, bytes]) => ({ kind: 'tree', id, bytes })) }
163
- }
164
-
165
- /**
166
- * `grow(root, changes)` → { root, objects }: the tree with the changes applied, touching only
167
- * the nodes they reach (tree.js updateTree). Nodes it made and has not sealed yet stay readable
168
- * to the next call, so a chain of commits can be grown before one seal.
169
- */
170
- function treeGrower (q) {
171
- const pending = new Map()
172
- const r = new TreeReader(async (id) => pending.get(id) || readObject(q, id))
173
- r.cache = reader(q).cache
174
- return async (root, changes) => {
175
- const { root: next, nodes } = await updateTree(r, root || EMPTY_TREE, changes)
176
- const objects = []
177
- for (const [id, bytes] of nodes) if (!pending.has(id)) { pending.set(id, bytes); objects.push({ kind: 'tree', id, bytes }) }
178
- return { root: next, objects }
179
- }
180
- }
181
-
182
- /**
183
- * A commit's snapshot root (03: eager for lines, lazy for squares): derived from the nearest
184
- * first-parent ancestor that has one plus the changes since, then stored.
185
- */
186
- async function snapshot (q, id) {
187
- if (!id) return EMPTY_TREE
188
- const chain = []
189
- let c = await getCommit(q, id)
190
- while (c && !c.snapshot_root) {
191
- chain.push(c)
192
- c = c.parents[0] ? await getCommit(q, c.parents[0]) : null
193
- }
194
- let root = c ? c.snapshot_root : EMPTY_TREE
195
- if (!chain.length) return root
196
- const toSeal = []
197
- const roots = []
198
- const grow = treeGrower(q)
199
- for (const x of chain.reverse()) {
200
- const t = await grow(root, x.changes)
201
- toSeal.push(...t.objects)
202
- roots.push([x.id, t.root])
203
- root = t.root
204
- }
205
- await seal(q, toSeal)
206
- for (const [cid, r] of roots) await store.setSnapshot({ quilt_id: q, commit_id: cid, root: r })
207
- return root
208
- }
209
-
210
- /** The conflict terms in a commit's tree: a square may hold them, a line never (D10). */
211
- // A commit never changes, so its terms are cached by id (the last 2,000 commits asked about).
212
- const conflictCache = new Map()
213
- async function conflictsAt (q, id) {
214
- const key = `${q}:${id}`
215
- if (conflictCache.has(key)) return conflictCache.get(key).map((e) => ({ ...e }))
216
- const c = await getCommit(q, id)
217
- const out = (!c.conflicted && !(await treeHasConflicts(q, c))) ? [] : (await entriesOf(q, await snapshot(q, id))).filter((e) => e.mode === 'conflict')
218
- conflictCache.set(key, out)
219
- if (conflictCache.size > 2000) conflictCache.delete(conflictCache.keys().next().value)
220
- return out.map((e) => ({ ...e }))
221
- }
222
-
223
- // Whether any first-parent ancestor inside the square left a conflict that may still be in the
224
- // tree. The walk stops at the head the square row last stored its conflicted paths for.
225
- async function treeHasConflicts (q, c) {
226
- const sq = c.square_id ? await store.getSquare(c.square_id) : null
227
- let x = c
228
- for (let i = 0; x && i < 10000; i++) {
229
- if (x.conflicted) return true
230
- if (sq?.conflicted_at && sq.conflicted_at === x.id) return (sq.conflicted_paths || []).length > 0
231
- if (x.patch || !x.parents[0]) return false
232
- const p = await store.getCommit(q, x.parents[0])
233
- if (!p || p.square_id !== c.square_id) return false
234
- x = p
235
- }
236
- return false
237
- }
238
-
239
- async function getCommit (q, id) {
240
- const c = await store.getCommit(q, id)
241
- if (!c) throw new QuiltError(404, 'not_found', `no commit ${String(id).slice(0, 12)}`)
242
- return c
243
- }
244
-
245
- // ---- revisions (02's grammar) ----
246
-
247
- async function squareByRef (q, ref) {
248
- const s = String(ref || '').replace(/^sq:/, '')
249
- if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) {
250
- const sq = await store.getSquare(s)
251
- if (sq && sq.quilt_id === q) return sq
252
- throw new QuiltError(404, 'not_found', 'no such square')
253
- }
254
- const tail = s.toLowerCase()
255
- if (!/^[0-9a-f]{4,32}$/.test(tail)) throw new QuiltError(404, 'not_found', `no square ${ref}`)
256
- const found = await store.squaresByTail(q, tail)
257
- if (!found.length) throw new QuiltError(404, 'not_found', `no square ${ref}`)
258
- if (found.length > 1) throw new QuiltError(400, 'bad_request', `${ref} names ${found.length} squares; give more of its id`)
259
- return found[0]
260
- }
261
-
262
- /**
263
- * A revision to a commit id, or null for an empty line or a square with nothing yet.
264
- * Tokens: main, release/x, main~3, main:12, patch:12, sq:<tail>[:base|~n], <hex>, c:<hex>,
265
- * git:<sha>, and <rev>^n.
266
- */
267
- async function resolve (q, token) {
268
- let s = String(token || 'main').trim()
269
- if (!s) s = 'main'
270
- let caret = 0
271
- const cm = s.match(/^(.*?)\^(\d*)$/)
272
- if (LIVE.test(s)) throw new QuiltError(400, 'live_revision', `${s} is the square's uncommitted work, not a commit: read it with ls, cat or diff`)
273
- if (cm && cm[1]) { s = cm[1]; caret = cm[2] === '' ? 1 : Number(cm[2]) }
274
- let tilde = 0
275
- const tm = s.match(/^(.*)~(\d+)$/)
276
- if (tm) { s = tm[1]; tilde = Number(tm[2]) }
277
- let id
278
- let stopAt = null
279
- if (/^\d+$/.test(s)) throw new QuiltError(400, 'bad_request', `say patch:${s} or c:${s}…`)
280
- if (s.startsWith('sq:')) {
281
- const base = s.endsWith(':base')
282
- const sq = await squareByRef(q, base ? s.slice(0, -5) : s)
283
- id = base ? sq.base : (sq.head || sq.base)
284
- stopAt = sq.base
285
- } else if (s.startsWith('tag:')) {
286
- const tag = await store.getTag(q, s.slice(4))
287
- if (!tag) throw new QuiltError(404, 'not_found', `no tag ${s.slice(4)}`)
288
- id = tag.commit
289
- } else if (s.startsWith('git:')) {
290
- id = await store.gitSha(q, s.slice(4))
291
- if (!id && store.gitShaPrefix) id = await store.gitShaPrefix(q, s.slice(4))
292
- if (!id) throw new QuiltError(404, 'not_found', `no commit for git ${s.slice(4)}`)
293
- } else if (/^(patch|[\w./-]+):\d+$/.test(s)) {
294
- const [ln, n] = s.startsWith('patch:') ? ['main', Number(s.slice(6))] : [s.slice(0, s.lastIndexOf(':')), Number(s.slice(s.lastIndexOf(':') + 1))]
295
- const row = await store.patch(q, ln, n)
296
- if (!row) throw new QuiltError(404, 'not_found', `no patch ${n} on ${ln}`)
297
- id = row.commit
298
- } else if (/^c:[0-9a-f]+$/.test(s) || (/^[0-9a-f]{4,64}$/.test(s) && !(await store.getLine(q, s)))) {
299
- id = await commitByPrefix(q, s.replace(/^c:/, ''))
300
- } else {
301
- const line = await store.getLine(q, s)
302
- if (!line) throw new QuiltError(404, 'not_found', `no line or revision ${token}`)
303
- id = line.tip
304
- }
305
- for (let i = 0; i < tilde && id; i++) {
306
- if (stopAt && id === stopAt) break
307
- const c = await getCommit(q, id)
308
- id = c.parents[0] || null
309
- }
310
- if (caret) {
311
- if (!id) throw new QuiltError(404, 'not_found', `${token} names nothing`)
312
- const c = await getCommit(q, id)
313
- id = c.parents[caret - 1]
314
- if (!id) throw new QuiltError(404, 'not_found', `${token}: that commit has no parent ${caret}`)
315
- }
316
- return id || null
317
- }
318
-
319
- /**
320
- * A revision's tree: { id, root }. sq:<id>:live is the square's head with its shared uncommitted
321
- * changes laid over it (whole files: each put or delete replaces what the head has); every
322
- * other revision is a commit's snapshot.
323
- */
324
- async function rootAt (q, token) {
325
- const s = String(token || 'main').trim()
326
- if (!LIVE.test(s)) { const id = await resolve(q, s); return { id, root: await snapshot(q, id) } }
327
- const sq = await squareByRef(q, s.slice(0, -5))
328
- const id = sq.head || sq.base
329
- const root = await snapshot(q, id)
330
- const live = store.getLive ? await store.getLive(q, sq.id) : null
331
- if (!live || !live.changes.length) return { id, root }
332
- const t = await treeGrower(q)(root, live.changes)
333
- await seal(q, t.objects)
334
- return { id, root: t.root, live: true }
335
- }
336
-
337
- async function commitByPrefix (q, prefix) {
338
- if (isId(prefix)) { await getCommit(q, prefix); return prefix }
339
- if (prefix.length < 4) throw new QuiltError(400, 'bad_request', 'give at least 4 hex characters of a commit id')
340
- const found = await store.resolvePrefix(q, prefix)
341
- if (!found.length) throw new QuiltError(404, 'not_found', `no commit ${prefix}`)
342
- if (found.length > 1) throw new QuiltError(400, 'bad_request', `${prefix} names more than one commit; give more of it`)
343
- return found[0]
344
- }
345
-
346
- // ---- describing ----
347
-
348
- /** { on: 'sq:…' } while the square's parent (06) is open or merging; nothing once it merged. */
349
- async function stackedOn (sq) {
350
- if (!sq.parent_square || sq.state !== 'open') return {}
351
- const p = await store.getSquare(sq.parent_square)
352
- return p && (p.state === 'open' || p.state === 'merging') ? { on: squareShort(p.id) } : {}
353
- }
354
-
355
- async function squareView (sq) {
356
- if (!sq) return null
357
- let behind = 0
358
- const line = await store.getLine(sq.quilt_id, sq.line)
359
- if (sq.state === 'open' && line && line.tip !== sq.base) {
360
- // behind_by: patches on the line since the square's base (06).
361
- const at = sq.base ? (await store.getCommit(sq.quilt_id, sq.base))?.patch : null
362
- behind = line.patch_count - (at && at.line === sq.line ? at.patch_no : 0)
363
- }
364
- const conflicted = (await conflictedPaths(sq)).length
365
- return {
366
- id: sq.id, short: squareShort(sq.id), title: sq.title, state: sq.state, line: sq.line,
367
- base: sq.base, head: sq.head, holder: sq.holder, opened_by: sq.opened_by,
368
- ...(sq.task_id ? { task_id: sq.task_id } : {}),
369
- ...(await stackedOn(sq)),
370
- writers: sq.writers || [], behind_by: behind, conflicted_paths: conflicted,
371
- ...(sq.merged ? { merged: sq.merged } : {}), ...(sq.folded_into ? { folded_into: sq.folded_into } : {}),
372
- opened_at: sq.opened_at, updated_at: sq.updated_at
373
- }
374
- }
375
-
376
- /** A square's conflicted paths: from its row when stored for its head, else worked out and stored. */
377
- async function conflictedPaths (sq) {
378
- if (!sq.head) return []
379
- if (sq.conflicted_at === sq.head && sq.conflicted_paths) return sq.conflicted_paths
380
- const paths = (await conflictsAt(sq.quilt_id, sq.head)).map((e) => e.path)
381
- await store.setConflicted({ square_id: sq.id, head: sq.head, paths })
382
- return paths
383
- }
384
-
385
- function commitView (c, { changes = true } = {}) {
386
- return {
387
- id: c.id, short: shortId(c.id), seq: c.seq, square_id: c.square_id, square: squareShort(c.square_id),
388
- parents: c.parents, ...(c.predecessor ? { predecessor: c.predecessor } : {}),
389
- author: c.author, ...(c.on_behalf_of ? { on_behalf_of: c.on_behalf_of } : {}), ...(c.task_id ? { task_id: c.task_id } : {}),
390
- message: c.message, authored_at: c.authored_at, at: c.at,
391
- ...(c.patch ? { patch: c.patch } : {}), ...(c.hidden_seq ? { hidden: true } : {}),
392
- conflicted: c.conflicted, stack: c.stack || {},
393
- ...(changes ? { changes: c.changes } : { files: c.changes.length })
394
- }
395
- }
396
-
397
- // ---- idempotency (04: (square_id, expect_head, op, body_hash), kept 24 h) ----
398
-
399
- async function once (op, squareId, expectHead, body, fn) {
400
- const key = keyOf('idempotency', Buffer.from(JSON.stringify([op, squareId, expectHead || null, body])))
401
- const prior = await store.recall(key, IDEM_MS, now())
402
- if (prior) return prior
403
- const result = await fn()
404
- await store.remember({ key, result, at: now() })
405
- return result
406
- }
407
-
408
- // ---- quilts and members ----
409
-
410
- async function createQuilt (me, { slug, name, import: importing = false }) {
411
- if (me.token) throw new QuiltError(403, 'forbidden', 'a quilt token reaches its one quilt; it cannot make quilts')
412
- const s = String(slug || '').toLowerCase()
413
- if (!SLUG.test(s)) throw new QuiltError(400, 'bad_request', 'a quilt slug is lowercase letters, digits and dashes')
414
- const id = uuidv7(now())
415
- const quilt = { id, owner: me.member_id, slug: s, name: String(name || s).slice(0, 200), shard: shardOf(id), settings: { importing: !!importing } }
416
- let row
417
- try { row = await store.createQuilt({ quilt, at: now() }) } catch (err) { throw fromStore(err) }
418
- // Ruling (2026-10-24): main starts at an empty root commit, so a line's tip is never empty.
419
- // An imported quilt takes git's own root commit instead (its history stays git's).
420
- if (!importing) await rootCommit(me, row)
421
- return importing ? row : store.getQuilt(id)
422
- }
423
-
424
- /** Patch 0 of a new quilt: an empty root commit on main, in a merged square of its own. */
425
- async function rootCommit (me, q) {
426
- const at = now()
427
- const square = { id: uuidv7(at), quilt_id: q.id, title: 'root', line: 'main', base: null, holder: me.member_id, opened_by: me.member_id, writers: [], import: true }
428
- const c = cleanCommit({ quilt_id: q.id, square_id: square.id, parents: [], author: authorOf(me), message: 'Root', authored_at: at, changes: [] })
429
- const id = commitId(c)
430
- await seal(q.id, [{ kind: 'commit', id, bytes: commitBytes(c) }], { by: me.member_id })
431
- try {
432
- await store.openSquare({ square, at })
433
- await store.stageFiles({ quilt_id: q.id, commit_id: id, files: [] })
434
- await store.merge({ commit: { ...c, id, at }, line: 'main', expected_tip: null, expect_head: null, snapshot_root: EMPTY_TREE, actor: me.member_id, at, payload: { root: true }, import: true, root: true })
435
- await store.fillLinePaths({ quilt_id: q.id })
436
- } catch (err) { throw fromStore(err) }
437
- }
438
-
439
- async function addMember (me, ref, { member_id: m, level }) {
440
- const q = await quiltFor(me, ref, 'admin')
441
- if (level && !LEVELS[level]) throw new QuiltError(400, 'bad_request', 'level is read, write or admin')
442
- if (!/^(person|agent|stub):\S+$/.test(String(m || ''))) throw new QuiltError(400, 'bad_request', 'member_id is person:<id> or agent:<id>')
443
- return store.setMember({ quilt_id: q.id, member_id: m, level: level || null, actor: me.member_id, at: now() })
444
- }
445
-
446
- // ---- squares ----
447
-
448
- async function openSquare (me, ref, { title, line = 'main', base, task_id: taskId, on } = {}) {
449
- const q = await quiltFor(me, ref, 'write')
450
- // Stacked (06): `on` another open square starts from its head, on its line, and merges after it.
451
- let parent = null
452
- if (on) {
453
- if (base) throw new QuiltError(400, 'bad_request', 'give on (a square to stack on) or base, not both')
454
- parent = await squareByRef(q.id, on)
455
- if (parent.state !== 'open') throw new QuiltError(409, 'square_closed', `${squareShort(parent.id)} is ${parent.state}: stack on an open square`)
456
- let depth = 1
457
- for (let p = parent; p.parent_square; depth++) {
458
- p = await store.getSquare(p.parent_square)
459
- if (!p || (p.state !== 'open' && p.state !== 'merging')) break
460
- if (depth >= MAX_STACK) throw new QuiltError(400, 'bad_request', `a stack is at most ${MAX_STACK} squares deep`)
461
- }
462
- line = parent.line
463
- }
464
- const ln = await store.getLine(q.id, line)
465
- if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
466
- const at = parent ? (parent.head || parent.base) : base ? await resolve(q.id, base) : ln.tip
467
- const square = {
468
- id: uuidv7(now()), quilt_id: q.id, title: String(title || 'untitled').slice(0, 200), line,
469
- base: at, holder: me.member_id, opened_by: me.member_id, writers: [],
470
- ...(parent ? { parent_square: parent.id } : {}),
471
- ...(taskId ? { task_id: String(taskId), task_primary: true } : {})
472
- }
473
- try { return await squareView(await store.openSquare({ square, at: now() })) } catch (err) { throw fromStore(err) }
474
- }
475
-
476
- async function writable (me, q, sqRef) {
477
- const sq = await squareByRef(q.id, sqRef)
478
- if (sq.holder !== me.member_id && !(sq.writers || []).includes(me.member_id) && q.level !== 'admin') {
479
- throw new QuiltError(403, 'not_holder', `this square is held by ${sq.holder}`, { holder: sq.holder })
480
- }
481
- return sq
482
- }
483
-
484
- // ---- commit ----
485
-
486
- /**
487
- * Seals changes as a commit in a square (D17). `changes`: [{ path, op: 'put', content
488
- * (base64) | blob, mode? } | { path, op: 'delete' }]; blobs named by id must be in the quilt
489
- * already (uploaded in a pack). `expect_head` is the head the caller saw (null before the
490
- * square's first commit).
491
- */
492
- async function commit (me, ref, sqRef, body) {
493
- const q = await quiltFor(me, ref, 'write')
494
- const sq = await writable(me, q, sqRef)
495
- const expect = body.expect_head === undefined ? sq.head : body.expect_head
496
- return once('commit', sq.id, expect, body, async () => {
497
- if (sq.state !== 'open') throw new QuiltError(409, sq.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${sq.state}`)
498
- const parents = [expect || sq.base].filter(Boolean)
499
- let c, id, resolved
500
- if (body.commit) {
501
- // 03: the client built the commit record and sealed it in its own pack with the blobs.
502
- ;({ c, id, resolved } = await clientRecord(q, sq, me, parents, body.commit))
503
- await guardClaims(store, q.id, sq, c.changes.map((x) => x.path))
504
- } else {
505
- const prep = await prepareChanges(q.id, sq, body.changes || [])
506
- if (!prep.changes.length) throw new QuiltError(400, 'bad_request', 'nothing to commit')
507
- resolved = prep.resolved
508
- c = cleanCommit({
509
- quilt_id: q.id, square_id: sq.id, parents, author: authorOf(me),
510
- ...(me.on_behalf_of ? { on_behalf_of: me.on_behalf_of } : {}),
511
- ...(sq.task_id ? { task_id: sq.task_id } : {}),
512
- message: body.message || '', authored_at: body.authored_at || now(), changes: prep.changes, stack: body.stack
513
- })
514
- id = commitId(c)
515
- await guardClaims(store, q.id, sq, c.changes.map((x) => x.path))
516
- await seal(q.id, [...prep.blobs, { kind: 'commit', id, bytes: commitBytes(c) }], { by: me.member_id })
517
- }
518
- await store.stageFiles({ quilt_id: q.id, commit_id: id, files: c.changes })
519
- try {
520
- const row = await store.commit({ commit: { ...c, id, at: now() }, expect_head: expect || null, actor: me.member_id, at: now(), payload: resolved.length ? { resolved } : {} })
521
- if (resolved.length) await rememberHand(q.id, resolved, c.changes, me)
522
- return { ok: true, seq: row.seq, square: await squareView(await store.getSquare(sq.id)), result: commitView({ ...row, changes: c.changes }) }
523
- } catch (err) { throw fromStore(err) }
524
- })
525
- }
526
-
527
- /**
528
- * Amends the square's last commit (04: POST /squares/:id/amend): a new commit with the same
529
- * parents, the old one's changes with `changes` laid over them, and `message` (or the old
530
- * message). The old commit becomes its predecessor and is hidden from the square's history.
531
- */
532
- async function amend (me, ref, sqRef, body = {}) {
533
- const q = await quiltFor(me, ref, 'write')
534
- const sq = await writable(me, q, sqRef)
535
- const expect = body.expect_head === undefined ? sq.head : body.expect_head
536
- return once('amend', sq.id, expect, body, async () => {
537
- if (sq.state !== 'open') throw new QuiltError(409, sq.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${sq.state}`)
538
- if ((sq.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: sq.head })
539
- const old = sq.head ? await getCommit(q.id, sq.head) : null
540
- if (!old || old.square_id !== sq.id || old.patch) throw new QuiltError(409, 'nothing_to_amend', 'the square has no commit of its own yet: commit instead')
541
- const prep = await prepareChanges(q.id, sq, body.changes || [])
542
- await guardClaims(store, q.id, sq, prep.changes.map((x) => x.path))
543
- const parentRoot = await snapshot(q.id, old.parents[0] || null)
544
- const byPath = new Map(old.changes.map((c) => [c.path, c]))
545
- for (const ch of prep.changes) {
546
- // A change that puts the file back as the parent had it drops out of the commit.
547
- const was = await reader(q.id).lookup(parentRoot, ch.path)
548
- const same = ch.op === 'delete' ? !was : (was && was.blob === ch.blob && was.mode === ch.mode)
549
- if (same) byPath.delete(ch.path); else byPath.set(ch.path, ch)
550
- }
551
- if (!byPath.size) throw new QuiltError(400, 'bad_request', 'the amended commit would change nothing: to drop the commit, reset to sq:<id>~1 and commit that')
552
- const c = cleanCommit({
553
- quilt_id: q.id, square_id: sq.id, parents: old.parents, predecessor: old.id, author: authorOf(me),
554
- ...(me.on_behalf_of ? { on_behalf_of: me.on_behalf_of } : {}),
555
- ...(sq.task_id ? { task_id: sq.task_id } : {}),
556
- message: body.message != null && body.message !== '' ? body.message : old.message, authored_at: now(), changes: [...byPath.values()], stack: old.stack
557
- })
558
- const id = commitId(c)
559
- await seal(q.id, [...prep.blobs, { kind: 'commit', id, bytes: commitBytes(c) }], { by: me.member_id })
560
- await store.stageFiles({ quilt_id: q.id, commit_id: id, files: c.changes })
561
- try {
562
- const row = await store.commit({ commit: { ...c, id, at: now() }, expect_head: old.id, actor: me.member_id, at: now(), payload: prep.resolved.length ? { resolved: prep.resolved, amend: true } : { amend: true } })
563
- if (prep.resolved.length) await rememberHand(q.id, prep.resolved, c.changes, me)
564
- return { ok: true, seq: row.seq, amended: old.id, square: await squareView(await store.getSquare(sq.id)), result: commitView({ ...row, changes: c.changes }) }
565
- } catch (err) { throw fromStore(err) }
566
- })
567
- }
568
-
569
- const authorOf = (me) => ({ member_id: me.member_id, kind: me.kind, name: me.name, ...(me.tool ? { tool: me.tool } : {}) })
570
-
571
- /**
572
- * A commit record the client sealed in its pack (03): read back, and accepted only when it is
573
- * exactly the record the server would have written for this caller, square and head.
574
- */
575
- async function clientRecord (q, sq, me, parents, recordId) {
576
- if (!isId(recordId)) throw new QuiltError(400, 'bad_request', 'commit is a commit record id')
577
- if (!(await hasObjects(q.id, [recordId])).length) throw new QuiltError(422, 'pack_unsealed', `commit record ${recordId.slice(0, 12)} is not uploaded`, { ids: [recordId] })
578
- let c
579
- try { c = cleanCommit(decodeCommit(await readObject(q.id, recordId))) } catch (err) { throw new QuiltError(422, 'record_mismatch', `commit record ${recordId.slice(0, 12)}: ${err.message}`) }
580
- if (commitId(c) !== recordId) throw new QuiltError(422, 'record_mismatch', 'the object is not a canonical commit record')
581
- const want = {
582
- quilt_id: q.id, square_id: sq.id, parents, author: cleanCommit({ quilt_id: q.id, square_id: sq.id, author: authorOf(me) }).author,
583
- on_behalf_of: me.on_behalf_of || undefined, task_id: sq.task_id || undefined, predecessor: undefined, conflicted: 0
584
- }
585
- for (const [k, v] of Object.entries(want)) {
586
- if (JSON.stringify(c[k]) !== JSON.stringify(v)) {
587
- if (k === 'parents') throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: sq.head })
588
- throw new QuiltError(422, 'record_mismatch', `commit record ${k} is not what this server records for you (${JSON.stringify(v ?? null)})`, { field: k })
589
- }
590
- }
591
- if (!c.changes.length) throw new QuiltError(400, 'bad_request', 'nothing to commit')
592
- const prep = await prepareChanges(q.id, sq, c.changes)
593
- if (JSON.stringify(cleanChanges(prep.changes)) !== JSON.stringify(c.changes)) throw new QuiltError(422, 'record_mismatch', 'the record\'s changes are not what this server would record (a file still conflicted, or a size that differs): commit the changes instead', { field: 'changes' })
594
- return { c, id: recordId, resolved: prep.resolved }
595
- }
596
-
597
- /** A conflict settled by hand is always remembered (07, D12): the committed file is the answer. */
598
- async function rememberHand (q, resolved, changes, me) {
599
- const byPath = new Map(changes.map((x) => [x.path, x]))
600
- const picks = resolved.map((r) => { const x = byPath.get(r.path); return { ...r, pick: x && x.op === 'put' ? { blob: x.blob, mode: x.mode, size: x.size } : null } })
601
- await remember(store, getTerm, q, picks, me, now())
602
- }
603
-
604
- /**
605
- * Checks a commit's changes and turns inline contents into blobs. A path that holds a conflict
606
- * term at the parent stays conflicted while its new content still carries the term's marker,
607
- * and is recorded as resolved by hand once it does not (07).
608
- */
609
- async function prepareChanges (q, sq, list) {
610
- const blobs = []
611
- const out = []
612
- const resolved = []
613
- const terms = new Map()
614
- // Every term still in the head's tree, also those an earlier commit of the square left.
615
- if (sq.head) for (const e of await conflictsAt(q, sq.head)) terms.set(e.path, e.term)
616
- for (const ch of list) {
617
- const path = cleanPath(ch.path)
618
- if (path.split('/')[0] === '.seam') throw new QuiltError(400, 'bad_request', '.seam/ is the block\'s own folder and is never committed')
619
- if (ch.op === 'delete') { out.push({ path, op: 'delete' }); continue }
620
- if (ch.op === 'conflict') { out.push(await importedTerm(q, path, ch, blobs)); continue }
621
- if (ch.op !== 'put') throw new QuiltError(400, 'bad_request', `${path}: op is put or delete`)
622
- let id = ch.blob
623
- let size = ch.size
624
- if (ch.content != null) {
625
- const bytes = Buffer.from(String(ch.content), 'base64')
626
- const term = terms.get(path)
627
- if (term && bytes.includes(Buffer.from(`quilt-conflict ${term.slice(0, 12)}`))) continue // still conflicted: the term stays
628
- if (term) resolved.push({ path, term, how: 'hand' })
629
- id = blobId(bytes)
630
- size = bytes.length
631
- blobs.push({ kind: 'blob', id, bytes })
632
- } else {
633
- if (!isId(id)) throw new QuiltError(400, 'bad_request', `${path}: give content (base64) or a blob id`)
634
- const row = await store.getBlob(q, id)
635
- if (!row) throw new QuiltError(422, 'pack_unsealed', `${path}: blob ${id.slice(0, 12)} is not uploaded`, { ids: [id] })
636
- size = row.size
637
- const term = terms.get(path)
638
- if (term && (await readBlob(q, id)).includes(Buffer.from(`quilt-conflict ${term.slice(0, 12)}`))) continue
639
- if (term) resolved.push({ path, term, how: 'hand' })
640
- }
641
- let copyFrom
642
- if (ch.copy_from) {
643
- // 02 invariant 14: the copy_from commit had that path (one path lookup); a bad one is refused.
644
- const from = cleanPath(ch.copy_from.path)
645
- const at = ch.copy_from.commit ? await resolve(q, ch.copy_from.commit).catch(() => null) : null
646
- if (!at || !(await reader(q).lookup(await snapshot(q, at), from))) throw new QuiltError(422, 'bad_copy_from', `${path}: copy_from ${from} is not in ${String(ch.copy_from.commit || '(no commit)').slice(0, 12)}`)
647
- copyFrom = { path: from, commit: at }
648
- }
649
- out.push({ path, op: 'put', blob: id, mode: ch.mode || 'file', size, ...(copyFrom ? { copy_from: copyFrom } : {}) })
650
- }
651
- return { changes: out, blobs, resolved }
652
- }
653
-
654
- /**
655
- * A conflict brought in from another system (16: an open merge of a session that became a
656
- * quilt): `{ path, op: 'conflict', reason: 'import', base, ours, theirs }`, each side
657
- * `{ content (base64), from_square?, from_commit?, by? }` or null for a side without the file.
658
- * Writes a `content` term (`edit_vs_delete` when a side is gone) whose origin says it was
659
- * imported; from then on it is a term like a merge's (07): materialised, settled by hand.
660
- */
661
- async function importedTerm (q, path, ch, blobs) {
662
- if (ch.reason !== 'import') throw new QuiltError(400, 'bad_request', `${path}: a commit writes a conflict only with reason 'import'; merges make the others`)
663
- if (!ch.ours && !ch.theirs) throw new QuiltError(400, 'bad_request', `${path}: a conflict needs at least one side with the file`)
664
- const side = async (s, which) => {
665
- if (s == null) return {}
666
- if (typeof s !== 'object') throw new QuiltError(400, 'bad_request', `${path}: ${which} is { content } or null`)
667
- const out = {}
668
- if (s.content != null) {
669
- const bytes = Buffer.from(String(s.content), 'base64')
670
- out.blob = blobId(bytes)
671
- out.mode = 'file'
672
- blobs.push({ kind: 'blob', id: out.blob, bytes })
673
- }
674
- if (s.from_commit != null) {
675
- if (!isId(s.from_commit) || !(await store.getCommit(q, s.from_commit))) throw new QuiltError(422, 'bad_request', `${path}: ${which}.from_commit is not a commit of this quilt`)
676
- out.from_commit = s.from_commit
677
- }
678
- if (s.from_square != null) {
679
- const sq = await store.getSquare(String(s.from_square))
680
- if (!sq || sq.quilt_id !== q) throw new QuiltError(422, 'bad_request', `${path}: ${which}.from_square is not a square of this quilt`)
681
- out.from_square = sq.id
682
- }
683
- if (s.by != null) out.by = String(s.by).slice(0, 200)
684
- return out
685
- }
686
- const [base, ours, theirs] = [await side(ch.base, 'base'), await side(ch.ours, 'ours'), await side(ch.theirs, 'theirs')]
687
- const term = { kind: ours.blob && theirs.blob ? 'content' : 'edit_vs_delete', adds: [ours, theirs], removes: [base], origin: { reason: 'import' } }
688
- const id = termId(term)
689
- blobs.push({ kind: 'term', id, bytes: termBytes(term) })
690
- return { path, op: 'conflict', term: id }
691
- }
692
-
693
- // ---- uploads (04: have?, then one pack of what is missing) ----
694
-
695
- async function have (me, ref, ids) {
696
- const q = await quiltFor(me, ref, 'write')
697
- const list = (ids || []).filter(isId).slice(0, 10000)
698
- const present = new Set([...(await store.hasBlobs(q.id, list)), ...(await hasObjects(q.id, list))])
699
- return { missing: list.filter((id) => !present.has(id)) }
700
- }
701
-
702
- /** A client's pack: verified entry by entry before any row is written (03, "Sealing a pack"). */
703
- async function uploadPack (me, ref, bytes) {
704
- const q = await quiltFor(me, ref, 'write')
705
- let index
706
- try { index = verifyPack(bytes).index } catch (err) { throw new QuiltError(422, 'pack_corrupt', err.message) }
707
- const { id } = verifyPack(bytes)
708
- const sizes = index.map((e) => readEntry(e, bytes.subarray(e.offset, e.offset + e.len)).length)
709
- if (index.some((e) => !['blob', 'commit', 'tree', 'term'].includes(e.kind))) throw new QuiltError(422, 'pack_corrupt', 'this server takes blobs, commits, tree nodes and terms only')
710
- await objects.putPack(q.id, id, bytes)
711
- await sealRows(q.id, { id, bytes, index }, sizes, 'client', me.member_id)
712
- return { pack: id, objects: index.length }
713
- }
714
-
715
- // ---- reads ----
716
-
717
- async function quilts (me) { return (await store.quiltsFor(me.member_id)).map((q) => ({ id: q.id, slug: q.slug, name: q.name, owner: q.owner, level: q.level })) }
718
-
719
- async function quiltInfo (me, ref) {
720
- const q = await quiltFor(me, ref)
721
- const lines = await store.lines(q.id)
722
- return { id: q.id, slug: q.slug, name: q.name, owner: q.owner, level: q.level, head_seq: q.head_seq, lines: lines.map((l) => ({ name: l.name, tip: l.tip, patch_count: l.patch_count, frozen: l.frozen })), members: await store.members(q.id), stub_members: await store.stubMembers(q.id), you: { ...authorOf(me), ...(me.on_behalf_of ? { on_behalf_of: me.on_behalf_of } : {}) } }
723
- }
724
-
725
- async function squares (me, ref, filter = {}) {
726
- const q = await quiltFor(me, ref)
727
- const list = await store.squares(q.id, { state: filter.state, holder: filter.mine ? me.member_id : filter.holder, task: filter.task })
728
- return Promise.all(list.map(squareView))
729
- }
730
-
731
- async function square (me, ref, sqRef) {
732
- const q = await quiltFor(me, ref)
733
- return squareView(await squareByRef(q.id, sqRef))
734
- }
735
-
736
- async function show (me, ref, rev, { format = 'unified', paths } = {}) {
737
- const q = await quiltFor(me, ref)
738
- const id = await resolve(q.id, rev)
739
- if (!id) throw new QuiltError(404, 'not_found', `${rev || 'main'} has no commits yet`)
740
- const c = await getCommit(q.id, id)
741
- const out = { commit: commitView(c, { changes: true }), square: await squareView(await store.getSquare(c.square_id)) }
742
- if (format !== 'none') {
743
- const from = c.parents[0] || null
744
- out.diff = await diffTrees(q.id, from, id, { format, paths })
745
- }
746
- return out
747
- }
748
-
749
- /**
750
- * The patches on `line` at or below `upTo` (and above `stop`'s) that touched any of `prefixes`
751
- * (a file's own rows, or a directory's 'dir/' rows), newest first: { ids, more }.
752
- */
753
- async function pathPatches (q, line, prefixes, upTo, stop, limit) {
754
- let floor = 0
755
- if (stop) { const s = await store.getCommit(q, stop); if (s && s.patch && s.patch.line === line) floor = s.patch.patch_no }
756
- const rows = new Map()
757
- for (const p of prefixes) {
758
- const keys = p ? [p, p + '/'] : null
759
- if (!keys) return { ids: (await store.patches(q, line, { after: floor, before: upTo + 1, limit })).map((r) => r.commit), more: false }
760
- for (const key of keys) for (const r of await store.linePaths(q, line, key, { upTo, limit: limit + 1 })) if (r.patch_no > floor) rows.set(r.patch_no, r.commit_id)
761
- }
762
- const ns = [...rows.keys()].sort((a, b) => b - a)
763
- return { ids: ns.slice(0, limit).map((n) => rows.get(n)), more: ns.length > limit }
764
- }
765
-
766
- /**
767
- * Inside a square: the commits on `c`'s first-parent chain that are in `c`'s square (from its
768
- * commit rows, no file rows), and `exit`, the first parent of the lowest of them (outside the
769
- * square). `cache` keeps a square's rows across calls.
770
- */
771
- async function squareChain (q, c, cache = new Map()) {
772
- let rows = cache.get(c.square_id)
773
- if (!rows) { rows = new Map((await store.squareCommits(q, c.square_id)).map((r) => [r.id, r])); cache.set(c.square_id, rows) }
774
- const chain = new Map()
775
- let id = c.id
776
- let exit = null
777
- while (id && rows.has(id) && !chain.has(id)) { const r = rows.get(id); chain.set(id, r.seq); exit = r.parents[0] || null; id = exit }
778
- if (id && !rows.has(id)) exit = id
779
- return { chain, exit }
780
- }
781
-
782
- /**
783
- * The commits on `c`'s chain inside its square that touched any of `prefixes`, newest first,
784
- * from the square's file rows (commit_files) instead of a walk over its commits.
785
- */
786
- async function squarePathIds (q, c, prefixes, chain) {
787
- const seqs = new Map()
788
- for (const p of prefixes) {
789
- if (!p) { for (const [id, seq] of chain) seqs.set(id, seq); continue }
790
- for (const key of [p, p + '/']) {
791
- for (const r of await store.squarePathCommits(q, c.square_id, key, { upTo: c.seq, limit: chain.size })) if (chain.has(r.commit_id)) seqs.set(r.commit_id, r.seq)
792
- }
793
- }
794
- return [...seqs].sort((a, b) => b[1] - a[1]).map(([id]) => id)
795
- }
796
-
797
- async function log (me, ref, { rev = 'main', paths = [], limit = 20, all_squares: allSquares = false } = {}) {
798
- const q = await quiltFor(me, ref)
799
- const prefixes = (paths || []).map(cleanPrefix)
800
- let range = String(rev || 'main')
801
- let stop = null
802
- if (range.includes('..')) {
803
- const [a, b] = range.split('..')
804
- stop = a ? await resolve(q.id, a) : null
805
- range = b || 'main'
806
- }
807
- let id = await resolve(q.id, range)
808
- const out = []
809
- const max = Math.min(Number(limit) || 20, 1000)
810
- let walked = 0
811
- const chains = new Map()
812
- while (id && id !== stop && out.length < max && walked < 100000) {
813
- walked++
814
- const c = await getCommit(q.id, id)
815
- // On a line, the patches that touched the paths come from line_paths, not a walk.
816
- if (prefixes.length && c.patch && c.patch.line) {
817
- const more = await pathPatches(q.id, c.patch.line, prefixes, c.patch.patch_no, stop, max - out.length)
818
- for (const cid of more.ids) out.push(commitView(await getCommit(q.id, cid), { changes: false }))
819
- return { commits: out, more: more.more }
820
- }
821
- // Inside a square, the commits that touched the paths come from its file rows, not a walk.
822
- if (prefixes.length && !c.patch) {
823
- const { chain, exit } = await squareChain(q.id, c, chains)
824
- const stopSeq = stop && chain.has(stop) ? chain.get(stop) : -1
825
- for (const cid of await squarePathIds(q.id, c, prefixes, chain)) {
826
- if (chain.get(cid) <= stopSeq) break
827
- if (out.length >= max) return { commits: out, more: true }
828
- out.push(commitView(await getCommit(q.id, cid), { changes: false }))
829
- }
830
- if (stopSeq >= 0) return { commits: out, more: false }
831
- walked += chain.size
832
- id = exit
833
- continue
834
- }
835
- if (!prefixes.length || c.changes.some((ch) => prefixes.some((p) => under(ch.path, p)))) out.push(commitView(c, { changes: false }))
836
- id = c.parents[0] || null
837
- }
838
- void allSquares
839
- return { commits: out, more: !!id && id !== stop }
840
- }
841
-
842
- /** A line's patches, newest first, in one store read (the dashboard's history, numbers and people). */
843
- async function linePatches (me, ref, line = 'main', { before, limit = 100 } = {}) {
844
- const q = await quiltFor(me, ref)
845
- const max = Math.min(Math.max(Number(limit) || 100, 1), 1000)
846
- const rows = await store.linePatchesView(q.id, line, { before: Number(before) > 0 ? Number(before) : undefined, limit: max + 1 })
847
- return { patches: rows.slice(0, max), more: rows.length > max }
848
- }
849
-
850
- /** The tree diff between two commits, with per-file text diffs in `format`. */
851
- async function diffTrees (q, fromId, toId, opts) {
852
- return diffRoots(q, await snapshot(q, fromId), await snapshot(q, toId), opts)
853
- }
854
-
855
- async function diffRoots (q, ra, rb, { format = 'unified', paths = [] } = {}) {
856
- const prefixes = (paths || []).map(cleanPrefix)
857
- const files = (await reader(q).diff(ra, rb)).filter((d) => !prefixes.length || prefixes.some((p) => under(d.path, p)))
858
- return describeFileDiffs(q, files, format)
859
- }
860
-
861
- async function describeFileDiffs (q, files, format) {
862
- const out = []
863
- let add = 0; let del = 0
864
- for (const d of files) {
865
- const status = !d.before ? 'added' : !d.after ? 'deleted' : (d.after.mode === 'conflict' ? 'conflicted' : 'modified')
866
- const f = { path: d.path, status, ...(d.before?.blob ? { before: d.before.blob } : {}), ...(d.after?.blob ? { after: d.after.blob } : {}) }
867
- if (format === 'names') { out.push(f); continue }
868
- const [a, b] = [d.before?.blob ? await readBlob(q, d.before.blob) : null, d.after?.blob ? await readBlob(q, d.after.blob) : null]
869
- if (!isText(a) || !isText(b)) { f.binary = true; out.push(f); continue }
870
- const [ta, tb] = [a ? a.toString('utf8') : null, b ? b.toString('utf8') : null]
871
- const st = lineStat(ta || '', tb || '')
872
- Object.assign(f, st)
873
- add += st.add; del += st.del
874
- if (format === 'unified') f.patch = d.after?.mode === 'conflict' ? `conflict in ${d.path} (term ${d.after.term.slice(0, 12)})\n` : unified(d.path, ta, tb)
875
- out.push(f)
876
- }
877
- return { files: out, add, del }
878
- }
879
-
880
- async function diff (me, ref, { from, to, paths, format = 'unified' } = {}) {
881
- const q = await quiltFor(me, ref)
882
- const b = await rootAt(q.id, to || 'main')
883
- let a
884
- if (from) a = await rootAt(q.id, from)
885
- else if (String(to || '').startsWith('sq:')) {
886
- const sq = await squareByRef(q.id, String(to).split(/[~:^]/).slice(0, 2).join(':'))
887
- // The square's live work against its head; any other square revision against its base.
888
- const id = b.live || LIVE.test(String(to)) ? (sq.head || sq.base) : sq.base
889
- a = { id, root: await snapshot(q.id, id) }
890
- } else { const id = b.id ? (await getCommit(q.id, b.id)).parents[0] || null : null; a = { id, root: await snapshot(q.id, id) } }
891
- return { from: a.id, to: b.id, ...(b.live ? { live: true } : {}), ...(await diffRoots(q.id, a.root, b.root, { format, paths })) }
892
- }
893
-
894
- /** The entries under `paths` at a revision (ls-tree; what a block checks out). */
895
- async function tree (me, ref, rev, { paths = [''] } = {}) {
896
- const q = await quiltFor(me, ref)
897
- const { id, root } = await rootAt(q.id, rev)
898
- const prefixes = (paths && paths.length ? paths : ['']).map(cleanPrefix)
899
- return { commit: id, root, entries: await reader(q.id).list(root, prefixes) }
900
- }
901
-
902
- /** Blob contents by id, base64 (want-only fetch, decision 31). */
903
- async function blobs (me, ref, ids) {
904
- const q = await quiltFor(me, ref)
905
- const out = {}
906
- let total = 0
907
- for (const id of (ids || []).slice(0, 2000)) {
908
- if (!isId(id)) continue
909
- const b = await readBlob(q.id, id)
910
- total += b.length
911
- out[id] = b.toString('base64')
912
- if (total > 32 * 1024 * 1024) break
913
- }
914
- return { blobs: out }
915
- }
916
-
917
- /** A file at a revision (`cat`), with a conflict term materialised. */
918
- async function cat (me, ref, rev, path, { format = 'diff' } = {}) {
919
- const q = await quiltFor(me, ref)
920
- const { root } = await rootAt(q.id, rev)
921
- const e = await reader(q.id).lookup(root, cleanPath(path))
922
- if (!e) throw new QuiltError(404, 'not_found', `${path} is not in ${rev || 'main'}`)
923
- if (e.mode === 'conflict') return { path, conflict: true, term: e.term, content: Buffer.from(await materialise(q.id, e.term, format)).toString('base64') }
924
- return { path, mode: e.mode, blob: e.blob, size: e.size, content: (await readBlob(q.id, e.blob)).toString('base64') }
925
- }
926
-
927
- async function getTerm (q, id) { return decodeTerm(await readObject(q, id)) }
928
-
929
- /** A conflict term as text a person or agent can edit (07): `diff` (default), `snapshot` or `git`. */
930
- async function materialise (q, termIdHex, format = 'diff') {
931
- const t = await getTerm(q, termIdHex)
932
- const side = async (s) => (s && s.blob ? (await readBlob(q, s.blob)).toString('utf8') : '')
933
- const label = (s, which) => `${which} (${[s.by || 'someone', s.from_square && squareShort(s.from_square), s.from_commit && s.from_commit.slice(0, 8)].filter(Boolean).join(' · ')})`
934
- const tag = `quilt-conflict ${termIdHex.slice(0, 12)}`
935
- if (format === 'git' && t.adds.length > 2) throw new QuiltError(400, 'too_many_sides', `git markers hold two sides; this term has ${t.adds.length}: ask for format diff or snapshot`)
936
- if (format === 'snapshot') {
937
- const parts = [`<<<<<<< ${tag} ${t.kind}`]
938
- for (let i = 0; i < t.adds.length; i++) parts.push(`+++++++ ${label(t.adds[i], `side ${i}`)}`, await side(t.adds[i]))
939
- for (let i = 0; i < t.removes.length; i++) parts.push(`------- base ${i}`, await side(t.removes[i]))
940
- parts.push(`>>>>>>> ${tag}`)
941
- return parts.join('\n') + '\n'
942
- }
943
- if (format === 'diff') {
944
- const { diffMarkers } = await import('./conflict-format.js')
945
- const sides = await Promise.all(t.adds.map(async (s, i) => ({ text: s.blob ? await side(s) : null, label: label(s, i === 0 ? 'ours' : 'theirs'), by: s.by })))
946
- return diffMarkers({ sides, bases: await Promise.all(t.removes.map(side)), tag, kind: t.kind })
947
- }
948
- const { gitMarkers } = await import('./conflict-format.js')
949
- const sideOf = async (s, which) => ({ text: s.blob ? await side(s) : null, label: label(s, which), by: s.by })
950
- return gitMarkers({ ours: await sideOf(t.adds[0], 'ours'), base: await side(t.removes[0]), theirs: await sideOf(t.adds[1], 'theirs'), tag })
951
- }
952
-
953
- return {
954
- // used by the other modules (merge.js, blame.js, import)
955
- internals: { rootAt, prepareChanges, pathPatches, squareChain, squarePathIds, conflictedPaths, store, objects, now, conflictsAt, quiltFor, squareByRef, resolve, getCommit, snapshot, entriesOf, treeObjects, treeGrower, reader, seal, readBlob, readObject, squareView, commitView, writable, once, getTerm, materialise, describeFileDiffs, fromStore },
956
- createQuilt, addMember, quilts, quiltInfo,
957
- openSquare, squares, square,
958
- commit, amend, have, uploadPack,
959
- show, log, linePatches, diff, tree, blobs, cat
960
- }
961
- }
962
-
963
- export { KIND, termBytes, termId, decodeCommit, uuidv7 }