@heyquilt/seam 0.27.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/seam.js +117 -17
  2. package/lib/quilts/agent-tokens.js +148 -0
  3. package/lib/quilts/archiver.js +241 -0
  4. package/lib/quilts/attest.js +212 -0
  5. package/lib/quilts/bisect-run.js +3 -2
  6. package/lib/quilts/block.js +497 -55
  7. package/lib/quilts/cas.js +78 -0
  8. package/lib/quilts/cbor.js +2 -0
  9. package/lib/quilts/claims.js +409 -0
  10. package/lib/quilts/cli.js +611 -69
  11. package/lib/quilts/client.js +305 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +281 -38
  15. package/lib/quilts/dict.js +34 -0
  16. package/lib/quilts/exit-archive.js +184 -0
  17. package/lib/quilts/export-format.js +52 -0
  18. package/lib/quilts/export-git.js +27 -3
  19. package/lib/quilts/export-lfs.js +52 -0
  20. package/lib/quilts/export-sign.js +49 -0
  21. package/lib/quilts/fastcdc.js +186 -0
  22. package/lib/quilts/hash.js +17 -2
  23. package/lib/quilts/ignored.js +46 -0
  24. package/lib/quilts/import-git.js +54 -2
  25. package/lib/quilts/journal.js +105 -0
  26. package/lib/quilts/limits.js +197 -0
  27. package/lib/quilts/linepolicy.js +109 -0
  28. package/lib/quilts/pack.js +146 -18
  29. package/lib/quilts/persona.js +70 -0
  30. package/lib/quilts/records.js +7 -3
  31. package/lib/quilts/recover.js +37 -0
  32. package/lib/quilts/runner.js +79 -12
  33. package/lib/quilts/sealed.js +231 -0
  34. package/lib/quilts/secrets.js +86 -0
  35. package/lib/quilts/signing.js +7 -3
  36. package/lib/quilts/tarball.js +107 -0
  37. package/lib/quilts/textdiff.js +7 -5
  38. package/lib/quilts/tools.js +401 -83
  39. package/lib/quilts/tree.js +351 -0
  40. package/lib/quilts/want.js +51 -0
  41. package/lib/quilts/webhook.js +116 -0
  42. package/package.json +3 -2
