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/block.js
DELETED
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
// Blocks on disk (plans/quilt-source-control/05): a folder holding part of one square, the
|
|
2
|
-
// subtrees named by `paths`, as of commit `at`. The folder is a projection of a known commit
|
|
3
|
-
// plus the edits made in it since; it is rebuilt from the server, never repaired. Its record is
|
|
4
|
-
// .seam/block.json: the quilt, the square, the paths, `at`, the head it last saw, and a
|
|
5
|
-
// manifest of what was written (blob, mode, size, mtime) so status needs no server call.
|
|
6
|
-
import fs from 'node:fs'
|
|
7
|
-
import path from 'node:path'
|
|
8
|
-
import ignore from 'ignore'
|
|
9
|
-
import { blobId, cleanPrefix, under, cmpPath, cleanCommit, commitBytes, commitId } from './records.js'
|
|
10
|
-
import { unified, isText } from './textdiff.js'
|
|
11
|
-
|
|
12
|
-
/** A block folder's own folder (seam's, not the Quilt app's .quilt). */
|
|
13
|
-
export const BLOCK_DIR = '.seam'
|
|
14
|
-
export const BLOCK_FILE = path.join(BLOCK_DIR, 'block.json')
|
|
15
|
-
const ALWAYS_IGNORED = ['.git', BLOCK_DIR, 'node_modules']
|
|
16
|
-
|
|
17
|
-
/** The block a folder (or one of its parents) holds, as { dir, block }, or null. */
|
|
18
|
-
export function findBlock (start = process.cwd()) {
|
|
19
|
-
let dir = path.resolve(start)
|
|
20
|
-
for (;;) {
|
|
21
|
-
const f = path.join(dir, BLOCK_FILE)
|
|
22
|
-
if (fs.existsSync(f)) return { dir, block: JSON.parse(fs.readFileSync(f, 'utf8')) }
|
|
23
|
-
const up = path.dirname(dir)
|
|
24
|
-
if (up === dir) return null
|
|
25
|
-
dir = up
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function saveBlock (dir, block) {
|
|
30
|
-
fs.mkdirSync(path.join(dir, BLOCK_DIR), { recursive: true })
|
|
31
|
-
const f = path.join(dir, BLOCK_FILE)
|
|
32
|
-
fs.writeFileSync(f + '.tmp', JSON.stringify(block, null, 1))
|
|
33
|
-
fs.renameSync(f + '.tmp', f)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const inBlock = (block, p) => block.paths.some((x) => under(p, x))
|
|
37
|
-
|
|
38
|
-
/** The folders that hold committed files: they are walked even when a .gitignore names them. */
|
|
39
|
-
function trackedDirs (block) {
|
|
40
|
-
const out = new Set()
|
|
41
|
-
for (const p of Object.keys(block.files)) for (let i = p.indexOf('/'); i > 0; i = p.indexOf('/', i + 1)) out.add(p.slice(0, i))
|
|
42
|
-
return out
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Reads `rel`/.gitignore into the stack of rules in force there (deepest last). */
|
|
46
|
-
function rulesIn (dir, rel, stack) {
|
|
47
|
-
try { return [...stack, { base: rel, ig: ignore().add(fs.readFileSync(path.join(dir, rel, '.gitignore'), 'utf8')) }] } catch { return stack }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** As git decides: the deepest .gitignore with a rule for the path wins (a `!` rule re-includes). */
|
|
51
|
-
function ignoredBy (stack, r, isDir) {
|
|
52
|
-
for (let i = stack.length - 1; i >= 0; i--) {
|
|
53
|
-
const { base, ig } = stack[i]
|
|
54
|
-
const rel = base ? r.slice(base.length + 1) : r
|
|
55
|
-
const res = ig.test(isDir ? rel + '/' : rel)
|
|
56
|
-
if (res.ignored) return true
|
|
57
|
-
if (res.unignored) return false
|
|
58
|
-
}
|
|
59
|
-
return false
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const ALWAYS = ignore().add(ALWAYS_IGNORED)
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Every file under the block's paths on disk: Map path → { abs, stat }. Every .gitignore on the
|
|
66
|
-
* way down is read, as git does; a committed file is never ignored.
|
|
67
|
-
*/
|
|
68
|
-
function scan (dir, block) {
|
|
69
|
-
const out = new Map()
|
|
70
|
-
const dirs = trackedDirs(block)
|
|
71
|
-
const walk = (rel, stack) => {
|
|
72
|
-
let names
|
|
73
|
-
try { names = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }) } catch { return }
|
|
74
|
-
for (const d of names) {
|
|
75
|
-
const r = rel ? `${rel}/${d.name}` : d.name
|
|
76
|
-
const n = r.normalize('NFC')
|
|
77
|
-
const isDir = d.isDirectory()
|
|
78
|
-
if (ALWAYS.ignores(isDir ? r + '/' : r)) continue
|
|
79
|
-
if (!(isDir ? dirs.has(n) : block.files[n]) && ignoredBy(stack, r, isDir)) continue
|
|
80
|
-
// A submodule's folder stands for its gitlink entry: what is inside belongs to the submodule.
|
|
81
|
-
if (isDir && block.files[n]?.mode === 'gitlink') { out.set(n, { abs: path.join(dir, r), gitlink: true }); continue }
|
|
82
|
-
if (isDir) { if (block.paths.some((x) => under(r, x) || under(x, r))) walk(r, rulesIn(dir, r, stack)); continue }
|
|
83
|
-
if (!(d.isFile() || d.isSymbolicLink()) || !inBlock(block, r)) continue
|
|
84
|
-
const abs = path.join(dir, r)
|
|
85
|
-
out.set(n, { abs, stat: fs.lstatSync(abs) })
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
walk('', rulesIn(dir, '', []))
|
|
89
|
-
return out
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function readFileEntry (abs, stat) {
|
|
93
|
-
if (stat.isSymbolicLink()) return { bytes: Buffer.from(fs.readlinkSync(abs)), mode: 'symlink' }
|
|
94
|
-
return { bytes: fs.readFileSync(abs), mode: (stat.mode & 0o111) && process.platform !== 'win32' ? 'exec' : 'file' }
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* What changed in the folder since `at`: [{ path, status: 'added' | 'modified' | 'deleted',
|
|
99
|
-
* blob?, mode?, bytes? }]. A conflicted file still carrying its term's marker is unchanged.
|
|
100
|
-
*/
|
|
101
|
-
export function localChanges (dir, block, { paths = [] } = {}) {
|
|
102
|
-
const only = paths.map((p) => cleanPrefix(path.relative(dir, path.resolve(p)).split(path.sep).join('/')))
|
|
103
|
-
const wanted = (p) => !only.length || only.some((x) => under(p, x))
|
|
104
|
-
const files = scan(dir, block)
|
|
105
|
-
const out = []
|
|
106
|
-
for (const [p, { abs, stat, gitlink }] of files) {
|
|
107
|
-
if (!wanted(p) || gitlink) continue
|
|
108
|
-
const known = block.files[p]
|
|
109
|
-
if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs) continue
|
|
110
|
-
const { bytes, mode } = readFileEntry(abs, stat)
|
|
111
|
-
const id = blobId(bytes)
|
|
112
|
-
if (known && known.blob === id && known.mode === mode) continue
|
|
113
|
-
if (known && known.term && bytes.includes(Buffer.from(`quilt-conflict ${known.term.slice(0, 12)}`))) continue
|
|
114
|
-
out.push({ path: p, status: known ? 'modified' : 'added', blob: id, mode, size: bytes.length, bytes })
|
|
115
|
-
}
|
|
116
|
-
for (const p of Object.keys(block.files)) if (!files.has(p) && wanted(p)) out.push({ path: p, status: 'deleted' })
|
|
117
|
-
return out.sort((a, b) => cmpPath(a.path, b.path))
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Writes one tree entry to the folder and returns its manifest record. */
|
|
121
|
-
async function writeEntry (dir, e, content) {
|
|
122
|
-
const abs = path.join(dir, ...e.path.split('/'))
|
|
123
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
124
|
-
try { fs.rmSync(abs, { force: true }) } catch {}
|
|
125
|
-
// A git submodule is an empty folder, as git leaves one it has not cloned.
|
|
126
|
-
if (e.mode === 'gitlink') { fs.mkdirSync(abs, { recursive: true }); return { blob: e.blob, mode: 'gitlink', size: e.size, mtimeMs: 0 } }
|
|
127
|
-
if (e.mode === 'symlink') fs.symlinkSync(content.toString('utf8'), abs)
|
|
128
|
-
else fs.writeFileSync(abs, content, { mode: e.mode === 'exec' ? 0o755 : 0o644 })
|
|
129
|
-
const st = fs.lstatSync(abs)
|
|
130
|
-
return { blob: e.mode === 'conflict' ? null : e.blob, mode: e.mode, size: st.size, mtimeMs: st.mtimeMs, ...(e.term ? { term: e.term } : {}) }
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Fetches blobs by id in batches (want-only, decision 31). */
|
|
134
|
-
async function fetchBlobs (client, quilt, ids) {
|
|
135
|
-
const out = new Map()
|
|
136
|
-
const list = [...new Set(ids)]
|
|
137
|
-
for (let i = 0; i < list.length; i += 200) {
|
|
138
|
-
const { blobs } = await client.blobs(quilt, list.slice(i, i + 200))
|
|
139
|
-
for (const [id, b64] of Object.entries(blobs)) out.set(id, Buffer.from(b64, 'base64'))
|
|
140
|
-
}
|
|
141
|
-
return out
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function contentsFor (client, quilt, rev, entries) {
|
|
145
|
-
const blobs = await fetchBlobs(client, quilt, entries.filter((e) => e.blob).map((e) => e.blob))
|
|
146
|
-
const get = async (e) => {
|
|
147
|
-
if (e.mode !== 'conflict') return blobs.get(e.blob)
|
|
148
|
-
const r = await client.cat(quilt, rev, e.path, { format: 'diff' })
|
|
149
|
-
return Buffer.from(r.content, 'base64')
|
|
150
|
-
}
|
|
151
|
-
return get
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Checks out `rev` (a square's head, or any revision) under `paths` into `dir` as a new block.
|
|
156
|
-
* Refuses a folder that already holds files, unless it is this block's own folder.
|
|
157
|
-
*/
|
|
158
|
-
export async function checkout (client, { quilt, square = null, rev, paths = [''], dir, api }) {
|
|
159
|
-
const prefixes = (paths.length ? paths : ['']).map(cleanPrefix)
|
|
160
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
161
|
-
const existing = fs.readdirSync(dir).filter((n) => n !== BLOCK_DIR)
|
|
162
|
-
if (existing.length) throw new Error(`${dir} is not empty; check out into an empty folder (or switch an existing block with quilt checkout inside it)`)
|
|
163
|
-
const sq = square ? await client.square(quilt, square) : null
|
|
164
|
-
const at = sq ? (sq.head || sq.base) : rev || 'main'
|
|
165
|
-
const t = await client.tree(quilt, at || 'main', prefixes)
|
|
166
|
-
const get = await contentsFor(client, quilt, at || 'main', t.entries)
|
|
167
|
-
const files = {}
|
|
168
|
-
for (const e of t.entries) files[e.path] = await writeEntry(dir, e, await get(e))
|
|
169
|
-
const block = { api, quilt, square: sq ? sq.id : null, square_short: sq ? sq.short : null, line: sq ? sq.line : (rev || 'main'), paths: prefixes, at: t.commit, head: sq ? sq.head : null, files }
|
|
170
|
-
saveBlock(dir, block)
|
|
171
|
-
return { block, files: t.entries.length }
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Moves the folder from `block.at` to commit `to`, file by file: paths changed on the server
|
|
176
|
-
* are rewritten; a path changed both here and there is left as it is here and reported.
|
|
177
|
-
*/
|
|
178
|
-
export async function syncTo (client, dir, block, to, { square } = {}) {
|
|
179
|
-
const local = new Map(localChanges(dir, block).map((c) => [c.path, c]))
|
|
180
|
-
const d = await client.diff(block.quilt, { from: block.at ? `c:${block.at}` : undefined, to: to ? `c:${to}` : 'main', paths: block.paths, format: 'names' })
|
|
181
|
-
const t = d.files.length ? await client.tree(block.quilt, to ? `c:${to}` : 'main', block.paths) : { entries: [] }
|
|
182
|
-
const byPath = new Map(t.entries.map((e) => [e.path, e]))
|
|
183
|
-
const get = await contentsFor(client, block.quilt, to ? `c:${to}` : 'main', d.files.map((f) => byPath.get(f.path)).filter(Boolean))
|
|
184
|
-
const kept = []
|
|
185
|
-
let written = 0
|
|
186
|
-
for (const f of d.files) {
|
|
187
|
-
if (local.has(f.path)) { kept.push(f.path); continue }
|
|
188
|
-
const e = byPath.get(f.path)
|
|
189
|
-
if (!e) {
|
|
190
|
-
const abs = path.join(dir, ...f.path.split('/'))
|
|
191
|
-
if (block.files[f.path]?.mode === 'gitlink') { try { fs.rmdirSync(abs) } catch {} } else fs.rmSync(abs, { force: true })
|
|
192
|
-
delete block.files[f.path]; written++; continue
|
|
193
|
-
}
|
|
194
|
-
block.files[f.path] = await writeEntry(dir, e, await get(e))
|
|
195
|
-
written++
|
|
196
|
-
}
|
|
197
|
-
block.at = to
|
|
198
|
-
if (square) { block.square = square.id; block.square_short = square.short; block.head = square.head; block.line = square.line }
|
|
199
|
-
saveBlock(dir, block)
|
|
200
|
-
return { written, kept }
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** status: the folder's changes, and the square as the server sees it. */
|
|
204
|
-
export async function status (client, dir, block) {
|
|
205
|
-
const changes = localChanges(dir, block).map(({ bytes, ...c }) => c)
|
|
206
|
-
const square = block.square ? await client.square(block.quilt, block.square) : null
|
|
207
|
-
const conflicted = Object.entries(block.files).filter(([, f]) => f.term).map(([p]) => p)
|
|
208
|
-
return { dir, quilt: block.quilt, square, paths: block.paths, at: block.at, changes, conflicted, stale: !!(square && square.head !== block.head) }
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** commit: uploads the changed files' blobs (have?, then a pack) and seals them as a commit. */
|
|
212
|
-
export async function commitFolder (client, dir, block, { message, paths = [] }) {
|
|
213
|
-
if (!block.square) throw new Error('This folder is a read-only checkout of a line; open a square (quilt square open) to commit.')
|
|
214
|
-
const changes = localChanges(dir, block, { paths })
|
|
215
|
-
if (!changes.length) return { nothing: true }
|
|
216
|
-
const blobs = changes.filter((c) => c.bytes).map((c) => ({ kind: 'blob', id: c.blob, bytes: c.bytes }))
|
|
217
|
-
const list = changes.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', blob: c.blob, mode: c.mode })
|
|
218
|
-
// 03: the commit record is built here and travels in the pack with its blobs, so the commit
|
|
219
|
-
// request names it by id. The server refuses a record that is not exactly what it would write
|
|
220
|
-
// (a file that still carries a conflict's markers, say); then the changes go as a list.
|
|
221
|
-
let r
|
|
222
|
-
try {
|
|
223
|
-
if (!block.author || !block.quilt_id) { const info = await client.quilt(block.quilt); block.author = info.you; block.quilt_id = info.id }
|
|
224
|
-
const sq = await client.square(block.quilt, block.square)
|
|
225
|
-
const { on_behalf_of: onBehalf, ...author } = block.author
|
|
226
|
-
const record = cleanCommit({
|
|
227
|
-
quilt_id: block.quilt_id, square_id: sq.id, parents: [block.head || sq.base].filter(Boolean), author,
|
|
228
|
-
...(onBehalf ? { on_behalf_of: onBehalf } : {}), ...(sq.task_id ? { task_id: sq.task_id } : {}),
|
|
229
|
-
message: message || '', authored_at: Date.now(),
|
|
230
|
-
changes: list.map((c) => c.op === 'delete' ? c : { ...c, mode: c.mode || 'file', size: changes.find((x) => x.path === c.path).bytes.length })
|
|
231
|
-
})
|
|
232
|
-
const id = commitId(record)
|
|
233
|
-
await client.upload(block.quilt, [...blobs, { kind: 'commit', id, bytes: commitBytes(record) }])
|
|
234
|
-
r = await client.commit(block.quilt, block.square, { expect_head: block.head, commit: id })
|
|
235
|
-
} catch (err) {
|
|
236
|
-
if (err.code !== 'record_mismatch') throw err
|
|
237
|
-
r = await client.commit(block.quilt, block.square, { expect_head: block.head, message, changes: list })
|
|
238
|
-
}
|
|
239
|
-
for (const c of changes) {
|
|
240
|
-
if (c.status === 'deleted') { delete block.files[c.path]; continue }
|
|
241
|
-
const st = fs.lstatSync(path.join(dir, ...c.path.split('/')))
|
|
242
|
-
block.files[c.path] = { blob: c.blob, mode: c.mode, size: st.size, mtimeMs: st.mtimeMs }
|
|
243
|
-
}
|
|
244
|
-
block.at = r.square.head
|
|
245
|
-
block.head = r.square.head
|
|
246
|
-
saveBlock(dir, block)
|
|
247
|
-
return r
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** Amends the square's last commit with the folder's changes (paths: only these) and, if given, a new message. */
|
|
251
|
-
export async function amendFolder (client, dir, block, { message, paths = [] }) {
|
|
252
|
-
if (!block.square) throw new Error('This folder is a read-only checkout of a line; there is no commit of yours to amend.')
|
|
253
|
-
const changes = localChanges(dir, block, { paths })
|
|
254
|
-
const blobs = changes.filter((c) => c.bytes).map((c) => ({ kind: 'blob', id: c.blob, bytes: c.bytes }))
|
|
255
|
-
if (blobs.length) await client.upload(block.quilt, blobs)
|
|
256
|
-
const list = changes.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', blob: c.blob, mode: c.mode || 'file' })
|
|
257
|
-
const r = await client.amend(block.quilt, block.square, { expect_head: block.head, ...(message ? { message } : {}), changes: list })
|
|
258
|
-
for (const c of changes) {
|
|
259
|
-
if (c.status === 'deleted') { delete block.files[c.path]; continue }
|
|
260
|
-
const st = fs.lstatSync(path.join(dir, ...c.path.split('/')))
|
|
261
|
-
block.files[c.path] = { blob: c.blob, mode: c.mode, size: st.size, mtimeMs: st.mtimeMs }
|
|
262
|
-
}
|
|
263
|
-
block.at = r.square.head
|
|
264
|
-
block.head = r.square.head
|
|
265
|
-
saveBlock(dir, block)
|
|
266
|
-
return r
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** The folder's changes as a unified diff against `at` (diff with no revision, in a block). */
|
|
270
|
-
export async function localDiff (client, dir, block, { paths = [] } = {}) {
|
|
271
|
-
const changes = localChanges(dir, block, { paths })
|
|
272
|
-
const olds = await fetchBlobs(client, block.quilt, changes.map((c) => block.files[c.path]?.blob).filter(Boolean))
|
|
273
|
-
const out = []
|
|
274
|
-
for (const c of changes) {
|
|
275
|
-
const before = block.files[c.path]?.blob ? olds.get(block.files[c.path].blob) : null
|
|
276
|
-
const after = c.bytes || null
|
|
277
|
-
if (!isText(before) || !isText(after)) { out.push(`Binary file ${c.path} ${c.status}\n`); continue }
|
|
278
|
-
out.push(unified(c.path, before ? before.toString('utf8') : null, after ? after.toString('utf8') : null))
|
|
279
|
-
}
|
|
280
|
-
return { changes: changes.map(({ bytes, ...c }) => c), text: out.join('') }
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const absOf = (dir, p) => path.join(dir, ...p.split('/'))
|
|
284
|
-
const removeEntry = (dir, p, mode) => { const abs = absOf(dir, p); if (mode === 'gitlink') { try { fs.rmdirSync(abs) } catch {} } else fs.rmSync(abs, { force: true }) }
|
|
285
|
-
|
|
286
|
-
/** Takes away the folders a removed file leaves empty, up to the block folder. */
|
|
287
|
-
function pruneEmpty (dir, p) {
|
|
288
|
-
for (let d = path.dirname(absOf(dir, p)); d.startsWith(dir + path.sep); d = path.dirname(d)) {
|
|
289
|
-
try { if (fs.readdirSync(d).length) return; fs.rmdirSync(d) } catch { return }
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* restore: puts the named paths back as they were at the folder's last commit (`at`), throwing
|
|
295
|
-
* away their uncommitted edits and deletions. Files nobody committed are left (clean removes them).
|
|
296
|
-
*/
|
|
297
|
-
export async function restore (client, dir, block, { paths }) {
|
|
298
|
-
if (!paths?.length) throw new Error('Which files? seam restore <paths> (. for everything here)')
|
|
299
|
-
const changes = localChanges(dir, block, { paths }).filter((c) => c.status !== 'added')
|
|
300
|
-
const entries = changes.map((c) => ({ path: c.path, ...block.files[c.path] }))
|
|
301
|
-
const get = await contentsFor(client, block.quilt, `c:${block.at}`, entries)
|
|
302
|
-
for (const e of entries) block.files[e.path] = { ...await writeEntry(dir, e, await get(e)), ...(e.term ? { term: e.term } : {}) }
|
|
303
|
-
saveBlock(dir, block)
|
|
304
|
-
return { restored: entries.map((e) => e.path) }
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* reset: makes the folder's files those of `rev` (default: the folder's last commit, throwing
|
|
309
|
-
* away every uncommitted edit). The folder stays on its square or line: what differs from there
|
|
310
|
-
* shows as uncommitted changes, so a commit makes the square match `rev`. Files nobody committed
|
|
311
|
-
* stay unless `rev` has them.
|
|
312
|
-
*/
|
|
313
|
-
export async function reset (client, dir, block, { rev } = {}) {
|
|
314
|
-
if (!rev) {
|
|
315
|
-
const r = await restore(client, dir, block, { paths: [dir] })
|
|
316
|
-
return { rev: block.at, written: r.restored.length, removed: 0, changes: 0 }
|
|
317
|
-
}
|
|
318
|
-
const t = await client.tree(block.quilt, rev, block.paths)
|
|
319
|
-
const local = new Map(localChanges(dir, block).map((c) => [c.path, c]))
|
|
320
|
-
const want = new Map(t.entries.map((e) => [e.path, e]))
|
|
321
|
-
const toWrite = t.entries.filter((e) => {
|
|
322
|
-
const known = block.files[e.path]
|
|
323
|
-
return local.has(e.path) || !known || known.blob !== e.blob || known.mode !== e.mode || (e.term || null) !== (known.term || null)
|
|
324
|
-
})
|
|
325
|
-
const get = await contentsFor(client, block.quilt, rev, toWrite)
|
|
326
|
-
let removed = 0
|
|
327
|
-
for (const e of toWrite) {
|
|
328
|
-
const rec = await writeEntry(dir, e, await get(e))
|
|
329
|
-
const known = block.files[e.path]
|
|
330
|
-
// Same as the folder's commit: clean again. Otherwise the manifest keeps the commit's record,
|
|
331
|
-
// so the file shows as changed.
|
|
332
|
-
if (known && known.blob === e.blob && known.mode === e.mode) block.files[e.path] = rec
|
|
333
|
-
}
|
|
334
|
-
for (const [p, f] of Object.entries(block.files)) {
|
|
335
|
-
if (want.has(p) || !fs.existsSync(absOf(dir, p))) continue
|
|
336
|
-
removeEntry(dir, p, f.mode); pruneEmpty(dir, p); removed++
|
|
337
|
-
}
|
|
338
|
-
saveBlock(dir, block)
|
|
339
|
-
return { rev: t.commit, written: toWrite.length, removed, changes: localChanges(dir, block).length }
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/** clean: removes the files under `paths` that nobody committed and no .gitignore names. */
|
|
343
|
-
export function clean (dir, block, { paths = [], dryRun = false } = {}) {
|
|
344
|
-
const added = localChanges(dir, block, { paths }).filter((c) => c.status === 'added').map((c) => c.path)
|
|
345
|
-
if (!dryRun) for (const p of added) { removeEntry(dir, p, 'file'); pruneEmpty(dir, p) }
|
|
346
|
-
return { removed: added, dry_run: dryRun }
|
|
347
|
-
}
|
package/src/quilts/bucket.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
// A private Supabase Storage bucket for a quilt's packs: write-once signed uploads, short-lived
|
|
2
|
-
// signed downloads, listing. Ids come from the content, so an upload never replaces what is there.
|
|
3
|
-
import { createClient } from '@supabase/supabase-js'
|
|
4
|
-
|
|
5
|
-
const LINK_S = 10 * 60
|
|
6
|
-
|
|
7
|
-
export class SupabaseBucket {
|
|
8
|
-
constructor ({ url, key, bucket }) {
|
|
9
|
-
this.bucket = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }).storage.from(bucket)
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// Supabase signed uploads can't bound size: the bucket's file_size_limit caps each object.
|
|
13
|
-
async uploadTarget (room, name) {
|
|
14
|
-
const { data, error } = await this.bucket.createSignedUploadUrl(`${room}/${name}`, { upsert: false })
|
|
15
|
-
if (error && (String(error.statusCode) === '409' || /exists|duplicate/i.test(String(error.message)))) return { exists: true }
|
|
16
|
-
if (error) throw new Error(`storage: ${error.message}`)
|
|
17
|
-
return { method: 'PUT', url: data.signedUrl }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async downloadTarget (room, name) {
|
|
21
|
-
const { data, error } = await this.bucket.createSignedUrl(`${room}/${name}`, LINK_S)
|
|
22
|
-
if (error) throw new Error(`storage: ${error.message}`)
|
|
23
|
-
return { url: data.signedUrl }
|
|
24
|
-
}
|
|
25
|
-
}
|
package/src/quilts/cbor.js
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// Deterministic CBOR (RFC 8949 §4.2.1, "core deterministic encoding") for Quilt records: every
|
|
2
|
-
// id is a hash of these bytes, so every client and server must write exactly the same ones.
|
|
3
|
-
// Supported: unsigned and negative integers (safe range), byte strings (Uint8Array/Buffer),
|
|
4
|
-
// text, arrays, maps (plain objects; keys sorted bytewise on their encoded form), true, false,
|
|
5
|
-
// null. No floats, no indefinite lengths. Undefined fields are left out, never encoded.
|
|
6
|
-
|
|
7
|
-
function head (major, n) {
|
|
8
|
-
if (!Number.isSafeInteger(n) || n < 0) throw new Error(`cbor: bad length or integer ${n}`)
|
|
9
|
-
if (n < 24) return Buffer.from([(major << 5) | n])
|
|
10
|
-
if (n < 0x100) return Buffer.from([(major << 5) | 24, n])
|
|
11
|
-
if (n < 0x10000) { const b = Buffer.alloc(3); b[0] = (major << 5) | 25; b.writeUInt16BE(n, 1); return b }
|
|
12
|
-
if (n < 0x100000000) { const b = Buffer.alloc(5); b[0] = (major << 5) | 26; b.writeUInt32BE(n, 1); return b }
|
|
13
|
-
const b = Buffer.alloc(9); b[0] = (major << 5) | 27; b.writeBigUInt64BE(BigInt(n), 1); return b
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function encodeInto (v, out) {
|
|
17
|
-
if (v === null) return out.push(Buffer.from([0xf6]))
|
|
18
|
-
if (v === true) return out.push(Buffer.from([0xf5]))
|
|
19
|
-
if (v === false) return out.push(Buffer.from([0xf4]))
|
|
20
|
-
if (typeof v === 'number') {
|
|
21
|
-
if (!Number.isSafeInteger(v)) throw new Error(`cbor: no floats (${v})`)
|
|
22
|
-
return out.push(v >= 0 ? head(0, v) : head(1, -1 - v))
|
|
23
|
-
}
|
|
24
|
-
if (typeof v === 'string') {
|
|
25
|
-
const b = Buffer.from(v, 'utf8')
|
|
26
|
-
out.push(head(3, b.length)); return out.push(b)
|
|
27
|
-
}
|
|
28
|
-
if (v instanceof Uint8Array) { out.push(head(2, v.length)); return out.push(Buffer.from(v)) }
|
|
29
|
-
if (Array.isArray(v)) {
|
|
30
|
-
out.push(head(4, v.length))
|
|
31
|
-
for (const x of v) {
|
|
32
|
-
if (x === undefined) throw new Error('cbor: undefined in an array')
|
|
33
|
-
encodeInto(x, out)
|
|
34
|
-
}
|
|
35
|
-
return
|
|
36
|
-
}
|
|
37
|
-
if (typeof v === 'object') {
|
|
38
|
-
const entries = Object.entries(v).filter(([, x]) => x !== undefined)
|
|
39
|
-
.map(([k, x]) => [encode(k), x])
|
|
40
|
-
.sort(([a], [b]) => Buffer.compare(a, b))
|
|
41
|
-
out.push(head(5, entries.length))
|
|
42
|
-
for (const [k, x] of entries) { out.push(k); encodeInto(x, out) }
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
|
-
throw new Error(`cbor: cannot encode ${typeof v}`)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** The deterministic CBOR bytes of `v`. */
|
|
49
|
-
export function encode (v) {
|
|
50
|
-
const out = []
|
|
51
|
-
encodeInto(v, out)
|
|
52
|
-
return Buffer.concat(out)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Decodes one CBOR item (as written by encode); byte strings come back as Buffers. */
|
|
56
|
-
export function decode (buf) {
|
|
57
|
-
const b = Buffer.from(buf.buffer ? buf : Buffer.from(buf))
|
|
58
|
-
let pos = 0
|
|
59
|
-
const arg = (info) => {
|
|
60
|
-
if (info < 24) return info
|
|
61
|
-
if (info === 24) return b[pos++]
|
|
62
|
-
if (info === 25) { const n = b.readUInt16BE(pos); pos += 2; return n }
|
|
63
|
-
if (info === 26) { const n = b.readUInt32BE(pos); pos += 4; return n }
|
|
64
|
-
if (info === 27) { const n = Number(b.readBigUInt64BE(pos)); pos += 8; return n }
|
|
65
|
-
throw new Error('cbor: indefinite or reserved length')
|
|
66
|
-
}
|
|
67
|
-
const item = () => {
|
|
68
|
-
if (pos >= b.length) throw new Error('cbor: truncated')
|
|
69
|
-
const ib = b[pos++]
|
|
70
|
-
const major = ib >> 5
|
|
71
|
-
const info = ib & 31
|
|
72
|
-
if (major === 7) {
|
|
73
|
-
if (info === 20) return false
|
|
74
|
-
if (info === 21) return true
|
|
75
|
-
if (info === 22) return null
|
|
76
|
-
throw new Error('cbor: unsupported simple value or float')
|
|
77
|
-
}
|
|
78
|
-
const n = arg(info)
|
|
79
|
-
switch (major) {
|
|
80
|
-
case 0: return n
|
|
81
|
-
case 1: return -1 - n
|
|
82
|
-
case 2: { const v = b.subarray(pos, pos + n); pos += n; if (v.length !== n) throw new Error('cbor: truncated'); return Buffer.from(v) }
|
|
83
|
-
case 3: { const v = b.toString('utf8', pos, pos + n); pos += n; if (pos > b.length) throw new Error('cbor: truncated'); return v }
|
|
84
|
-
case 4: { const a = []; for (let i = 0; i < n; i++) a.push(item()); return a }
|
|
85
|
-
case 5: { const o = {}; for (let i = 0; i < n; i++) { const k = item(); o[k] = item() } return o }
|
|
86
|
-
default: throw new Error(`cbor: unsupported major type ${major}`)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
const v = item()
|
|
90
|
-
if (pos !== b.length) throw new Error('cbor: trailing bytes')
|
|
91
|
-
return v
|
|
92
|
-
}
|
package/src/quilts/claims.js
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
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
|
-
/** The claims that still hold: those of open squares. */
|
|
63
|
-
export async function liveClaims (store, q) {
|
|
64
|
-
const rows = await store.claims(q)
|
|
65
|
-
if (!rows.length) return []
|
|
66
|
-
const squares = new Map()
|
|
67
|
-
const out = []
|
|
68
|
-
for (const c of rows) {
|
|
69
|
-
if (!squares.has(c.square_id)) squares.set(c.square_id, await store.getSquare(c.square_id))
|
|
70
|
-
const sq = squares.get(c.square_id)
|
|
71
|
-
if (sq && ACTIVE.has(sq.state)) out.push({ ...c, line: sq.line })
|
|
72
|
-
}
|
|
73
|
-
return out
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Refuses a write to `paths` from square `sq` when another open square on its line claims one. */
|
|
77
|
-
export async function guardClaims (store, q, sq, paths) {
|
|
78
|
-
if (!paths.length) return
|
|
79
|
-
// A stacked square writes under its parents' claims (06): it builds on their work.
|
|
80
|
-
const mine = new Set([sq.id])
|
|
81
|
-
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 }
|
|
82
|
-
const others = (await liveClaims(store, q)).filter((c) => !mine.has(c.square_id) && c.line === sq.line)
|
|
83
|
-
for (const c of others) {
|
|
84
|
-
const m = globMatcher(c.pattern)
|
|
85
|
-
const hit = paths.find((p) => m(p))
|
|
86
|
-
if (hit) {
|
|
87
|
-
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`,
|
|
88
|
-
{ path: hit, claim: view(c) })
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
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 })
|
|
94
|
-
|
|
95
|
-
export function claimOps (svc) {
|
|
96
|
-
const { store, now, quiltFor, squareByRef, writable, entriesOf, snapshot, fromStore } = svc.internals
|
|
97
|
-
|
|
98
|
-
/** GET /claims[?square=]: the claims that hold now, oldest first. */
|
|
99
|
-
async function claims (me, ref, { square } = {}) {
|
|
100
|
-
const q = await quiltFor(me, ref, 'read')
|
|
101
|
-
let rows = await liveClaims(store, q.id)
|
|
102
|
-
if (square) { const sq = await squareByRef(q.id, square); rows = rows.filter((c) => c.square_id === sq.id) }
|
|
103
|
-
return { claims: rows.sort((a, b) => a.at - b.at).map(view) }
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** POST /squares/:id/claims { pattern, note? }: claims paths for the square. */
|
|
107
|
-
async function claim (me, ref, sqRef, body = {}) {
|
|
108
|
-
const q = await quiltFor(me, ref, 'write')
|
|
109
|
-
const sq = await writable(me, q, sqRef)
|
|
110
|
-
if (sq.state !== 'open') throw new QuiltError(409, 'square_closed', `the square is ${sq.state}`)
|
|
111
|
-
const pattern = claimPattern(body.pattern)
|
|
112
|
-
const note = String(body.note || '')
|
|
113
|
-
if (note.length > MAX_NOTE) throw new QuiltError(400, 'bad_request', `a claim's note is up to ${MAX_NOTE} characters`)
|
|
114
|
-
const all = await liveClaims(store, q.id)
|
|
115
|
-
const mine = all.filter((c) => c.square_id === sq.id)
|
|
116
|
-
const same = mine.find((c) => c.pattern === pattern)
|
|
117
|
-
if (same) return view(same)
|
|
118
|
-
if (mine.length >= MAX_CLAIMS) throw new QuiltError(400, 'bad_request', `a square holds up to ${MAX_CLAIMS} claims`)
|
|
119
|
-
const others = all.filter((c) => c.square_id !== sq.id && c.line === sq.line)
|
|
120
|
-
if (others.length) {
|
|
121
|
-
const tip = (await store.getLine(q.id, sq.line))?.tip || sq.base
|
|
122
|
-
const paths = tip ? (await entriesOf(q.id, await snapshot(q.id, tip))).map((e) => e.path) : []
|
|
123
|
-
const taken = others.find((c) => patternsOverlap(c.pattern, pattern, paths))
|
|
124
|
-
if (taken) throw new QuiltError(409, 'claim_taken', `${taken.pattern} is already claimed by ${taken.holder} in ${squareShort(taken.square_id)}${taken.note ? ` (${taken.note})` : ''}`, { claim: view(taken) })
|
|
125
|
-
}
|
|
126
|
-
try {
|
|
127
|
-
const row = await store.createClaim({ quilt_id: q.id, id: uuidv7(now()), square_id: sq.id, pattern, holder: me.member_id, note, at: now() })
|
|
128
|
-
return view({ ...row, line: sq.line })
|
|
129
|
-
} catch (err) { throw fromStore(err) }
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** DELETE /squares/:id/claims/:claim (its id or its pattern): lets go of a claim. */
|
|
133
|
-
async function unclaim (me, ref, sqRef, which) {
|
|
134
|
-
const q = await quiltFor(me, ref, 'write')
|
|
135
|
-
const sq = await writable(me, q, sqRef)
|
|
136
|
-
const key = String(which || '').trim().replace(/^\.\//, '')
|
|
137
|
-
const c = (await store.claims(q.id)).find((x) => x.square_id === sq.id && (x.id === key || x.pattern === key))
|
|
138
|
-
if (!c) throw new QuiltError(404, 'not_found', `${squareShort(sq.id)} has no claim ${key}`)
|
|
139
|
-
try { await store.deleteClaim({ quilt_id: q.id, id: c.id }) } catch (err) { throw fromStore(err) }
|
|
140
|
-
return { ok: true, released: view({ ...c, line: sq.line }) }
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return { claims, claim, unclaim }
|
|
144
|
-
}
|