@dev.fast/whiteboard 0.3.4-preview.20261019.126 → 0.3.4
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/dist/{account-alias-B5-HYi1D.js → account-alias-CprZojXd.js} +8 -13
- package/dist/agent-cli-C6SPSII0.js +2 -0
- package/dist/{agent-cli-CohHf6Ua.js → agent-cli-CThN7Q59.js} +10 -15
- package/dist/{agent-client-Dtc-Kn_w.js → agent-client-BOOi7EUR.js} +3 -13
- package/dist/{authoring-tools-B7KU04f7.js → authoring-tools-BNwuMnz6.js} +27 -34
- package/dist/{background-server-C03RKxKS.js → background-server-CkvxA-6O.js} +5 -5
- package/dist/{background-server-CB9C4uoc.js → background-server-DLSDogkI.js} +1 -1
- package/dist/build-info.json +1 -1
- package/dist/{cli-CJaEb7zC.js → cli-R4yfa5DT.js} +1 -1
- package/dist/{cli-runner-DGDIqh3V.js → cli-runner-Cuc9FSzu.js} +26 -26
- package/dist/cli.js +3 -4
- package/dist/{client-lKVF_Elo.js → client-BN_FDxFr.js} +47 -253
- package/dist/{fuzzy-match-BmVJJm9I.js → fuzzy-match-BO9bK37N.js} +4 -6
- package/dist/{headless-host-DRtjf9WA.js → headless-host-CVBOQyRS.js} +1 -1
- package/dist/{headless-host-XhL1X4vX.js → headless-host-Cog6SpS9.js} +140 -513
- package/dist/{local-data-Cpl4ENbJ.js → local-data-D_9tvdsp.js} +323 -2419
- package/dist/{mcp-ovmiUE_x.js → mcp-D1hziANa.js} +9 -5
- package/dist/{package-paths-BWxO-UEL.js → package-paths-C--__jEv.js} +1 -1
- package/dist/profile-CYPmvBoA.js +2 -0
- package/dist/{profile-BadSkCkI.js → profile-DokFw782.js} +8 -20
- package/dist/{remote-attach-Dd-YobM6.js → remote-attach-CEJ-kvDY.js} +4 -4
- package/dist/{remote-extensions-CNXwqK0D.js → remote-extensions-Blcjr9Ze.js} +3 -3
- package/dist/{remote-extensions-VP-kfq0C.js → remote-extensions-GN8Gf_qG.js} +1 -1
- package/dist/{remote-language-server-BXk87KCr.js → remote-language-server-BTKBQ7x4.js} +5 -5
- package/dist/{remote-uninstall-DuOzsvaD.js → remote-uninstall-DW7Gdryh.js} +6 -6
- package/dist/{request-origin-B9qCPiVB.js → request-origin-Zcn_CjX4.js} +12 -45
- package/dist/{review-app-launcher-C7el2Uoy.js → review-app-launcher-CkwLclzR.js} +2 -2
- package/dist/{review-home-paths-D8AoCdd2.js → review-home-paths-j2BRAaNv.js} +17 -19
- package/dist/{review-telemetry-BsoRMC2w.js → review-telemetry-BLNwYnLV.js} +5 -5
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +1 -1
- package/dist/{s3-CsG2EWED.js → s3-DtJFWC0G.js} +1 -1
- package/dist/{s3-config-CjcLyFV5.js → s3-config-BTO12tCC.js} +2 -4
- package/dist/{s3-config-zz4GWh2T.js → s3-config-CSy3fx8H.js} +1 -1
- package/dist/server/desktop-host.js +76 -29
- package/dist/{server-discovery-CKjw3-6h.js → server-discovery-Bey8IqXH.js} +3 -3
- package/dist/sharing/index.d.ts +70 -484
- package/dist/sharing/index.js +1 -1
- package/dist/{src-WPaQn75m.js → src-BMPuxWv4.js} +73 -208
- package/dist/{src-BiWNbcF_.js → src-DsM3jO8G.js} +12 -89
- package/dist/{stored-document-migration-dFbewRTG.js → stored-document-migration-CChxtFAy.js} +269 -270
- package/dist/{thread-state-Do5LX10I.js → thread-state-5pYr6_zM.js} +2 -7
- package/dist/{tool-failure-DvI-gys2.js → tool-failure-C9MpbXJJ.js} +3 -10
- package/instructions/authoring.md +2 -3
- package/instructions/scratchpad.md +23 -0
- package/package.json +4 -4
- package/src/ask/agents.ts +1 -13
- package/src/ask/checkout-files.ts +5 -2
- package/src/ask/thread-state.ts +0 -4
- package/src/ask/thread.ts +0 -16
- package/src/ask/threads.ts +3 -24
- package/src/cli-install.ts +2 -13
- package/src/cli-runner.ts +1 -4
- package/src/cli.ts +1 -4
- package/src/diff-selection-migration.ts +34 -48
- package/src/lens-selection.ts +2 -38
- package/src/review-api/README.md +13 -52
- package/src/review-api/activity.ts +4 -1
- package/src/review-api/agent-cli.ts +1 -9
- package/src/review-api/anchor-quotes.ts +7 -14
- package/src/review-api/ask-history.ts +51 -137
- package/src/review-api/authoring-tools.ts +30 -45
- package/src/review-api/blocks/definition.ts +1 -1
- package/src/review-api/blocks/markdown.ts +2 -2
- package/src/review-api/document-headings.ts +27 -58
- package/src/review-api/document-text.ts +2 -31
- package/src/review-api/document.ts +34 -281
- package/src/review-api/github-provider.ts +13 -51
- package/src/review-api/http.ts +123 -702
- package/src/review-api/instructions.ts +19 -0
- package/src/review-api/local-data.ts +46 -310
- package/src/review-api/mcp.ts +11 -3
- package/src/review-api/onboarding.ts +1 -1
- package/src/review-api/profile.ts +6 -37
- package/src/review-api/public-tools.ts +2 -24
- package/src/review-api/pull-request.ts +49 -82
- package/src/review-api/request-origin.ts +0 -10
- package/src/review-api/review-progress.ts +27 -61
- package/src/review-api/store-schema.ts +29 -38
- package/src/review-api/store.ts +478 -2142
- package/src/review-api/worktree-events.ts +5 -3
- package/src/review-api/worktree-source.ts +7 -3
- package/src/review-api/worktree-structural.integration.ts +0 -72
- package/src/review-preferences.ts +88 -0
- package/src/review-stack.ts +23 -15
- package/src/runtime.ts +30 -4
- package/src/server/desktop-host.ts +1 -6
- package/src/server/desktop-server.ts +31 -1
- package/src/server/headless-host.ts +2 -0
- package/src/server/hono-http.ts +1 -2
- package/src/server/legacy-tutorial-cleanup.ts +3 -2
- package/src/server/review-gateway-list.ts +5 -3
- package/src/server/review-gateway-memory.ts +1 -1
- package/src/server/review-lifecycle-telemetry.ts +2 -2
- package/src/server/review-server-core.ts +5 -1
- package/src/server/structural-comparisons.ts +0 -47
- package/src/sharing/export.ts +7 -36
- package/src/sharing/host.ts +14 -34
- package/src/sharing/import.ts +24 -156
- package/src/sharing/repository.ts +20 -128
- package/src/startup-trace.ts +3 -3
- package/src/stored-document-migration.ts +19 -110
- package/src/ui-telemetry-events.ts +11 -33
- package/dist/agent-cli-_YyMIMRY.js +0 -2
- package/dist/lens-selection-CyJapcsl.js +0 -214
- package/dist/profile-DJvkBJNQ.js +0 -2
- package/dist/runtime-Bj2tD33R.js +0 -27
- package/src/heading-anchors.ts +0 -42
- package/src/review-api/board-order.ts +0 -144
- package/src/review-api/checkout-groups.ts +0 -560
- package/src/review-api/foreign-references.ts +0 -328
- package/src/review-api/link-preview.ts +0 -113
- package/src/review-api/scratchpad-plan-upgrade.ts +0 -250
- package/src/review-api/store-migrations.ts +0 -357
package/src/review-api/store.ts
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
|
-
import { isDeepStrictEqual } from "node:util";
|
|
6
5
|
|
|
7
|
-
import {
|
|
8
|
-
import { runVcs } from "@dev.fast/local-vcs";
|
|
6
|
+
import { resolveRepoContextSync } from "@dev.fast/local-vcs";
|
|
9
7
|
import {
|
|
10
|
-
type BoardIconKind,
|
|
11
8
|
type ReviewApiSummary,
|
|
12
|
-
|
|
13
|
-
boardTitle,
|
|
9
|
+
SCRATCHPAD_REVIEW_ID,
|
|
14
10
|
} from "@dev.fast/review-protocol";
|
|
15
11
|
import { sourceAnchors } from "@review/lens-selection.js";
|
|
16
12
|
import {
|
|
13
|
+
liftFileLenses,
|
|
14
|
+
migrateStoredDocument,
|
|
17
15
|
migrateStoredLenses,
|
|
18
|
-
readStoredDocument,
|
|
19
16
|
} from "@review/stored-document-migration.js";
|
|
20
17
|
import {
|
|
21
18
|
type Coverage,
|
|
@@ -27,12 +24,6 @@ import { z } from "zod";
|
|
|
27
24
|
|
|
28
25
|
import { type ActivitySurface, ReviewActivity } from "./activity.js";
|
|
29
26
|
import { AskHistory } from "./ask-history.js";
|
|
30
|
-
import {
|
|
31
|
-
type SiblingFacts,
|
|
32
|
-
dependencyCycle,
|
|
33
|
-
orderSiblings,
|
|
34
|
-
} from "./board-order.js";
|
|
35
|
-
import { CheckoutGroups } from "./checkout-groups.js";
|
|
36
27
|
import {
|
|
37
28
|
type Lens,
|
|
38
29
|
applyLensEdit,
|
|
@@ -42,55 +33,49 @@ import {
|
|
|
42
33
|
import {
|
|
43
34
|
type Applied,
|
|
44
35
|
type Block,
|
|
45
|
-
type BoardLinkRef,
|
|
46
36
|
type EditSummary,
|
|
47
37
|
type Element,
|
|
48
38
|
type FileLineRange,
|
|
49
39
|
type Pins,
|
|
50
40
|
ReviewInputError,
|
|
51
41
|
type ReviewTarget,
|
|
52
|
-
type SourcePins,
|
|
53
42
|
type WrittenComponent,
|
|
54
|
-
adoptFlowUnits,
|
|
55
43
|
anchorPins,
|
|
56
44
|
applyEdit,
|
|
57
45
|
assignFreshIds,
|
|
58
|
-
assignMissingIds,
|
|
59
|
-
boardLinks,
|
|
60
|
-
boardRepositoryId,
|
|
61
46
|
checkReferences,
|
|
62
|
-
commitLinkCommits,
|
|
63
47
|
documentAdvice,
|
|
64
48
|
documentSchema,
|
|
65
49
|
editSchema,
|
|
66
50
|
elements,
|
|
67
51
|
explicitPins,
|
|
68
52
|
isUnit,
|
|
69
|
-
pinDocumentReferences,
|
|
70
53
|
pinnedRepositoryIds,
|
|
71
54
|
resourceReferences,
|
|
72
|
-
respellCommitLinks,
|
|
73
|
-
respellDocumentLinks,
|
|
74
|
-
respellEditLinks,
|
|
75
55
|
reviewTargetSchema,
|
|
76
56
|
sourceReferences,
|
|
77
57
|
summarizeEdit,
|
|
78
58
|
} from "./document.js";
|
|
79
|
-
import { type BoardLinkPreview, boardLinkPreview } from "./link-preview.js";
|
|
80
59
|
import { pullRequestKey, pullRequestUrl, setPullRequest } from "./origin.js";
|
|
81
|
-
import {
|
|
82
|
-
import {
|
|
83
|
-
BOARD_REPOSITORY_ID_SQL,
|
|
84
|
-
initializeReviewStoreSchema,
|
|
85
|
-
} from "./store-schema.js";
|
|
60
|
+
import { initializeReviewStoreSchema } from "./store-schema.js";
|
|
86
61
|
|
|
87
62
|
const reviewId = z.string().min(1);
|
|
88
63
|
|
|
89
64
|
/** The key older hosts read and write. */
|
|
90
65
|
const statsKey = (pins: Pins) => JSON.stringify([pins, "structural"]);
|
|
91
66
|
|
|
92
|
-
/**
|
|
93
|
-
const
|
|
67
|
+
/** There is one scratchpad. Its id is fixed so a skill can name it. */
|
|
68
|
+
export const SCRATCHPAD_ID = SCRATCHPAD_REVIEW_ID;
|
|
69
|
+
|
|
70
|
+
const DIAGRAM_TYPES = new Set([
|
|
71
|
+
"sequence",
|
|
72
|
+
"flow_diagram",
|
|
73
|
+
"call_stack_diff",
|
|
74
|
+
"database_lens",
|
|
75
|
+
"software_map",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export const SCRATCHPAD_TITLE = "Scratchpad";
|
|
94
79
|
|
|
95
80
|
const activityId = z
|
|
96
81
|
.string()
|
|
@@ -100,16 +85,6 @@ const activityId = z
|
|
|
100
85
|
"The activityId from review_activity_begin: your courier draws this edit. Never rejects a write.",
|
|
101
86
|
);
|
|
102
87
|
|
|
103
|
-
const parentId = reviewId.describe(
|
|
104
|
-
"The reviewId of a top-level review in the same project to nest this one under.",
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const dependsOn = z
|
|
108
|
-
.array(reviewId)
|
|
109
|
-
.describe(
|
|
110
|
-
"The reviewIds of reviews in the same project this one builds on. Siblings list after what they depend on.",
|
|
111
|
-
);
|
|
112
|
-
|
|
113
88
|
export const commandSchema = z.strictObject({
|
|
114
89
|
operation: z.discriminatedUnion("type", [
|
|
115
90
|
z.strictObject({ type: z.literal("delete"), reviewId }),
|
|
@@ -125,10 +100,8 @@ export const commandSchema = z.strictObject({
|
|
|
125
100
|
}),
|
|
126
101
|
z.strictObject({
|
|
127
102
|
type: z.literal("create"),
|
|
128
|
-
/** Required unless pullRequestUrl alone names the source; then the PR
|
|
129
|
-
|
|
130
|
-
* "Untitled" until they name it. */
|
|
131
|
-
title: z.string().trim().optional(),
|
|
103
|
+
/** Required unless pullRequestUrl alone names the source; then the PR title. */
|
|
104
|
+
title: z.string().trim().min(1).optional(),
|
|
132
105
|
target: reviewTargetSchema.optional(),
|
|
133
106
|
pullRequestUrl: pullRequestUrl.optional(),
|
|
134
107
|
/** With pullRequestUrl and no target: the checkout to fetch the PR into. */
|
|
@@ -137,7 +110,7 @@ export const commandSchema = z.strictObject({
|
|
|
137
110
|
.min(1)
|
|
138
111
|
.optional()
|
|
139
112
|
.describe(
|
|
140
|
-
"Only with pullRequestUrl and no target: the registered checkout to fetch the PR into.",
|
|
113
|
+
"Only with pullRequestUrl and no target: the registered checkout to fetch the PR into. Default: the existing review's, else the first registered checkout with a remote for the PR's repository.",
|
|
141
114
|
),
|
|
142
115
|
/** Return the existing review for pullRequestUrl instead of creating one. */
|
|
143
116
|
reuseExisting: z
|
|
@@ -146,18 +119,8 @@ export const commandSchema = z.strictObject({
|
|
|
146
119
|
.describe(
|
|
147
120
|
"Default true: return the existing review for pullRequestUrl. false creates a separate review.",
|
|
148
121
|
),
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}),
|
|
152
|
-
z.strictObject({
|
|
153
|
-
type: z.literal("set_parent"),
|
|
154
|
-
reviewId,
|
|
155
|
-
parentId: parentId.nullable(),
|
|
156
|
-
}),
|
|
157
|
-
z.strictObject({
|
|
158
|
-
type: z.literal("set_dependencies"),
|
|
159
|
-
reviewId,
|
|
160
|
-
dependsOn,
|
|
122
|
+
/** The one scratchpad: no target, no pins; every reference names its own. */
|
|
123
|
+
kind: z.literal("scratchpad").optional(),
|
|
161
124
|
}),
|
|
162
125
|
z.strictObject({
|
|
163
126
|
type: z.literal("set_target"),
|
|
@@ -190,50 +153,6 @@ export const commandSchema = z.strictObject({
|
|
|
190
153
|
]),
|
|
191
154
|
});
|
|
192
155
|
|
|
193
|
-
/** A person's save of a whole board from the canvas. A block they added
|
|
194
|
-
* has no id and may carry a clientKey, which the response maps to its id. */
|
|
195
|
-
export const personEditSchema = z.strictObject({
|
|
196
|
-
baseVersion: z.number().int().nonnegative(),
|
|
197
|
-
/** Empty until the person names the board; it reads "Untitled". */
|
|
198
|
-
title: z.string().trim(),
|
|
199
|
-
document: z.array(z.json()),
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
/** The block a person's canvas has focus in; null clears it. */
|
|
203
|
-
export const focusLeaseSchema = z.strictObject({
|
|
204
|
-
clientId: z.string().min(1),
|
|
205
|
-
blockId: z.string().min(1).nullable(),
|
|
206
|
-
/** false when the canvas closes the board. */
|
|
207
|
-
open: z.boolean().optional(),
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
/** A focus lease lasts this long unless renewed. The canvas renews it every
|
|
211
|
-
* few seconds, so a closed or crashed app releases its block on its own. */
|
|
212
|
-
export const FOCUS_LEASE_TTL_MS = 15_000;
|
|
213
|
-
|
|
214
|
-
/** How often a discard looks again for a canvas letting go of the board. */
|
|
215
|
-
const DISCARD_POLL_MS = 100;
|
|
216
|
-
|
|
217
|
-
/** What a person's save wrote: the version, and the id each new block got
|
|
218
|
-
* by its clientKey. */
|
|
219
|
-
export interface PersonEditResult {
|
|
220
|
-
reviewId: string;
|
|
221
|
-
version: number;
|
|
222
|
-
ids: Record<string, string>;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** One row of a board's history. */
|
|
226
|
-
export interface HistoryEntry {
|
|
227
|
-
version: number;
|
|
228
|
-
title: string;
|
|
229
|
-
createdAt: string;
|
|
230
|
-
/** "you" for a version a person saved from the canvas; absent for one
|
|
231
|
-
* saved before Whiteboard recorded who wrote it. */
|
|
232
|
-
author?: "you" | "agent";
|
|
233
|
-
/** The Ask thread whose edit made this version, if any. */
|
|
234
|
-
threadId?: string;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
156
|
/** Source identity displayed in the review header and Home, alongside immutable pins. */
|
|
238
157
|
export interface SnapshotOrigin {
|
|
239
158
|
branch?: string;
|
|
@@ -248,68 +167,6 @@ export interface SnapshotOrigin {
|
|
|
248
167
|
revision?: string;
|
|
249
168
|
}
|
|
250
169
|
|
|
251
|
-
/**
|
|
252
|
-
* The project of a registered checkout, made on first use. Checkouts of one
|
|
253
|
-
* repository (worktrees, or clones of one GitHub repository) share it. A
|
|
254
|
-
* deleted checkout joins the project of the existing one it belonged to
|
|
255
|
-
* (see `CheckoutGroups.ownerOf`), else gets its own.
|
|
256
|
-
*/
|
|
257
|
-
export function projectOfCheckout(
|
|
258
|
-
db: DatabaseSync,
|
|
259
|
-
repositoryId: string,
|
|
260
|
-
groups = new CheckoutGroups(db),
|
|
261
|
-
): string {
|
|
262
|
-
const {
|
|
263
|
-
path: root,
|
|
264
|
-
name,
|
|
265
|
-
project_id: projectOf,
|
|
266
|
-
} = db
|
|
267
|
-
.prepare("SELECT path, name, project_id FROM repositories WHERE id=?")
|
|
268
|
-
.get(repositoryId)!;
|
|
269
|
-
|
|
270
|
-
if (projectOf) return String(projectOf);
|
|
271
|
-
|
|
272
|
-
const join = (projectId: string) => {
|
|
273
|
-
db.prepare("UPDATE repositories SET project_id=? WHERE id=?").run(
|
|
274
|
-
projectId,
|
|
275
|
-
repositoryId,
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
return projectId;
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
if (!existsSync(String(root))) {
|
|
282
|
-
const owner = groups.ownerOf(repositoryId);
|
|
283
|
-
|
|
284
|
-
if (owner) return join(projectOfCheckout(db, owner, groups));
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const group = groups.group(String(root));
|
|
288
|
-
|
|
289
|
-
const sibling =
|
|
290
|
-
group &&
|
|
291
|
-
db
|
|
292
|
-
.prepare(
|
|
293
|
-
"SELECT project_id, path FROM repositories WHERE project_id IS NOT NULL",
|
|
294
|
-
)
|
|
295
|
-
.all()
|
|
296
|
-
.find(
|
|
297
|
-
({ path: checkout }) =>
|
|
298
|
-
groups.group(String(checkout))?.key === group.key,
|
|
299
|
-
);
|
|
300
|
-
|
|
301
|
-
if (sibling) return join(String(sibling.project_id));
|
|
302
|
-
|
|
303
|
-
const projectId = randomUUID();
|
|
304
|
-
|
|
305
|
-
db.prepare("INSERT INTO projects(id,name) VALUES(?,?)").run(
|
|
306
|
-
projectId,
|
|
307
|
-
group?.label ?? String(name),
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
return join(projectId);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
170
|
export interface Snapshot {
|
|
314
171
|
/** Current lifecycle state, projected onto every version when read. */
|
|
315
172
|
trashedAt?: string | null;
|
|
@@ -317,6 +174,8 @@ export interface Snapshot {
|
|
|
317
174
|
reviewId: string;
|
|
318
175
|
version: number;
|
|
319
176
|
title: string;
|
|
177
|
+
/** Absent for a review. The scratchpad has no pins, target or lifecycle. */
|
|
178
|
+
kind?: "scratchpad";
|
|
320
179
|
/** The default pins for references that name none. A document whose
|
|
321
180
|
* references all carry their own pins has neither pins nor target. */
|
|
322
181
|
pins?: Pins;
|
|
@@ -332,27 +191,8 @@ export interface Snapshot {
|
|
|
332
191
|
/** The edit that produced this version, when one did; absent for a
|
|
333
192
|
* rename, set_target, restore or import, which the canvas does not draw. */
|
|
334
193
|
lastEdit?: EditSummary;
|
|
335
|
-
/** The Ask thread that caused this version when it made no edit to
|
|
336
|
-
* name it on: a create, rename, retarget or restore. */
|
|
337
|
-
threadId?: string;
|
|
338
|
-
/** A person saved this version from the canvas: the blocks they added,
|
|
339
|
-
* removed or changed. Absent on every other version. */
|
|
340
|
-
personEdit?: { changed: string[]; ids?: Record<string, string> };
|
|
341
194
|
}
|
|
342
195
|
|
|
343
|
-
/** The Ask thread a version names, on its edit or on itself; stored in
|
|
344
|
-
* `versions.thread_id` so a thread's boards are one index away. */
|
|
345
|
-
const versionThread = (snapshot: Snapshot) =>
|
|
346
|
-
snapshot.lastEdit?.threadId ?? snapshot.threadId ?? null;
|
|
347
|
-
|
|
348
|
-
/** Who is writing: the rules a write keeps depend on it. */
|
|
349
|
-
export type Author =
|
|
350
|
-
/** An agent, through the API; a thread's agent stamps its versions
|
|
351
|
-
* with the thread. */
|
|
352
|
-
| { kind: "agent"; threadId?: string }
|
|
353
|
-
/** A person, from Desktop or the canvas. */
|
|
354
|
-
| { kind: "person" };
|
|
355
|
-
|
|
356
196
|
export interface Result {
|
|
357
197
|
/** Create only: false when an existing review for the same PR came back. */
|
|
358
198
|
created?: boolean;
|
|
@@ -376,27 +216,9 @@ export interface Result {
|
|
|
376
216
|
attention?: true;
|
|
377
217
|
lifecycle?: true;
|
|
378
218
|
deleted?: true;
|
|
379
|
-
/** After set_parent or set_dependencies: the board's relations. */
|
|
380
|
-
relations?: BoardRelations;
|
|
381
219
|
warnings?: string[];
|
|
382
220
|
}
|
|
383
221
|
|
|
384
|
-
/** A board as its place in a project shows it. */
|
|
385
|
-
export interface PlacedBoard {
|
|
386
|
-
reviewId: string;
|
|
387
|
-
title: string;
|
|
388
|
-
target?: ReviewTarget;
|
|
389
|
-
origin?: SnapshotOrigin;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** Where a board sits among its project's boards. Kept outside its versions. */
|
|
393
|
-
export interface BoardRelations {
|
|
394
|
-
parentId: string | null;
|
|
395
|
-
/** In sibling order. */
|
|
396
|
-
children: string[];
|
|
397
|
-
dependsOn: string[];
|
|
398
|
-
}
|
|
399
|
-
|
|
400
222
|
/** A PR's current comparison: GitHub's head and diff base, fetched locally. */
|
|
401
223
|
export interface ResolvedPullRequest {
|
|
402
224
|
target: ReviewTarget;
|
|
@@ -410,10 +232,11 @@ export interface ResolvedPullRequest {
|
|
|
410
232
|
export interface ReviewProviders {
|
|
411
233
|
headBranch?(pins: Pins, headRef?: string): Promise<string | undefined>;
|
|
412
234
|
projectSource?(snapshot: Snapshot, pins: Pins): Promise<Snapshot>;
|
|
413
|
-
/** Fetch a PR into
|
|
235
|
+
/** Fetch a PR into a registered checkout: the named one, else the first
|
|
236
|
+
* preferred one that still matches, else any whose remote is the PR's. */
|
|
414
237
|
resolvePullRequest?(
|
|
415
238
|
url: string,
|
|
416
|
-
repository: { id
|
|
239
|
+
repository: { id?: string; preferred?: string },
|
|
417
240
|
): Promise<ResolvedPullRequest>;
|
|
418
241
|
/** Undefined when the forge can't be read. */
|
|
419
242
|
pullRequestTitle?(url: string): Promise<string | undefined>;
|
|
@@ -427,11 +250,6 @@ export interface ReviewProviders {
|
|
|
427
250
|
/** Ids of references whose own pins no longer name a usable checkout. */
|
|
428
251
|
unavailableAnchors?(snapshot: Snapshot): Promise<string[]>;
|
|
429
252
|
validatePins(pins: Pins): Promise<void>;
|
|
430
|
-
/** The full commit a commit ID or its prefix names, if any. */
|
|
431
|
-
resolveCommit?(
|
|
432
|
-
repositoryId: string,
|
|
433
|
-
commit: string,
|
|
434
|
-
): Promise<string | undefined>;
|
|
435
253
|
/** The files each side's commit changed from one set of pins to another,
|
|
436
254
|
* or undefined when they can't be diffed commit to commit. */
|
|
437
255
|
filesChangedBetween?(
|
|
@@ -444,8 +262,6 @@ export interface ReviewProviders {
|
|
|
444
262
|
options: { peek: boolean },
|
|
445
263
|
): Promise<void>;
|
|
446
264
|
validateResource(pins: Pins | undefined, block: Block): Promise<void>;
|
|
447
|
-
/** A PR's base branch, as last read from GitHub; undefined until read. */
|
|
448
|
-
pullRequestBaseBranch?(url: string): string | undefined;
|
|
449
265
|
}
|
|
450
266
|
|
|
451
267
|
/** One instance owned by the desktop server. All writers go through execute().
|
|
@@ -679,6 +495,15 @@ export class ReviewStore {
|
|
|
679
495
|
this.catalogListeners.delete(listener);
|
|
680
496
|
};
|
|
681
497
|
}
|
|
498
|
+
/** For a host whose listing changed without the store: a preference flip. */
|
|
499
|
+
invalidateCatalog(): void {
|
|
500
|
+
for (const listener of this.catalogListeners)
|
|
501
|
+
try {
|
|
502
|
+
listener();
|
|
503
|
+
} catch {
|
|
504
|
+
// A disconnected viewer must not block other catalog subscribers.
|
|
505
|
+
}
|
|
506
|
+
}
|
|
682
507
|
subscribe(listener: (result: Result) => void) {
|
|
683
508
|
this.listeners.add(listener);
|
|
684
509
|
|
|
@@ -689,42 +514,14 @@ export class ReviewStore {
|
|
|
689
514
|
constructor(
|
|
690
515
|
databasePath: string,
|
|
691
516
|
private readonly providers: ReviewProviders,
|
|
692
|
-
/** How the checkouts group, if `groupCheckoutsAhead` worked it out. */
|
|
693
|
-
private readonly checkoutOwners?: ReadonlyMap<string, string>,
|
|
694
517
|
) {
|
|
695
518
|
// WAL plus a busy timeout: another host on the same home waits instead of failing.
|
|
696
519
|
this.db = new DatabaseSync(databasePath, { timeout: 5000 });
|
|
697
520
|
initializeReviewStoreSchema(this.db);
|
|
698
|
-
// Each upgrade reads a quoted file once, before its write lock and in it.
|
|
699
|
-
const files = new Map<string, string | undefined>();
|
|
700
|
-
|
|
701
|
-
upgradeStore({
|
|
702
|
-
db: this.db,
|
|
703
|
-
prepareCheckouts: () => this.checkouts.prepare(),
|
|
704
|
-
repositories: () => this.repositories(),
|
|
705
|
-
repositoryPath: (repositoryId) => this.repositoryPath(repositoryId),
|
|
706
|
-
repositoryName: (repositoryId) => this.repositoryName(repositoryId),
|
|
707
|
-
fileAtCommit: (repositoryId, commit, file) => {
|
|
708
|
-
const key = JSON.stringify([repositoryId, commit, file]);
|
|
709
|
-
|
|
710
|
-
if (!files.has(key))
|
|
711
|
-
files.set(key, this.fileAtCommit(repositoryId, commit, file));
|
|
712
|
-
|
|
713
|
-
return files.get(key);
|
|
714
|
-
},
|
|
715
|
-
projectOfRepository: (repositoryId) =>
|
|
716
|
-
this.projectOfRepository(repositoryId),
|
|
717
|
-
fallbackProject: () => this.fallbackProject(),
|
|
718
|
-
rewriteVersion: (reviewId, version, snapshot) =>
|
|
719
|
-
this.rewriteVersion(reviewId, version, snapshot),
|
|
720
|
-
firstRepositoryProject: () => this.firstRepositoryProject(),
|
|
721
|
-
});
|
|
722
521
|
this.activity = new ReviewActivity(this.db, (id) =>
|
|
723
522
|
this.assertWritable(id),
|
|
724
523
|
);
|
|
725
|
-
this.askHistory = new AskHistory(this.db
|
|
726
|
-
this.assertOutOfTrash(id),
|
|
727
|
-
);
|
|
524
|
+
this.askHistory = new AskHistory(this.db);
|
|
728
525
|
|
|
729
526
|
this.purgeExpired();
|
|
730
527
|
this.purgeTimer = setInterval(() => this.purgeExpired(), 60_000);
|
|
@@ -740,136 +537,6 @@ export class ReviewStore {
|
|
|
740
537
|
this.externalChanges.unref();
|
|
741
538
|
}
|
|
742
539
|
|
|
743
|
-
/** Saves a version over what it was; the current one re-indexes its
|
|
744
|
-
* board's links, as every written version does. */
|
|
745
|
-
private rewriteVersion(
|
|
746
|
-
reviewId: string,
|
|
747
|
-
version: number,
|
|
748
|
-
snapshot: JsonValue,
|
|
749
|
-
) {
|
|
750
|
-
this.db
|
|
751
|
-
.prepare("UPDATE versions SET snapshot=? WHERE review_id=? AND version=?")
|
|
752
|
-
.run(JSON.stringify(snapshot), reviewId, version);
|
|
753
|
-
|
|
754
|
-
if (version !== this.currentVersion(reviewId)) return;
|
|
755
|
-
|
|
756
|
-
try {
|
|
757
|
-
this.indexBoardLinks(reviewId, this.read(reviewId).document);
|
|
758
|
-
} catch {
|
|
759
|
-
// A version that no longer reads indexes nothing.
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/** The project of a registered checkout, made on first use. Checkouts of
|
|
764
|
-
* one repository (worktrees, or clones of one GitHub repository) share it. */
|
|
765
|
-
projectOfRepository(repositoryId: string): string {
|
|
766
|
-
const row = this.db
|
|
767
|
-
.prepare("SELECT project_id FROM repositories WHERE id=?")
|
|
768
|
-
.get(repositoryId);
|
|
769
|
-
|
|
770
|
-
if (!row) return this.fallbackProject();
|
|
771
|
-
|
|
772
|
-
if (row.project_id) return String(row.project_id);
|
|
773
|
-
|
|
774
|
-
return projectOfCheckout(this.db, repositoryId, this.checkouts);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/** For a board with no repository: the project of the board opened last,
|
|
778
|
-
* else of the first registered checkout, else one with no repository. */
|
|
779
|
-
private fallbackProject(): string {
|
|
780
|
-
const recent = this.db
|
|
781
|
-
.prepare(
|
|
782
|
-
`SELECT reviews.project_id FROM reviews
|
|
783
|
-
JOIN review_attention ON review_attention.review_id=reviews.id
|
|
784
|
-
WHERE review_attention.viewed_at IS NOT NULL
|
|
785
|
-
ORDER BY review_attention.viewed_at DESC LIMIT 1`,
|
|
786
|
-
)
|
|
787
|
-
.get();
|
|
788
|
-
|
|
789
|
-
return recent ? String(recent.project_id) : this.firstRepositoryProject();
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
private firstRepositoryProject(): string {
|
|
793
|
-
const [first] = this.repositories();
|
|
794
|
-
|
|
795
|
-
if (first) return this.projectOfRepository(first.id);
|
|
796
|
-
|
|
797
|
-
const empty = this.db
|
|
798
|
-
.prepare(
|
|
799
|
-
"SELECT id FROM projects WHERE id NOT IN (SELECT project_id FROM repositories WHERE project_id IS NOT NULL) LIMIT 1",
|
|
800
|
-
)
|
|
801
|
-
.get();
|
|
802
|
-
|
|
803
|
-
if (empty) return String(empty.id);
|
|
804
|
-
|
|
805
|
-
const projectId = randomUUID();
|
|
806
|
-
|
|
807
|
-
this.db
|
|
808
|
-
.prepare("INSERT INTO projects(id,name) VALUES(?,?)")
|
|
809
|
-
.run(projectId, "Whiteboards");
|
|
810
|
-
|
|
811
|
-
return projectId;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/** A checkout's project, as the catalog lists it; undefined once the
|
|
815
|
-
* checkout is unregistered. */
|
|
816
|
-
checkoutProject(
|
|
817
|
-
repositoryId: string,
|
|
818
|
-
): ReviewApiSummary["project"] | undefined {
|
|
819
|
-
if (!this.repositories().some(({ id }) => id === repositoryId))
|
|
820
|
-
return undefined;
|
|
821
|
-
|
|
822
|
-
const id = this.projectOfRepository(repositoryId);
|
|
823
|
-
|
|
824
|
-
const { name } = this.db
|
|
825
|
-
.prepare("SELECT name FROM projects WHERE id=?")
|
|
826
|
-
.get(id)!;
|
|
827
|
-
|
|
828
|
-
return { id, name: String(name) };
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
/** Undefined when the file can't be read. */
|
|
832
|
-
private fileAtCommit(
|
|
833
|
-
repositoryId: string,
|
|
834
|
-
commit: string,
|
|
835
|
-
file: string,
|
|
836
|
-
): string | undefined {
|
|
837
|
-
try {
|
|
838
|
-
return this.checkouts.fileAt(
|
|
839
|
-
this.repositoryPath(repositoryId),
|
|
840
|
-
commit,
|
|
841
|
-
file,
|
|
842
|
-
);
|
|
843
|
-
} catch {
|
|
844
|
-
return undefined;
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
/** A checkout's name as its project labels it, else its folder. */
|
|
849
|
-
private repositoryName(repositoryId: string): string {
|
|
850
|
-
try {
|
|
851
|
-
const root = this.repositoryPath(repositoryId);
|
|
852
|
-
|
|
853
|
-
return (
|
|
854
|
-
this.repositoryGroup(root)?.label.split("/").at(-1) ??
|
|
855
|
-
path.basename(root)
|
|
856
|
-
);
|
|
857
|
-
} catch {
|
|
858
|
-
return "another repository";
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/** A board's project. */
|
|
863
|
-
projectOf(id: string): string {
|
|
864
|
-
const row = this.db
|
|
865
|
-
.prepare("SELECT project_id FROM reviews WHERE id=?")
|
|
866
|
-
.get(id);
|
|
867
|
-
|
|
868
|
-
if (!row) this.assertExists(id);
|
|
869
|
-
|
|
870
|
-
return String(row!.project_id);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
540
|
private dataVersion() {
|
|
874
541
|
return Number(this.db.prepare("PRAGMA data_version").get()!.data_version);
|
|
875
542
|
}
|
|
@@ -991,19 +658,34 @@ export class ReviewStore {
|
|
|
991
658
|
throw error;
|
|
992
659
|
}
|
|
993
660
|
}
|
|
994
|
-
private
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
661
|
+
private readonly repositoryGroups = new Map<
|
|
662
|
+
string,
|
|
663
|
+
ReviewApiSummary["repositoryGroup"]
|
|
664
|
+
>();
|
|
665
|
+
|
|
666
|
+
private repositoryGroup(root: string): ReviewApiSummary["repositoryGroup"] {
|
|
667
|
+
if (this.repositoryGroups.has(root)) return this.repositoryGroups.get(root);
|
|
668
|
+
|
|
669
|
+
// Resolving a missing checkout would spawn on every listing.
|
|
670
|
+
if (!existsSync(root)) return undefined;
|
|
671
|
+
|
|
672
|
+
const context = resolveRepoContextSync(root);
|
|
673
|
+
|
|
674
|
+
if (!context) return undefined;
|
|
675
|
+
|
|
676
|
+
const group = context.githubSlug
|
|
677
|
+
? {
|
|
678
|
+
key: `remote:https://github.com/${context.githubSlug.toLowerCase()}.git`,
|
|
679
|
+
label: context.githubSlug,
|
|
680
|
+
}
|
|
681
|
+
: {
|
|
682
|
+
key: `git:${context.commonDir}`,
|
|
683
|
+
label: path.basename(path.dirname(context.commonDir)),
|
|
684
|
+
};
|
|
1004
685
|
|
|
1005
|
-
|
|
1006
|
-
|
|
686
|
+
this.repositoryGroups.set(root, group);
|
|
687
|
+
|
|
688
|
+
return group;
|
|
1007
689
|
}
|
|
1008
690
|
|
|
1009
691
|
registerRepository(root: string) {
|
|
@@ -1015,66 +697,16 @@ export class ReviewStore {
|
|
|
1015
697
|
.prepare("SELECT id,name FROM repositories WHERE path=?")
|
|
1016
698
|
.get(root)!;
|
|
1017
699
|
|
|
1018
|
-
this.projectOfRepository(String(row.id));
|
|
1019
|
-
|
|
1020
700
|
return { id: String(row.id), name: String(row.name) };
|
|
1021
701
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const used = this.db
|
|
1030
|
-
// Document pins and per-reference pins both spell the id in the snapshot.
|
|
1031
|
-
.prepare(`SELECT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
|
|
1032
|
-
OR EXISTS (SELECT 1 FROM resources WHERE repository_id=?) AS used`)
|
|
1033
|
-
.get(`"repositoryId":${JSON.stringify(id)}`, id)!.used;
|
|
1034
|
-
|
|
1035
|
-
// Its project stays, with any boards in it.
|
|
1036
|
-
if (!used)
|
|
1037
|
-
this.db.prepare("DELETE FROM repositories WHERE id=?").run(id);
|
|
1038
|
-
|
|
1039
|
-
this.db.exec("COMMIT");
|
|
1040
|
-
} catch (error) {
|
|
1041
|
-
if (this.db.isTransaction) this.db.exec("ROLLBACK");
|
|
1042
|
-
throw error;
|
|
1043
|
-
}
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
this.pending = run.catch(() => {});
|
|
1047
|
-
|
|
1048
|
-
return run;
|
|
1049
|
-
}
|
|
1050
|
-
/** Checkouts whose remote is cloneUrl, most recently used first. */
|
|
1051
|
-
checkoutsOf(cloneUrl: string): string[] {
|
|
1052
|
-
const key = `remote:${cloneUrl.toLowerCase()}`;
|
|
1053
|
-
|
|
1054
|
-
const used = (repositoryId: string) =>
|
|
1055
|
-
String(
|
|
1056
|
-
this.db
|
|
1057
|
-
.prepare(
|
|
1058
|
-
`SELECT MAX(json_extract(versions.snapshot,'$.createdAt')) AS at
|
|
1059
|
-
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
1060
|
-
WHERE json_extract(versions.snapshot,'$.target.repositoryId')=?`,
|
|
1061
|
-
)
|
|
1062
|
-
.get(repositoryId)?.at ?? "",
|
|
1063
|
-
);
|
|
1064
|
-
|
|
1065
|
-
return this.repositories()
|
|
1066
|
-
.filter((repository) => {
|
|
1067
|
-
try {
|
|
1068
|
-
return this.repositoryGroup(repository.path)?.key === key;
|
|
1069
|
-
} catch {
|
|
1070
|
-
return false;
|
|
1071
|
-
}
|
|
1072
|
-
})
|
|
1073
|
-
.map((repository) => ({ id: repository.id, at: used(repository.id) }))
|
|
1074
|
-
.sort((left, right) => right.at.localeCompare(left.at))
|
|
1075
|
-
.map((repository) => repository.id);
|
|
702
|
+
unregisterRepository(id: string) {
|
|
703
|
+
this.db
|
|
704
|
+
// Document pins and per-reference pins both spell the id in the snapshot.
|
|
705
|
+
.prepare(`DELETE FROM repositories WHERE id=?
|
|
706
|
+
AND NOT EXISTS (SELECT 1 FROM versions WHERE instr(snapshot, ?) > 0)
|
|
707
|
+
AND NOT EXISTS (SELECT 1 FROM resources WHERE repository_id=?)`)
|
|
708
|
+
.run(id, `"repositoryId":${JSON.stringify(id)}`, id);
|
|
1076
709
|
}
|
|
1077
|
-
|
|
1078
710
|
/** Registered checkouts, oldest registration first. */
|
|
1079
711
|
repositories() {
|
|
1080
712
|
return this.db
|
|
@@ -1176,14 +808,8 @@ export class ReviewStore {
|
|
|
1176
808
|
404,
|
|
1177
809
|
);
|
|
1178
810
|
}
|
|
1179
|
-
/** A board that exists and isn't in Trash: the one rule every write
|
|
1180
|
-
* about a board keeps. */
|
|
1181
811
|
assertWritable(id: string) {
|
|
1182
812
|
this.assertExists(id);
|
|
1183
|
-
this.assertOutOfTrash(id);
|
|
1184
|
-
}
|
|
1185
|
-
/** Refuses a board in Trash, which is read-only until it is restored. */
|
|
1186
|
-
assertOutOfTrash(id: string) {
|
|
1187
813
|
const trashedAt = this.trashTime(id);
|
|
1188
814
|
|
|
1189
815
|
if (trashedAt)
|
|
@@ -1199,57 +825,45 @@ export class ReviewStore {
|
|
|
1199
825
|
|
|
1200
826
|
return row?.deleted_at ? String(row.deleted_at) : null;
|
|
1201
827
|
}
|
|
1202
|
-
/** Removes a board for good, from Delete… or Trash's purge. Links into
|
|
1203
|
-
* it stay, and show it as deleted. */
|
|
1204
828
|
private removePermanently(id: string) {
|
|
1205
|
-
const { title } = this.read(id);
|
|
1206
|
-
|
|
1207
829
|
for (const table of [
|
|
830
|
+
"ask_conversations",
|
|
1208
831
|
"authoring_presences",
|
|
1209
832
|
"review_coverage",
|
|
1210
833
|
"review_attention",
|
|
1211
834
|
"versions",
|
|
1212
835
|
])
|
|
1213
836
|
this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(id);
|
|
1214
|
-
this.askHistory.removeReview(id);
|
|
1215
|
-
// Its children move up to top level, its dependencies and the links
|
|
1216
|
-
// on it go, by the schema's foreign keys. Links into it stay, and the
|
|
1217
|
-
// tombstone shows it as deleted, in the project it was in.
|
|
1218
|
-
this.db
|
|
1219
|
-
.prepare(
|
|
1220
|
-
"INSERT OR REPLACE INTO deleted_boards(id,title,deleted_at,project_id) VALUES(?,?,?,(SELECT project_id FROM reviews WHERE id=?))",
|
|
1221
|
-
)
|
|
1222
|
-
.run(id, title, new Date().toISOString(), id);
|
|
1223
837
|
this.db.prepare("DELETE FROM reviews WHERE id=?").run(id);
|
|
1224
838
|
this.liveSources.delete(id);
|
|
1225
839
|
}
|
|
1226
|
-
/** Deletes the boards 30 days in Trash, each as Delete… would. */
|
|
1227
840
|
private purgeExpired() {
|
|
1228
|
-
const cutoff = new Date(Date.now() -
|
|
841
|
+
const cutoff = new Date(Date.now() - 30 * 86400000).toISOString();
|
|
842
|
+
const expired: Result[] = [];
|
|
843
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1229
844
|
|
|
1230
|
-
|
|
1231
|
-
this.db
|
|
845
|
+
try {
|
|
846
|
+
for (const row of this.db
|
|
1232
847
|
.prepare(
|
|
1233
|
-
"SELECT review_id FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?",
|
|
848
|
+
"SELECT review_id,version FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?",
|
|
1234
849
|
)
|
|
1235
|
-
.all(cutoff)
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
this.
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
if (!expired().includes(reviewId))
|
|
1247
|
-
throw new ReviewInputError("Restored from Trash.", 409);
|
|
1248
|
-
},
|
|
1249
|
-
},
|
|
1250
|
-
);
|
|
1251
|
-
}
|
|
850
|
+
.all(cutoff)) {
|
|
851
|
+
const reviewId = String(row.review_id);
|
|
852
|
+
expired.push({ reviewId, version: Number(row.version), deleted: true });
|
|
853
|
+
this.removePermanently(reviewId);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
this.db.exec("COMMIT");
|
|
857
|
+
} catch (error) {
|
|
858
|
+
this.db.exec("ROLLBACK");
|
|
859
|
+
throw error;
|
|
860
|
+
}
|
|
1252
861
|
|
|
862
|
+
for (const result of expired) {
|
|
863
|
+
this.activity.deleted(result.reviewId);
|
|
864
|
+
this.notify(result);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
1253
867
|
read(id: string, version?: number): Snapshot {
|
|
1254
868
|
const row =
|
|
1255
869
|
version === undefined
|
|
@@ -1276,7 +890,9 @@ export class ReviewStore {
|
|
|
1276
890
|
// retired attachment representations with their canonical equivalent and
|
|
1277
891
|
// drops retired fields.
|
|
1278
892
|
// Lenses saved as document blocks read as the snapshot's own.
|
|
1279
|
-
const { document, lenses } =
|
|
893
|
+
const { document, lenses } = liftFileLenses(
|
|
894
|
+
migrateStoredDocument(snapshot.document),
|
|
895
|
+
);
|
|
1280
896
|
|
|
1281
897
|
// SAFETY: stored blocks were validated on write; migration only replaces
|
|
1282
898
|
// retired representations and lifts retired lens blocks out.
|
|
@@ -1322,24 +938,14 @@ export class ReviewStore {
|
|
|
1322
938
|
}
|
|
1323
939
|
}
|
|
1324
940
|
|
|
1325
|
-
/**
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
const tutorials = this.db
|
|
941
|
+
/** Reviews of the tutorial earlier versions shipped, for their removal. */
|
|
942
|
+
legacyTutorialIds(): string[] {
|
|
943
|
+
return this.db
|
|
1329
944
|
.prepare(
|
|
1330
945
|
`SELECT reviews.id FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version WHERE json_extract(versions.snapshot,'$.origin.tutorial') = 1`,
|
|
1331
946
|
)
|
|
1332
947
|
.all()
|
|
1333
948
|
.map((row) => String(row.id));
|
|
1334
|
-
|
|
1335
|
-
for (const reviewId of tutorials) {
|
|
1336
|
-
if (!this.trashTime(reviewId))
|
|
1337
|
-
await this.execute({
|
|
1338
|
-
operation: { type: "lifecycle", reviewId, state: "trash" },
|
|
1339
|
-
});
|
|
1340
|
-
|
|
1341
|
-
await this.execute({ operation: { type: "delete", reviewId } });
|
|
1342
|
-
}
|
|
1343
949
|
}
|
|
1344
950
|
|
|
1345
951
|
list(): ReviewApiSummary[] {
|
|
@@ -1357,13 +963,11 @@ export class ReviewStore {
|
|
|
1357
963
|
.prepare(
|
|
1358
964
|
`SELECT json_remove(versions.snapshot,'$.document') AS summary,
|
|
1359
965
|
(SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS first_created_at,
|
|
1360
|
-
review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
|
|
1361
|
-
projects.id AS project_id, projects.name AS project_name
|
|
966
|
+
review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
|
|
1362
967
|
FROM reviews
|
|
1363
|
-
JOIN projects ON projects.id=reviews.project_id
|
|
1364
968
|
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
1365
969
|
LEFT JOIN review_attention ON review_attention.review_id=reviews.id
|
|
1366
|
-
LEFT JOIN repositories ON repositories.id
|
|
970
|
+
LEFT JOIN repositories ON repositories.id=json_extract(versions.snapshot,'$.pins.repositoryId')
|
|
1367
971
|
${id === undefined ? "" : "WHERE reviews.id=?"}
|
|
1368
972
|
ORDER BY reviews.rowid`,
|
|
1369
973
|
)
|
|
@@ -1399,37 +1003,23 @@ export class ReviewStore {
|
|
|
1399
1003
|
repositoryPath: row.repository_path
|
|
1400
1004
|
? String(row.repository_path)
|
|
1401
1005
|
: undefined,
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
},
|
|
1006
|
+
repositoryGroup: row.repository_path
|
|
1007
|
+
? this.repositoryGroup(String(row.repository_path))
|
|
1008
|
+
: undefined,
|
|
1406
1009
|
repositoryName: row.repository_name
|
|
1407
1010
|
? String(row.repository_name)
|
|
1408
|
-
:
|
|
1011
|
+
: (summary.pins?.repositoryId ?? ""),
|
|
1409
1012
|
viewedAt: row.viewed_at ? String(row.viewed_at) : null,
|
|
1410
1013
|
trashedAt: row.deleted_at ? String(row.deleted_at) : null,
|
|
1411
|
-
// Any unseen version counts, whoever saved it.
|
|
1412
|
-
changed: !row.viewed_at || summary.createdAt > String(row.viewed_at),
|
|
1413
1014
|
working: this.activity.isWorking(summary.reviewId),
|
|
1414
1015
|
};
|
|
1415
1016
|
|
|
1017
|
+
if (summary.kind === "scratchpad")
|
|
1018
|
+
listed.contents = this.scratchpadContents();
|
|
1019
|
+
|
|
1416
1020
|
return listed;
|
|
1417
1021
|
});
|
|
1418
1022
|
|
|
1419
|
-
const graph = this.relationGraph();
|
|
1420
|
-
|
|
1421
|
-
for (const listed of reviews) {
|
|
1422
|
-
const parentId = graph.parents.get(listed.reviewId);
|
|
1423
|
-
const children = graph.children.get(listed.reviewId);
|
|
1424
|
-
const dependsOn = graph.dependsOn.get(listed.reviewId);
|
|
1425
|
-
|
|
1426
|
-
if (parentId) listed.parentId = parentId;
|
|
1427
|
-
|
|
1428
|
-
if (children) listed.children = this.orderChildren(children, graph);
|
|
1429
|
-
|
|
1430
|
-
if (dependsOn) listed.dependsOn = dependsOn;
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
1023
|
return this.withDiffStats(reviews);
|
|
1434
1024
|
}
|
|
1435
1025
|
|
|
@@ -1456,6 +1046,34 @@ export class ReviewStore {
|
|
|
1456
1046
|
}));
|
|
1457
1047
|
}
|
|
1458
1048
|
|
|
1049
|
+
/** What the pad holds, for its Home card: blocks, and the diagrams among them. */
|
|
1050
|
+
private scratchpadContents(): NonNullable<ReviewApiSummary["contents"]> {
|
|
1051
|
+
const blocks = elements(this.read(SCRATCHPAD_ID).document).filter(
|
|
1052
|
+
(element) => !isUnit(element),
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
return {
|
|
1056
|
+
blocks: blocks.length,
|
|
1057
|
+
diagrams: blocks.filter((block) => DIAGRAM_TYPES.has(block.type)).length,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/** The one scratchpad, made on first use. A host that loses the race to
|
|
1061
|
+
* another on the same home finds it made. */
|
|
1062
|
+
async ensureScratchpad(): Promise<void> {
|
|
1063
|
+
if (this.has(SCRATCHPAD_ID)) return;
|
|
1064
|
+
|
|
1065
|
+
try {
|
|
1066
|
+
await this.execute({
|
|
1067
|
+
operation: {
|
|
1068
|
+
type: "create",
|
|
1069
|
+
title: SCRATCHPAD_TITLE,
|
|
1070
|
+
kind: "scratchpad",
|
|
1071
|
+
},
|
|
1072
|
+
});
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
if (!this.has(SCRATCHPAD_ID)) throw error;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1459
1077
|
/** Two saved board/day pairs in the last fourteen days. */
|
|
1460
1078
|
feedbackEligible(now = Date.now()): boolean {
|
|
1461
1079
|
const row = this.db
|
|
@@ -1476,33 +1094,17 @@ export class ReviewStore {
|
|
|
1476
1094
|
return Number(row?.pairs) >= 2;
|
|
1477
1095
|
}
|
|
1478
1096
|
|
|
1479
|
-
history(id: string)
|
|
1097
|
+
history(id: string) {
|
|
1480
1098
|
return this.db
|
|
1481
1099
|
.prepare(
|
|
1482
|
-
|
|
1483
|
-
CASE WHEN json_valid(snapshot) THEN json_extract(snapshot,'$.title') END AS title,
|
|
1484
|
-
CASE WHEN json_valid(snapshot) THEN json_extract(snapshot,'$.createdAt') END AS created_at,
|
|
1485
|
-
author_kind,
|
|
1486
|
-
thread_id
|
|
1487
|
-
FROM versions WHERE review_id=? ORDER BY version`,
|
|
1100
|
+
"SELECT version,json_extract(snapshot,'$.title') AS title,json_extract(snapshot,'$.createdAt') AS created_at FROM versions WHERE review_id=? ORDER BY version",
|
|
1488
1101
|
)
|
|
1489
1102
|
.all(id)
|
|
1490
|
-
.map((row) => {
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
createdAt: String(row.created_at ?? ""),
|
|
1496
|
-
};
|
|
1497
|
-
|
|
1498
|
-
if (row.author_kind !== null)
|
|
1499
|
-
entry.author = row.author_kind === "person" ? "you" : "agent";
|
|
1500
|
-
|
|
1501
|
-
if (row.author_kind !== "person" && row.thread_id !== null)
|
|
1502
|
-
entry.threadId = String(row.thread_id);
|
|
1503
|
-
|
|
1504
|
-
return entry;
|
|
1505
|
-
});
|
|
1103
|
+
.map((row) => ({
|
|
1104
|
+
version: Number(row.version),
|
|
1105
|
+
title: String(row.title),
|
|
1106
|
+
createdAt: String(row.created_at),
|
|
1107
|
+
}));
|
|
1506
1108
|
}
|
|
1507
1109
|
inspect(id: string, targetId?: string, version?: number) {
|
|
1508
1110
|
const snapshot = this.read(id, version);
|
|
@@ -1514,7 +1116,6 @@ export class ReviewStore {
|
|
|
1514
1116
|
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Command boundary: commandSchema.parse below rejects malformed input before mutation.
|
|
1515
1117
|
input: unknown,
|
|
1516
1118
|
initial?: { document: Block[]; origin: SnapshotOrigin },
|
|
1517
|
-
author: Author = { kind: "agent" },
|
|
1518
1119
|
): Promise<Result> {
|
|
1519
1120
|
if (this.closing)
|
|
1520
1121
|
return Promise.reject(new Error("Review store is closing."));
|
|
@@ -1535,11 +1136,37 @@ export class ReviewStore {
|
|
|
1535
1136
|
)
|
|
1536
1137
|
: this.startPullRequestTitle(command);
|
|
1537
1138
|
|
|
1538
|
-
|
|
1139
|
+
const run = this.pending.then(async () => {
|
|
1539
1140
|
const op = command.operation;
|
|
1540
1141
|
this.purgeExpired();
|
|
1541
1142
|
|
|
1542
|
-
if (
|
|
1143
|
+
if (
|
|
1144
|
+
op.type !== "create" &&
|
|
1145
|
+
op.type !== "delete" &&
|
|
1146
|
+
op.type !== "lifecycle"
|
|
1147
|
+
)
|
|
1148
|
+
this.assertWritable(op.reviewId);
|
|
1149
|
+
|
|
1150
|
+
// The scratchpad is edited and restored like a review, and nothing else.
|
|
1151
|
+
if (
|
|
1152
|
+
op.type !== "create" &&
|
|
1153
|
+
op.type !== "edit" &&
|
|
1154
|
+
op.type !== "lens_edit" &&
|
|
1155
|
+
op.type !== "restore" &&
|
|
1156
|
+
this.read(op.reviewId).kind === "scratchpad"
|
|
1157
|
+
)
|
|
1158
|
+
throw new ReviewInputError(
|
|
1159
|
+
"The scratchpad has no lifecycle, title or pins of its own.",
|
|
1160
|
+
409,
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
if (op.type === "create" && op.kind === "scratchpad") {
|
|
1164
|
+
if (op.target)
|
|
1165
|
+
throw new ReviewInputError("A scratchpad has no target of its own.");
|
|
1166
|
+
|
|
1167
|
+
if (this.has(SCRATCHPAD_ID))
|
|
1168
|
+
throw new ReviewInputError("The scratchpad already exists.", 409);
|
|
1169
|
+
} else if (op.type === "create") {
|
|
1543
1170
|
if (!op.target && !op.pullRequestUrl)
|
|
1544
1171
|
throw new ReviewInputError("Supply a target or a pullRequestUrl.");
|
|
1545
1172
|
|
|
@@ -1548,7 +1175,7 @@ export class ReviewStore {
|
|
|
1548
1175
|
"A checkout for the PR applies only to a create from pullRequestUrl alone; name it in the target instead.",
|
|
1549
1176
|
);
|
|
1550
1177
|
|
|
1551
|
-
if (!op.title && op.target
|
|
1178
|
+
if (!op.title && op.target)
|
|
1552
1179
|
throw new ReviewInputError("Supply a title.");
|
|
1553
1180
|
}
|
|
1554
1181
|
|
|
@@ -1559,15 +1186,10 @@ export class ReviewStore {
|
|
|
1559
1186
|
|
|
1560
1187
|
const fromPullRequest = await pullRequest;
|
|
1561
1188
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
{ target: requestedTarget })
|
|
1567
|
-
: requestedTarget
|
|
1568
|
-
? await (this.providers.resolveTarget?.(requestedTarget) ??
|
|
1569
|
-
namedCommits(requestedTarget))
|
|
1570
|
-
: fromPullRequest;
|
|
1189
|
+
const resolvedTarget = requestedTarget
|
|
1190
|
+
? await (this.providers.resolveTarget?.(requestedTarget) ??
|
|
1191
|
+
namedCommits(requestedTarget))
|
|
1192
|
+
: fromPullRequest;
|
|
1571
1193
|
|
|
1572
1194
|
if (requestedTarget && !resolvedTarget)
|
|
1573
1195
|
throw new ReviewInputError("Review targets are unavailable.");
|
|
@@ -1585,1424 +1207,339 @@ export class ReviewStore {
|
|
|
1585
1207
|
const result = this.existingReview(
|
|
1586
1208
|
found,
|
|
1587
1209
|
others,
|
|
1588
|
-
|
|
1589
|
-
resolvedTarget!.pins!,
|
|
1210
|
+
resolvedTarget!.pins,
|
|
1590
1211
|
);
|
|
1591
1212
|
|
|
1592
|
-
if (op.parentId || op.dependsOn)
|
|
1593
|
-
result.note += ` Its parent and dependencies were not changed either: set them with session_set_parent and session_set_dependencies.`;
|
|
1594
|
-
|
|
1595
1213
|
return result;
|
|
1596
1214
|
}
|
|
1597
1215
|
}
|
|
1598
1216
|
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
return this.commitBoardFacts(op.reviewId, author, {
|
|
1602
|
-
parentId: op.parentId,
|
|
1603
|
-
});
|
|
1604
|
-
|
|
1605
|
-
if (op.type === "set_dependencies")
|
|
1606
|
-
return this.commitBoardFacts(op.reviewId, author, {
|
|
1607
|
-
dependsOn: op.dependsOn,
|
|
1608
|
-
});
|
|
1217
|
+
if (op.type === "delete") {
|
|
1218
|
+
this.assertExists(op.reviewId);
|
|
1609
1219
|
|
|
1610
|
-
|
|
1611
|
-
|
|
1220
|
+
if (!this.trashTime(op.reviewId))
|
|
1221
|
+
throw new ReviewInputError(
|
|
1222
|
+
"Move this whiteboard to Trash before deleting it.",
|
|
1223
|
+
409,
|
|
1224
|
+
);
|
|
1612
1225
|
|
|
1613
|
-
|
|
1614
|
-
|
|
1226
|
+
const result: Result = {
|
|
1227
|
+
reviewId: op.reviewId,
|
|
1228
|
+
version: this.read(op.reviewId).version,
|
|
1229
|
+
deleted: true,
|
|
1230
|
+
};
|
|
1615
1231
|
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1232
|
+
this.commitCommand(
|
|
1233
|
+
result,
|
|
1234
|
+
() => this.removePermanently(op.reviewId),
|
|
1235
|
+
() => this.assertMutation(op.reviewId, result.version),
|
|
1236
|
+
);
|
|
1620
1237
|
|
|
1621
|
-
|
|
1238
|
+
return result;
|
|
1239
|
+
}
|
|
1622
1240
|
|
|
1623
|
-
|
|
1241
|
+
if (op.type === "attention" || op.type === "lifecycle") {
|
|
1242
|
+
const result: Result = {
|
|
1243
|
+
reviewId: op.reviewId,
|
|
1244
|
+
version: this.read(op.reviewId).version,
|
|
1245
|
+
attention: true,
|
|
1246
|
+
...(op.type === "lifecycle" && {
|
|
1247
|
+
lifecycle: true as const,
|
|
1248
|
+
}),
|
|
1249
|
+
};
|
|
1624
1250
|
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
op,
|
|
1630
|
-
resolvedTarget!,
|
|
1631
|
-
fromPullRequest &&
|
|
1632
|
-
(fromPullRequest.title ||
|
|
1633
|
-
`PR #${op.pullRequestUrl!.split("/").at(-1)}`),
|
|
1634
|
-
author.kind === "person",
|
|
1251
|
+
this.commitCommand(result, () => {
|
|
1252
|
+
this.db
|
|
1253
|
+
.prepare(
|
|
1254
|
+
"INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)",
|
|
1635
1255
|
)
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
delete snapshot.trashedAt;
|
|
1256
|
+
.run(op.reviewId);
|
|
1639
1257
|
|
|
1640
|
-
|
|
1641
|
-
delete snapshot.sourceUnavailable;
|
|
1642
|
-
// Each version describes only its own edit.
|
|
1643
|
-
delete snapshot.lastEdit;
|
|
1644
|
-
|
|
1645
|
-
let nextId = previous
|
|
1646
|
-
? Number(
|
|
1647
|
-
this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id)!
|
|
1648
|
-
.next_id,
|
|
1649
|
-
)
|
|
1650
|
-
: 0;
|
|
1651
|
-
|
|
1652
|
-
let applied: Applied | undefined;
|
|
1653
|
-
let lensTarget: { targetId: string; type: "lens" } | undefined;
|
|
1654
|
-
/** The head a worktree board read at, when it becomes a plan. */
|
|
1655
|
-
let unpinnedWorktree: string | undefined;
|
|
1656
|
-
|
|
1657
|
-
if (
|
|
1658
|
-
(op.type === "create" || op.type === "set_target") &&
|
|
1659
|
-
this.providers.headBranch
|
|
1660
|
-
) {
|
|
1661
|
-
const pins = resolvedTarget?.pins ?? snapshot.pins;
|
|
1662
|
-
|
|
1663
|
-
if (pins) {
|
|
1664
|
-
const headRef =
|
|
1665
|
-
requestedTarget?.kind === "commits"
|
|
1666
|
-
? requestedTarget.head
|
|
1667
|
-
: undefined;
|
|
1668
|
-
|
|
1669
|
-
const branch = await this.providers.headBranch(pins, headRef);
|
|
1670
|
-
|
|
1671
|
-
snapshot.origin = { ...snapshot.origin, branch };
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
if (!requestedTarget && fromPullRequest)
|
|
1676
|
-
snapshot.origin = {
|
|
1677
|
-
...snapshot.origin,
|
|
1678
|
-
branch: fromPullRequest.branch ?? snapshot.origin?.branch,
|
|
1679
|
-
baseBranch: fromPullRequest.baseBranch,
|
|
1680
|
-
};
|
|
1681
|
-
|
|
1682
|
-
// A base named rather than given as a commit, whole or abbreviated;
|
|
1683
|
-
// a stacked review names the branch below it. A plan compares nothing.
|
|
1684
|
-
if (requestedTarget && requestedTarget.kind !== "project") {
|
|
1685
|
-
const named = requestedTarget.base;
|
|
1686
|
-
const base = resolvedTarget?.pins?.base;
|
|
1687
|
-
|
|
1688
|
-
snapshot.origin = {
|
|
1689
|
-
...snapshot.origin,
|
|
1690
|
-
baseBranch:
|
|
1691
|
-
named && !base?.startsWith(named.toLowerCase()) ? named : undefined,
|
|
1692
|
-
};
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
switch (op.type) {
|
|
1696
|
-
case "create":
|
|
1697
|
-
if (initial) {
|
|
1698
|
-
snapshot.document = await this.resolveCommitLinks(
|
|
1699
|
-
documentSchema.parse(initial.document),
|
|
1700
|
-
snapshot,
|
|
1701
|
-
);
|
|
1702
|
-
snapshot.origin = {
|
|
1703
|
-
...snapshot.origin,
|
|
1704
|
-
...structuredClone(initial.origin),
|
|
1705
|
-
};
|
|
1706
|
-
|
|
1707
|
-
for (const block of snapshot.document)
|
|
1708
|
-
assignFreshIds(block, (prefix) => `${prefix}-${++nextId}`);
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
setPullRequest(snapshot, op.pullRequestUrl, await pullRequestTitle);
|
|
1712
|
-
break;
|
|
1713
|
-
case "rename":
|
|
1714
|
-
snapshot.title = op.title;
|
|
1715
|
-
break;
|
|
1716
|
-
case "set_target":
|
|
1717
|
-
setPullRequest(
|
|
1718
|
-
snapshot,
|
|
1719
|
-
op.pullRequestUrl ??
|
|
1720
|
-
(op.pullRequestUrl === null ||
|
|
1721
|
-
snapshot.target?.repositoryId !==
|
|
1722
|
-
resolvedTarget!.target.repositoryId
|
|
1723
|
-
? null
|
|
1724
|
-
: undefined),
|
|
1725
|
-
await pullRequestTitle,
|
|
1726
|
-
);
|
|
1727
|
-
snapshot.staleSources = [];
|
|
1728
|
-
|
|
1729
|
-
// A board that loses its pins keeps each reference pointing where it
|
|
1730
|
-
// did.
|
|
1731
|
-
if (snapshot.pins && !resolvedTarget!.pins) {
|
|
1732
|
-
const pinned = pinDocumentReferences(
|
|
1733
|
-
snapshot.document,
|
|
1734
|
-
snapshot.pins,
|
|
1735
|
-
);
|
|
1736
|
-
|
|
1737
|
-
// A worktree quotes uncommitted lines its head commit lacks.
|
|
1738
|
-
if (
|
|
1739
|
-
snapshot.target?.kind === "worktree" &&
|
|
1740
|
-
JSON.stringify(pinned) !== JSON.stringify(snapshot.document)
|
|
1741
|
-
)
|
|
1742
|
-
unpinnedWorktree = snapshot.pins.head;
|
|
1743
|
-
snapshot.document = pinned;
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
snapshot.target = resolvedTarget!.target;
|
|
1747
|
-
|
|
1748
|
-
if (resolvedTarget!.pins) snapshot.pins = resolvedTarget!.pins;
|
|
1749
|
-
else delete snapshot.pins;
|
|
1750
|
-
break;
|
|
1751
|
-
case "restore":
|
|
1752
|
-
snapshot = this.read(id, op.version);
|
|
1753
|
-
delete snapshot.lastEdit;
|
|
1754
|
-
break;
|
|
1755
|
-
case "lens_edit": {
|
|
1756
|
-
const lenses = snapshot.lenses ?? [];
|
|
1757
|
-
const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
|
|
1758
|
-
|
|
1759
|
-
if (lenses.length) snapshot.lenses = lenses;
|
|
1760
|
-
else delete snapshot.lenses;
|
|
1761
|
-
lensTarget = { targetId: lens.id, type: "lens" };
|
|
1762
|
-
snapshot.lastEdit = {
|
|
1763
|
-
type: op.edit.type,
|
|
1764
|
-
targetId: lens.id,
|
|
1765
|
-
blockId: lens.id,
|
|
1766
|
-
kind: "lens",
|
|
1767
|
-
...(op.edit.type === "update" && {
|
|
1768
|
-
fields: Object.keys(op.edit).filter(
|
|
1769
|
-
(key) => key !== "type" && key !== "targetId",
|
|
1770
|
-
),
|
|
1771
|
-
}),
|
|
1772
|
-
};
|
|
1773
|
-
break;
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
case "edit": {
|
|
1777
|
-
const edit = respellEditLinks(op.edit);
|
|
1778
|
-
|
|
1779
|
-
applied = applyEdit(
|
|
1780
|
-
snapshot.document,
|
|
1781
|
-
edit,
|
|
1782
|
-
(prefix) => `${prefix}-${++nextId}`,
|
|
1783
|
-
);
|
|
1784
|
-
|
|
1785
|
-
snapshot.lastEdit = summarizeEdit(
|
|
1786
|
-
edit,
|
|
1787
|
-
applied,
|
|
1788
|
-
previous!.document,
|
|
1789
|
-
snapshot.document,
|
|
1790
|
-
);
|
|
1791
|
-
|
|
1792
|
-
// commitVersion normalizes it, as it does every version.
|
|
1793
|
-
break;
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
const warnings = unpinnedWorktree
|
|
1798
|
-
? [
|
|
1799
|
-
`Its references now read at ${unpinnedWorktree.slice(0, 7)}, the checkout's last commit, which lacks any uncommitted lines they quoted. Check them.`,
|
|
1800
|
-
]
|
|
1801
|
-
: [];
|
|
1802
|
-
|
|
1803
|
-
return this.commitVersion({
|
|
1804
|
-
snapshot,
|
|
1805
|
-
previous,
|
|
1806
|
-
nextId,
|
|
1807
|
-
author,
|
|
1808
|
-
warnings,
|
|
1809
|
-
restore: op.type === "restore",
|
|
1810
|
-
repin: op.type === "set_target",
|
|
1811
|
-
projectLive: op.type !== "restore" && !resolvedTarget,
|
|
1812
|
-
result: {
|
|
1813
|
-
...(op.type === "create" && { created: true }),
|
|
1814
|
-
targetId: applied?.targetId ?? lensTarget?.targetId,
|
|
1815
|
-
...(applied && { type: applied.type }),
|
|
1816
|
-
...(lensTarget && { type: lensTarget.type }),
|
|
1817
|
-
...(applied?.children && { children: applied.children }),
|
|
1818
|
-
},
|
|
1819
|
-
...(op.type === "create" && {
|
|
1820
|
-
relations: { parentId: op.parentId, dependsOn: op.dependsOn },
|
|
1821
|
-
}),
|
|
1822
|
-
...((op.type === "edit" || op.type === "lens_edit") && {
|
|
1823
|
-
courier: {
|
|
1824
|
-
surface: op.type === "edit" ? "document" : "lenses",
|
|
1825
|
-
activityId: op.activityId,
|
|
1826
|
-
},
|
|
1827
|
-
}),
|
|
1828
|
-
});
|
|
1829
|
-
});
|
|
1830
|
-
}
|
|
1831
|
-
/**
|
|
1832
|
-
* Commits a board's next version: the one place a version is written.
|
|
1833
|
-
* Whoever writes it, it keeps the same rules:
|
|
1834
|
-
* - an agent may not change a block a person holds;
|
|
1835
|
-
* - the version names its author: a thread's names the thread, a
|
|
1836
|
-
* person's is theirs and never inherits a thread;
|
|
1837
|
-
* - only a change of repository moves a board to another project, and an
|
|
1838
|
-
* existing board never takes the project of the board opened last;
|
|
1839
|
-
* - only links the change adds are checked;
|
|
1840
|
-
* - the version, its link index, the board's project and its relations
|
|
1841
|
-
* are written together, and only if the board is still at the version
|
|
1842
|
-
* read and the rules still hold.
|
|
1843
|
-
* Runs inside the write queue.
|
|
1844
|
-
*/
|
|
1845
|
-
private async commitVersion(change: {
|
|
1846
|
-
snapshot: Snapshot;
|
|
1847
|
-
previous: Snapshot | undefined;
|
|
1848
|
-
nextId: number;
|
|
1849
|
-
author: Author;
|
|
1850
|
-
warnings?: string[];
|
|
1851
|
-
restore?: boolean;
|
|
1852
|
-
repin?: boolean;
|
|
1853
|
-
projectLive: boolean;
|
|
1854
|
-
/** The ids a person's save gave new blocks, by client key. */
|
|
1855
|
-
personIds?: Record<string, string>;
|
|
1856
|
-
/** Base version a person's save was made against. */
|
|
1857
|
-
baseVersion?: number;
|
|
1858
|
-
/** A save that, normalized, changes nothing saves no version. */
|
|
1859
|
-
unchangedSavesNothing?: boolean;
|
|
1860
|
-
result?: Omit<Result, "reviewId" | "version">;
|
|
1861
|
-
relations?: { parentId?: string | null; dependsOn?: readonly string[] };
|
|
1862
|
-
courier?: { surface: ActivitySurface; activityId?: string };
|
|
1863
|
-
}): Promise<Result> {
|
|
1864
|
-
const { previous, author } = change;
|
|
1865
|
-
const id = change.snapshot.reviewId;
|
|
1866
|
-
let snapshot = change.snapshot;
|
|
1867
|
-
|
|
1868
|
-
// Attribution: stripped, then stamped for this author alone.
|
|
1869
|
-
delete snapshot.threadId;
|
|
1870
|
-
delete snapshot.personEdit;
|
|
1871
|
-
|
|
1872
|
-
if (snapshot.lastEdit) delete snapshot.lastEdit.threadId;
|
|
1873
|
-
|
|
1874
|
-
if (previous && !change.restore)
|
|
1875
|
-
await this.normalizeDocument(snapshot, previous);
|
|
1876
|
-
|
|
1877
|
-
if (
|
|
1878
|
-
change.unchangedSavesNothing &&
|
|
1879
|
-
previous &&
|
|
1880
|
-
snapshot.title === previous.title &&
|
|
1881
|
-
isDeepStrictEqual(snapshot.document, previous.document)
|
|
1882
|
-
)
|
|
1883
|
-
return { reviewId: id, version: previous.version };
|
|
1884
|
-
|
|
1885
|
-
const touched = previous
|
|
1886
|
-
? changedBlocks(previous.document, snapshot.document)
|
|
1887
|
-
: [];
|
|
1888
|
-
|
|
1889
|
-
// Refused before any slow check, and again as it commits.
|
|
1890
|
-
const holds = () => {
|
|
1891
|
-
if (author.kind !== "person") this.assertUnleased(id, touched);
|
|
1892
|
-
};
|
|
1893
|
-
|
|
1894
|
-
holds();
|
|
1895
|
-
|
|
1896
|
-
if (author.kind === "person")
|
|
1897
|
-
snapshot.personEdit = {
|
|
1898
|
-
changed: touched,
|
|
1899
|
-
...(change.personIds && { ids: change.personIds }),
|
|
1900
|
-
};
|
|
1901
|
-
else if (author.threadId) {
|
|
1902
|
-
if (snapshot.lastEdit) snapshot.lastEdit.threadId = author.threadId;
|
|
1903
|
-
else snapshot.threadId = author.threadId;
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
let warnings: string[];
|
|
1907
|
-
|
|
1908
|
-
({ snapshot, warnings } = await this.prepareVersion(snapshot, previous, {
|
|
1909
|
-
restore: change.restore,
|
|
1910
|
-
repin: change.repin,
|
|
1911
|
-
projectLive: change.projectLive,
|
|
1912
|
-
person: author.kind === "person",
|
|
1913
|
-
}));
|
|
1914
|
-
warnings = [...(change.warnings ?? []), ...warnings];
|
|
1915
|
-
|
|
1916
|
-
const result: Result = {
|
|
1917
|
-
...change.result,
|
|
1918
|
-
reviewId: id,
|
|
1919
|
-
version: snapshot.version,
|
|
1920
|
-
};
|
|
1921
|
-
|
|
1922
|
-
if (warnings.length) result.warnings = warnings;
|
|
1923
|
-
|
|
1924
|
-
const projectId = this.projectFor(snapshot, previous);
|
|
1925
|
-
|
|
1926
|
-
const previousProject = previous ? this.projectOf(id) : undefined;
|
|
1927
|
-
|
|
1928
|
-
this.commitCommand(
|
|
1929
|
-
result,
|
|
1930
|
-
() => {
|
|
1931
|
-
this.saveVersion(snapshot, change.nextId, projectId, change.author);
|
|
1932
|
-
|
|
1933
|
-
if (change.relations?.parentId)
|
|
1934
|
-
this.setParent(id, change.relations.parentId);
|
|
1935
|
-
|
|
1936
|
-
if (change.relations?.dependsOn)
|
|
1937
|
-
this.setDependencies(id, change.relations.dependsOn);
|
|
1938
|
-
|
|
1939
|
-
// Relations stay inside a project: a retarget out of it drops them.
|
|
1940
|
-
if (
|
|
1941
|
-
previous &&
|
|
1942
|
-
projectId !== previousProject &&
|
|
1943
|
-
this.dropRelationsOutside(id, projectId)
|
|
1944
|
-
)
|
|
1945
|
-
result.warnings = [
|
|
1946
|
-
...(result.warnings ?? []),
|
|
1947
|
-
"Its parent, children and dependencies were in its old project, so they were dropped: whiteboards relate only within a project.",
|
|
1948
|
-
];
|
|
1949
|
-
},
|
|
1950
|
-
previous
|
|
1951
|
-
? () => {
|
|
1952
|
-
// A board in Trash is read-only, whoever writes.
|
|
1953
|
-
this.assertWritable(id);
|
|
1954
|
-
|
|
1955
|
-
if (change.baseVersion !== undefined)
|
|
1956
|
-
assertBaseVersion(change.baseVersion, this.currentVersion(id));
|
|
1957
|
-
else this.assertMutation(id, previous.version);
|
|
1958
|
-
holds();
|
|
1959
|
-
}
|
|
1960
|
-
: undefined,
|
|
1961
|
-
change.courier && {
|
|
1962
|
-
...change.courier,
|
|
1963
|
-
// The version names the agent whose courier draws it.
|
|
1964
|
-
owned: (owner) => {
|
|
1965
|
-
if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
|
|
1966
|
-
},
|
|
1967
|
-
},
|
|
1968
|
-
);
|
|
1969
|
-
|
|
1970
|
-
return result;
|
|
1971
|
-
}
|
|
1972
|
-
/** The project a version puts its board in: a new board's repository's,
|
|
1973
|
-
* or, for a board that has one, its own unless the version moves it to
|
|
1974
|
-
* another registered repository. */
|
|
1975
|
-
private projectFor(snapshot: Snapshot, previous: Snapshot | undefined) {
|
|
1976
|
-
const repository = snapshot.target?.repositoryId;
|
|
1977
|
-
|
|
1978
|
-
if (
|
|
1979
|
-
previous &&
|
|
1980
|
-
(!repository ||
|
|
1981
|
-
repository === previous.target?.repositoryId ||
|
|
1982
|
-
!this.db
|
|
1983
|
-
.prepare("SELECT 1 FROM repositories WHERE id=?")
|
|
1984
|
-
.get(repository))
|
|
1985
|
-
)
|
|
1986
|
-
return this.projectOf(snapshot.reviewId);
|
|
1987
|
-
|
|
1988
|
-
return repository
|
|
1989
|
-
? this.projectOfRepository(repository)
|
|
1990
|
-
: this.fallbackProject();
|
|
1991
|
-
}
|
|
1992
|
-
private currentVersion(reviewId: string) {
|
|
1993
|
-
return Number(
|
|
1994
|
-
this.db.prepare("SELECT version FROM reviews WHERE id=?").get(reviewId)!
|
|
1995
|
-
.version,
|
|
1996
|
-
);
|
|
1997
|
-
}
|
|
1998
|
-
/** Runs one write after every write queued before it. */
|
|
1999
|
-
private enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
2000
|
-
if (this.closing)
|
|
2001
|
-
return Promise.reject(new Error("Review store is closing."));
|
|
2002
|
-
|
|
2003
|
-
const run = this.pending.then(work);
|
|
2004
|
-
|
|
2005
|
-
this.pending = run.catch(() => {});
|
|
2006
|
-
|
|
2007
|
-
return run;
|
|
2008
|
-
}
|
|
2009
|
-
/** Respells a document's links and resolves its commit links, as every
|
|
2010
|
-
* save does. A changed reference is no longer stale. */
|
|
2011
|
-
private async normalizeDocument(snapshot: Snapshot, previous: Snapshot) {
|
|
2012
|
-
snapshot.document = await this.resolveCommitLinks(
|
|
2013
|
-
respellDocumentLinks(snapshot.document),
|
|
2014
|
-
snapshot,
|
|
2015
|
-
previous.document,
|
|
2016
|
-
);
|
|
2017
|
-
|
|
2018
|
-
if (snapshot.staleSources?.length) {
|
|
2019
|
-
const oldSources = new Map(
|
|
2020
|
-
sourceReferences(previous.document, {
|
|
2021
|
-
repositoryId: boardRepositoryId(previous),
|
|
2022
|
-
}).map((item) => [item.id, JSON.stringify(item.source)]),
|
|
2023
|
-
);
|
|
2024
|
-
|
|
2025
|
-
const newSources = new Map(
|
|
2026
|
-
sourceReferences(snapshot.document, {
|
|
2027
|
-
repositoryId: boardRepositoryId(snapshot),
|
|
2028
|
-
}).map((item) => [item.id, JSON.stringify(item.source)]),
|
|
2029
|
-
);
|
|
2030
|
-
|
|
2031
|
-
snapshot.staleSources = snapshot.staleSources.filter(
|
|
2032
|
-
(id) => oldSources.get(id) === newSources.get(id) && newSources.has(id),
|
|
2033
|
-
);
|
|
2034
|
-
}
|
|
2035
|
-
}
|
|
2036
|
-
/** Checks a candidate version and numbers it. A worktree board's version
|
|
2037
|
-
* may come back with its live source projected. */
|
|
2038
|
-
private async prepareVersion(
|
|
2039
|
-
candidate: Snapshot,
|
|
2040
|
-
previous: Snapshot | undefined,
|
|
2041
|
-
options: {
|
|
2042
|
-
restore?: boolean;
|
|
2043
|
-
repin?: boolean;
|
|
2044
|
-
projectLive: boolean;
|
|
2045
|
-
/** A person's save: links may name boards deleted since. */
|
|
2046
|
-
person?: boolean;
|
|
2047
|
-
},
|
|
2048
|
-
): Promise<{ snapshot: Snapshot; warnings: string[] }> {
|
|
2049
|
-
const snapshot =
|
|
2050
|
-
candidate.target?.kind === "worktree" && options.projectLive
|
|
2051
|
-
? await this.projectLiveSource(candidate)
|
|
2052
|
-
: candidate;
|
|
2053
|
-
|
|
2054
|
-
// Component shapes were checked at entry (or when merging a field patch).
|
|
2055
|
-
// Check cross-references here; do not reparse the whole stored document.
|
|
2056
|
-
checkReferences(snapshot.document);
|
|
2057
|
-
|
|
2058
|
-
// A restore may bring back links to deleted boards, so skip the check.
|
|
2059
|
-
if (!options.restore)
|
|
2060
|
-
this.checkBoardLinks(snapshot, previous, options.person);
|
|
2061
|
-
|
|
2062
|
-
if (
|
|
2063
|
-
snapshot.pins &&
|
|
2064
|
-
(!previous ||
|
|
2065
|
-
JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))
|
|
2066
|
-
)
|
|
2067
|
-
await this.providers.validatePins(snapshot.pins);
|
|
2068
|
-
|
|
2069
|
-
// Advice is about what this command wrote; earlier advice stays said.
|
|
2070
|
-
const advised = new Set(documentAdvice(previous?.document ?? []));
|
|
2071
|
-
|
|
2072
|
-
const warnings = [
|
|
2073
|
-
...documentAdvice(snapshot.document).filter(
|
|
2074
|
-
(advice) => !advised.has(advice),
|
|
2075
|
-
),
|
|
2076
|
-
...(await this.validateExternal(snapshot, previous, options.repin)),
|
|
2077
|
-
];
|
|
2078
|
-
|
|
2079
|
-
snapshot.version = previous ? previous.version + 1 : 0;
|
|
2080
|
-
snapshot.createdAt = new Date().toISOString();
|
|
2081
|
-
|
|
2082
|
-
return { snapshot, warnings };
|
|
2083
|
-
}
|
|
2084
|
-
/** Writes a version; call inside commitCommand. */
|
|
2085
|
-
private saveVersion(
|
|
2086
|
-
snapshot: Snapshot,
|
|
2087
|
-
nextId: number,
|
|
2088
|
-
projectId: string,
|
|
2089
|
-
author: Author,
|
|
2090
|
-
) {
|
|
2091
|
-
const id = snapshot.reviewId;
|
|
2092
|
-
|
|
2093
|
-
this.db
|
|
2094
|
-
.prepare(
|
|
2095
|
-
"INSERT INTO reviews(id,version,next_id,project_id) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,next_id=excluded.next_id,project_id=excluded.project_id",
|
|
2096
|
-
)
|
|
2097
|
-
.run(id, snapshot.version, nextId, projectId);
|
|
2098
|
-
this.db
|
|
2099
|
-
.prepare(
|
|
2100
|
-
"INSERT INTO versions(review_id,version,snapshot,thread_id,author_kind) VALUES(?,?,?,?,?)",
|
|
2101
|
-
)
|
|
2102
|
-
.run(
|
|
2103
|
-
id,
|
|
2104
|
-
snapshot.version,
|
|
2105
|
-
JSON.stringify(snapshot),
|
|
2106
|
-
versionThread(snapshot),
|
|
2107
|
-
author.kind,
|
|
2108
|
-
);
|
|
2109
|
-
this.indexBoardLinks(id, snapshot.document);
|
|
2110
|
-
}
|
|
2111
|
-
/** Deletes a board and its history, if it is still at the version read
|
|
2112
|
-
* and passes `guard`: Delete… from Trash, Trash's purge and a discarded
|
|
2113
|
-
* empty board all end here. An agent can't delete a board while a person
|
|
2114
|
-
* holds a block in it. */
|
|
2115
|
-
private deleteBoard(
|
|
2116
|
-
{ reviewId, version }: Snapshot,
|
|
2117
|
-
author: Author,
|
|
2118
|
-
{
|
|
2119
|
-
guard,
|
|
2120
|
-
fromTrash = false,
|
|
2121
|
-
}: {
|
|
2122
|
-
guard?: () => void;
|
|
2123
|
-
/** Delete…, which is only for a board in Trash. */
|
|
2124
|
-
fromTrash?: boolean;
|
|
2125
|
-
} = {},
|
|
2126
|
-
): Result {
|
|
2127
|
-
const result: Result = { reviewId, version, deleted: true };
|
|
2128
|
-
|
|
2129
|
-
const rules = () => {
|
|
2130
|
-
guard?.();
|
|
2131
|
-
|
|
2132
|
-
if (fromTrash && !this.trashTime(reviewId))
|
|
2133
|
-
throw new ReviewInputError(
|
|
2134
|
-
"Move this whiteboard to Trash before deleting it.",
|
|
2135
|
-
409,
|
|
2136
|
-
);
|
|
2137
|
-
this.assertMutation(reviewId, version);
|
|
2138
|
-
|
|
2139
|
-
if (author.kind !== "person") this.assertUnheld(reviewId);
|
|
2140
|
-
};
|
|
2141
|
-
|
|
2142
|
-
rules();
|
|
2143
|
-
|
|
2144
|
-
this.commitCommand(result, () => this.removePermanently(reviewId), rules);
|
|
2145
|
-
this.leases.delete(reviewId);
|
|
2146
|
-
|
|
2147
|
-
return result;
|
|
2148
|
-
}
|
|
2149
|
-
/** Writes what a board is outside its versions: its parent or what it
|
|
2150
|
-
* depends on, Trash or back, or that it was viewed. No version is saved,
|
|
2151
|
-
* but the rules are a version's: a board in Trash takes nothing but a
|
|
2152
|
-
* restore, and an agent can't put a board a person is in to Trash. */
|
|
2153
|
-
private commitBoardFacts(
|
|
2154
|
-
reviewId: string,
|
|
2155
|
-
author: Author,
|
|
2156
|
-
fact:
|
|
2157
|
-
| { parentId: string | null }
|
|
2158
|
-
| { dependsOn: readonly string[] }
|
|
2159
|
-
| { state: "active" | "trash" }
|
|
2160
|
-
| { viewed: true },
|
|
2161
|
-
): Result {
|
|
2162
|
-
const lifecycle = "state" in fact;
|
|
2163
|
-
|
|
2164
|
-
const rules = () => {
|
|
2165
|
-
this.assertExists(reviewId);
|
|
2166
|
-
|
|
2167
|
-
if (!lifecycle) this.assertOutOfTrash(reviewId);
|
|
2168
|
-
else if (fact.state === "trash" && author.kind !== "person")
|
|
2169
|
-
this.assertUnheld(reviewId);
|
|
2170
|
-
};
|
|
2171
|
-
|
|
2172
|
-
rules();
|
|
2173
|
-
|
|
2174
|
-
const result: Result = { reviewId, version: this.read(reviewId).version };
|
|
2175
|
-
|
|
2176
|
-
if (lifecycle || "viewed" in fact) result.attention = true;
|
|
2177
|
-
|
|
2178
|
-
if (lifecycle) result.lifecycle = true;
|
|
2179
|
-
|
|
2180
|
-
this.commitCommand(
|
|
2181
|
-
result,
|
|
2182
|
-
() => {
|
|
2183
|
-
if ("parentId" in fact) this.setParent(reviewId, fact.parentId);
|
|
2184
|
-
else if ("dependsOn" in fact)
|
|
2185
|
-
this.setDependencies(reviewId, fact.dependsOn);
|
|
2186
|
-
else {
|
|
2187
|
-
this.db
|
|
2188
|
-
.prepare(
|
|
2189
|
-
"INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)",
|
|
2190
|
-
)
|
|
2191
|
-
.run(reviewId);
|
|
2192
|
-
|
|
2193
|
-
if ("viewed" in fact)
|
|
1258
|
+
if (op.type === "attention")
|
|
2194
1259
|
this.db
|
|
2195
1260
|
.prepare(
|
|
2196
1261
|
"UPDATE review_attention SET viewed_at=? WHERE review_id=?",
|
|
2197
1262
|
)
|
|
2198
|
-
.run(new Date().toISOString(), reviewId);
|
|
1263
|
+
.run(new Date().toISOString(), op.reviewId);
|
|
2199
1264
|
else {
|
|
2200
|
-
// A second move to Trash keeps the first deadline.
|
|
2201
1265
|
const timestamp =
|
|
2202
|
-
|
|
1266
|
+
op.state === "trash" ? new Date().toISOString() : null;
|
|
2203
1267
|
|
|
2204
1268
|
this.db
|
|
2205
1269
|
.prepare(
|
|
2206
1270
|
"UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?",
|
|
2207
1271
|
)
|
|
2208
|
-
.run(timestamp, timestamp, reviewId);
|
|
1272
|
+
.run(timestamp, timestamp, op.reviewId);
|
|
2209
1273
|
}
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
if ("parentId" in fact || "dependsOn" in fact)
|
|
2213
|
-
result.relations = this.relations(reviewId);
|
|
2214
|
-
},
|
|
2215
|
-
rules,
|
|
2216
|
-
);
|
|
2217
|
-
|
|
2218
|
-
return result;
|
|
2219
|
-
}
|
|
2220
|
-
/** Deletes a board the person made and left as it was: with no title
|
|
2221
|
-
* (it reads "Untitled"), no blocks and no child boards. A title typed as
|
|
2222
|
-
* "Untitled" is a title. A board still open on a canvas, or
|
|
2223
|
-
* one an agent is working on, is kept. A closing canvas saves, then lets
|
|
2224
|
-
* go of the board, so the discard waits for that first. */
|
|
2225
|
-
async discard(reviewId: string): Promise<{ deleted: boolean }> {
|
|
2226
|
-
const deadline = Date.now() + FOCUS_LEASE_TTL_MS;
|
|
2227
|
-
|
|
2228
|
-
while (this.leased(reviewId) && Date.now() < deadline)
|
|
2229
|
-
await new Promise((resolve) => setTimeout(resolve, DISCARD_POLL_MS));
|
|
2230
|
-
|
|
2231
|
-
return this.enqueue(async () => {
|
|
2232
|
-
if (!this.has(reviewId)) return { deleted: false };
|
|
2233
|
-
const snapshot = this.read(reviewId);
|
|
2234
|
-
|
|
2235
|
-
if (
|
|
2236
|
-
this.leased(reviewId) ||
|
|
2237
|
-
this.activity.isWorking(reviewId) ||
|
|
2238
|
-
snapshot.title !== "" ||
|
|
2239
|
-
snapshot.document.length ||
|
|
2240
|
-
this.relations(reviewId).children.length
|
|
2241
|
-
)
|
|
2242
|
-
return { deleted: false };
|
|
2243
|
-
|
|
2244
|
-
this.deleteBoard(snapshot, { kind: "person" });
|
|
2245
|
-
|
|
2246
|
-
return { deleted: true };
|
|
2247
|
-
});
|
|
2248
|
-
}
|
|
2249
|
-
/** Each board's canvas leases by client: the board is open there, and
|
|
2250
|
-
* `blockId` is the block the person is in, if any. In memory: a lease
|
|
2251
|
-
* outlives neither its renewals nor the host. */
|
|
2252
|
-
private readonly leases = new Map<
|
|
2253
|
-
string,
|
|
2254
|
-
Map<string, { blockId: string | null; expiresAt: number }>
|
|
2255
|
-
>();
|
|
2256
|
-
|
|
2257
|
-
/** Whether a canvas still holds the board open. */
|
|
2258
|
-
private leased(reviewId: string) {
|
|
2259
|
-
const now = Date.now();
|
|
2260
|
-
|
|
2261
|
-
return [...(this.leases.get(reviewId)?.values() ?? [])].some(
|
|
2262
|
-
(lease) => lease.expiresAt > now,
|
|
2263
|
-
);
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
/** Records the block a person's canvas has focus in (null for none), or
|
|
2267
|
-
* with `open: false` that the canvas closed the board. */
|
|
2268
|
-
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Lease boundary: focusLeaseSchema.parse below validates incoming JSON.
|
|
2269
|
-
focus(reviewId: string, input: unknown) {
|
|
2270
|
-
const { clientId, blockId, open } = focusLeaseSchema.parse(input);
|
|
2271
|
-
|
|
2272
|
-
this.assertExists(reviewId);
|
|
2273
|
-
const leases = this.leases.get(reviewId) ?? new Map();
|
|
2274
|
-
|
|
2275
|
-
if (open === false) leases.delete(clientId);
|
|
2276
|
-
else
|
|
2277
|
-
leases.set(clientId, {
|
|
2278
|
-
blockId,
|
|
2279
|
-
expiresAt: Date.now() + FOCUS_LEASE_TTL_MS,
|
|
2280
|
-
});
|
|
2281
|
-
|
|
2282
|
-
if (leases.size) this.leases.set(reviewId, leases);
|
|
2283
|
-
else this.leases.delete(reviewId);
|
|
2284
|
-
|
|
2285
|
-
return { ok: true as const };
|
|
2286
|
-
}
|
|
2287
|
-
/** Refuses an agent's change to a board while a person holds any block
|
|
2288
|
-
* in it. */
|
|
2289
|
-
private assertUnheld(reviewId: string) {
|
|
2290
|
-
const now = Date.now();
|
|
2291
|
-
|
|
2292
|
-
for (const lease of this.leases.get(reviewId)?.values() ?? [])
|
|
2293
|
-
if (lease.expiresAt > now && lease.blockId)
|
|
2294
|
-
throw new ReviewInputError(
|
|
2295
|
-
`The user is editing this block (${lease.blockId}). Edit another block, or try again after they move on.`,
|
|
2296
|
-
409,
|
|
2297
|
-
);
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
/** Refuses an agent's edit to a block a person is editing. */
|
|
2301
|
-
private assertUnleased(reviewId: string, changed: readonly string[]) {
|
|
2302
|
-
const leases = this.leases.get(reviewId);
|
|
2303
|
-
|
|
2304
|
-
if (!leases || !changed.length) return;
|
|
2305
|
-
const now = Date.now();
|
|
1274
|
+
});
|
|
2306
1275
|
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
else if (lease.blockId && changed.includes(lease.blockId))
|
|
2310
|
-
throw new ReviewInputError(
|
|
2311
|
-
`The user is editing this block (${lease.blockId}). Edit another block, or try again after they move on.`,
|
|
2312
|
-
409,
|
|
2313
|
-
);
|
|
2314
|
-
}
|
|
2315
|
-
/** Saves a person's whole board as their own version, against the version
|
|
2316
|
-
* they started from. Blocks without ids get fresh ones. */
|
|
2317
|
-
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Save boundary: personEditSchema and documentSchema parse below validate incoming JSON.
|
|
2318
|
-
personEdit(reviewId: string, input: unknown): Promise<PersonEditResult> {
|
|
2319
|
-
const body = personEditSchema.parse(input);
|
|
1276
|
+
return result;
|
|
1277
|
+
}
|
|
2320
1278
|
|
|
2321
|
-
|
|
2322
|
-
|
|
1279
|
+
const id =
|
|
1280
|
+
op.type !== "create"
|
|
1281
|
+
? op.reviewId
|
|
1282
|
+
: op.kind === "scratchpad"
|
|
1283
|
+
? SCRATCHPAD_ID
|
|
1284
|
+
: randomUUID();
|
|
2323
1285
|
|
|
2324
|
-
|
|
1286
|
+
const previous = op.type === "create" ? undefined : this.read(id);
|
|
2325
1287
|
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
1288
|
+
let snapshot: Snapshot =
|
|
1289
|
+
op.type === "create"
|
|
1290
|
+
? createdSnapshot(
|
|
1291
|
+
id,
|
|
1292
|
+
op,
|
|
1293
|
+
resolvedTarget,
|
|
1294
|
+
fromPullRequest &&
|
|
1295
|
+
(fromPullRequest.title ||
|
|
1296
|
+
`PR #${op.pullRequestUrl!.split("/").at(-1)}`),
|
|
1297
|
+
)
|
|
1298
|
+
: structuredClone(previous!);
|
|
2334
1299
|
|
|
2335
|
-
|
|
1300
|
+
delete snapshot.trashedAt;
|
|
2336
1301
|
|
|
1302
|
+
// Overlay state: a degraded read must not persist unavailability.
|
|
2337
1303
|
delete snapshot.sourceUnavailable;
|
|
1304
|
+
// Each version describes only its own edit.
|
|
2338
1305
|
delete snapshot.lastEdit;
|
|
2339
|
-
snapshot.title = body.title;
|
|
2340
|
-
snapshot.document = document;
|
|
2341
|
-
|
|
2342
|
-
// `ids` lets the canvas recognise its own save on the stream, even
|
|
2343
|
-
// when the save's response was lost.
|
|
2344
|
-
const { version } = await this.commitVersion({
|
|
2345
|
-
snapshot,
|
|
2346
|
-
previous,
|
|
2347
|
-
nextId,
|
|
2348
|
-
author: { kind: "person" },
|
|
2349
|
-
projectLive: true,
|
|
2350
|
-
personIds: ids,
|
|
2351
|
-
baseVersion: body.baseVersion,
|
|
2352
|
-
unchangedSavesNothing: true,
|
|
2353
|
-
});
|
|
2354
|
-
|
|
2355
|
-
return { reviewId, version, ids };
|
|
2356
|
-
});
|
|
2357
|
-
}
|
|
2358
|
-
/** Where a board sits: its parent, and the parent's children in order,
|
|
2359
|
-
* each with what its header shows. */
|
|
2360
|
-
place(id: string) {
|
|
2361
|
-
const graph = this.relationGraph(true);
|
|
2362
|
-
const parentId = graph.parents.get(id);
|
|
2363
|
-
|
|
2364
|
-
const placed = (reviewId: string): PlacedBoard => {
|
|
2365
|
-
const row = this.db
|
|
2366
|
-
.prepare(
|
|
2367
|
-
`SELECT json_extract(versions.snapshot,'$.title') AS title,
|
|
2368
|
-
json_extract(versions.snapshot,'$.target') AS target,
|
|
2369
|
-
json_extract(versions.snapshot,'$.origin') AS origin
|
|
2370
|
-
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2371
|
-
WHERE reviews.id=?`,
|
|
2372
|
-
)
|
|
2373
|
-
.get(reviewId)!;
|
|
2374
|
-
|
|
2375
|
-
return {
|
|
2376
|
-
reviewId,
|
|
2377
|
-
title: String(row.title),
|
|
2378
|
-
...(row.target && {
|
|
2379
|
-
// SAFETY: the target was validated when the version was saved.
|
|
2380
|
-
target: JSON.parse(String(row.target)) as ReviewTarget,
|
|
2381
|
-
}),
|
|
2382
|
-
...(row.origin && {
|
|
2383
|
-
// SAFETY: the origin was validated when the version was saved.
|
|
2384
|
-
origin: JSON.parse(String(row.origin)) as SnapshotOrigin,
|
|
2385
|
-
}),
|
|
2386
|
-
};
|
|
2387
|
-
};
|
|
2388
|
-
|
|
2389
|
-
return {
|
|
2390
|
-
board: placed(id),
|
|
2391
|
-
...(parentId && { parent: placed(parentId) }),
|
|
2392
|
-
siblings: parentId
|
|
2393
|
-
? this.orderChildren(
|
|
2394
|
-
this.listedSiblings(graph.children.get(parentId)!, id),
|
|
2395
|
-
graph,
|
|
2396
|
-
).map(placed)
|
|
2397
|
-
: [],
|
|
2398
|
-
// A top-level board's own, in the order the sidebar lists them.
|
|
2399
|
-
...(!parentId && {
|
|
2400
|
-
children: this.orderChildren(
|
|
2401
|
-
this.listedSiblings(graph.children.get(id) ?? [], id),
|
|
2402
|
-
graph,
|
|
2403
|
-
).map(placed),
|
|
2404
|
-
}),
|
|
2405
|
-
};
|
|
2406
|
-
}
|
|
2407
|
-
/** The siblings Home and the sidebar list, which a board's place counts
|
|
2408
|
-
* among: all but those in Trash, and the board itself. */
|
|
2409
|
-
private listedSiblings(ids: readonly string[], self: string) {
|
|
2410
|
-
const trashed = new Set(this.trashedIds());
|
|
2411
|
-
|
|
2412
|
-
return ids.filter((id) => id === self || !trashed.has(id));
|
|
2413
|
-
}
|
|
2414
|
-
/** A board's parent, its children in sibling order, and what it depends on. */
|
|
2415
|
-
relations(id: string): BoardRelations {
|
|
2416
|
-
const graph = this.relationGraph();
|
|
2417
|
-
|
|
2418
|
-
return {
|
|
2419
|
-
parentId: graph.parents.get(id) ?? null,
|
|
2420
|
-
children: this.orderChildren(graph.children.get(id) ?? [], graph),
|
|
2421
|
-
dependsOn: graph.dependsOn.get(id) ?? [],
|
|
2422
|
-
};
|
|
2423
|
-
}
|
|
2424
|
-
/** Parents, children and dependencies. A board in Trash is out of them
|
|
2425
|
-
* until it is restored, its rows kept; only its own place sees it. */
|
|
2426
|
-
private relationGraph(withTrash = false) {
|
|
2427
|
-
const parents = new Map<string, string>();
|
|
2428
|
-
const children = new Map<string, string[]>();
|
|
2429
|
-
const dependsOn = new Map<string, string[]>();
|
|
2430
|
-
const trashed = new Set(withTrash ? [] : this.trashedIds());
|
|
2431
|
-
|
|
2432
|
-
for (const row of this.db
|
|
2433
|
-
.prepare(
|
|
2434
|
-
"SELECT id, parent_id FROM reviews WHERE parent_id IS NOT NULL ORDER BY rowid",
|
|
2435
|
-
)
|
|
2436
|
-
.all()) {
|
|
2437
|
-
const board = String(row.id);
|
|
2438
|
-
const parent = String(row.parent_id);
|
|
2439
|
-
|
|
2440
|
-
if (trashed.has(board) || trashed.has(parent)) continue;
|
|
2441
|
-
|
|
2442
|
-
parents.set(board, parent);
|
|
2443
|
-
children.set(parent, [...(children.get(parent) ?? []), board]);
|
|
2444
|
-
}
|
|
2445
|
-
|
|
2446
|
-
for (const row of this.db
|
|
2447
|
-
.prepare(
|
|
2448
|
-
"SELECT review_id, depends_on FROM board_dependencies ORDER BY rowid",
|
|
2449
|
-
)
|
|
2450
|
-
.all()) {
|
|
2451
|
-
const board = String(row.review_id);
|
|
2452
|
-
const dependency = String(row.depends_on);
|
|
2453
1306
|
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
}
|
|
2461
|
-
/** Drops a board's parent, children and dependencies in other projects,
|
|
2462
|
-
* after it moved to `projectId`. Whether it dropped any. */
|
|
2463
|
-
private dropRelationsOutside(id: string, projectId: string): boolean {
|
|
2464
|
-
const outside = "SELECT id FROM reviews WHERE project_id!=?";
|
|
2465
|
-
|
|
2466
|
-
const parents = this.db
|
|
2467
|
-
.prepare(
|
|
2468
|
-
`UPDATE reviews SET parent_id=NULL WHERE (id=? AND parent_id IN (${outside})) OR (parent_id=? AND project_id!=?)`,
|
|
2469
|
-
)
|
|
2470
|
-
.run(id, projectId, id, projectId).changes;
|
|
2471
|
-
|
|
2472
|
-
const dependencies = this.db
|
|
2473
|
-
.prepare(
|
|
2474
|
-
`DELETE FROM board_dependencies WHERE (review_id=? AND depends_on IN (${outside})) OR (depends_on=? AND review_id IN (${outside}))`,
|
|
2475
|
-
)
|
|
2476
|
-
.run(id, projectId, id, projectId).changes;
|
|
2477
|
-
|
|
2478
|
-
return Number(parents) + Number(dependencies) > 0;
|
|
2479
|
-
}
|
|
2480
|
-
private orderChildren(
|
|
2481
|
-
children: readonly string[],
|
|
2482
|
-
graph: ReturnType<ReviewStore["relationGraph"]>,
|
|
2483
|
-
): string[] {
|
|
2484
|
-
if (children.length < 2) return [...children];
|
|
2485
|
-
|
|
2486
|
-
return orderSiblings(
|
|
2487
|
-
this.siblingFacts(children),
|
|
2488
|
-
(id) => graph.dependsOn.get(id) ?? [],
|
|
2489
|
-
(repositoryId, ancestor, of) =>
|
|
2490
|
-
this.isAncestor(repositoryId, ancestor, of),
|
|
2491
|
-
);
|
|
2492
|
-
}
|
|
2493
|
-
private siblingFacts(ids: readonly string[]): SiblingFacts[] {
|
|
2494
|
-
return this.db
|
|
2495
|
-
.prepare(
|
|
2496
|
-
`SELECT reviews.id,
|
|
2497
|
-
(SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first
|
|
2498
|
-
WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS created_at,
|
|
2499
|
-
json_extract(versions.snapshot,'$.pins') AS pins,
|
|
2500
|
-
json_extract(versions.snapshot,'$.origin.branch') AS branch,
|
|
2501
|
-
json_extract(versions.snapshot,'$.origin.pullRequestUrl') AS url
|
|
2502
|
-
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2503
|
-
WHERE reviews.id IN (${ids.map(() => "?").join(",")})`,
|
|
2504
|
-
)
|
|
2505
|
-
.all(...ids)
|
|
2506
|
-
.map((row) => {
|
|
2507
|
-
const id = String(row.id);
|
|
2508
|
-
|
|
2509
|
-
// SAFETY: pins were validated when the version was saved.
|
|
2510
|
-
const saved = row.pins
|
|
2511
|
-
? (JSON.parse(String(row.pins)) as Pins)
|
|
2512
|
-
: undefined;
|
|
2513
|
-
|
|
2514
|
-
// A worktree's pins follow its checkout.
|
|
2515
|
-
const pins = this.liveSources.get(id)?.pins ?? saved;
|
|
2516
|
-
const url = row.url ? String(row.url) : undefined;
|
|
2517
|
-
const baseBranch = url && this.providers.pullRequestBaseBranch?.(url);
|
|
2518
|
-
|
|
2519
|
-
return {
|
|
2520
|
-
id,
|
|
2521
|
-
createdAt: String(row.created_at),
|
|
2522
|
-
...(pins && {
|
|
2523
|
-
repositoryId: pins.repositoryId,
|
|
2524
|
-
base: pins.base,
|
|
2525
|
-
head: pins.head,
|
|
2526
|
-
}),
|
|
2527
|
-
...(url && row.branch && { headBranch: String(row.branch) }),
|
|
2528
|
-
...(baseBranch && { baseBranch }),
|
|
2529
|
-
};
|
|
2530
|
-
});
|
|
2531
|
-
}
|
|
2532
|
-
/** Commits never change, so a definite answer is kept. */
|
|
2533
|
-
private readonly ancestry = new Map<string, boolean>();
|
|
2534
|
-
|
|
2535
|
-
/** When git was last asked, for questions with no answer yet. */
|
|
2536
|
-
private readonly ancestryAsked = new Map<string, number>();
|
|
2537
|
-
|
|
2538
|
-
/** Whether `ancestor` is an ancestor of `of`, as far as is known. git is
|
|
2539
|
-
* asked in the background, never on the server thread: until it answers,
|
|
2540
|
-
* or when it fails, the answer is no, and a yes re-sorts the catalog. */
|
|
2541
|
-
private isAncestor(repositoryId: string, ancestor: string, of: string) {
|
|
2542
|
-
const key = `${repositoryId}:${ancestor}:${of}`;
|
|
2543
|
-
const known = this.ancestry.get(key);
|
|
2544
|
-
|
|
2545
|
-
if (known !== undefined) return known;
|
|
2546
|
-
|
|
2547
|
-
const asked = this.ancestryAsked.get(key);
|
|
2548
|
-
|
|
2549
|
-
// A commit not fetched yet may arrive: ask again, but not on every read.
|
|
2550
|
-
if (asked !== undefined && Date.now() - asked < 60_000) return false;
|
|
2551
|
-
|
|
2552
|
-
this.ancestryAsked.set(key, Date.now());
|
|
2553
|
-
let root: string;
|
|
2554
|
-
|
|
2555
|
-
try {
|
|
2556
|
-
root = this.repositoryPath(repositoryId);
|
|
2557
|
-
} catch {
|
|
2558
|
-
return false;
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
// At most a few at once: a parent with many children asks many pairs.
|
|
2562
|
-
this.ancestryQueue.push(() =>
|
|
2563
|
-
runVcs(
|
|
2564
|
-
"git",
|
|
2565
|
-
[
|
|
2566
|
-
"-C",
|
|
2567
|
-
root,
|
|
2568
|
-
"merge-base",
|
|
2569
|
-
"--is-ancestor",
|
|
2570
|
-
{ rev: ancestor },
|
|
2571
|
-
{ rev: of },
|
|
2572
|
-
],
|
|
2573
|
-
{ timeout: 10_000 },
|
|
2574
|
-
).then(
|
|
2575
|
-
() => this.knowAncestry(key, true),
|
|
2576
|
-
(error: { code?: unknown }) => {
|
|
2577
|
-
// Exit 1 means no; anything else (a missing commit, a timeout, a
|
|
2578
|
-
// refused operand) is no answer.
|
|
2579
|
-
if (error.code === 1) this.knowAncestry(key, false);
|
|
2580
|
-
},
|
|
2581
|
-
),
|
|
2582
|
-
);
|
|
2583
|
-
this.askNextAncestry();
|
|
2584
|
-
|
|
2585
|
-
return false;
|
|
2586
|
-
}
|
|
2587
|
-
private readonly ancestryQueue: (() => Promise<void>)[] = [];
|
|
2588
|
-
private ancestryRunning = 0;
|
|
2589
|
-
private askNextAncestry() {
|
|
2590
|
-
while (
|
|
2591
|
-
this.ancestryRunning < ANCESTRY_CONCURRENCY &&
|
|
2592
|
-
this.ancestryQueue.length
|
|
2593
|
-
) {
|
|
2594
|
-
this.ancestryRunning++;
|
|
2595
|
-
void this.ancestryQueue.shift()!().finally(() => {
|
|
2596
|
-
this.ancestryRunning--;
|
|
2597
|
-
this.askNextAncestry();
|
|
2598
|
-
});
|
|
2599
|
-
}
|
|
2600
|
-
}
|
|
2601
|
-
private knowAncestry(key: string, ancestor: boolean) {
|
|
2602
|
-
this.ancestry.set(key, ancestor);
|
|
2603
|
-
this.ancestryAsked.delete(key);
|
|
2604
|
-
|
|
2605
|
-
if (ancestor && !this.closing) this.catalogChanged();
|
|
2606
|
-
}
|
|
2607
|
-
private catalogChanged() {
|
|
2608
|
-
for (const listener of this.catalogListeners)
|
|
2609
|
-
try {
|
|
2610
|
-
listener();
|
|
2611
|
-
} catch {
|
|
2612
|
-
// A disconnected viewer must not block other catalog subscribers.
|
|
2613
|
-
}
|
|
2614
|
-
}
|
|
2615
|
-
private boardName(id: string) {
|
|
2616
|
-
return `"${boardTitle(this.read(id).title)}" (${id})`;
|
|
2617
|
-
}
|
|
2618
|
-
/** The other board exists and is in the same project. */
|
|
2619
|
-
private checkRelated(id: string, other: string) {
|
|
2620
|
-
if (!this.has(other))
|
|
2621
|
-
throw new ReviewInputError(
|
|
2622
|
-
`Whiteboard ${other} does not exist. Name a whiteboard by its sessionId from session_list.`,
|
|
2623
|
-
);
|
|
2624
|
-
|
|
2625
|
-
if (this.projectOf(id) !== this.projectOf(other))
|
|
2626
|
-
throw new ReviewInputError(
|
|
2627
|
-
`${this.boardName(other)} is in another project than ${this.boardName(id)}. A whiteboard's parent and dependencies are in its own project.`,
|
|
2628
|
-
);
|
|
2629
|
-
}
|
|
2630
|
-
/** One parent, one level: a parent is top-level and a child has no
|
|
2631
|
-
* children. Null makes the board top-level. */
|
|
2632
|
-
private setParent(id: string, parentId: string | null) {
|
|
2633
|
-
if (parentId !== null) {
|
|
2634
|
-
if (parentId === id)
|
|
2635
|
-
throw new ReviewInputError("A whiteboard can't be its own parent.");
|
|
2636
|
-
this.checkRelated(id, parentId);
|
|
2637
|
-
const { parents, children } = this.relationGraph();
|
|
2638
|
-
const grandparent = parents.get(parentId);
|
|
2639
|
-
|
|
2640
|
-
if (grandparent)
|
|
2641
|
-
throw new ReviewInputError(
|
|
2642
|
-
`${this.boardName(parentId)} is a child of ${this.boardName(grandparent)}, and whiteboards nest one level deep. Use ${grandparent} as the parent instead.`,
|
|
2643
|
-
);
|
|
2644
|
-
const own = children.get(id) ?? [];
|
|
2645
|
-
|
|
2646
|
-
if (own.length)
|
|
2647
|
-
throw new ReviewInputError(
|
|
2648
|
-
`${this.boardName(id)} has children (${own.map((child) => this.boardName(child)).join(", ")}), and whiteboards nest one level deep, so it can't become a child. Move its children first with session_set_parent.`,
|
|
2649
|
-
);
|
|
2650
|
-
}
|
|
2651
|
-
|
|
2652
|
-
this.db
|
|
2653
|
-
.prepare("UPDATE reviews SET parent_id=? WHERE id=?")
|
|
2654
|
-
.run(parentId, id);
|
|
2655
|
-
}
|
|
2656
|
-
private setDependencies(id: string, dependsOn: readonly string[]) {
|
|
2657
|
-
const unique = [...new Set(dependsOn)];
|
|
2658
|
-
const graph = this.relationGraph();
|
|
2659
|
-
|
|
2660
|
-
for (const dependency of unique) {
|
|
2661
|
-
if (dependency === id)
|
|
2662
|
-
throw new ReviewInputError("A whiteboard can't depend on itself.");
|
|
2663
|
-
|
|
2664
|
-
const cycle = dependencyCycle(
|
|
2665
|
-
id,
|
|
2666
|
-
dependency,
|
|
2667
|
-
(board) => graph.dependsOn.get(board) ?? [],
|
|
2668
|
-
);
|
|
2669
|
-
|
|
2670
|
-
if (cycle)
|
|
2671
|
-
throw new ReviewInputError(
|
|
2672
|
-
`Depending on ${this.boardName(dependency)} would close a cycle: ${cycle.map((board) => this.boardName(board)).join(" → ")}. Drop one of those dependencies first.`,
|
|
2673
|
-
);
|
|
2674
|
-
this.checkRelated(id, dependency);
|
|
2675
|
-
}
|
|
2676
|
-
|
|
2677
|
-
this.db.prepare("DELETE FROM board_dependencies WHERE review_id=?").run(id);
|
|
2678
|
-
|
|
2679
|
-
const insert = this.db.prepare(
|
|
2680
|
-
"INSERT INTO board_dependencies(review_id,depends_on) VALUES(?,?)",
|
|
2681
|
-
);
|
|
2682
|
-
|
|
2683
|
-
for (const dependency of unique) insert.run(id, dependency);
|
|
2684
|
-
}
|
|
2685
|
-
/** New links must point at a board and block that exist. Saved links are
|
|
2686
|
-
* not rechecked, since their target may have been deleted. */
|
|
2687
|
-
private checkBoardLinks(
|
|
2688
|
-
snapshot: Snapshot,
|
|
2689
|
-
previous?: Snapshot,
|
|
2690
|
-
/** ⌘Z can bring back a link to a board or block deleted since; it
|
|
2691
|
-
* shows as deleted, like any link whose target went away. */
|
|
2692
|
-
person = false,
|
|
2693
|
-
) {
|
|
2694
|
-
const key = ({ to }: BoardLinkRef) => `${to.board}#${to.block ?? ""}`;
|
|
2695
|
-
|
|
2696
|
-
const kept = new Set(
|
|
2697
|
-
boardLinks(snapshot.reviewId, previous?.document ?? []).map(key),
|
|
2698
|
-
);
|
|
2699
|
-
|
|
2700
|
-
for (const link of boardLinks(snapshot.reviewId, snapshot.document)) {
|
|
2701
|
-
if (kept.has(key(link))) continue;
|
|
2702
|
-
const { from, to } = link;
|
|
2703
|
-
|
|
2704
|
-
const document =
|
|
2705
|
-
to.board === snapshot.reviewId
|
|
2706
|
-
? snapshot.document
|
|
2707
|
-
: this.has(to.board)
|
|
2708
|
-
? this.read(to.board).document
|
|
2709
|
-
: undefined;
|
|
2710
|
-
|
|
2711
|
-
if (!document && person && this.wasDeleted(to.board)) continue;
|
|
2712
|
-
|
|
2713
|
-
if (!document)
|
|
2714
|
-
throw new ReviewInputError(
|
|
2715
|
-
`Block ${from.block} links to board ${to.board}, which does not exist. Link a board by its sessionId from session_list.`,
|
|
2716
|
-
);
|
|
2717
|
-
|
|
2718
|
-
if (
|
|
2719
|
-
!person &&
|
|
2720
|
-
to.block !== undefined &&
|
|
2721
|
-
!elements(document).some((element) => element.id === to.block)
|
|
2722
|
-
)
|
|
2723
|
-
throw new ReviewInputError(
|
|
2724
|
-
`Block ${from.block} links to block ${to.block}, which is not on board ${to.board}. Read the board with session_get for its block ids.`,
|
|
2725
|
-
);
|
|
2726
|
-
}
|
|
2727
|
-
}
|
|
2728
|
-
private wasDeleted(id: string) {
|
|
2729
|
-
return (
|
|
2730
|
-
this.db.prepare("SELECT 1 FROM deleted_boards WHERE id=?").get(id) !==
|
|
2731
|
-
undefined
|
|
2732
|
-
);
|
|
2733
|
-
}
|
|
2734
|
-
/** The boards that link to a board or its blocks. */
|
|
2735
|
-
linkingBoards(id: string): string[] {
|
|
2736
|
-
return this.db
|
|
2737
|
-
.prepare("SELECT DISTINCT from_review FROM board_links WHERE to_review=?")
|
|
2738
|
-
.all(id)
|
|
2739
|
-
.map((row) => String(row.from_review));
|
|
2740
|
-
}
|
|
2741
|
-
/** Rebuilds the board_links rows for one board. */
|
|
2742
|
-
private indexBoardLinks(id: string, document: Block[]) {
|
|
2743
|
-
this.db.prepare("DELETE FROM board_links WHERE from_review=?").run(id);
|
|
1307
|
+
let nextId = previous
|
|
1308
|
+
? Number(
|
|
1309
|
+
this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id)!
|
|
1310
|
+
.next_id,
|
|
1311
|
+
)
|
|
1312
|
+
: 0;
|
|
2744
1313
|
|
|
2745
|
-
|
|
2746
|
-
"
|
|
2747
|
-
);
|
|
1314
|
+
let applied: Applied | undefined;
|
|
1315
|
+
let lensTarget: { targetId: string; type: "lens" } | undefined;
|
|
2748
1316
|
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
1317
|
+
if (
|
|
1318
|
+
(op.type === "create" || op.type === "set_target") &&
|
|
1319
|
+
this.providers.headBranch
|
|
1320
|
+
) {
|
|
1321
|
+
const pins = resolvedTarget?.pins ?? snapshot.pins;
|
|
2754
1322
|
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
1323
|
+
if (pins) {
|
|
1324
|
+
const headRef =
|
|
1325
|
+
requestedTarget?.kind === "commits"
|
|
1326
|
+
? requestedTarget.head
|
|
1327
|
+
: undefined;
|
|
2758
1328
|
|
|
2759
|
-
|
|
2760
|
-
preview = boardLinkPreview(target.document, block);
|
|
1329
|
+
const branch = await this.providers.headBranch(pins, headRef);
|
|
2761
1330
|
|
|
2762
|
-
|
|
2763
|
-
|
|
1331
|
+
snapshot.origin = { ...snapshot.origin, branch };
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
2764
1334
|
|
|
2765
|
-
|
|
2766
|
-
|
|
1335
|
+
if (!requestedTarget && fromPullRequest)
|
|
1336
|
+
snapshot.origin = {
|
|
1337
|
+
...snapshot.origin,
|
|
1338
|
+
branch: fromPullRequest.branch ?? snapshot.origin?.branch,
|
|
1339
|
+
baseBranch: fromPullRequest.baseBranch,
|
|
1340
|
+
};
|
|
2767
1341
|
|
|
2768
|
-
|
|
2769
|
-
|
|
1342
|
+
// A base named rather than given as a commit; a stacked review names
|
|
1343
|
+
// the branch below it.
|
|
1344
|
+
if (requestedTarget)
|
|
1345
|
+
snapshot.origin = {
|
|
1346
|
+
...snapshot.origin,
|
|
1347
|
+
baseBranch:
|
|
1348
|
+
requestedTarget.base === resolvedTarget?.pins.base
|
|
1349
|
+
? undefined
|
|
1350
|
+
: requestedTarget.base,
|
|
1351
|
+
};
|
|
2770
1352
|
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
const linked = boardLinks(id, this.read(id, version).document).some(
|
|
2780
|
-
(link) => link.to.board === to.board && link.to.block === to.block,
|
|
2781
|
-
);
|
|
1353
|
+
switch (op.type) {
|
|
1354
|
+
case "create":
|
|
1355
|
+
if (initial) {
|
|
1356
|
+
snapshot.document = documentSchema.parse(initial.document);
|
|
1357
|
+
snapshot.origin = {
|
|
1358
|
+
...snapshot.origin,
|
|
1359
|
+
...structuredClone(initial.origin),
|
|
1360
|
+
};
|
|
2782
1361
|
|
|
2783
|
-
|
|
2784
|
-
|
|
1362
|
+
for (const block of snapshot.document)
|
|
1363
|
+
assignFreshIds(block, (prefix) => `${prefix}-${++nextId}`);
|
|
1364
|
+
}
|
|
2785
1365
|
|
|
2786
|
-
|
|
2787
|
-
|
|
1366
|
+
setPullRequest(snapshot, op.pullRequestUrl, await pullRequestTitle);
|
|
1367
|
+
break;
|
|
1368
|
+
case "rename":
|
|
1369
|
+
snapshot.title = op.title;
|
|
1370
|
+
break;
|
|
1371
|
+
case "set_target":
|
|
1372
|
+
setPullRequest(
|
|
1373
|
+
snapshot,
|
|
1374
|
+
op.pullRequestUrl ??
|
|
1375
|
+
(op.pullRequestUrl === null ||
|
|
1376
|
+
snapshot.pins?.repositoryId !== resolvedTarget!.pins.repositoryId
|
|
1377
|
+
? null
|
|
1378
|
+
: undefined),
|
|
1379
|
+
await pullRequestTitle,
|
|
1380
|
+
);
|
|
1381
|
+
snapshot.staleSources = [];
|
|
1382
|
+
snapshot.target = resolvedTarget!.target;
|
|
1383
|
+
snapshot.pins = resolvedTarget!.pins;
|
|
1384
|
+
break;
|
|
1385
|
+
case "restore":
|
|
1386
|
+
snapshot = this.read(id, op.version);
|
|
1387
|
+
delete snapshot.lastEdit;
|
|
1388
|
+
break;
|
|
1389
|
+
case "lens_edit": {
|
|
1390
|
+
if (snapshot.kind === "scratchpad")
|
|
1391
|
+
throw new ReviewInputError(
|
|
1392
|
+
"The scratchpad has no changes of its own to lens.",
|
|
1393
|
+
409,
|
|
1394
|
+
);
|
|
2788
1395
|
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
const repositoryId = board.target?.repositoryId;
|
|
1396
|
+
const lenses = snapshot.lenses ?? [];
|
|
1397
|
+
const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
|
|
2792
1398
|
|
|
2793
|
-
|
|
1399
|
+
if (lenses.length) snapshot.lenses = lenses;
|
|
1400
|
+
else delete snapshot.lenses;
|
|
1401
|
+
lensTarget = { targetId: lens.id, type: "lens" };
|
|
1402
|
+
snapshot.lastEdit = {
|
|
1403
|
+
type: op.edit.type,
|
|
1404
|
+
targetId: lens.id,
|
|
1405
|
+
blockId: lens.id,
|
|
1406
|
+
kind: "lens",
|
|
1407
|
+
...(op.edit.type === "update" && {
|
|
1408
|
+
fields: Object.keys(op.edit).filter(
|
|
1409
|
+
(key) => key !== "type" && key !== "targetId",
|
|
1410
|
+
),
|
|
1411
|
+
}),
|
|
1412
|
+
};
|
|
1413
|
+
break;
|
|
1414
|
+
}
|
|
2794
1415
|
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
1416
|
+
case "edit": {
|
|
1417
|
+
applied = applyEdit(
|
|
1418
|
+
snapshot.document,
|
|
1419
|
+
op.edit,
|
|
1420
|
+
(prefix) => `${prefix}-${++nextId}`,
|
|
1421
|
+
// The scratchpad is a running log: the newest thought goes on top.
|
|
1422
|
+
{ placement: snapshot.kind === "scratchpad" ? "first" : "last" },
|
|
1423
|
+
);
|
|
2803
1424
|
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
1425
|
+
snapshot.lastEdit = summarizeEdit(
|
|
1426
|
+
op.edit,
|
|
1427
|
+
applied,
|
|
1428
|
+
previous!.document,
|
|
1429
|
+
snapshot.document,
|
|
1430
|
+
);
|
|
2808
1431
|
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
1432
|
+
if (snapshot.staleSources?.length) {
|
|
1433
|
+
const oldSources = new Map(
|
|
1434
|
+
sourceReferences(previous!.document).map((item) => [
|
|
1435
|
+
item.id,
|
|
1436
|
+
JSON.stringify(item.source),
|
|
1437
|
+
]),
|
|
1438
|
+
);
|
|
2815
1439
|
|
|
2816
|
-
|
|
2817
|
-
|
|
1440
|
+
const newSources = new Map(
|
|
1441
|
+
sourceReferences(snapshot.document).map((item) => [
|
|
1442
|
+
item.id,
|
|
1443
|
+
JSON.stringify(item.source),
|
|
1444
|
+
]),
|
|
1445
|
+
);
|
|
2818
1446
|
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
!target ||
|
|
2825
|
-
(to.block !== undefined &&
|
|
2826
|
-
!elements(target.document).some(
|
|
2827
|
-
(element) => element.id === to.block,
|
|
2828
|
-
));
|
|
2829
|
-
|
|
2830
|
-
const deleted = target
|
|
2831
|
-
? undefined
|
|
2832
|
-
: this.db
|
|
2833
|
-
.prepare("SELECT title,deleted_at FROM deleted_boards WHERE id=?")
|
|
2834
|
-
.get(to.board);
|
|
2835
|
-
|
|
2836
|
-
return {
|
|
2837
|
-
from,
|
|
2838
|
-
to,
|
|
2839
|
-
...(target && {
|
|
2840
|
-
title: target.title,
|
|
2841
|
-
icon: boardIconKind(target.target),
|
|
2842
|
-
...(this.repositoryLabel(target) && {
|
|
2843
|
-
repository: this.repositoryLabel(target),
|
|
2844
|
-
}),
|
|
2845
|
-
...(target.origin?.pullRequestNumber && {
|
|
2846
|
-
pullRequestNumber: target.origin.pullRequestNumber,
|
|
2847
|
-
pullRequestUrl: target.origin.pullRequestUrl,
|
|
2848
|
-
}),
|
|
2849
|
-
updatedAt: target.createdAt,
|
|
2850
|
-
}),
|
|
2851
|
-
missing,
|
|
2852
|
-
...(deleted && {
|
|
2853
|
-
title: String(deleted.title),
|
|
2854
|
-
deletedAt: String(deleted.deleted_at),
|
|
2855
|
-
}),
|
|
2856
|
-
};
|
|
2857
|
-
},
|
|
2858
|
-
);
|
|
1447
|
+
snapshot.staleSources = snapshot.staleSources.filter(
|
|
1448
|
+
(id) =>
|
|
1449
|
+
oldSources.get(id) === newSources.get(id) && newSources.has(id),
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
2859
1452
|
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
)
|
|
2864
|
-
.all(id, id)
|
|
2865
|
-
.map((row) => {
|
|
2866
|
-
const from = board(String(row.from_review));
|
|
1453
|
+
break;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
2867
1456
|
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
? { board: id }
|
|
2876
|
-
: { board: id, block: String(row.to_block) },
|
|
2877
|
-
title: from?.title ?? "",
|
|
2878
|
-
...(from && { icon: boardIconKind(from.target) }),
|
|
2879
|
-
...(from?.origin?.pullRequestNumber && {
|
|
2880
|
-
pullRequestNumber: from.origin.pullRequestNumber,
|
|
2881
|
-
pullRequestUrl: from.origin.pullRequestUrl,
|
|
2882
|
-
}),
|
|
2883
|
-
};
|
|
2884
|
-
});
|
|
1457
|
+
if (
|
|
1458
|
+
snapshot.target?.kind === "worktree" &&
|
|
1459
|
+
op.type !== "restore" &&
|
|
1460
|
+
!resolvedTarget
|
|
1461
|
+
) {
|
|
1462
|
+
snapshot = await this.projectLiveSource(snapshot);
|
|
1463
|
+
}
|
|
2885
1464
|
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
boardsChangedBy(threadId: string): {
|
|
2890
|
-
reviewId: string;
|
|
2891
|
-
title: string;
|
|
2892
|
-
icon: BoardIconKind;
|
|
2893
|
-
blockId?: string;
|
|
2894
|
-
}[] {
|
|
2895
|
-
return this.db
|
|
2896
|
-
.prepare(
|
|
2897
|
-
`SELECT edits.review_id, MAX(edits.version) AS last
|
|
2898
|
-
FROM versions edits
|
|
2899
|
-
JOIN reviews ON reviews.id=edits.review_id
|
|
2900
|
-
WHERE edits.thread_id=?
|
|
2901
|
-
GROUP BY edits.review_id ORDER BY MAX(json_extract(edits.snapshot,'$.createdAt')) DESC`,
|
|
2902
|
-
)
|
|
2903
|
-
.all(threadId)
|
|
2904
|
-
.map((row) => {
|
|
2905
|
-
const board = this.read(String(row.review_id));
|
|
2906
|
-
|
|
2907
|
-
// The block its last edit there touched, if the board still has it.
|
|
2908
|
-
const blockId = this.read(board.reviewId, Number(row.last)).lastEdit
|
|
2909
|
-
?.blockId;
|
|
2910
|
-
|
|
2911
|
-
return {
|
|
2912
|
-
reviewId: board.reviewId,
|
|
2913
|
-
title: board.title,
|
|
2914
|
-
icon: boardIconKind(board.target),
|
|
2915
|
-
...(blockId &&
|
|
2916
|
-
elements(board.document).some(
|
|
2917
|
-
(element) => element.id === blockId,
|
|
2918
|
-
) && { blockId }),
|
|
2919
|
-
};
|
|
2920
|
-
});
|
|
2921
|
-
}
|
|
2922
|
-
/** The boards each thread changed, newest first, for many threads in one
|
|
2923
|
-
* query. */
|
|
2924
|
-
/** A board's title and icon, read without its document. */
|
|
2925
|
-
/** Where a board lives, as Recent names it: its checkout's folder, else
|
|
2926
|
-
* its project's name. */
|
|
2927
|
-
boardPlace(reviewId: string): string {
|
|
2928
|
-
const row = this.db
|
|
2929
|
-
.prepare(
|
|
2930
|
-
`SELECT repositories.path, projects.name FROM reviews
|
|
2931
|
-
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2932
|
-
LEFT JOIN repositories ON repositories.id=${BOARD_REPOSITORY_ID_SQL}
|
|
2933
|
-
LEFT JOIN projects ON projects.id=reviews.project_id
|
|
2934
|
-
WHERE reviews.id=?`,
|
|
2935
|
-
)
|
|
2936
|
-
.get(reviewId);
|
|
1465
|
+
// Component shapes were checked at entry (or when merging a field patch).
|
|
1466
|
+
// Check cross-references here; do not reparse the whole stored document.
|
|
1467
|
+
checkReferences(snapshot.document);
|
|
2937
1468
|
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
}
|
|
2943
|
-
boardFace(reviewId: string) {
|
|
2944
|
-
const row = this.db
|
|
2945
|
-
.prepare(
|
|
2946
|
-
`SELECT json_extract(versions.snapshot,'$.title') AS title, json_extract(versions.snapshot,'$.target') AS target
|
|
2947
|
-
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
2948
|
-
WHERE reviews.id=?`,
|
|
1469
|
+
if (
|
|
1470
|
+
snapshot.pins &&
|
|
1471
|
+
(!previous ||
|
|
1472
|
+
JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))
|
|
2949
1473
|
)
|
|
2950
|
-
|
|
1474
|
+
await this.providers.validatePins(snapshot.pins);
|
|
2951
1475
|
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
title: String(row.title),
|
|
2955
|
-
icon: boardIconKind(
|
|
2956
|
-
// SAFETY: the target was validated when the version was saved.
|
|
2957
|
-
row.target
|
|
2958
|
-
? (JSON.parse(String(row.target)) as ReviewTarget)
|
|
2959
|
-
: undefined,
|
|
2960
|
-
),
|
|
2961
|
-
};
|
|
2962
|
-
}
|
|
2963
|
-
boardsChangedByThreads(
|
|
2964
|
-
threadIds: readonly string[],
|
|
2965
|
-
): Map<string, { reviewId: string; title: string; icon: BoardIconKind }[]> {
|
|
2966
|
-
const changed = new Map<
|
|
2967
|
-
string,
|
|
2968
|
-
{ reviewId: string; title: string; icon: BoardIconKind }[]
|
|
2969
|
-
>();
|
|
1476
|
+
// Advice is about what this command wrote; earlier advice stays said.
|
|
1477
|
+
const advised = new Set(documentAdvice(previous?.document ?? []));
|
|
2970
1478
|
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
)
|
|
2982
|
-
.all(...threadIds);
|
|
1479
|
+
const warnings = [
|
|
1480
|
+
...documentAdvice(snapshot.document).filter(
|
|
1481
|
+
(advice) => !advised.has(advice),
|
|
1482
|
+
),
|
|
1483
|
+
...(await this.validateExternal(
|
|
1484
|
+
snapshot,
|
|
1485
|
+
previous,
|
|
1486
|
+
op.type === "set_target",
|
|
1487
|
+
)),
|
|
1488
|
+
];
|
|
2983
1489
|
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
1490
|
+
snapshot.version = previous ? previous.version + 1 : 0;
|
|
1491
|
+
snapshot.createdAt = new Date().toISOString();
|
|
1492
|
+
|
|
1493
|
+
const result: Result = {
|
|
1494
|
+
...(op.type === "create" && { created: true }),
|
|
1495
|
+
reviewId: id,
|
|
1496
|
+
version: snapshot.version,
|
|
1497
|
+
targetId: applied?.targetId ?? lensTarget?.targetId,
|
|
1498
|
+
...(applied && { type: applied.type }),
|
|
1499
|
+
...(lensTarget && { type: lensTarget.type }),
|
|
1500
|
+
...(applied?.children && { children: applied.children }),
|
|
1501
|
+
};
|
|
2988
1502
|
|
|
2989
|
-
|
|
2990
|
-
const reviewId = String(row.review_id);
|
|
1503
|
+
if (warnings.length) result.warnings = warnings;
|
|
2991
1504
|
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
this.
|
|
2996
|
-
|
|
2997
|
-
|
|
1505
|
+
this.commitCommand(
|
|
1506
|
+
result,
|
|
1507
|
+
() => {
|
|
1508
|
+
this.db
|
|
1509
|
+
.prepare(
|
|
1510
|
+
"INSERT INTO reviews(id,version,next_id) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,next_id=excluded.next_id",
|
|
1511
|
+
)
|
|
1512
|
+
.run(id, snapshot.version, nextId);
|
|
1513
|
+
this.db
|
|
1514
|
+
.prepare(
|
|
1515
|
+
"INSERT INTO versions(review_id,version,snapshot) VALUES(?,?,?)",
|
|
1516
|
+
)
|
|
1517
|
+
.run(id, snapshot.version, JSON.stringify(snapshot));
|
|
1518
|
+
},
|
|
1519
|
+
previous
|
|
1520
|
+
? () => {
|
|
1521
|
+
this.assertWritable(id);
|
|
1522
|
+
this.assertMutation(id, previous.version);
|
|
1523
|
+
}
|
|
1524
|
+
: undefined,
|
|
1525
|
+
op.type === "edit" || op.type === "lens_edit"
|
|
1526
|
+
? {
|
|
1527
|
+
surface: op.type === "edit" ? "document" : "lenses",
|
|
1528
|
+
activityId: op.activityId,
|
|
1529
|
+
// The version names the agent whose courier draws it.
|
|
1530
|
+
owned: (owner) => {
|
|
1531
|
+
if (snapshot.lastEdit) snapshot.lastEdit.activityId = owner;
|
|
1532
|
+
},
|
|
1533
|
+
}
|
|
1534
|
+
: undefined,
|
|
1535
|
+
);
|
|
2998
1536
|
|
|
2999
|
-
|
|
3000
|
-
|
|
1537
|
+
return result;
|
|
1538
|
+
});
|
|
3001
1539
|
|
|
3002
|
-
|
|
3003
|
-
}
|
|
1540
|
+
this.pending = run.catch(() => {});
|
|
3004
1541
|
|
|
3005
|
-
return
|
|
1542
|
+
return run;
|
|
3006
1543
|
}
|
|
3007
1544
|
/** Resolve a target-less PR create before it queues, when it will need it. */
|
|
3008
1545
|
private startPullRequest(
|
|
@@ -3010,7 +1547,7 @@ export class ReviewStore {
|
|
|
3010
1547
|
): Promise<ResolvedPullRequest> | undefined {
|
|
3011
1548
|
const op = command.operation;
|
|
3012
1549
|
|
|
3013
|
-
if (op.type !== "create" || op.target || !op.pullRequestUrl)
|
|
1550
|
+
if (op.type !== "create" || op.kind || op.target || !op.pullRequestUrl)
|
|
3014
1551
|
return undefined;
|
|
3015
1552
|
|
|
3016
1553
|
if (!this.providers.resolvePullRequest)
|
|
@@ -3018,14 +1555,7 @@ export class ReviewStore {
|
|
|
3018
1555
|
new ReviewInputError("Pull request targets are unavailable."),
|
|
3019
1556
|
);
|
|
3020
1557
|
|
|
3021
|
-
|
|
3022
|
-
return Promise.reject(
|
|
3023
|
-
new ReviewInputError(
|
|
3024
|
-
'Name the checkout to fetch the PR into: target {kind:"pullRequest", repositoryPath, pullRequest}.',
|
|
3025
|
-
),
|
|
3026
|
-
);
|
|
3027
|
-
|
|
3028
|
-
// A PR whose review is in Trash is refused before it is fetched.
|
|
1558
|
+
// Keep an existing review's checkout so headMoved compares like with like.
|
|
3029
1559
|
const [existing] =
|
|
3030
1560
|
op.reuseExisting === false
|
|
3031
1561
|
? []
|
|
@@ -3041,6 +1571,7 @@ export class ReviewStore {
|
|
|
3041
1571
|
|
|
3042
1572
|
return this.providers.resolvePullRequest(op.pullRequestUrl, {
|
|
3043
1573
|
id: op.repositoryId,
|
|
1574
|
+
preferred: existing && this.read(existing).pins?.repositoryId,
|
|
3044
1575
|
});
|
|
3045
1576
|
}
|
|
3046
1577
|
private startPullRequestTitle(
|
|
@@ -3089,8 +1620,9 @@ export class ReviewStore {
|
|
|
3089
1620
|
|
|
3090
1621
|
this.assertReusableReview(reviewId);
|
|
3091
1622
|
|
|
3092
|
-
|
|
3093
|
-
|
|
1623
|
+
const headMoved =
|
|
1624
|
+
snapshot.pins?.repositoryId !== requested.repositoryId ||
|
|
1625
|
+
snapshot.pins?.head !== requested.head;
|
|
3094
1626
|
|
|
3095
1627
|
const working = this.activity.read(reviewId).activities ?? [];
|
|
3096
1628
|
|
|
@@ -3177,7 +1709,7 @@ export class ReviewStore {
|
|
|
3177
1709
|
else if (!result.attention)
|
|
3178
1710
|
this.observedVersions.set(result.reviewId, result.version);
|
|
3179
1711
|
|
|
3180
|
-
if (
|
|
1712
|
+
if (!result.attention || result.lifecycle)
|
|
3181
1713
|
for (const listener of this.listeners)
|
|
3182
1714
|
try {
|
|
3183
1715
|
listener(result);
|
|
@@ -3207,42 +1739,6 @@ export class ReviewStore {
|
|
|
3207
1739
|
undefined
|
|
3208
1740
|
);
|
|
3209
1741
|
}
|
|
3210
|
-
/** Saves each new commit link's commit in full, resolved in the board's
|
|
3211
|
-
* repository; one it does not have rejects the edit, as pins do. A link the
|
|
3212
|
-
* board already had stays as it was saved, even if its commit is gone. */
|
|
3213
|
-
private async resolveCommitLinks(
|
|
3214
|
-
document: Block[],
|
|
3215
|
-
board: Pick<Snapshot, "target" | "pins">,
|
|
3216
|
-
previous?: Block[],
|
|
3217
|
-
): Promise<Block[]> {
|
|
3218
|
-
const saved = new Set(previous ? commitLinkCommits(previous) : []);
|
|
3219
|
-
|
|
3220
|
-
const commits = commitLinkCommits(document).filter(
|
|
3221
|
-
(commit) => !saved.has(commit),
|
|
3222
|
-
);
|
|
3223
|
-
|
|
3224
|
-
const repositoryId = boardRepositoryId(board);
|
|
3225
|
-
|
|
3226
|
-
// Without a repository the links are rejected as references.
|
|
3227
|
-
if (!commits.length || !repositoryId || !this.providers.resolveCommit)
|
|
3228
|
-
return document;
|
|
3229
|
-
|
|
3230
|
-
const resolved = new Map<string, string>();
|
|
3231
|
-
|
|
3232
|
-
await Promise.all(
|
|
3233
|
-
commits.map(async (commit) => {
|
|
3234
|
-
const full = await this.providers.resolveCommit!(repositoryId, commit);
|
|
3235
|
-
|
|
3236
|
-
if (!full)
|
|
3237
|
-
throw new ReviewInputError(
|
|
3238
|
-
`Commit ${commit} is not in the board's repository. Fetch it first, or link a commit the repository has.`,
|
|
3239
|
-
);
|
|
3240
|
-
resolved.set(commit, full);
|
|
3241
|
-
}),
|
|
3242
|
-
);
|
|
3243
|
-
|
|
3244
|
-
return respellCommitLinks(document, resolved);
|
|
3245
|
-
}
|
|
3246
1742
|
private async validateExternal(
|
|
3247
1743
|
snapshot: Snapshot,
|
|
3248
1744
|
previous?: Snapshot,
|
|
@@ -3251,11 +1747,10 @@ export class ReviewStore {
|
|
|
3251
1747
|
const warnings: string[] = [];
|
|
3252
1748
|
|
|
3253
1749
|
const references = (
|
|
3254
|
-
|
|
1750
|
+
document: Block[],
|
|
3255
1751
|
tolerant = false,
|
|
1752
|
+
lenses: readonly Lens[] = [],
|
|
3256
1753
|
) => {
|
|
3257
|
-
const { document, lenses = [] } = board;
|
|
3258
|
-
|
|
3259
1754
|
const sources = new Map<
|
|
3260
1755
|
string,
|
|
3261
1756
|
{ source: FileLineRange; peek: boolean }
|
|
@@ -3269,10 +1764,7 @@ export class ReviewStore {
|
|
|
3269
1764
|
sources.set(key, { source, peek: peek || (kept?.peek ?? false) });
|
|
3270
1765
|
};
|
|
3271
1766
|
|
|
3272
|
-
for (const { source, peek } of sourceReferences(document, {
|
|
3273
|
-
tolerant,
|
|
3274
|
-
repositoryId: boardRepositoryId(board),
|
|
3275
|
-
}))
|
|
1767
|
+
for (const { source, peek } of sourceReferences(document, { tolerant }))
|
|
3276
1768
|
add(source, peek === true);
|
|
3277
1769
|
|
|
3278
1770
|
// A lens range is a prose-like link: it must exist, not read as a peek.
|
|
@@ -3285,7 +1777,7 @@ export class ReviewStore {
|
|
|
3285
1777
|
return { sources, resources };
|
|
3286
1778
|
};
|
|
3287
1779
|
|
|
3288
|
-
const current = references(snapshot, repin);
|
|
1780
|
+
const current = references(snapshot.document, repin, snapshot.lenses);
|
|
3289
1781
|
|
|
3290
1782
|
// Stored content is not re-validated: an edit may fix a link that the
|
|
3291
1783
|
// current rules reject.
|
|
@@ -3302,7 +1794,11 @@ export class ReviewStore {
|
|
|
3302
1794
|
.catch(() => undefined)
|
|
3303
1795
|
: undefined;
|
|
3304
1796
|
|
|
3305
|
-
const retained = references(
|
|
1797
|
+
const retained = references(
|
|
1798
|
+
previous?.document ?? [],
|
|
1799
|
+
true,
|
|
1800
|
+
previous?.lenses,
|
|
1801
|
+
);
|
|
3306
1802
|
|
|
3307
1803
|
// Independent reads of immutable commits: run them concurrently.
|
|
3308
1804
|
const checks: Promise<void>[] = [];
|
|
@@ -3335,7 +1831,6 @@ export class ReviewStore {
|
|
|
3335
1831
|
() => {
|
|
3336
1832
|
if (
|
|
3337
1833
|
repin &&
|
|
3338
|
-
!readsWhereItDid(source, previous?.pins) &&
|
|
3339
1834
|
(!changed || changed[source.side].has(source.file))
|
|
3340
1835
|
)
|
|
3341
1836
|
warnings.push(
|
|
@@ -3377,167 +1872,6 @@ export class ReviewStore {
|
|
|
3377
1872
|
}
|
|
3378
1873
|
}
|
|
3379
1874
|
|
|
3380
|
-
/** The highest number in a document's `prefix-N` ids, units included. */
|
|
3381
|
-
function highestIdNumber(document: Block[]): number {
|
|
3382
|
-
let highest = 0;
|
|
3383
|
-
|
|
3384
|
-
for (const [, number] of JSON.stringify(document).matchAll(
|
|
3385
|
-
/"id":"[^"]*?-(\d+)"/g,
|
|
3386
|
-
))
|
|
3387
|
-
highest = Math.max(highest, Number(number));
|
|
3388
|
-
|
|
3389
|
-
return highest;
|
|
3390
|
-
}
|
|
3391
|
-
|
|
3392
|
-
/** The title of a board a person made and has not named. */
|
|
3393
|
-
|
|
3394
|
-
/** A person's save must start from the board's current version. */
|
|
3395
|
-
function assertBaseVersion(base: number, current: number) {
|
|
3396
|
-
if (base !== current)
|
|
3397
|
-
throw new ReviewInputError(
|
|
3398
|
-
`The board changed since version ${base}; it is now at version ${current}. Merge the latest version and save again.`,
|
|
3399
|
-
409,
|
|
3400
|
-
);
|
|
3401
|
-
}
|
|
3402
|
-
|
|
3403
|
-
/**
|
|
3404
|
-
* A person's whole document as the store saves it: each new block's
|
|
3405
|
-
* clientKey taken, the blocks parsed, a flow's units adopted and every block
|
|
3406
|
-
* without an id given the next one after `nextId`. Every save then respells
|
|
3407
|
-
* its links (respellDocumentLinks) and resolves commit links it has not
|
|
3408
|
-
* saved before. Mutates `input`.
|
|
3409
|
-
*/
|
|
3410
|
-
export function personDocument(input: JsonValue[], nextId: number) {
|
|
3411
|
-
const keys = takeClientKeys(input);
|
|
3412
|
-
const document = documentSchema.parse(input);
|
|
3413
|
-
const allocate = (prefix: string) => `${prefix}-${++nextId}`;
|
|
3414
|
-
|
|
3415
|
-
adoptFlowUnits(document, allocate);
|
|
3416
|
-
|
|
3417
|
-
for (const block of document) assignMissingIds(block, allocate);
|
|
3418
|
-
assertUniqueIds(document);
|
|
3419
|
-
|
|
3420
|
-
return {
|
|
3421
|
-
document,
|
|
3422
|
-
// An id ⌘Z brought back stays; the counter moves past it, so an
|
|
3423
|
-
// agent's next insert can't be given it again.
|
|
3424
|
-
nextId: Math.max(nextId, highestIdNumber(document)),
|
|
3425
|
-
ids: Object.fromEntries(
|
|
3426
|
-
[...keys.byPath].map(([path, key]) => [key, blockAt(document, path).id!]),
|
|
3427
|
-
),
|
|
3428
|
-
};
|
|
3429
|
-
}
|
|
3430
|
-
|
|
3431
|
-
const clientKeySchema = z.string().min(1).optional();
|
|
3432
|
-
|
|
3433
|
-
/** Removes each block's clientKey from a person's raw document, keeping it
|
|
3434
|
-
* by the block's path when the block has no id. Counts the bare blocks. */
|
|
3435
|
-
function takeClientKeys(document: JsonValue[]) {
|
|
3436
|
-
const byPath = new Map<string, string>();
|
|
3437
|
-
const seen = new Set<string>();
|
|
3438
|
-
let bare = 0;
|
|
3439
|
-
|
|
3440
|
-
const visit = (list: JsonValue[], prefix: string) =>
|
|
3441
|
-
list.forEach((block, index) => {
|
|
3442
|
-
if (!isJsonObject(block)) return;
|
|
3443
|
-
const path = `${prefix}${index}`;
|
|
3444
|
-
const clientKey = clientKeySchema.parse(block.clientKey);
|
|
3445
|
-
|
|
3446
|
-
delete block.clientKey;
|
|
3447
|
-
|
|
3448
|
-
if (block.id === undefined) {
|
|
3449
|
-
bare++;
|
|
3450
|
-
|
|
3451
|
-
if (clientKey !== undefined) {
|
|
3452
|
-
if (seen.has(clientKey))
|
|
3453
|
-
throw new ReviewInputError(
|
|
3454
|
-
`clientKey ${clientKey} names more than one block. Give each new block its own clientKey.`,
|
|
3455
|
-
);
|
|
3456
|
-
seen.add(clientKey);
|
|
3457
|
-
byPath.set(path, clientKey);
|
|
3458
|
-
}
|
|
3459
|
-
}
|
|
3460
|
-
|
|
3461
|
-
if (Array.isArray(block.children)) visit(block.children, `${path}/`);
|
|
3462
|
-
});
|
|
3463
|
-
|
|
3464
|
-
visit(document, "");
|
|
3465
|
-
|
|
3466
|
-
return { byPath, bare };
|
|
3467
|
-
}
|
|
3468
|
-
|
|
3469
|
-
/** The block at a path takeClientKeys recorded. */
|
|
3470
|
-
function blockAt(document: Block[], path: string): Block {
|
|
3471
|
-
const [first, ...rest] = path.split("/").map(Number);
|
|
3472
|
-
let block = document[first!]!;
|
|
3473
|
-
|
|
3474
|
-
for (const index of rest)
|
|
3475
|
-
if ("children" in block) block = block.children[index]!;
|
|
3476
|
-
|
|
3477
|
-
return block;
|
|
3478
|
-
}
|
|
3479
|
-
|
|
3480
|
-
/** Ids name one component each across the whole document. */
|
|
3481
|
-
function assertUniqueIds(document: Block[]) {
|
|
3482
|
-
const seen = new Set<string>();
|
|
3483
|
-
|
|
3484
|
-
for (const { id } of elements(document)) {
|
|
3485
|
-
if (id === undefined) continue;
|
|
3486
|
-
|
|
3487
|
-
if (seen.has(id))
|
|
3488
|
-
throw new ReviewInputError(
|
|
3489
|
-
`Id ${id} names more than one component. Give each block its own id, or leave a new block's id out.`,
|
|
3490
|
-
);
|
|
3491
|
-
seen.add(id);
|
|
3492
|
-
}
|
|
3493
|
-
}
|
|
3494
|
-
|
|
3495
|
-
/** Each block's own content by id, nested blocks included. A section or
|
|
3496
|
-
* callout is kept without its children, so a change to a child is only the
|
|
3497
|
-
* child's. */
|
|
3498
|
-
function ownBlocks(document: Block[], into = new Map<string, unknown>()) {
|
|
3499
|
-
for (const block of document) {
|
|
3500
|
-
if ("children" in block) {
|
|
3501
|
-
const { children, ...own } = block;
|
|
3502
|
-
|
|
3503
|
-
if (block.id !== undefined) into.set(block.id, own);
|
|
3504
|
-
ownBlocks(children, into);
|
|
3505
|
-
} else if (block.id !== undefined) into.set(block.id, block);
|
|
3506
|
-
}
|
|
3507
|
-
|
|
3508
|
-
return into;
|
|
3509
|
-
}
|
|
3510
|
-
|
|
3511
|
-
/** The blocks added, removed or changed from one document to the next. A
|
|
3512
|
-
* block moved with its content unchanged did not change. */
|
|
3513
|
-
function changedBlocks(before: Block[], after: Block[]): string[] {
|
|
3514
|
-
const old = ownBlocks(before);
|
|
3515
|
-
const now = ownBlocks(after);
|
|
3516
|
-
|
|
3517
|
-
return [...new Set([...old.keys(), ...now.keys()])].filter(
|
|
3518
|
-
(id) => !isDeepStrictEqual(old.get(id), now.get(id)),
|
|
3519
|
-
);
|
|
3520
|
-
}
|
|
3521
|
-
|
|
3522
|
-
/** Whether a reference's own pins read the commit the board's previous
|
|
3523
|
-
* pins did. */
|
|
3524
|
-
function readsWhereItDid(
|
|
3525
|
-
source: { side: "base" | "head"; pins?: SourcePins },
|
|
3526
|
-
previous: Pins | undefined,
|
|
3527
|
-
): boolean {
|
|
3528
|
-
if (!source.pins || !previous) return false;
|
|
3529
|
-
|
|
3530
|
-
const commit =
|
|
3531
|
-
source.side === "base"
|
|
3532
|
-
? (source.pins.base ?? source.pins.head)
|
|
3533
|
-
: source.pins.head;
|
|
3534
|
-
|
|
3535
|
-
return (
|
|
3536
|
-
source.pins.repositoryId === previous.repositoryId &&
|
|
3537
|
-
(commit === previous.head || commit === previous.base)
|
|
3538
|
-
);
|
|
3539
|
-
}
|
|
3540
|
-
|
|
3541
1875
|
/** Without a resolver, a commits target that names both commits pins them as
|
|
3542
1876
|
* given; validatePins still checks they exist. */
|
|
3543
1877
|
function namedCommits(target: ReviewTarget) {
|
|
@@ -3552,28 +1886,33 @@ function namedCommits(target: ReviewTarget) {
|
|
|
3552
1886
|
* is kept so stored JSON reads as it always has. */
|
|
3553
1887
|
function createdSnapshot(
|
|
3554
1888
|
id: string,
|
|
3555
|
-
op: { title?: string },
|
|
3556
|
-
resolved: { target: ReviewTarget; pins
|
|
1889
|
+
op: { title?: string; kind?: "scratchpad" },
|
|
1890
|
+
resolved: { target: ReviewTarget; pins: Pins } | undefined,
|
|
3557
1891
|
defaultTitle?: string,
|
|
3558
|
-
/** A person's new board may start untitled. */
|
|
3559
|
-
untitled = false,
|
|
3560
1892
|
): Snapshot {
|
|
3561
|
-
const title = op.title
|
|
1893
|
+
const title = op.title ?? defaultTitle;
|
|
3562
1894
|
|
|
3563
|
-
if (title
|
|
1895
|
+
if (!title) throw new ReviewInputError("Supply a title.");
|
|
3564
1896
|
|
|
3565
|
-
|
|
1897
|
+
if (!resolved)
|
|
1898
|
+
return {
|
|
1899
|
+
reviewId: id,
|
|
1900
|
+
version: 0,
|
|
1901
|
+
title,
|
|
1902
|
+
kind: op.kind,
|
|
1903
|
+
document: [],
|
|
1904
|
+
createdAt: "",
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
return {
|
|
3566
1908
|
reviewId: id,
|
|
3567
1909
|
version: 0,
|
|
3568
1910
|
title,
|
|
1911
|
+
pins: resolved.pins,
|
|
3569
1912
|
target: resolved.target,
|
|
3570
1913
|
document: [],
|
|
3571
1914
|
createdAt: "",
|
|
3572
1915
|
};
|
|
3573
|
-
|
|
3574
|
-
if (resolved.pins) snapshot.pins = resolved.pins;
|
|
3575
|
-
|
|
3576
|
-
return snapshot;
|
|
3577
1916
|
}
|
|
3578
1917
|
|
|
3579
1918
|
export function inspectSnapshot(snapshot: Snapshot, targetId?: string) {
|
|
@@ -3601,6 +1940,3 @@ export function inspectSnapshot(snapshot: Snapshot, targetId?: string) {
|
|
|
3601
1940
|
: undefined,
|
|
3602
1941
|
}));
|
|
3603
1942
|
}
|
|
3604
|
-
|
|
3605
|
-
/** How many ancestry questions git answers at once. */
|
|
3606
|
-
const ANCESTRY_CONCURRENCY = 8;
|