@@ -0,0 +1,78 @@
1
+ // The machine cache (plans/quilt-source-control/04 "Three tiers", 05): immutable objects by id,
2
+ // shared by every block on this machine and every quilt, in ~/.seam/cas/<kind>/<id[0:2]>/<id>.
3
+ // It plans downloads and nothing else (04): what is here is never a reason to skip an upload.
4
+ // Every read checks the bytes against the id, so a file changed on disk is dropped, never served.
5
+ // Kept under a byte cap (SEAM_CAS_BYTES, default 2 GiB; 0 turns it off), least recently used
6
+ // first out. Only plain objects whose id is the hash of their bytes go in: tree nodes and the
7
+ // files of quilts that are not sealed.
8
+ import fs from 'node:fs'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+ import { idOf, isId } from './hash.js'
12
+
13
+ export const CAS_BYTES = 2 * 1024 ** 3
14
+
15
+ export function machineCas ({ dir = path.join(os.homedir(), '.seam', 'cas'), env = process.env, capBytes } = {}) {
16
+ const cap = capBytes ?? (env.SEAM_CAS_BYTES !== undefined && env.SEAM_CAS_BYTES !== '' ? Number(env.SEAM_CAS_BYTES) : CAS_BYTES)
17
+ const on = Number.isFinite(cap) && cap > 0
18
+ let total = null // bytes held, counted once per process on first write
19
+ const file = (kind, id) => path.join(dir, kind, id.slice(0, 2), id)
20
+ const all = () => {
21
+ const out = []
22
+ for (const kind of fs.existsSync(dir) ? fs.readdirSync(dir) : []) {
23
+ const kd = path.join(dir, kind)
24
+ for (const pre of fs.statSync(kd).isDirectory() ? fs.readdirSync(kd) : []) {
25
+ for (const id of fs.readdirSync(path.join(kd, pre))) {
26
+ const f = path.join(kd, pre, id)
27
+ try { const st = fs.statSync(f); out.push({ f, size: st.size, used: st.mtimeMs }) } catch {}
28
+ }
29
+ }
30
+ }
31
+ return out
32
+ }
33
+ const count = () => { if (total === null) total = all().reduce((n, x) => n + x.size, 0); return total }
34
+ const prune = () => {
35
+ const list = all().sort((a, b) => a.used - b.used)
36
+ total = list.reduce((n, x) => n + x.size, 0)
37
+ for (const x of list) {
38
+ if (total <= cap) break
39
+ try { fs.rmSync(x.f, { force: true }); total -= x.size } catch {}
40
+ }
41
+ }
42
+ return {
43
+ file,
44
+ bytes: () => count(),
45
+ /** The object's bytes, or null (not here, off, or changed on disk: then removed). */
46
+ get (kind, id) {
47
+ if (!on || !isId(id)) return null
48
+ const f = file(kind, id)
49
+ let b
50
+ try { b = fs.readFileSync(f) } catch { return null }
51
+ if (idOf(kind, b) !== id) { fs.rmSync(f, { force: true }); if (total !== null) total -= b.length; return null }
52
+ const t = new Date()
53
+ try { fs.utimesSync(f, t, t) } catch {}
54
+ return b
55
+ },
56
+ /** Keeps an object whose bytes hash to its id; false when they do not, or the cache is off. */
57
+ put (kind, id, bytes) {
58
+ if (!on || !isId(id) || bytes.length > cap || idOf(kind, bytes) !== id) return false
59
+ const f = file(kind, id)
60
+ if (fs.existsSync(f)) return true
61
+ // A home folder this process cannot write (a locked-down CI job) leaves the cache empty, never fails a checkout.
62
+ try {
63
+ count()
64
+ fs.mkdirSync(path.dirname(f), { recursive: true })
65
+ const tmp = `${f}.${process.pid}.tmp`
66
+ fs.writeFileSync(tmp, bytes)
67
+ fs.renameSync(tmp, f)
68
+ total += bytes.length
69
+ if (total > cap) prune()
70
+ return true
71
+ } catch { return false }
72
+ }
73
+ }
74
+ }
75
+
76
+ let shared = null
77
+ /** This process's machine cache (home and settings read once). */
78
+ export const theCas = () => (shared ??= machineCas())
@@ -56,8 +56,10 @@ export function encode (v) {
56
56
  export function decode (buf) {
57
57
  const b = Buffer.from(buf.buffer ? buf : Buffer.from(buf))
58
58
  let pos = 0
59
+ const need = (n) => { if (pos + n > b.length) throw new Error('cbor: truncated') }
59
60
  const arg = (info) => {
60
61
  if (info < 24) return info
62
+ if (info <= 27) need(1 << (info - 24))
61
63
  if (info === 24) return b[pos++]
62
64
  if (info === 25) { const n = b.readUInt16BE(pos); pos += 2; return n }
63
65
  if (info === 26) { const n = b.readUInt32BE(pos); pos += 4; return n }
@@ -0,0 +1,409 @@
1
+ // Claims on a square's files (plans/quilt-source-control/06, "Claims on squares"): a claim says
2
+ // "this square is working on these paths" so nobody else spends work on them. A write under a
3
+ // claimed pattern from another open square on the same line is refused (412 claim_refused) and the
4
+ // writer is told who holds it and why. A claim lasts while its square is open: merging, abandoning
5
+ // or archiving the square lets go of it. Merges never check claims; overlapping work that was
6
+ // written before a claim existed still meets at the merge (07).
7
+ import { QuiltError, squareShort } from './errors.js'
8
+ import { uuidv7 } from './ids.js'
9
+
10
+ export const MAX_PATTERN = 500
11
+ export const MAX_NOTE = 300
12
+ export const MAX_CLAIMS = 200 // per square
13
+
14
+ /** A path, folder or simple glob (*, **, ?) as a matcher over file paths. */
15
+ export function globMatcher (pattern) {
16
+ const p = String(pattern).trim().replace(/^\.\//, '')
17
+ if (!/[*?]/.test(p)) {
18
+ const base = p.replace(/\/+$/, '')
19
+ return (rel) => rel === base || rel.startsWith(base + '/')
20
+ }
21
+ let re = ''
22
+ for (let i = 0; i < p.length; i++) {
23
+ const c = p[i]
24
+ if (c === '*' && p[i + 1] === '*') {
25
+ re += '.*'
26
+ i++
27
+ if (p[i + 1] === '/') i++
28
+ } else if (c === '*') re += '[^/]*'
29
+ else if (c === '?') re += '[^/]'
30
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
31
+ }
32
+ const rx = new RegExp(`^${re}$`)
33
+ return (rel) => rx.test(rel)
34
+ }
35
+
36
+ /** Whether two patterns can cover the same file (paths: files that exist now). */
37
+ export function patternsOverlap (a, b, paths = []) {
38
+ if (a === b) return true
39
+ const ma = globMatcher(a)
40
+ const mb = globMatcher(b)
41
+ if (ma(b) || mb(a) || paths.some((p) => ma(p) && mb(p))) return true
42
+ // A glob reaches every file under the text before its first wildcard: when one pattern's stem
43
+ // is inside the other's, they may meet on a file that does not exist yet.
44
+ const stem = (p) => p.split(/[*?]/)[0]
45
+ const [sa, sb] = [stem(a), stem(b)]
46
+ if (sa === a && sb === b) return false
47
+ const [short, long] = sa.length <= sb.length ? [sa, sb] : [sb, sa]
48
+ return short !== '' && (long.startsWith(short.endsWith('/') ? short : short + '/') || long === short)
49
+ }
50
+
51
+ /** A claim pattern: a relative path, folder or glob, no `..`. */
52
+ export function claimPattern (raw) {
53
+ const p = String(raw || '').trim().replace(/^\.\//, '')
54
+ if (!p || p.length > MAX_PATTERN || p.startsWith('/') || p.split('/').includes('..') || p.includes('\0') || p.includes('\\')) {
55
+ throw new QuiltError(400, 'bad_request', `a claim is a relative path, folder or glob (*, **, ?), up to ${MAX_PATTERN} characters`)
56
+ }
57
+ return p
58
+ }
59
+
60
+ const ACTIVE = new Set(['open', 'merging'])
61
+
62
+ /** 06 "Auto-claims following edits": an auto-claim lets go this long after the last write to it. */
63
+ export const AUTO_CLAIM_QUIET_MS = 10 * 60_000
64
+
65
+ /**
66
+ * After square `sq` wrote `paths` (SEAM_AUTO_CLAIMS): claims each path nobody's live claim covers
67
+ * for it, `auto`, and refreshes its own auto-claims on paths it wrote again. A path under a claim
68
+ * (its own made on purpose, or a parent's it writes under) is left alone. Best effort: a write never
69
+ * fails for it.
70
+ */
71
+ export async function autoClaim (store, q, sq, paths, holder, at, idleMs = 0) {
72
+ if (!paths.length || sq.state !== 'open') return
73
+ try {
74
+ const live = (await liveClaims(store, q, at, idleMs)).filter((c) => c.line === sq.line)
75
+ let mine = live.filter((c) => c.square_id === sq.id).length
76
+ for (const p of new Set(paths)) {
77
+ if (/[*?]/.test(p) || p.length > MAX_PATTERN) continue
78
+ const covering = live.filter((c) => globMatcher(c.pattern)(p))
79
+ const own = covering.find((c) => c.auto && c.square_id === sq.id && c.pattern === p)
80
+ if (own) {
81
+ await store.deleteClaim({ quilt_id: q, id: own.id })
82
+ await store.createClaim({ quilt_id: q, id: uuidv7(at), square_id: sq.id, pattern: p, holder: own.holder, note: own.note || '', at, auto: true })
83
+ continue
84
+ }
85
+ if (covering.length || mine >= MAX_CLAIMS) continue
86
+ await store.createClaim({ quilt_id: q, id: uuidv7(at), square_id: sq.id, pattern: p, holder, note: '', at, auto: true })
87
+ mine++
88
+ }
89
+ } catch {}
90
+ }
91
+
92
+ /** 06 "Owner mode": the default owner paths, the costliest files to conflict in. */
93
+ export const OWNER_PATHS_DEFAULT = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'Cargo.lock', 'go.sum', 'supabase/migrations/**']
94
+ /** An owner claim lets go this long after the last write under it from its square (owner_idle_hours' default). */
95
+ export const OWNER_IDLE_MS = 2 * 3600_000
96
+ /** A quilt's owner idle time: settings.owner_idle_hours (1..24) or the 2-hour default. */
97
+ export const ownerIdleMsOf = (quilt) => Number.isInteger(quilt?.settings?.owner_idle_hours) ? quilt.settings.owner_idle_hours * 3600_000 : OWNER_IDLE_MS
98
+
99
+ /** SEAM_OWNER_PATHS: unset is off, `default` the list above, else comma-separated patterns. */
100
+ export function ownerPathsFrom (v) {
101
+ const s = String(v ?? '').trim()
102
+ if (!s) return []
103
+ if (s === 'default') return [...OWNER_PATHS_DEFAULT]
104
+ return s.split(',').map((x) => x.trim()).filter(Boolean).map(claimPattern)
105
+ }
106
+
107
+ /**
108
+ * After square `sq` wrote `paths` (SEAM_OWNER_PATHS): a write under an owner pattern claims the
109
+ * whole pattern for the square, `owner`, or refreshes the square's own owner claim on it. A pattern
110
+ * another square holds was already refused at the write. Best effort, like autoClaim.
111
+ */
112
+ export async function ownerClaim (store, q, sq, paths, holder, at, idleMs = 0, ownerPaths = []) {
113
+ if (!paths.length || !ownerPaths.length || sq.state !== 'open') return
114
+ try {
115
+ const hit = ownerPaths.filter((o) => paths.some((p) => globMatcher(o)(p)))
116
+ if (!hit.length) return
117
+ const live = (await liveClaims(store, q, at, idleMs)).filter((c) => c.line === sq.line && c.square_id === sq.id)
118
+ for (const o of hit) {
119
+ const own = live.find((c) => c.pattern === o)
120
+ if (own && !own.owner) continue // claimed on purpose: it holds until the square ends
121
+ if (own) await store.deleteClaim({ quilt_id: q, id: own.id })
122
+ else if (live.length >= MAX_CLAIMS) continue
123
+ await store.createClaim({ quilt_id: q, id: uuidv7(at), square_id: sq.id, pattern: o, holder: own?.holder || holder, note: own?.note || '', at, owner: true })
124
+ }
125
+ } catch {}
126
+ }
127
+
128
+ /** Claims after a write: owner patterns first (they cover their paths), then auto-claims. */
129
+ export async function claimWrites (store, q, sq, paths, holder, at, { idleMs = 0, auto = false, ownerPaths = [], unchanged = [] } = {}) {
130
+ // 06: a write that leaves an owner path as the square had it (an install that changed nothing) takes no owner claim.
131
+ if (ownerPaths.length) await ownerClaim(store, q, sq, paths.filter((p) => !unchanged.includes(p)), holder, at, idleMs, ownerPaths)
132
+ if (auto) await autoClaim(store, q, sq, paths, holder, at, idleMs)
133
+ }
134
+
135
+ /**
136
+ * Moves claim `c` to request `r`'s square and holder, re-points the rest of its queue at the new
137
+ * square in order, and writes the `claim_handoff` row the events job turns into claim.granted and
138
+ * claim.handoff. Used by a handoff and by the idle sweep (06 "Queues and idle release").
139
+ */
140
+ export async function moveClaim (store, qid, c, r, note, actor, at, why = null) {
141
+ await store.deleteClaim({ quilt_id: qid, id: c.id })
142
+ const row = await store.createClaim({ quilt_id: qid, id: uuidv7(at), square_id: r.by_square, pattern: c.pattern, holder: r.by, note: note || c.note || '', at, ...(c.expires_at ? { expires_at: c.expires_at } : {}) })
143
+ await store.deleteClaimRequest({ quilt_id: qid, id: r.id })
144
+ for (const x of queueOf(await store.claimRequests(qid), c)) {
145
+ await store.deleteClaimRequest({ quilt_id: qid, id: x.id })
146
+ if (x.by_square !== r.by_square) await store.addClaimRequest({ ...x, square_id: r.by_square })
147
+ }
148
+ await store.setSquare({ square_id: r.by_square, op: 'claim_handoff', patch: {}, log: { pattern: c.pattern, from: c.holder, from_square: c.square_id, to: r.by, note: note || c.note || '', ...(why ? { why } : {}) }, actor, at }).catch(() => {})
149
+ return row
150
+ }
151
+
152
+ /**
153
+ * 06 owner mode: an owner claim lets go once its square no longer changes anything under the
154
+ * pattern (`changed`: the paths that differ between the square's base and its new head, after a
155
+ * revert or a restore). The first waiter gets it (claim.granted, why settled); with nobody waiting
156
+ * it is released.
157
+ */
158
+ export async function releaseSettled (store, q, sq, changed, at) {
159
+ const mine = (await store.claims(q)).filter((c) => c.owner && c.square_id === sq.id)
160
+ for (const c of mine) {
161
+ const m = globMatcher(c.pattern)
162
+ if (changed.some((p) => m(p))) continue
163
+ const waiting = queueOf(await store.claimRequests(q), c)
164
+ if (waiting.length) await moveClaim(store, q, c, waiting[0], '', 'system', at, 'settled')
165
+ else await store.deleteClaim({ quilt_id: q, id: c.id })
166
+ }
167
+ }
168
+
169
+ /** When square `sq` (open) last moved for claim `c`: its last change, its live work, or the claim itself. */
170
+ export async function claimActiveAt (store, qid, sq, c) {
171
+ return Math.max(sq.updated_at || 0, sq.opened_at || 0, (await store.getLive(qid, sq.id))?.at || 0, c.at || 0)
172
+ }
173
+
174
+ /**
175
+ * The claims that still hold at `at`: those of open squares not past their `expires_at`. With
176
+ * `idleMs` (06 "Queues and idle release", SEAM_CLAIM_IDLE_MINUTES) a claim whose square has seen
177
+ * nothing for that long (no commit or other change to the square, no live work, the claim itself
178
+ * not newer) has let go.
179
+ */
180
+ export async function liveClaims (store, q, at = Date.now(), idleMs = 0) {
181
+ const rows = await store.claims(q)
182
+ if (!rows.length) return []
183
+ const squares = new Map()
184
+ const active = new Map()
185
+ const out = []
186
+ let ownerIdleMs = null
187
+ for (const c of rows) {
188
+ if (c.owner && ownerIdleMs === null) ownerIdleMs = ownerIdleMsOf(await store.getQuilt(q))
189
+ if (!squares.has(c.square_id)) squares.set(c.square_id, await store.getSquare(c.square_id))
190
+ const sq = squares.get(c.square_id)
191
+ if (!sq || !ACTIVE.has(sq.state) || c.expires_at <= at) continue
192
+ if (c.auto && (c.at || 0) + AUTO_CLAIM_QUIET_MS <= at) continue
193
+ if (c.owner && (c.at || 0) + ownerIdleMs <= at) continue
194
+ if (idleMs > 0 && sq.state === 'open') {
195
+ if (!active.has(sq.id)) active.set(sq.id, Math.max(sq.updated_at || 0, sq.opened_at || 0, (await store.getLive(q, sq.id))?.at || 0))
196
+ if (Math.max(active.get(sq.id), c.at || 0) + idleMs <= at) continue
197
+ }
198
+ out.push({ ...c, line: sq.line })
199
+ }
200
+ return out
201
+ }
202
+
203
+ /**
204
+ * Refuses a write to `paths` from square `sq` when another open square on its line claims one.
205
+ * Given the `writer` (a commit, not live work, which repeats), the refusal is also written to the
206
+ * square's log so the writer hears claim.refused (12) wherever it reads events.
207
+ */
208
+ export async function guardClaims (store, q, sq, paths, at = Date.now(), idleMs = 0, writer = null) {
209
+ if (!paths.length) return
210
+ // A stacked square writes under its parents' claims (06): it builds on their work.
211
+ const mine = new Set([sq.id])
212
+ for (let p = sq; p.parent_square && mine.size <= 10;) { mine.add(p.parent_square); p = await store.getSquare(p.parent_square); if (!p) break }
213
+ const others = (await liveClaims(store, q, at, idleMs)).filter((c) => !mine.has(c.square_id) && c.line === sq.line)
214
+ for (const c of others) {
215
+ const m = globMatcher(c.pattern)
216
+ const hit = paths.find((p) => m(p))
217
+ if (hit) {
218
+ if (writer) {
219
+ const pos = queueOf(await store.claimRequests(q), c).findIndex((r) => r.by_square === sq.id)
220
+ await store.setSquare({ square_id: sq.id, op: 'claim_refused', patch: {}, log: { path: hit, claim: c.id, pattern: c.pattern, holder: c.holder, holder_square: c.square_id, note: c.note || '', queue_position: pos < 0 ? null : pos + 1 }, actor: writer, at }).catch(() => {})
221
+ }
222
+ throw new QuiltError(412, 'claim_refused', `${hit} is claimed by ${c.holder} in ${squareShort(c.square_id)}${c.note ? ` (${c.note})` : ''}: wait for it, ask them to hand it over, or work on other files`,
223
+ { path: hit, claim: view(c) })
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * 06's `claims_overlap` for square `sq`: the claims that hold on its line, held by other squares
230
+ * (not its own stack's parents, whose claims it writes under), that cover a path it changed
231
+ * (`changed`) or overlap a pattern it claims. Each with holder, task and the paths it covers.
232
+ */
233
+ export async function claimsOverlap (store, q, sq, changed = [], at = Date.now(), idleMs = 0) {
234
+ if (!['open', 'merging'].includes(sq.state)) return []
235
+ const mine = new Set([sq.id])
236
+ for (let p = sq; p.parent_square && mine.size <= 10;) { mine.add(p.parent_square); p = await store.getSquare(p.parent_square); if (!p) break }
237
+ const live = await liveClaims(store, q, at, idleMs)
238
+ const held = live.filter((c) => c.square_id === sq.id).map((c) => c.pattern)
239
+ const out = []
240
+ for (const c of live) {
241
+ if (mine.has(c.square_id) || c.line !== sq.line) continue
242
+ const m = globMatcher(c.pattern)
243
+ const paths = changed.filter((p) => m(p))
244
+ const patterns = held.filter((h) => patternsOverlap(h, c.pattern, changed))
245
+ if (!paths.length && !patterns.length) continue
246
+ const holderSq = await store.getSquare(c.square_id)
247
+ out.push({ ...view(c), ...(holderSq?.task_id ? { task_id: holderSq.task_id } : {}), paths: paths.slice(0, 50), ...(patterns.length ? { your_claims: patterns } : {}) })
248
+ }
249
+ return out.sort((a, b) => a.at - b.at)
250
+ }
251
+
252
+ const view = (c) => ({ id: c.id, pattern: c.pattern, square: squareShort(c.square_id), square_id: c.square_id, line: c.line, holder: c.holder, note: c.note || '', at: c.at, ...(c.auto ? { auto: true } : {}), ...(c.owner ? { owner: true } : {}), ...(c.expires_at ? { expires_at: c.expires_at } : {}) })
253
+
254
+ // A claim made with `expires_in` (seconds) lets go by itself then (06 `expires_at`): from a minute to a week.
255
+ const MIN_EXPIRY = 60
256
+ const MAX_EXPIRY = 7 * 24 * 3600
257
+ function expiresIn (v) {
258
+ if (v === undefined || v === null || v === '') return 0
259
+ const n = Number(v)
260
+ if (!Number.isInteger(n) || n < MIN_EXPIRY || n > MAX_EXPIRY) throw new QuiltError(400, 'bad_request', `expires_in is whole seconds from ${MIN_EXPIRY} to ${MAX_EXPIRY} (a week)`)
261
+ return n
262
+ }
263
+
264
+ // 06 "Reuse": today's claim-request limits.
265
+ export const MAX_QUEUE = 20
266
+ export const MAX_REQUEST_TITLE = 120
267
+ export const MAX_REQUEST_TEXT = 300
268
+ export const MAX_HANDOFF_TEXT = 2000
269
+
270
+ const requestView = (r) => ({ id: r.id, by: r.by, square: squareShort(r.by_square), square_id: r.by_square, title: r.title, description: r.description || '', at: r.at })
271
+ const queueOf = (requests, c) => requests.filter((r) => r.square_id === c.square_id && r.pattern === c.pattern)
272
+
273
+ export function claimOps (svc) {
274
+ const { store, now, quiltFor, squareByRef, writable, entriesOf, snapshot, fromStore, claimIdleMs, memberName } = svc.internals
275
+ // TODO 0.6: a claim names its holder by name too (holder_name), the id staying in holder.
276
+ const named = async (v) => { const n = memberName ? await memberName(v.holder) : null; return n ? { ...v, holder_name: n } : v }
277
+
278
+ /** GET /claims[?square=]: the claims that hold now, oldest first. */
279
+ async function claims (me, ref, { square } = {}) {
280
+ const q = await quiltFor(me, ref, 'read')
281
+ let rows = await liveClaims(store, q.id, now(), claimIdleMs)
282
+ if (square) { const sq = await squareByRef(q.id, square); rows = rows.filter((c) => c.square_id === sq.id) }
283
+ const requests = await store.claimRequests(q.id)
284
+ return { claims_version: await store.claimsVersion(q.id), claims: await Promise.all(rows.sort((a, b) => a.at - b.at).map((c) => { const queue = queueOf(requests, c); return named({ ...view(c), ...(queue.length ? { queue: queue.map(requestView) } : {}) }) })) }
285
+ }
286
+
287
+ /** POST /squares/:id/claims { pattern, note? }: claims paths for the square. */
288
+ async function claim (me, ref, sqRef, body = {}) {
289
+ const q = await quiltFor(me, ref, 'write')
290
+ const sq = await writable(me, q, sqRef)
291
+ if (sq.state !== 'open') throw new QuiltError(409, 'square_closed', `the square is ${sq.state}`)
292
+ const pattern = claimPattern(body.pattern)
293
+ const note = String(body.note || '')
294
+ if (note.length > MAX_NOTE) throw new QuiltError(400, 'bad_request', `a claim's note is up to ${MAX_NOTE} characters`)
295
+ const ttl = expiresIn(body.expires_in)
296
+ const at = now()
297
+ const all = await liveClaims(store, q.id, at, claimIdleMs)
298
+ const mine = all.filter((c) => c.square_id === sq.id)
299
+ const same = mine.find((c) => c.pattern === pattern)
300
+ if (same && !same.auto) return view(same)
301
+ if (mine.length >= MAX_CLAIMS) throw new QuiltError(400, 'bad_request', `a square holds up to ${MAX_CLAIMS} claims`)
302
+ const others = all.filter((c) => c.square_id !== sq.id && c.line === sq.line)
303
+ if (others.length) {
304
+ const tip = (await store.getLine(q.id, sq.line))?.tip || sq.base
305
+ const paths = tip ? (await entriesOf(q.id, await snapshot(q.id, tip))).map((e) => e.path) : []
306
+ const taken = others.find((c) => patternsOverlap(c.pattern, pattern, paths))
307
+ if (taken) throw new QuiltError(409, 'claim_taken', `${taken.pattern} is already claimed by ${(memberName && await memberName(taken.holder)) || taken.holder} in ${squareShort(taken.square_id)}${taken.note ? ` (${taken.note})` : ''}`, { claim: view(taken) })
308
+ }
309
+ try {
310
+ // A lapsed claim of this square's is a dead row: clear them so unclaim by pattern finds the live one.
311
+ // Claiming on purpose a path the square holds by an auto-claim makes it a claim made on purpose.
312
+ if (same) await store.deleteClaim({ quilt_id: q.id, id: same.id })
313
+ for (const x of await store.claims(q.id)) if (x.square_id === sq.id && x.expires_at <= at) await store.deleteClaim({ quilt_id: q.id, id: x.id })
314
+ const row = await store.createClaim({ quilt_id: q.id, id: uuidv7(at), square_id: sq.id, pattern, holder: me.member_id, note, at, ...(ttl ? { expires_at: at + ttl * 1000 } : {}) })
315
+ return view({ ...row, line: sq.line })
316
+ } catch (err) { throw fromStore(err) }
317
+ }
318
+
319
+ /** DELETE /squares/:id/claims/:claim (its id or its pattern): lets go of a claim. */
320
+ async function unclaim (me, ref, sqRef, which) {
321
+ const q = await quiltFor(me, ref, 'write')
322
+ const sq = await writable(me, q, sqRef)
323
+ const key = String(which || '').trim().replace(/^\.\//, '')
324
+ const c = (await store.claims(q.id)).find((x) => x.square_id === sq.id && (x.id === key || x.pattern === key))
325
+ if (!c) throw new QuiltError(404, 'not_found', `${squareShort(sq.id)} has no claim ${key}`)
326
+ const waiting = queueOf(await store.claimRequests(q.id), c)
327
+ if (waiting.length) throw new QuiltError(409, 'claim_queued', `${waiting.length} waiting for ${c.pattern} (first: ${waiting[0].by}, "${waiting[0].title}"): hand it over with seam claim handoff ${c.pattern} and a note instead`, { queue: waiting.map(requestView) })
328
+ try { await store.deleteClaim({ quilt_id: q.id, id: c.id }) } catch (err) { throw fromStore(err) }
329
+ return { ok: true, released: view({ ...c, line: sq.line }) }
330
+ }
331
+
332
+ /** A live claim on square `sq` by id or pattern, or 404. */
333
+ async function heldClaim (q, sq, which) {
334
+ const key = String(which || '').trim().replace(/^\.\//, '')
335
+ const c = (await liveClaims(store, q.id, now(), claimIdleMs)).find((x) => x.square_id === sq.id && (x.id === key || x.pattern === key))
336
+ if (!c) throw new QuiltError(404, 'not_found', `${squareShort(sq.id)} holds no claim ${key}`)
337
+ return c
338
+ }
339
+
340
+ /** POST /claims/requests { claim (id or pattern), square, title, description? }: waits in a claim's queue. */
341
+ async function requestClaim (me, ref, body = {}) {
342
+ const q = await quiltFor(me, ref, 'write')
343
+ const mine = await writable(me, q, body.square)
344
+ const title = String(body.title || '').trim()
345
+ const description = String(body.description || '')
346
+ if (!title || title.length > MAX_REQUEST_TITLE) throw new QuiltError(400, 'bad_request', `a request has a title of 1 to ${MAX_REQUEST_TITLE} characters: what you need the files for`)
347
+ if (description.length > MAX_REQUEST_TEXT) throw new QuiltError(400, 'bad_request', `a request's description is up to ${MAX_REQUEST_TEXT} characters`)
348
+ const key = String(body.claim || '').trim().replace(/^\.\//, '')
349
+ const c = (await liveClaims(store, q.id, now(), claimIdleMs)).find((x) => x.line === mine.line && (x.id === key || x.pattern === key) && x.square_id !== mine.id)
350
+ if (!c) throw new QuiltError(400, 'bad_request', `no other square on ${mine.line} holds a claim ${key}`)
351
+ const queue = queueOf(await store.claimRequests(q.id), c)
352
+ const already = queue.find((r) => r.by_square === mine.id)
353
+ if (already) return requestView(already)
354
+ if (queue.length >= MAX_QUEUE) throw new QuiltError(409, 'queue_full', `${MAX_QUEUE} already wait for ${c.pattern}`)
355
+ try {
356
+ const at = now()
357
+ return requestView(await store.addClaimRequest({ quilt_id: q.id, id: uuidv7(at), square_id: c.square_id, pattern: c.pattern, by: me.member_id, by_square: mine.id, title, description, at }))
358
+ } catch (err) { throw fromStore(err) }
359
+ }
360
+
361
+ /** DELETE /claims/requests/:id: takes your request out of the queue. */
362
+ async function withdrawRequest (me, ref, id) {
363
+ const q = await quiltFor(me, ref, 'write')
364
+ const r = (await store.claimRequests(q.id)).find((x) => x.id === id && x.by === me.member_id)
365
+ if (!r) throw new QuiltError(404, 'not_found', `you have no request ${id}`)
366
+ try { await store.deleteClaimRequest({ quilt_id: q.id, id: r.id }) } catch (err) { throw fromStore(err) }
367
+ return { ok: true, withdrawn: requestView(r) }
368
+ }
369
+
370
+ /** POST /squares/:id/claims/handoff { claim, to? (request id), note? }: gives the claim to the first in its queue (or `to`). */
371
+ async function handoffClaim (me, ref, sqRef, body = {}) {
372
+ const q = await quiltFor(me, ref, 'write')
373
+ const sq = await writable(me, q, sqRef)
374
+ const note = String(body.note || '')
375
+ if (note.length > MAX_HANDOFF_TEXT) throw new QuiltError(400, 'bad_request', `a handoff note is up to ${MAX_HANDOFF_TEXT} characters`)
376
+ const c = await heldClaim(q, sq, body.claim)
377
+ const queue = []
378
+ for (const r of queueOf(await store.claimRequests(q.id), c)) if ((await store.getSquare(r.by_square))?.state === 'open') queue.push(r)
379
+ const r = body.to ? queue.find((x) => x.id === body.to) : queue[0]
380
+ if (!r) throw new QuiltError(409, 'no_request', body.to ? `no open request ${body.to} waits for ${c.pattern}` : `nobody waits for ${c.pattern}`)
381
+ try {
382
+ const row = await moveClaim(store, q.id, c, r, note, me.member_id, now())
383
+ return { claim: view({ ...row, line: c.line }), request: requestView(r), from: { square: squareShort(sq.id), holder: c.holder } }
384
+ } catch (err) { throw fromStore(err) }
385
+ }
386
+
387
+ /**
388
+ * POST /squares/:id/claims/finish: the writer says it is finished (06 "Auto-claims following
389
+ * edits"). Lets go of the square's auto-claims now instead of after 10 quiet minutes; one someone
390
+ * waits for goes to the first open square in its queue. Claims made on purpose stay.
391
+ */
392
+ async function finishClaims (me, ref, sqRef, body = {}) {
393
+ const q = await quiltFor(me, ref, 'write')
394
+ const sq = await writable(me, q, sqRef)
395
+ const note = String(body.note || '')
396
+ if (note.length > MAX_HANDOFF_TEXT) throw new QuiltError(400, 'bad_request', `a handoff note is up to ${MAX_HANDOFF_TEXT} characters`)
397
+ const auto = (await liveClaims(store, q.id, now(), claimIdleMs)).filter((c) => c.square_id === sq.id && c.auto).sort((a, b) => a.at - b.at)
398
+ const released = []
399
+ const handed = []
400
+ for (const c of auto) {
401
+ try { handed.push(await handoffClaim(me, ref, sq.id, { claim: c.id, ...(note ? { note } : {}) })); continue } catch (err) { if (err.code !== 'no_request') throw err }
402
+ try { await store.deleteClaim({ quilt_id: q.id, id: c.id }) } catch (err) { throw fromStore(err) }
403
+ released.push(view(c))
404
+ }
405
+ return { ok: true, released, handed }
406
+ }
407
+
408
+ return { claims, claim, unclaim, requestClaim, withdrawRequest, handoffClaim, finishClaims }
409
+ }