heyquilt 0.3.121 → 0.3.122
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 +19 -0
- package/bin/quilt.js +23 -2
- package/package.json +2 -1
- package/src/api/memory-store.js +5 -1
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +36 -0
- package/src/api/server.js +37 -9
- package/src/api/supabase-store.js +10 -0
- package/src/connection.js +4 -1
- package/src/control.js +12 -0
- package/src/history.js +15 -1
- package/src/mcp.js +12 -1
- package/src/quilts/accounts.js +136 -0
- package/src/quilts/affected.js +132 -0
- package/src/quilts/checks.js +308 -0
- package/src/quilts/cli.js +97 -10
- package/src/quilts/client.js +9 -0
- package/src/quilts/describe.js +31 -1
- package/src/quilts/events.js +13 -2
- package/src/quilts/export-remote.js +156 -0
- package/src/quilts/import-follow.js +236 -0
- package/src/quilts/import-git.js +6 -3
- package/src/quilts/import.js +67 -5
- package/src/quilts/index.js +28 -6
- package/src/quilts/keys.js +186 -0
- package/src/quilts/merge.js +16 -1
- package/src/quilts/oauth.js +202 -0
- package/src/quilts/reviews.js +18 -5
- package/src/quilts/routes.js +61 -1
- package/src/quilts/runner.js +95 -0
- package/src/quilts/server.js +144 -0
- package/src/quilts/service.js +41 -1
- package/src/quilts/setup.js +9 -2
- package/src/quilts/store-memory.js +164 -3
- package/src/quilts/store-rpc.js +12 -1
- package/src/quilts/tools.js +39 -11
- package/src/quilts/window.js +2 -0
- package/src/relay-mcp.js +33 -3
- package/src/relay-prune.js +4 -1
- package/src/server.js +317 -14
- package/src/session-follow.js +81 -0
- package/src/session-layout3.js +60 -0
- package/src/session-migrate.js +119 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +71 -0
- package/src/session.js +74 -2
- package/src/source-live/live-block.js +4 -3
- package/src/source-live/live-square.js +1 -1
package/src/server.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// editors change files, viewers only watch and chat, and agents can be
|
|
15
15
|
// limited to some folders. The relay enforces it by undoing file changes a
|
|
16
16
|
// member isn't allowed to make, before anyone else sees them.
|
|
17
|
+
import { quiltOf, gitTokenRefusal } from './session-seam.js'
|
|
17
18
|
import http from 'node:http'
|
|
18
19
|
import { makeChatLink, extendChatLink, pruneChatLinks, handleChatLink, fetchPublicFile } from './chat-links.js'
|
|
19
20
|
import fs from 'node:fs'
|
|
@@ -34,7 +35,15 @@ import {
|
|
|
34
35
|
ROOM_DOC, syncHeader,
|
|
35
36
|
syncStep1Message, updateMessage, awarenessMessage, bytesMessage, jsonMessage
|
|
36
37
|
} from './protocol.js'
|
|
37
|
-
import { isLiveKey, liveRequest } from './source-live/live-square.js'
|
|
38
|
+
import { isLiveKey, liveRequest, liveKey, liveMaps } from './source-live/live-square.js'
|
|
39
|
+
import { planSessionQuilt, quiltPlanRefusal, describeQuiltPlan, fileHash } from './session-quilt.js'
|
|
40
|
+
import { makeQuiltWithoutHistory } from './session-migrate.js'
|
|
41
|
+
import { LAYOUT_QUILT, treeIds, overlayFor, writeOverlay, checkOverlay } from './session-layout3.js'
|
|
42
|
+
import { readMerges } from './merges.js'
|
|
43
|
+
import { readTasks, updateTask } from './tasks.js'
|
|
44
|
+
import { addComment } from './task-comments.js'
|
|
45
|
+
// Seam over HTTP through its client package (@heyquilt/seam), never Seam's code.
|
|
46
|
+
import { quiltClient } from '@heyquilt/seam/lib/quilts/client.js'
|
|
38
47
|
import { BranchStore, DEFAULT_KEY, BRANCH_IDLE_MS, DETACHED_TTL_MS, MAX_BRANCHES, validBranchKey, covers, splitLegacyDoc } from './branchdocs.js'
|
|
39
48
|
import { parsePublicKey, verifyChallenge } from './identity.js'
|
|
40
49
|
import { verifyPass, PASS_TTL_MS } from './passes.js'
|
|
@@ -1026,7 +1035,7 @@ class Room {
|
|
|
1026
1035
|
* are never only in memory, and a crash part-way is finished at the next load.
|
|
1027
1036
|
*/
|
|
1028
1037
|
migrateLegacy () {
|
|
1029
|
-
if (this.meta.layout === 2) return
|
|
1038
|
+
if (this.meta.layout === 2 || this.meta.layout === LAYOUT_QUILT) return
|
|
1030
1039
|
const legacy = this.doc
|
|
1031
1040
|
if (this.docFile && (legacy.getMap('files').size || legacy.getMap('blobs').size)) {
|
|
1032
1041
|
const stored = [...legacy.getMap('blobs').values()].filter((x) => x && x.stored && x.stored.id).map((x) => x.stored.id).sort()
|
|
@@ -1384,6 +1393,7 @@ class Room {
|
|
|
1384
1393
|
|
|
1385
1394
|
/** A room whose move to branch documents failed on the relay's disk (migrateLegacy) stays read-only: no branch document is used. */
|
|
1386
1395
|
assertMigrated () {
|
|
1396
|
+
if (this.meta.layout === LAYOUT_QUILT) throw new Error(`This session is the quilt ${(this.meta.quilt && this.meta.quilt.slug) || this.meta.quilt_id} now: its files are in its squares, not in branches. Use Seam (squares, commits, merges).`)
|
|
1387
1397
|
if (this.meta.layout !== 2) throw new Error("The relay couldn't finish updating how it stores this session (its disk refused), so the session is read-only until the relay is fixed.")
|
|
1388
1398
|
}
|
|
1389
1399
|
|
|
@@ -1811,8 +1821,18 @@ class Room {
|
|
|
1811
1821
|
if (this.cfg.upstreamAuto) this.checkUpstreams().catch(() => {})
|
|
1812
1822
|
}
|
|
1813
1823
|
|
|
1824
|
+
/**
|
|
1825
|
+
* What every branch-list message carries: the list and, once the session is a quilt (behind
|
|
1826
|
+
* QUILT_SOURCE_URL), its link { id, slug, squares: { branch: square id } } so each member's
|
|
1827
|
+
* app and agents can use Seam (plans/quilt-source-control/16).
|
|
1828
|
+
*/
|
|
1829
|
+
branchesFields () {
|
|
1830
|
+
const q = this.meta.quilt_id && process.env.QUILT_SOURCE_URL ? { id: this.meta.quilt_id, slug: (this.meta.quilt || {}).slug || '', squares: (this.meta.quilt || {}).squares || {}, ...((this.meta.quilt || {}).at ? { at: this.meta.quilt.at } : {}), ...(this.meta.layout === LAYOUT_QUILT ? { layout: LAYOUT_QUILT, live: this.meta.quilt.live || {}, ...(this.meta.quilt.adoption ? { adoption: this.meta.quilt.adoption } : {}) } : {}) } : null
|
|
1831
|
+
return { branches: this.branchList(), ...(q ? { quilt: q } : {}) }
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1814
1834
|
broadcastBranches () {
|
|
1815
|
-
const msg = jsonMessage(MSG_BRANCHES, {
|
|
1835
|
+
const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields() })
|
|
1816
1836
|
for (const ws of this.conns.keys()) send(ws, msg)
|
|
1817
1837
|
}
|
|
1818
1838
|
|
|
@@ -1838,7 +1858,7 @@ class Room {
|
|
|
1838
1858
|
|
|
1839
1859
|
/** The relay pushed `sha` to branch `key`'s upstream: folders on it fetch now and follow it (no file is written: the session has it). */
|
|
1840
1860
|
tellPushed (key, sha) {
|
|
1841
|
-
const msg = jsonMessage(MSG_BRANCHES, {
|
|
1861
|
+
const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), pushed: { branch: key, sha } })
|
|
1842
1862
|
for (const ws of this.conns.keys()) send(ws, msg)
|
|
1843
1863
|
}
|
|
1844
1864
|
|
|
@@ -1961,7 +1981,7 @@ class Room {
|
|
|
1961
1981
|
if (head && sync && sync.pushed === head && sync.base === head) { echo = true; this.saveMeta(); continue } // our own push, come back round
|
|
1962
1982
|
const at = this.cfg.gitSync && this.mirrors && this.meta.sync && this.meta.sync[key] && this.meta.sync[key].base ? this.meta.sync[key].base : rec.sha
|
|
1963
1983
|
if (head && at === head) { this.saveMeta(); continue } // already there
|
|
1964
|
-
const msg = jsonMessage(MSG_BRANCHES, {
|
|
1984
|
+
const msg = jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), pushed: { branch: key, sha: head, ...(forced ? { forced: true } : {}) } })
|
|
1965
1985
|
for (const ws of this.conns.keys()) send(ws, msg)
|
|
1966
1986
|
this.checkUpstreamsNow([key]).catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
|
|
1967
1987
|
}
|
|
@@ -2017,14 +2037,22 @@ class Room {
|
|
|
2017
2037
|
* app asks before it clears its folder for a switch).
|
|
2018
2038
|
*/
|
|
2019
2039
|
branchRequest (ws, req) {
|
|
2040
|
+
// Seam, behind QUILT_SOURCE_URL (plans/quilt-source-control/16): what making this session a
|
|
2041
|
+
// quilt would do, read only. The owner's (or, in a session without one, any editor's).
|
|
2042
|
+
if (req.op === 'quilt-plan') {
|
|
2043
|
+
if (!process.env.QUILT_SOURCE_URL) throw new Error('Seam is not on for this relay, so a session cannot become a quilt here yet')
|
|
2044
|
+
return { ok: true, plan: this.quiltPlan(this.access.get(ws), req.tip) }
|
|
2045
|
+
}
|
|
2020
2046
|
const key = String(req.branch ?? '')
|
|
2021
2047
|
if (!validBranchKey(key)) throw new Error(`"${key.slice(0, 60)}" isn't a branch name`)
|
|
2022
2048
|
if (req.op === 'join') {
|
|
2023
|
-
|
|
2049
|
+
// At layout 3 the only documents members join are squares' live documents (quiltSwitch).
|
|
2050
|
+
const square = this.meta.layout === LAYOUT_QUILT && isLiveKey(key)
|
|
2051
|
+
if (!square) this.assertMigrated()
|
|
2024
2052
|
// An app from before branch documents names the branch the old document was; else the first real branch takes ∅.
|
|
2025
2053
|
let adopted = false
|
|
2026
|
-
if (validBranchKey(String(req.adopt || ''))) adopted = this.adoptDefault(String(req.adopt))
|
|
2027
|
-
adopted = this.adoptDefault(key) || adopted
|
|
2054
|
+
if (!square && validBranchKey(String(req.adopt || ''))) adopted = this.adoptDefault(String(req.adopt))
|
|
2055
|
+
if (!square) adopted = this.adoptDefault(key) || adopted
|
|
2028
2056
|
const k = this.resolveKey(key)
|
|
2029
2057
|
const created = !this.meta.branches[k]
|
|
2030
2058
|
if (created) {
|
|
@@ -2095,9 +2123,240 @@ class Room {
|
|
|
2095
2123
|
throw new Error('unknown branch request')
|
|
2096
2124
|
}
|
|
2097
2125
|
|
|
2126
|
+
/**
|
|
2127
|
+
* Tasks with a pull request, once the room is a quilt (16's table): one whose branch became a
|
|
2128
|
+
* square is kept in `meta.quilt.tasks` (task id -> square id); one whose branch did not loses
|
|
2129
|
+
* its `pr`, kept as a task comment. Returns the task id -> square id map.
|
|
2130
|
+
*/
|
|
2131
|
+
quiltTasks (squares) {
|
|
2132
|
+
const tasks = readTasks(this.tasks)
|
|
2133
|
+
const out = {}
|
|
2134
|
+
for (const t of tasks) {
|
|
2135
|
+
if (!t.pr) continue
|
|
2136
|
+
const sq = t.pr.branch && squares[t.pr.branch]
|
|
2137
|
+
if (sq) { out[t.id] = sq; continue }
|
|
2138
|
+
const which = `${t.pr.number ? ` #${t.pr.number}` : ''}${t.pr.url ? ` (${t.pr.url})` : ''}`
|
|
2139
|
+
updateTask(this.doc, this.tasks, { id: t.id, pr: null }, 'quilt-migrate')
|
|
2140
|
+
addComment(this.doc, this.taskComments, tasks, { taskId: t.id, by: 'Quilt', text: `This task's pull request${which} did not carry over when the session became a quilt: no square was made from its branch${t.pr.branch ? ` ${t.pr.branch}` : ''}.` }, 'quilt-migrate')
|
|
2141
|
+
}
|
|
2142
|
+
return out
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/**
|
|
2146
|
+
* Open commit requests have no replacement in a quilt (12): each becomes a comment on its task,
|
|
2147
|
+
* or a chat line when it has none, "Please get this into history: <paths>" with its square.
|
|
2148
|
+
*/
|
|
2149
|
+
quiltCommitRequests (squares) {
|
|
2150
|
+
const tasks = readTasks(this.tasks)
|
|
2151
|
+
for (const r of this.commitRequests.values()) {
|
|
2152
|
+
if (!r || r.state !== 'open') continue
|
|
2153
|
+
const sq = squares[r.branch || this.defaultKey]
|
|
2154
|
+
const text = `Please get this into history: ${(r.files || []).join(', ')}${sq ? ` (square sq:${sq.slice(0, 8)})` : ''}. ${r.by || 'Someone'} asked for a commit ("${String(r.message || '').slice(0, 200)}") before the session became a quilt: commit it in Seam.`
|
|
2155
|
+
if (r.task && tasks.some((t) => t.id === r.task.id)) addComment(this.doc, this.taskComments, tasks, { taskId: r.task.id, by: 'Quilt', text }, 'quilt-migrate')
|
|
2156
|
+
else this.postChat({ by: 'Quilt', text, kind: 'note' })
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* The owner's app asks to make the session a quilt (`op: 'quilt-migrate'`, `token`: their Seam
|
|
2162
|
+
* sign-in). Answered when it is done, on the branch channel like every branch request.
|
|
2163
|
+
*/
|
|
2164
|
+
quiltMigrateRequest (ws, req) {
|
|
2165
|
+
const answer = (fields) => send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply: { id: req.id, op: req.op, ...fields } }))
|
|
2166
|
+
const api = process.env.QUILT_SOURCE_URL
|
|
2167
|
+
if (!api) return answer({ ok: false, error: 'Seam is not on for this relay, so a session cannot become a quilt here yet' })
|
|
2168
|
+
if (!req.token || typeof req.token !== 'string') return answer({ ok: false, error: 'sign in to Seam first: making a session a quilt is done as its owner' })
|
|
2169
|
+
const seam = (this.seamClient || quiltClient)({ api, token: req.token })
|
|
2170
|
+
if (req.op === 'quilt-switch') {
|
|
2171
|
+
return this.quiltSwitch(this.access.get(ws), seam).then((r) => answer({ ok: true, ...r }), (err) => answer({ ok: false, error: err.message }))
|
|
2172
|
+
}
|
|
2173
|
+
this.quiltMigrate(this.access.get(ws), seam).then(
|
|
2174
|
+
(r) => answer({ ok: true, quilt: r }),
|
|
2175
|
+
(err) => answer({ ok: false, error: err.message, ...(err.quilt_id ? { quilt_id: err.quilt_id } : {}) })
|
|
2176
|
+
)
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
/** The dry run of making this session a quilt, for someone with access `a` (session-quilt.js). */
|
|
2180
|
+
quiltPlan (a, tip = null) {
|
|
2181
|
+
const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
|
|
2182
|
+
if (no) throw new Error(no)
|
|
2183
|
+
this.assertMigrated()
|
|
2184
|
+
return planSessionQuilt(this.quiltSnapshot(), { tip: tip && typeof tip === 'object' ? tip : null })
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
/**
|
|
2188
|
+
* The room as session-quilt.js's dry run reads it: each branch document's files (by hash),
|
|
2189
|
+
* history and merge records, the claims, tasks with pull requests, and commit requests.
|
|
2190
|
+
* Live square documents (quilt/<id>) are already Seam's and are left out.
|
|
2191
|
+
*/
|
|
2192
|
+
quiltSnapshot () {
|
|
2193
|
+
const branches = []
|
|
2194
|
+
for (const [key, b] of Object.entries(this.meta.branches || {})) {
|
|
2195
|
+
if (isLiveKey(key)) continue
|
|
2196
|
+
const e = this.store.load(key)
|
|
2197
|
+
if (!e) continue
|
|
2198
|
+
const files = {}
|
|
2199
|
+
for (const [p, t] of e.files) { const s = t.toString(); files[p] = { hash: fileHash(s), bytes: Buffer.byteLength(s) } }
|
|
2200
|
+
for (const [p, x] of e.blobs) if (x && !files[p]) files[p] = { hash: String(x.hash || x.sha256 || ''), bytes: Number(x.size) || 0 }
|
|
2201
|
+
const merges = [...e.doc.getMap('merges').values()].map((m) => ({ id: m && m.id, state: m && m.state }))
|
|
2202
|
+
branches.push({ key, by: b.by || '', files, history: e.doc.getArray('history').toArray(), merges })
|
|
2203
|
+
}
|
|
2204
|
+
const claims = Object.values(this.meta.claims || {}).map((c) => ({ pattern: c.pattern, branch: this.branchOf(c), by: c.by || '', byId: c.byId || '', queue: c.queue || [] }))
|
|
2205
|
+
const tasks = [...this.tasks.entries()].map(([id, t]) => ({ id, pr: t && typeof t.get === 'function' ? t.get('pr') : t && t.pr })).filter((t) => t.pr)
|
|
2206
|
+
return {
|
|
2207
|
+
room: this.name,
|
|
2208
|
+
owner: this.meta.ownerSub || this.meta.ownerName || this.meta.owner || '',
|
|
2209
|
+
defaultKey: this.defaultKey,
|
|
2210
|
+
hasRemote: Object.keys(this.meta.upstreams || {}).length > 0,
|
|
2211
|
+
branches,
|
|
2212
|
+
claims,
|
|
2213
|
+
tasks,
|
|
2214
|
+
commitRequests: this.doc.getMap('commitRequests').size
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* Makes this session a quilt (plans/quilt-source-control/16, "Without history"), behind
|
|
2220
|
+
* QUILT_SOURCE_URL: the owner's (as quiltPlan), with `seam` a Seam client acting as them.
|
|
2221
|
+
* The room is linked (`meta.quilt_id`, `meta.quilt`) only when every step worked, so a
|
|
2222
|
+
* failure leaves it a git-mode session as before. A room with a git remote goes with history
|
|
2223
|
+
* (the import and the cut-over, 14 and 16 step 3), not here.
|
|
2224
|
+
*/
|
|
2225
|
+
async quiltMigrate (a, seam, { date = new Date() } = {}) {
|
|
2226
|
+
const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
|
|
2227
|
+
if (no) throw new Error(no.replace('see what making it a quilt would do', 'make it a quilt'))
|
|
2228
|
+
this.assertMigrated()
|
|
2229
|
+
if (this.meta.quilt_id) throw new Error(`this session is already the quilt ${(this.meta.quilt && this.meta.quilt.slug) || this.meta.quilt_id}`)
|
|
2230
|
+
if (Object.keys(this.meta.upstreams || {}).length) throw new Error('this session has a git remote: it becomes a quilt with its history, by importing the remote and cutting over (docs/seam-cutover.md)')
|
|
2231
|
+
if (this.quiltMoving) throw new Error('this session is already being made a quilt')
|
|
2232
|
+
this.quiltMoving = true
|
|
2233
|
+
try {
|
|
2234
|
+
const texts = (key) => {
|
|
2235
|
+
const e = this.store.load(key)
|
|
2236
|
+
const files = {}; const big = []
|
|
2237
|
+
if (!e) return { files, big, merges: [] }
|
|
2238
|
+
for (const [p, t] of e.files) files[p] = t.toString()
|
|
2239
|
+
for (const [p, x] of e.blobs) if (x && !(p in files)) big.push(p)
|
|
2240
|
+
return { files, big, merges: e.doc ? readMerges(e.doc.getMap('merges')) : [] }
|
|
2241
|
+
}
|
|
2242
|
+
const def = texts(this.defaultKey)
|
|
2243
|
+
const leftOut = new Set(def.big)
|
|
2244
|
+
const branches = []
|
|
2245
|
+
for (const key of Object.keys(this.meta.branches || {})) {
|
|
2246
|
+
if (key === this.defaultKey || isLiveKey(key)) continue
|
|
2247
|
+
const t = texts(key)
|
|
2248
|
+
for (const p of t.big) leftOut.add(`${key}:${p}`)
|
|
2249
|
+
branches.push({ key, files: t.files, merges: t.merges })
|
|
2250
|
+
}
|
|
2251
|
+
const r = await makeQuiltWithoutHistory({ seam, room: this.name, defaultFiles: def.files, defaultMerges: def.merges, branches, leftOut: [...leftOut], date })
|
|
2252
|
+
this.meta.quilt_id = r.quilt_id
|
|
2253
|
+
this.meta.quilt = { slug: r.slug, patch: r.patch, squares: Object.fromEntries(r.squares.map((s) => [s.from, s.id])), at: new Date(date).toISOString(), by: (a && (a.sub || a.key || a.name)) || '' }
|
|
2254
|
+
// Open merges now live in Seam as conflict terms; the ones with no text to carry are named.
|
|
2255
|
+
if (r.terms.length || r.merges_left.length) this.meta.quilt.merges = { terms: r.terms, left: r.merges_left }
|
|
2256
|
+
this.meta.quilt.tasks = this.quiltTasks(this.meta.quilt.squares)
|
|
2257
|
+
this.quiltCommitRequests(this.meta.quilt.squares)
|
|
2258
|
+
this.saveMeta()
|
|
2259
|
+
this.broadcastBranches()
|
|
2260
|
+
return { ...r, tasks: this.meta.quilt.tasks }
|
|
2261
|
+
} finally {
|
|
2262
|
+
this.quiltMoving = false
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* The layout-3 switch (16, "How a session becomes a quilt"), after quiltMigrate linked the
|
|
2268
|
+
* quilt: each branch document is copied into its square's live document on the relay
|
|
2269
|
+
* (`quilt/<square id>`, an overlay over the square's head), the default branch's difference
|
|
2270
|
+
* from `main` into an adoption square ("Uncommitted work from <room>"), every copy is checked
|
|
2271
|
+
* against its branch document, and only then `meta.layout = 3` is written. A failed check
|
|
2272
|
+
* removes the copies and leaves the room at layout 2. The branch documents are not touched: at
|
|
2273
|
+
* layout 3 they are read-only, and quiltUnswitch goes back to them. Apps without `quilts` are
|
|
2274
|
+
* sent away (supported). The owner's, with `seam` acting as them, behind QUILT_SOURCE_URL.
|
|
2275
|
+
*/
|
|
2276
|
+
async quiltSwitch (a, seam, { date = new Date() } = {}) {
|
|
2277
|
+
const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
|
|
2278
|
+
if (no) throw new Error(no.replace('see what making it a quilt would do', 'move its files into its squares'))
|
|
2279
|
+
if (this.meta.layout === LAYOUT_QUILT) throw new Error('this session\'s files are already in its squares')
|
|
2280
|
+
this.assertMigrated()
|
|
2281
|
+
const q = this.meta.quilt_id
|
|
2282
|
+
if (!q || !this.meta.quilt) throw new Error('this session is not a quilt yet: make it one first (quilt session migrate)')
|
|
2283
|
+
if (this.quiltMoving) throw new Error('this session is already being moved')
|
|
2284
|
+
this.quiltMoving = true
|
|
2285
|
+
const made = []
|
|
2286
|
+
const by = (a && (a.name || a.sub || a.key)) || ''
|
|
2287
|
+
try {
|
|
2288
|
+
const texts = (key) => {
|
|
2289
|
+
const e = this.store.load(key)
|
|
2290
|
+
const files = {}
|
|
2291
|
+
if (e) for (const [p, t] of e.files) files[p] = t.toString()
|
|
2292
|
+
return files
|
|
2293
|
+
}
|
|
2294
|
+
const held = (from) => new Set(((this.meta.quilt.merges && this.meta.quilt.merges.terms) || []).filter((t) => t.from === from).map((t) => t.path))
|
|
2295
|
+
const without = (o, skip) => Object.fromEntries(Object.entries(o).filter(([p]) => !skip.has(p)))
|
|
2296
|
+
const plan = []
|
|
2297
|
+
for (const [key, sq] of Object.entries(this.meta.quilt.squares || {})) {
|
|
2298
|
+
if (key === this.defaultKey || !this.meta.branches[key] || isLiveKey(key)) continue
|
|
2299
|
+
const s = await seam.square(q, sq)
|
|
2300
|
+
const head = s.head || s.base || null
|
|
2301
|
+
const skip = held(key)
|
|
2302
|
+
plan.push({ key, sq, head, files: without(texts(key), skip), tree: without(head ? treeIds(await seam.tree(q, head)) : {}, skip) })
|
|
2303
|
+
}
|
|
2304
|
+
const def = texts(this.defaultKey)
|
|
2305
|
+
const main = this.meta.quilt.patch ? treeIds(await seam.tree(q, this.meta.quilt.patch.commit)) : {}
|
|
2306
|
+
const adopt = overlayFor(def, main)
|
|
2307
|
+
let adoption = null
|
|
2308
|
+
if (Object.keys(adopt.puts).length || adopt.gone.length) {
|
|
2309
|
+
const s = await seam.openSquare(q, { title: `Uncommitted work from ${this.name}`.slice(0, 200) })
|
|
2310
|
+
adoption = s.id
|
|
2311
|
+
plan.push({ key: this.defaultKey, sq: s.id, head: (this.meta.quilt.patch && this.meta.quilt.patch.commit) || null, files: def, tree: main })
|
|
2312
|
+
}
|
|
2313
|
+
for (const p of plan) {
|
|
2314
|
+
const lk = liveKey(p.sq)
|
|
2315
|
+
if (this.meta.branches[lk] && this.store.load(lk) && liveMaps(this.store.load(lk).doc).live.size) throw new Error(`square ${p.sq.slice(0, 8)} already has a live document on the relay`)
|
|
2316
|
+
this.noteBranch(lk, { by })
|
|
2317
|
+
made.push(lk)
|
|
2318
|
+
writeOverlay(this.store.load(lk).doc, overlayFor(p.files, p.tree), p.head, by)
|
|
2319
|
+
}
|
|
2320
|
+
for (const p of plan) {
|
|
2321
|
+
const bad = checkOverlay(this.store.load(liveKey(p.sq)).doc, p.files, p.tree)
|
|
2322
|
+
if (bad) throw new Error(`the check after copying ${p.key === this.defaultKey ? 'the default branch' : p.key} failed: ${bad}`)
|
|
2323
|
+
}
|
|
2324
|
+
this.meta.quilt.live = Object.fromEntries(plan.map((p) => [p.key, liveKey(p.sq)]))
|
|
2325
|
+
if (adoption) this.meta.quilt.adoption = adoption
|
|
2326
|
+
this.meta.quilt.layoutAt = new Date(date).toISOString()
|
|
2327
|
+
this.meta.layout = LAYOUT_QUILT // last
|
|
2328
|
+
this.saveMeta()
|
|
2329
|
+
for (const ws of [...this.conns.keys(), ...this.pending.keys()]) {
|
|
2330
|
+
if (!ws.hosted && !this.supported(ws)) { ws.endReason = 'needs_update'; ws.close(CLOSE_NEEDS_UPDATE, NEEDS_UPDATE) }
|
|
2331
|
+
}
|
|
2332
|
+
this.broadcastBranches()
|
|
2333
|
+
return { layout: LAYOUT_QUILT, live: this.meta.quilt.live, adoption }
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
for (const lk of made) this.removeBranch(lk)
|
|
2336
|
+
if (made.length) this.broadcastBranches()
|
|
2337
|
+
throw new Error(`Migration undone: ${err.message}. The session stays as it was.`)
|
|
2338
|
+
} finally {
|
|
2339
|
+
this.quiltMoving = false
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/** Back to layout 2 from the kept branch documents (16's rollback before R3). The squares' live documents stay. */
|
|
2344
|
+
quiltUnswitch (a) {
|
|
2345
|
+
const no = quiltPlanRefusal({ controlled: this.controlled, access: a })
|
|
2346
|
+
if (no) throw new Error(no.replace('see what making it a quilt would do', 'move it back to branches'))
|
|
2347
|
+
if (this.meta.layout !== LAYOUT_QUILT) throw new Error('this session\'s files are not in squares')
|
|
2348
|
+
this.meta.layout = 2
|
|
2349
|
+
this.meta.quilt.unswitchedAt = new Date().toISOString()
|
|
2350
|
+
this.saveMeta()
|
|
2351
|
+
this.broadcastBranches()
|
|
2352
|
+
return { layout: 2 }
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2098
2355
|
/** Can this connection's app handle the session as it is now? */
|
|
2099
2356
|
supported (ws) {
|
|
2100
|
-
|
|
2357
|
+
const f = ws.features || []
|
|
2358
|
+
if (this.meta.layout === LAYOUT_QUILT && !f.includes('quilts')) return false
|
|
2359
|
+
return !this.meta.largeFiles || f.includes('large-files')
|
|
2101
2360
|
}
|
|
2102
2361
|
|
|
2103
2362
|
/**
|
|
@@ -2975,7 +3234,7 @@ class Room {
|
|
|
2975
3234
|
this.conns.set(ws, new Set())
|
|
2976
3235
|
this.names.set(ws, name)
|
|
2977
3236
|
send(ws, syncStep1Message(this.doc))
|
|
2978
|
-
send(ws, jsonMessage(MSG_BRANCHES, {
|
|
3237
|
+
send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields() }))
|
|
2979
3238
|
const states = [...this.awareness.getStates().keys()]
|
|
2980
3239
|
if (states.length) send(ws, awarenessMessage(this.awareness, states))
|
|
2981
3240
|
send(ws, jsonMessage(MSG_CLAIMS, { claims: this.claimList(ws.branch) }))
|
|
@@ -3051,6 +3310,8 @@ class Room {
|
|
|
3051
3310
|
// A branch over its size limit takes nothing new; the room and every other branch carry
|
|
3052
3311
|
// on, and nobody is disconnected for it (its members hear it in the branch list).
|
|
3053
3312
|
if (!this.unsavable && this.branchFull(k) && decoding.peekVarUint(dec) !== syncProtocol.messageYjsSyncStep1) return
|
|
3313
|
+
// At layout 3 the branch documents are kept read-only: the work is in the squares (quiltSwitch).
|
|
3314
|
+
if (this.meta.layout === LAYOUT_QUILT && !isLiveKey(k) && decoding.peekVarUint(dec) !== syncProtocol.messageYjsSyncStep1) return
|
|
3054
3315
|
}
|
|
3055
3316
|
if (docId === ROOM_DOC ? this.full : this.unsavable) {
|
|
3056
3317
|
// Over quota: still answer "what do you have?" so people can read, but refuse new data.
|
|
@@ -3069,13 +3330,15 @@ class Room {
|
|
|
3069
3330
|
let reply
|
|
3070
3331
|
try {
|
|
3071
3332
|
req = JSON.parse(decoding.readVarString(dec))
|
|
3072
|
-
|
|
3333
|
+
if (req.op === 'quilt-migrate' || req.op === 'quilt-switch') { this.quiltMigrateRequest(ws, req); return }
|
|
3334
|
+
if (req.op === 'quilt-unswitch') reply = { id: req.id, op: req.op, ok: true, ...this.quiltUnswitch(this.access.get(ws)) }
|
|
3335
|
+
else reply = { id: req.id, op: req.op, ...this.branchRequest(ws, req) }
|
|
3073
3336
|
} catch (err) {
|
|
3074
3337
|
reply = { id: req.id, op: req.op, ok: false, error: err.message }
|
|
3075
3338
|
}
|
|
3076
3339
|
const listChanged = reply.listChanged
|
|
3077
3340
|
delete reply.listChanged
|
|
3078
|
-
send(ws, jsonMessage(MSG_BRANCHES, {
|
|
3341
|
+
send(ws, jsonMessage(MSG_BRANCHES, { ...this.branchesFields(), reply }))
|
|
3079
3342
|
if (reply.ok && reply.op === 'join') {
|
|
3080
3343
|
// What the relay has of the branch, and its claims: the app answers with what it has.
|
|
3081
3344
|
send(ws, syncStep1Message(this.store.get(ws.branch).doc, ws.branchAs))
|
|
@@ -3127,7 +3390,9 @@ class Room {
|
|
|
3127
3390
|
const id = req.id
|
|
3128
3391
|
const a = this.access.get(ws)
|
|
3129
3392
|
if (!a || !ws.branch) throw new Error('not in this session')
|
|
3130
|
-
|
|
3393
|
+
// In a session that is a quilt, an agent's git gets no GitHub token: it uses Seam (session-seam.js).
|
|
3394
|
+
const onSeam = gitTokenRefusal(quiltOf(this.meta), a.kind === 'agent' ? 'agent' : 'human')
|
|
3395
|
+
;(onSeam ? Promise.reject(new Error(onSeam)) : this.gitTokenFor(ws.branch, { access: req.access === 'write' ? 'write' : 'read', who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', id: a.id }, repo: typeof req.repo === 'string' ? req.repo : null }))
|
|
3131
3396
|
.then((git) => ({ id, ok: true, git }), (err) => ({ id, ok: false, error: err.message }))
|
|
3132
3397
|
.then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
|
|
3133
3398
|
return
|
|
@@ -4036,6 +4301,44 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
|
|
|
4036
4301
|
json(200, { ok: true, rooms: n })
|
|
4037
4302
|
})
|
|
4038
4303
|
}
|
|
4304
|
+
// Making a session a quilt from the accounts API (POST /v1/sessions/:room/migrate, which checked the
|
|
4305
|
+
// owner's sign-in): { room, account, dry_run } plans it; { room, account, token } makes it, as the
|
|
4306
|
+
// owner, with their Seam sign-in. The room's own owner check still applies (Room.quiltPlan).
|
|
4307
|
+
if (url.pathname === '/quilt/migrate' && req.method === 'POST') {
|
|
4308
|
+
if (!fromApi(req)) return json(401, { error: 'only the accounts API may say this' })
|
|
4309
|
+
return readJson(req, 16 * 1024, (err, b) => {
|
|
4310
|
+
if (err || !b) return json(400, { error: 'send JSON' })
|
|
4311
|
+
const name = String(b.room || '')
|
|
4312
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return json(400, { error: 'give the room' })
|
|
4313
|
+
if (!/^[a-z]+:[A-Za-z0-9_-]{1,64}$/.test(String(b.account || ''))) return json(400, { error: 'give the account' })
|
|
4314
|
+
const api = process.env.QUILT_SOURCE_URL
|
|
4315
|
+
if (!api) return json(409, { error: 'Seam is not on for this relay, so a session cannot become a quilt here yet' })
|
|
4316
|
+
const onDisk = dataDir && fs.existsSync(path.join(dataDir, `${name}.json`))
|
|
4317
|
+
if ((!rooms.has(name) && !onDisk) || roomEnded(name)) return json(404, { error: 'no such session' })
|
|
4318
|
+
const room = getRoom(name)
|
|
4319
|
+
if (!room) return json(409, { error: 'this session is not on this relay machine' })
|
|
4320
|
+
const access = { state: 'approved', role: 'editor', scopes: [], owner: !room.controlled || room.meta.ownerSub === b.account, sub: b.account }
|
|
4321
|
+
if (room.controlled && !access.owner) return json(403, { error: 'only the session\'s owner can make it a quilt' })
|
|
4322
|
+
if (b.dry_run === true) {
|
|
4323
|
+
try {
|
|
4324
|
+
const plan = room.quiltPlan(access, b.tip && typeof b.tip === 'object' ? b.tip : null)
|
|
4325
|
+
return json(200, { ok: true, plan, text: describeQuiltPlan(plan) })
|
|
4326
|
+
} catch (e) { return json(409, { error: e.message }) }
|
|
4327
|
+
}
|
|
4328
|
+
if (b.step === 'unswitch') {
|
|
4329
|
+
try { return json(200, { ok: true, ...room.quiltUnswitch(access) }) } catch (e) { return json(409, { error: e.message }) }
|
|
4330
|
+
}
|
|
4331
|
+
if (!b.token || typeof b.token !== 'string') return json(400, { error: 'sign in to Seam first: making a session a quilt is done as its owner' })
|
|
4332
|
+
const seam = (room.seamClient || quiltClient)({ api, token: b.token })
|
|
4333
|
+
if (b.step === 'switch') {
|
|
4334
|
+
return room.quiltSwitch(access, seam).then((r) => json(200, { ok: true, ...r }), (e) => json(409, { error: e.message }))
|
|
4335
|
+
}
|
|
4336
|
+
room.quiltMigrate(access, seam).then(
|
|
4337
|
+
(quilt) => json(200, { ok: true, quilt }),
|
|
4338
|
+
(e) => json(409, { error: e.message, ...(e.quilt_id ? { quilt_id: e.quilt_id } : {}) })
|
|
4339
|
+
)
|
|
4340
|
+
})
|
|
4341
|
+
}
|
|
4039
4342
|
// Moving a room between machines (the accounts API runs it, src/api/relay-cluster.js): the source
|
|
4040
4343
|
// lets it go, the target copies it from the source, the source forgets its copy (or takes it back).
|
|
4041
4344
|
const cm = url.pathname.match(/^\/cluster\/rooms\/([A-Za-z0-9_-]{1,64})\/(release|export|fetch|forget|restore)$/)
|
|
@@ -4379,7 +4682,7 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
|
|
|
4379
4682
|
return reject(socket, auth === 'need-key' ? 403 : 401, auth === 'need-key' ? 'Relay key required to create rooms' : 'Wrong room secret')
|
|
4380
4683
|
}
|
|
4381
4684
|
// Checked after the secret, so only members learn what the session needs.
|
|
4382
|
-
if (room.meta.largeFiles && !features.includes('large-files')) {
|
|
4685
|
+
if ((room.meta.largeFiles && !features.includes('large-files')) || (room.meta.layout === LAYOUT_QUILT && !features.includes('quilts'))) {
|
|
4383
4686
|
if (!room.conns.size && room.onEmpty) room.onEmpty() // don't keep it in memory for nobody
|
|
4384
4687
|
return reject(socket, 400, NEEDS_UPDATE)
|
|
4385
4688
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Quilt's side of Seam (plans/quilt-source-control/16, "How a session becomes a quilt"): once a
|
|
2
|
+
// session is switched to layout 3 (session-layout3.js), each branch's live work is a square's live
|
|
3
|
+
// document on the relay (`quilt/<square>`), and a folder follows it through a live block
|
|
4
|
+
// (source-live/live-block.js): its files mirror the square, every edit goes into the live document.
|
|
5
|
+
//
|
|
6
|
+
// SquareFollower is that link for one Session: `follow({ dir, client })` opens the block for the
|
|
7
|
+
// session's branch, and the relay's `quilt` message (switched back, square moved) or the session
|
|
8
|
+
// stopping closes it. Nothing here runs unless QUILT_SOURCE_URL is set and the relay says the
|
|
9
|
+
// session is a quilt at layout 3, so GitHub-backed sessions never reach it.
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { FEATURES } from './protocol.js'
|
|
12
|
+
import { openLiveBlock } from './source-live/live-block.js'
|
|
13
|
+
|
|
14
|
+
const LAYOUT_QUILT = 3
|
|
15
|
+
const PREFIX = 'quilt/'
|
|
16
|
+
|
|
17
|
+
/** The square the folder on `branch` follows: `{ square, key }`, or null while the session is not a layout-3 quilt. */
|
|
18
|
+
export function squareFor (quilt, branch) {
|
|
19
|
+
if (!quilt || quilt.layout !== LAYOUT_QUILT || !branch) return null
|
|
20
|
+
const key = (quilt.live || {})[branch]
|
|
21
|
+
if (typeof key !== 'string' || !key.startsWith(PREFIX) || key.length === PREFIX.length) return null
|
|
22
|
+
return { square: key.slice(PREFIX.length), key }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const withQuilts = (f) => (String(f || FEATURES).split(',').includes('quilts') ? String(f || FEATURES) : `${f || FEATURES},quilts`)
|
|
26
|
+
|
|
27
|
+
export class SquareFollower {
|
|
28
|
+
/** @param {object} session a Session (server, room, secret, name, identity, passes, tool, kind, features, quilt, branchKey(), log) */
|
|
29
|
+
constructor (session, { open = openLiveBlock } = {}) {
|
|
30
|
+
this.session = session
|
|
31
|
+
this.open = open
|
|
32
|
+
this.following = null // { square, dir, block }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Opens the block at `dir` (its own folder, signed in to Seam with `client`) on the session's branch's square. */
|
|
36
|
+
async follow ({ dir, client } = {}) {
|
|
37
|
+
if (!process.env.QUILT_SOURCE_URL) throw new Error('Seam is not on for this app (QUILT_SOURCE_URL is not set).')
|
|
38
|
+
if (!dir) throw new Error('Give the folder the square\'s files go to.')
|
|
39
|
+
const s = this.session
|
|
40
|
+
// Its own folder: inside the session's folder, the block's files would sync a second time.
|
|
41
|
+
if (s.root) {
|
|
42
|
+
const rel = path.relative(path.resolve(s.root), path.resolve(dir))
|
|
43
|
+
if (!rel || (!rel.startsWith('..') && !path.isAbsolute(rel))) throw new Error('Give a folder outside this session\'s folder: the square\'s files are synced there by Seam, not by this folder.')
|
|
44
|
+
}
|
|
45
|
+
if (!s.quilt) throw new Error('This session is not a quilt.')
|
|
46
|
+
const branch = s.branchKey()
|
|
47
|
+
const at = squareFor(s.quilt, branch)
|
|
48
|
+
if (!at) throw new Error(`The session is a quilt, but ${branch} has no live square yet (the owner switches it with quilt session migrate --switch).`)
|
|
49
|
+
if (this.following && this.following.square === at.square && this.following.dir === dir) return { square: at.square, dir }
|
|
50
|
+
await this.close()
|
|
51
|
+
const block = await this.open({
|
|
52
|
+
dir,
|
|
53
|
+
client,
|
|
54
|
+
quilt: s.quilt.id,
|
|
55
|
+
square: at.square,
|
|
56
|
+
api: process.env.QUILT_SOURCE_URL,
|
|
57
|
+
relay: { server: s.server, room: s.room, secret: s.secret, name: s.name, identity: s.identity, passes: s.passes, tool: s.tool, kind: s.kind, features: withQuilts(s.features) }
|
|
58
|
+
})
|
|
59
|
+
this.following = { square: at.square, dir, block }
|
|
60
|
+
if (s.log) s.log(`🧵 ${dir} follows the square ${at.square} of ${s.quilt.slug || s.quilt.id}: edits there are live for everyone on it.`)
|
|
61
|
+
return { square: at.square, dir }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The relay's `quilt` message: let go of a square the branch no longer has (switched back, moved). */
|
|
65
|
+
async onQuilt (quilt) {
|
|
66
|
+
if (!this.following) return
|
|
67
|
+
const at = squareFor(quilt, this.session.branchKey())
|
|
68
|
+
if (at && at.square === this.following.square) return
|
|
69
|
+
const was = this.following.square
|
|
70
|
+
await this.close()
|
|
71
|
+
if (this.session.log) this.session.log(`🧵 stopped following the square ${was}: ${at ? `the branch is on ${at.square} now` : 'the session is back on its branch documents'}.`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async close () {
|
|
75
|
+
const f = this.following
|
|
76
|
+
this.following = null
|
|
77
|
+
if (f && f.block && f.block.close) await f.block.close().catch(() => {})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
stop () { return this.close() }
|
|
81
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Quilt's side of Seam: the room's switch to layout 3 (plans/quilt-source-control/16, "How a
|
|
2
|
+
// session becomes a quilt"). After the session became a quilt (session-migrate.js), each branch
|
|
3
|
+
// document is copied into its square's live document on the relay (`quilt/<square id>`,
|
|
4
|
+
// source-live/live-square.js), whose shape is an overlay: only the paths that differ from the
|
|
5
|
+
// square's head. The default branch's difference from `main` goes to an adoption square. The copy
|
|
6
|
+
// is checked against the branch document before `meta.layout = 3` is written; the branch
|
|
7
|
+
// documents themselves stay where they are, read-only, so going back to layout 2 loses nothing.
|
|
8
|
+
// Seam (src/quilts/) never imports this.
|
|
9
|
+
import * as Y from 'yjs'
|
|
10
|
+
import { blobId } from '@heyquilt/seam/lib/quilts/records.js'
|
|
11
|
+
import { liveMaps, startHead, entryOf, entryId } from './source-live/live-square.js'
|
|
12
|
+
|
|
13
|
+
export const LAYOUT_QUILT = 3
|
|
14
|
+
|
|
15
|
+
const idOf = (text) => blobId(Buffer.from(String(text), 'utf8'))
|
|
16
|
+
|
|
17
|
+
/** A Seam tree answer ({ entries: [{ path, blob, mode }] }) as path -> blob id (conflict terms by term id). */
|
|
18
|
+
export function treeIds (tree) {
|
|
19
|
+
const out = {}
|
|
20
|
+
for (const e of (tree && tree.entries) || []) out[e.path] = e.blob || e.term || null
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What turns `head` (path -> id) into `files` (path -> text): { puts: { path: text }, gone: [path] }. */
|
|
25
|
+
export function overlayFor (files, head) {
|
|
26
|
+
const puts = {}
|
|
27
|
+
for (const [p, t] of Object.entries(files || {})) if (head[p] !== idOf(t)) puts[p] = t
|
|
28
|
+
const gone = Object.keys(head || {}).filter((p) => !Object.prototype.hasOwnProperty.call(files || {}, p)).sort()
|
|
29
|
+
return { puts, gone }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Writes an overlay into an empty live document, with `head` as its first head. */
|
|
33
|
+
export function writeOverlay (doc, { puts, gone }, head, by = '') {
|
|
34
|
+
const m = liveMaps(doc)
|
|
35
|
+
doc.transact(() => {
|
|
36
|
+
for (const [p, t] of Object.entries(puts)) { const y = new Y.Text(); y.insert(0, t); m.files.set(p, y) }
|
|
37
|
+
for (const p of gone) m.gone.set(p, { by, ts: Date.now() })
|
|
38
|
+
}, 'relay-quilt')
|
|
39
|
+
if (head) startHead(doc, head, by)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The post-copy check (16, "Dry run and check"): the live document over its head holds exactly
|
|
44
|
+
* the branch document's files, by id. Returns null, or the first difference in words.
|
|
45
|
+
*/
|
|
46
|
+
export function checkOverlay (doc, files, head) {
|
|
47
|
+
const want = {}
|
|
48
|
+
for (const [p, t] of Object.entries(files || {})) want[p] = idOf(t)
|
|
49
|
+
const have = { ...head }
|
|
50
|
+
const m = liveMaps(doc)
|
|
51
|
+
for (const p of new Set([...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys()])) {
|
|
52
|
+
const e = entryOf(doc, p)
|
|
53
|
+
if (!e) continue
|
|
54
|
+
if (e.op === 'delete') delete have[p]; else have[p] = entryId(e)
|
|
55
|
+
}
|
|
56
|
+
for (const p of new Set([...Object.keys(want), ...Object.keys(have)])) {
|
|
57
|
+
if (want[p] !== have[p]) return want[p] == null ? `${p} is in the square but not in the branch document` : have[p] == null ? `${p} is in the branch document but not in the square` : `${p} differs between the branch document and the square`
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|