@dev.fast/whiteboard 0.3.4 → 0.3.5-preview.20261020.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/{account-alias-CprZojXd.js → account-alias-CMyXCuuq.js} +13 -8
  2. package/dist/{agent-cli-CThN7Q59.js → agent-cli-CahKfDT0.js} +15 -10
  3. package/dist/agent-cli-CvYV2R6c.js +2 -0
  4. package/dist/{agent-client-BOOi7EUR.js → agent-client-D2RmE9NL.js} +13 -3
  5. package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BLhYKrNN.js} +34 -27
  6. package/dist/{background-server-DLSDogkI.js → background-server-CFUU4VtN.js} +1 -1
  7. package/dist/{background-server-CkvxA-6O.js → background-server-k4K_Hvh1.js} +5 -5
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-R4yfa5DT.js → cli-BUVSMGud.js} +1 -1
  10. package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-C7W6VA94.js} +26 -26
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-B0f4EJ7v.js} +253 -47
  13. package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-BbaX-dGh.js} +6 -4
  14. package/dist/{headless-host-Cog6SpS9.js → headless-host-C7xYMe74.js} +513 -140
  15. package/dist/{headless-host-CVBOQyRS.js → headless-host-cYdlRYPB.js} +1 -1
  16. package/dist/lens-selection-CAtsGaym.js +214 -0
  17. package/dist/{local-data-D_9tvdsp.js → local-data-DrNSaiS5.js} +2416 -320
  18. package/dist/{mcp-D1hziANa.js → mcp-yXnjUkOV.js} +6 -10
  19. package/dist/{package-paths-C--__jEv.js → package-paths-BWxO-UEL.js} +1 -1
  20. package/dist/{profile-DokFw782.js → profile-BlqZlCPZ.js} +20 -8
  21. package/dist/profile-CGxNfeYQ.js +2 -0
  22. package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-CZTrtWIk.js} +4 -4
  23. package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CNXwqK0D.js} +3 -3
  24. package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-VP-kfq0C.js} +1 -1
  25. package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-BiPzol99.js} +5 -5
  26. package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-CFTkoioA.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin--8lLCclv.js} +55 -14
  28. package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-KA2_mOeo.js} +2 -2
  29. package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-D8AoCdd2.js} +19 -17
  30. package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-B2JniTIs.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-DtJFWC0G.js → s3-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-BTO12tCC.js → s3-config-CjcLyFV5.js} +4 -2
  36. package/dist/{s3-config-CSy3fx8H.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-Bey8IqXH.js → server-discovery-CRXG48Uz.js} +3 -3
  39. package/dist/sharing/index.d.ts +484 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-CyYCvVHx.js} +90 -17
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B_BijGHF.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-D6MlnGv7.js} +7 -2
  45. package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-_makh_Sr.js} +10 -3
  46. package/instructions/authoring.md +18 -5
  47. package/package.json +6 -6
  48. package/src/ask/agents.ts +13 -1
  49. package/src/ask/checkout-files.ts +2 -5
  50. package/src/ask/thread-state.ts +4 -0
  51. package/src/ask/thread.ts +16 -0
  52. package/src/ask/threads.ts +24 -3
  53. package/src/cli-install.ts +13 -2
  54. package/src/cli-runner.ts +4 -1
  55. package/src/cli.ts +4 -1
  56. package/src/diff-selection-migration.ts +48 -34
  57. package/src/heading-anchors.ts +42 -0
  58. package/src/lens-selection.ts +38 -2
  59. package/src/review-api/README.md +52 -13
  60. package/src/review-api/activity.ts +1 -4
  61. package/src/review-api/agent-cli.ts +9 -1
  62. package/src/review-api/anchor-quotes.ts +14 -7
  63. package/src/review-api/ask-history.ts +137 -51
  64. package/src/review-api/authoring-tools.ts +45 -30
  65. package/src/review-api/blocks/definition.ts +1 -1
  66. package/src/review-api/blocks/markdown.ts +2 -2
  67. package/src/review-api/board-order.ts +144 -0
  68. package/src/review-api/checkout-groups.ts +560 -0
  69. package/src/review-api/document-headings.ts +58 -27
  70. package/src/review-api/document-text.ts +31 -2
  71. package/src/review-api/document.ts +281 -34
  72. package/src/review-api/foreign-references.ts +328 -0
  73. package/src/review-api/github-provider.ts +51 -13
  74. package/src/review-api/http.ts +702 -123
  75. package/src/review-api/instructions.ts +0 -19
  76. package/src/review-api/link-preview.ts +113 -0
  77. package/src/review-api/local-data.ts +310 -46
  78. package/src/review-api/mcp.ts +4 -12
  79. package/src/review-api/onboarding.ts +1 -1
  80. package/src/review-api/profile.ts +37 -6
  81. package/src/review-api/public-tools.ts +24 -2
  82. package/src/review-api/pull-request.ts +82 -49
  83. package/src/review-api/request-origin.ts +10 -0
  84. package/src/review-api/review-progress.ts +61 -27
  85. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  86. package/src/review-api/store-migrations.ts +357 -0
  87. package/src/review-api/store-schema.ts +38 -29
  88. package/src/review-api/store.ts +2144 -480
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +44 -14
  111. package/dist/agent-cli-C6SPSII0.js +0 -2
  112. package/dist/profile-CYPmvBoA.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -1,18 +1,21 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
+ import { isDeepStrictEqual } from "node:util";
5
6
 
6
- import { resolveRepoContextSync } from "@dev.fast/local-vcs";
7
+ import { type JsonValue, isJsonObject } from "@dev.fast/json";
8
+ import { runVcs } from "@dev.fast/local-vcs";
7
9
  import {
10
+ type BoardIconKind,
8
11
  type ReviewApiSummary,
9
- SCRATCHPAD_REVIEW_ID,
12
+ boardIconKind,
13
+ boardTitle,
10
14
  } from "@dev.fast/review-protocol";
11
15
  import { sourceAnchors } from "@review/lens-selection.js";
12
16
  import {
13
- liftFileLenses,
14
- migrateStoredDocument,
15
17
  migrateStoredLenses,
18
+ readStoredDocument,
16
19
  } from "@review/stored-document-migration.js";
17
20
  import {
18
21
  type Coverage,
@@ -24,6 +27,12 @@ import { z } from "zod";
24
27
 
25
28
  import { type ActivitySurface, ReviewActivity } from "./activity.js";
26
29
  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";
27
36
  import {
28
37
  type Lens,
29
38
  applyLensEdit,
@@ -33,49 +42,55 @@ import {
33
42
  import {
34
43
  type Applied,
35
44
  type Block,
45
+ type BoardLinkRef,
36
46
  type EditSummary,
37
47
  type Element,
38
48
  type FileLineRange,
39
49
  type Pins,
40
50
  ReviewInputError,
41
51
  type ReviewTarget,
52
+ type SourcePins,
42
53
  type WrittenComponent,
54
+ adoptFlowUnits,
43
55
  anchorPins,
44
56
  applyEdit,
45
57
  assignFreshIds,
58
+ assignMissingIds,
59
+ boardLinks,
60
+ boardRepositoryId,
46
61
  checkReferences,
62
+ commitLinkCommits,
47
63
  documentAdvice,
48
64
  documentSchema,
49
65
  editSchema,
50
66
  elements,
51
67
  explicitPins,
52
68
  isUnit,
69
+ pinDocumentReferences,
53
70
  pinnedRepositoryIds,
54
71
  resourceReferences,
72
+ respellCommitLinks,
73
+ respellDocumentLinks,
74
+ respellEditLinks,
55
75
  reviewTargetSchema,
56
76
  sourceReferences,
57
77
  summarizeEdit,
58
78
  } from "./document.js";
79
+ import { type BoardLinkPreview, boardLinkPreview } from "./link-preview.js";
59
80
  import { pullRequestKey, pullRequestUrl, setPullRequest } from "./origin.js";
60
- import { initializeReviewStoreSchema } from "./store-schema.js";
81
+ import { upgradeStore } from "./store-migrations.js";
82
+ import {
83
+ BOARD_REPOSITORY_ID_SQL,
84
+ initializeReviewStoreSchema,
85
+ } from "./store-schema.js";
61
86
 
62
87
  const reviewId = z.string().min(1);
63
88
 
64
89
  /** The key older hosts read and write. */
65
90
  const statsKey = (pins: Pins) => JSON.stringify([pins, "structural"]);
66
91
 
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";
92
+ /** How long a board stays in Trash before it is deleted. */
93
+ const TRASH_DAYS = 30;
79
94
 
80
95
  const activityId = z
81
96
  .string()
@@ -85,6 +100,16 @@ const activityId = z
85
100
  "The activityId from review_activity_begin: your courier draws this edit. Never rejects a write.",
86
101
  );
87
102
 
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
+
88
113
  export const commandSchema = z.strictObject({
89
114
  operation: z.discriminatedUnion("type", [
90
115
  z.strictObject({ type: z.literal("delete"), reviewId }),
@@ -100,8 +125,10 @@ export const commandSchema = z.strictObject({
100
125
  }),
101
126
  z.strictObject({
102
127
  type: z.literal("create"),
103
- /** Required unless pullRequestUrl alone names the source; then the PR title. */
104
- title: z.string().trim().min(1).optional(),
128
+ /** Required unless pullRequestUrl alone names the source; then the PR
129
+ * title. A person's new board may start with none; it reads
130
+ * "Untitled" until they name it. */
131
+ title: z.string().trim().optional(),
105
132
  target: reviewTargetSchema.optional(),
106
133
  pullRequestUrl: pullRequestUrl.optional(),
107
134
  /** With pullRequestUrl and no target: the checkout to fetch the PR into. */
@@ -110,7 +137,7 @@ export const commandSchema = z.strictObject({
110
137
  .min(1)
111
138
  .optional()
112
139
  .describe(
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.",
140
+ "Only with pullRequestUrl and no target: the registered checkout to fetch the PR into.",
114
141
  ),
115
142
  /** Return the existing review for pullRequestUrl instead of creating one. */
116
143
  reuseExisting: z
@@ -119,8 +146,18 @@ export const commandSchema = z.strictObject({
119
146
  .describe(
120
147
  "Default true: return the existing review for pullRequestUrl. false creates a separate review.",
121
148
  ),
122
- /** The one scratchpad: no target, no pins; every reference names its own. */
123
- kind: z.literal("scratchpad").optional(),
149
+ parentId: parentId.optional(),
150
+ dependsOn: dependsOn.optional(),
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,
124
161
  }),
125
162
  z.strictObject({
126
163
  type: z.literal("set_target"),
@@ -153,6 +190,50 @@ export const commandSchema = z.strictObject({
153
190
  ]),
154
191
  });
155
192
 
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
+
156
237
  /** Source identity displayed in the review header and Home, alongside immutable pins. */
157
238
  export interface SnapshotOrigin {
158
239
  branch?: string;
@@ -167,6 +248,68 @@ export interface SnapshotOrigin {
167
248
  revision?: string;
168
249
  }
169
250
 
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
+
170
313
  export interface Snapshot {
171
314
  /** Current lifecycle state, projected onto every version when read. */
172
315
  trashedAt?: string | null;
@@ -174,8 +317,6 @@ export interface Snapshot {
174
317
  reviewId: string;
175
318
  version: number;
176
319
  title: string;
177
- /** Absent for a review. The scratchpad has no pins, target or lifecycle. */
178
- kind?: "scratchpad";
179
320
  /** The default pins for references that name none. A document whose
180
321
  * references all carry their own pins has neither pins nor target. */
181
322
  pins?: Pins;
@@ -191,8 +332,27 @@ export interface Snapshot {
191
332
  /** The edit that produced this version, when one did; absent for a
192
333
  * rename, set_target, restore or import, which the canvas does not draw. */
193
334
  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> };
194
341
  }
195
342
 
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
+
196
356
  export interface Result {
197
357
  /** Create only: false when an existing review for the same PR came back. */
198
358
  created?: boolean;
@@ -216,9 +376,27 @@ export interface Result {
216
376
  attention?: true;
217
377
  lifecycle?: true;
218
378
  deleted?: true;
379
+ /** After set_parent or set_dependencies: the board's relations. */
380
+ relations?: BoardRelations;
219
381
  warnings?: string[];
220
382
  }
221
383
 
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
+
222
400
  /** A PR's current comparison: GitHub's head and diff base, fetched locally. */
223
401
  export interface ResolvedPullRequest {
224
402
  target: ReviewTarget;
@@ -232,11 +410,10 @@ export interface ResolvedPullRequest {
232
410
  export interface ReviewProviders {
233
411
  headBranch?(pins: Pins, headRef?: string): Promise<string | undefined>;
234
412
  projectSource?(snapshot: Snapshot, pins: Pins): Promise<Snapshot>;
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. */
413
+ /** Fetch a PR into the registered checkout the caller names. */
237
414
  resolvePullRequest?(
238
415
  url: string,
239
- repository: { id?: string; preferred?: string },
416
+ repository: { id: string },
240
417
  ): Promise<ResolvedPullRequest>;
241
418
  /** Undefined when the forge can't be read. */
242
419
  pullRequestTitle?(url: string): Promise<string | undefined>;
@@ -250,6 +427,11 @@ export interface ReviewProviders {
250
427
  /** Ids of references whose own pins no longer name a usable checkout. */
251
428
  unavailableAnchors?(snapshot: Snapshot): Promise<string[]>;
252
429
  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>;
253
435
  /** The files each side's commit changed from one set of pins to another,
254
436
  * or undefined when they can't be diffed commit to commit. */
255
437
  filesChangedBetween?(
@@ -262,6 +444,8 @@ export interface ReviewProviders {
262
444
  options: { peek: boolean },
263
445
  ): Promise<void>;
264
446
  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;
265
449
  }
266
450
 
267
451
  /** One instance owned by the desktop server. All writers go through execute().
@@ -495,15 +679,6 @@ export class ReviewStore {
495
679
  this.catalogListeners.delete(listener);
496
680
  };
497
681
  }
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
- }
507
682
  subscribe(listener: (result: Result) => void) {
508
683
  this.listeners.add(listener);
509
684
 
@@ -514,14 +689,42 @@ export class ReviewStore {
514
689
  constructor(
515
690
  databasePath: string,
516
691
  private readonly providers: ReviewProviders,
692
+ /** How the checkouts group, if `groupCheckoutsAhead` worked it out. */
693
+ private readonly checkoutOwners?: ReadonlyMap<string, string>,
517
694
  ) {
518
695
  // WAL plus a busy timeout: another host on the same home waits instead of failing.
519
696
  this.db = new DatabaseSync(databasePath, { timeout: 5000 });
520
697
  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
+ });
521
722
  this.activity = new ReviewActivity(this.db, (id) =>
522
723
  this.assertWritable(id),
523
724
  );
524
- this.askHistory = new AskHistory(this.db);
725
+ this.askHistory = new AskHistory(this.db, (id) =>
726
+ this.assertOutOfTrash(id),
727
+ );
525
728
 
526
729
  this.purgeExpired();
527
730
  this.purgeTimer = setInterval(() => this.purgeExpired(), 60_000);
@@ -537,6 +740,136 @@ export class ReviewStore {
537
740
  this.externalChanges.unref();
538
741
  }
539
742
 
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
+
540
873
  private dataVersion() {
541
874
  return Number(this.db.prepare("PRAGMA data_version").get()!.data_version);
542
875
  }
@@ -658,34 +991,19 @@ export class ReviewStore {
658
991
  throw error;
659
992
  }
660
993
  }
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
- };
685
-
686
- this.repositoryGroups.set(root, group);
994
+ private checkoutGroups: CheckoutGroups | undefined;
995
+
996
+ /** How the store's checkouts group into projects, kept for the open. */
997
+ private get checkouts() {
998
+ return (this.checkoutGroups ??= new CheckoutGroups(
999
+ this.db,
1000
+ undefined,
1001
+ this.checkoutOwners,
1002
+ ));
1003
+ }
687
1004
 
688
- return group;
1005
+ private repositoryGroup(root: string) {
1006
+ return this.checkouts.group(root);
689
1007
  }
690
1008
 
691
1009
  registerRepository(root: string) {
@@ -697,16 +1015,66 @@ export class ReviewStore {
697
1015
  .prepare("SELECT id,name FROM repositories WHERE path=?")
698
1016
  .get(root)!;
699
1017
 
1018
+ this.projectOfRepository(String(row.id));
1019
+
700
1020
  return { id: String(row.id), name: String(row.name) };
701
1021
  }
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);
1022
+ /** Forgets a checkout no board uses, after every write queued before it,
1023
+ * so a board being made with it keeps it. */
1024
+ unregisterRepository(id: string): Promise<void> {
1025
+ const run = this.pending.then(async () => {
1026
+ this.db.exec("BEGIN IMMEDIATE");
1027
+
1028
+ try {
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);
709
1076
  }
1077
+
710
1078
  /** Registered checkouts, oldest registration first. */
711
1079
  repositories() {
712
1080
  return this.db
@@ -808,8 +1176,14 @@ export class ReviewStore {
808
1176
  404,
809
1177
  );
810
1178
  }
1179
+ /** A board that exists and isn't in Trash: the one rule every write
1180
+ * about a board keeps. */
811
1181
  assertWritable(id: string) {
812
1182
  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) {
813
1187
  const trashedAt = this.trashTime(id);
814
1188
 
815
1189
  if (trashedAt)
@@ -825,45 +1199,57 @@ export class ReviewStore {
825
1199
 
826
1200
  return row?.deleted_at ? String(row.deleted_at) : null;
827
1201
  }
1202
+ /** Removes a board for good, from Delete… or Trash's purge. Links into
1203
+ * it stay, and show it as deleted. */
828
1204
  private removePermanently(id: string) {
1205
+ const { title } = this.read(id);
1206
+
829
1207
  for (const table of [
830
- "ask_conversations",
831
1208
  "authoring_presences",
832
1209
  "review_coverage",
833
1210
  "review_attention",
834
1211
  "versions",
835
1212
  ])
836
1213
  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);
837
1223
  this.db.prepare("DELETE FROM reviews WHERE id=?").run(id);
838
1224
  this.liveSources.delete(id);
839
1225
  }
1226
+ /** Deletes the boards 30 days in Trash, each as Delete… would. */
840
1227
  private purgeExpired() {
841
- const cutoff = new Date(Date.now() - 30 * 86400000).toISOString();
842
- const expired: Result[] = [];
843
- this.db.exec("BEGIN IMMEDIATE");
1228
+ const cutoff = new Date(Date.now() - TRASH_DAYS * 86400000).toISOString();
844
1229
 
845
- try {
846
- for (const row of this.db
1230
+ const expired = () =>
1231
+ this.db
847
1232
  .prepare(
848
- "SELECT review_id,version FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?",
1233
+ "SELECT review_id FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?",
849
1234
  )
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
- }
861
-
862
- for (const result of expired) {
863
- this.activity.deleted(result.reviewId);
864
- this.notify(result);
865
- }
1235
+ .all(cutoff)
1236
+ .map((row) => String(row.review_id));
1237
+
1238
+ for (const reviewId of expired())
1239
+ // Restored meanwhile, by another host on this home: it stays.
1240
+ // Trash's own deadline, which no hold or thread stops.
1241
+ this.deleteBoard(
1242
+ this.read(reviewId),
1243
+ { kind: "person" },
1244
+ {
1245
+ guard: () => {
1246
+ if (!expired().includes(reviewId))
1247
+ throw new ReviewInputError("Restored from Trash.", 409);
1248
+ },
1249
+ },
1250
+ );
866
1251
  }
1252
+
867
1253
  read(id: string, version?: number): Snapshot {
868
1254
  const row =
869
1255
  version === undefined
@@ -890,9 +1276,7 @@ export class ReviewStore {
890
1276
  // retired attachment representations with their canonical equivalent and
891
1277
  // drops retired fields.
892
1278
  // Lenses saved as document blocks read as the snapshot's own.
893
- const { document, lenses } = liftFileLenses(
894
- migrateStoredDocument(snapshot.document),
895
- );
1279
+ const { document, lenses } = readStoredDocument(snapshot.document);
896
1280
 
897
1281
  // SAFETY: stored blocks were validated on write; migration only replaces
898
1282
  // retired representations and lifts retired lens blocks out.
@@ -938,14 +1322,24 @@ export class ReviewStore {
938
1322
  }
939
1323
  }
940
1324
 
941
- /** Reviews of the tutorial earlier versions shipped, for their removal. */
942
- legacyTutorialIds(): string[] {
943
- return this.db
1325
+ /** Deletes the tutorial earlier versions shipped. Like any board, each
1326
+ * goes to Trash first, then is deleted from there. */
1327
+ async removeLegacyTutorials(): Promise<void> {
1328
+ const tutorials = this.db
944
1329
  .prepare(
945
1330
  `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`,
946
1331
  )
947
1332
  .all()
948
1333
  .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
+ }
949
1343
  }
950
1344
 
951
1345
  list(): ReviewApiSummary[] {
@@ -963,11 +1357,13 @@ export class ReviewStore {
963
1357
  .prepare(
964
1358
  `SELECT json_remove(versions.snapshot,'$.document') AS summary,
965
1359
  (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,
966
- review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
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
967
1362
  FROM reviews
1363
+ JOIN projects ON projects.id=reviews.project_id
968
1364
  JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
969
1365
  LEFT JOIN review_attention ON review_attention.review_id=reviews.id
970
- LEFT JOIN repositories ON repositories.id=json_extract(versions.snapshot,'$.pins.repositoryId')
1366
+ LEFT JOIN repositories ON repositories.id=${BOARD_REPOSITORY_ID_SQL}
971
1367
  ${id === undefined ? "" : "WHERE reviews.id=?"}
972
1368
  ORDER BY reviews.rowid`,
973
1369
  )
@@ -1003,23 +1399,37 @@ export class ReviewStore {
1003
1399
  repositoryPath: row.repository_path
1004
1400
  ? String(row.repository_path)
1005
1401
  : undefined,
1006
- repositoryGroup: row.repository_path
1007
- ? this.repositoryGroup(String(row.repository_path))
1008
- : undefined,
1402
+ project: {
1403
+ id: String(row.project_id),
1404
+ name: String(row.project_name),
1405
+ },
1009
1406
  repositoryName: row.repository_name
1010
1407
  ? String(row.repository_name)
1011
- : (summary.pins?.repositoryId ?? ""),
1408
+ : "(unknown repository)",
1012
1409
  viewedAt: row.viewed_at ? String(row.viewed_at) : null,
1013
1410
  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),
1014
1413
  working: this.activity.isWorking(summary.reviewId),
1015
1414
  };
1016
1415
 
1017
- if (summary.kind === "scratchpad")
1018
- listed.contents = this.scratchpadContents();
1019
-
1020
1416
  return listed;
1021
1417
  });
1022
1418
 
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
+
1023
1433
  return this.withDiffStats(reviews);
1024
1434
  }
1025
1435
 
@@ -1046,34 +1456,6 @@ export class ReviewStore {
1046
1456
  }));
1047
1457
  }
1048
1458
 
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
- }
1077
1459
  /** Two saved board/day pairs in the last fourteen days. */
1078
1460
  feedbackEligible(now = Date.now()): boolean {
1079
1461
  const row = this.db
@@ -1094,17 +1476,33 @@ export class ReviewStore {
1094
1476
  return Number(row?.pairs) >= 2;
1095
1477
  }
1096
1478
 
1097
- history(id: string) {
1479
+ history(id: string): HistoryEntry[] {
1098
1480
  return this.db
1099
1481
  .prepare(
1100
- "SELECT version,json_extract(snapshot,'$.title') AS title,json_extract(snapshot,'$.createdAt') AS created_at FROM versions WHERE review_id=? ORDER BY version",
1482
+ `SELECT version,
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`,
1101
1488
  )
1102
1489
  .all(id)
1103
- .map((row) => ({
1104
- version: Number(row.version),
1105
- title: String(row.title),
1106
- createdAt: String(row.created_at),
1107
- }));
1490
+ .map((row) => {
1491
+ const entry: HistoryEntry = {
1492
+ version: Number(row.version),
1493
+ // A version that won't parse still has its place in history.
1494
+ title: String(row.title ?? ""),
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
+ });
1108
1506
  }
1109
1507
  inspect(id: string, targetId?: string, version?: number) {
1110
1508
  const snapshot = this.read(id, version);
@@ -1116,6 +1514,7 @@ export class ReviewStore {
1116
1514
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Command boundary: commandSchema.parse below rejects malformed input before mutation.
1117
1515
  input: unknown,
1118
1516
  initial?: { document: Block[]; origin: SnapshotOrigin },
1517
+ author: Author = { kind: "agent" },
1119
1518
  ): Promise<Result> {
1120
1519
  if (this.closing)
1121
1520
  return Promise.reject(new Error("Review store is closing."));
@@ -1136,37 +1535,11 @@ export class ReviewStore {
1136
1535
  )
1137
1536
  : this.startPullRequestTitle(command);
1138
1537
 
1139
- const run = this.pending.then(async () => {
1538
+ return this.enqueue(async () => {
1140
1539
  const op = command.operation;
1141
1540
  this.purgeExpired();
1142
1541
 
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") {
1542
+ if (op.type === "create") {
1170
1543
  if (!op.target && !op.pullRequestUrl)
1171
1544
  throw new ReviewInputError("Supply a target or a pullRequestUrl.");
1172
1545
 
@@ -1175,7 +1548,7 @@ export class ReviewStore {
1175
1548
  "A checkout for the PR applies only to a create from pullRequestUrl alone; name it in the target instead.",
1176
1549
  );
1177
1550
 
1178
- if (!op.title && op.target)
1551
+ if (!op.title && op.target && author.kind !== "person")
1179
1552
  throw new ReviewInputError("Supply a title.");
1180
1553
  }
1181
1554
 
@@ -1186,10 +1559,15 @@ export class ReviewStore {
1186
1559
 
1187
1560
  const fromPullRequest = await pullRequest;
1188
1561
 
1189
- const resolvedTarget = requestedTarget
1190
- ? await (this.providers.resolveTarget?.(requestedTarget) ??
1191
- namedCommits(requestedTarget))
1192
- : fromPullRequest;
1562
+ // No commits to resolve; repositoryPath throws if it is unregistered.
1563
+ const resolvedTarget: { target: ReviewTarget; pins?: Pins } | undefined =
1564
+ requestedTarget?.kind === "project"
1565
+ ? (this.repositoryPath(requestedTarget.repositoryId),
1566
+ { target: requestedTarget })
1567
+ : requestedTarget
1568
+ ? await (this.providers.resolveTarget?.(requestedTarget) ??
1569
+ namedCommits(requestedTarget))
1570
+ : fromPullRequest;
1193
1571
 
1194
1572
  if (requestedTarget && !resolvedTarget)
1195
1573
  throw new ReviewInputError("Review targets are unavailable.");
@@ -1207,339 +1585,1424 @@ export class ReviewStore {
1207
1585
  const result = this.existingReview(
1208
1586
  found,
1209
1587
  others,
1210
- resolvedTarget!.pins,
1588
+ // A PR always resolves to commits, so pins are set.
1589
+ resolvedTarget!.pins!,
1211
1590
  );
1212
1591
 
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
+
1213
1595
  return result;
1214
1596
  }
1215
1597
  }
1216
1598
 
1217
- if (op.type === "delete") {
1218
- this.assertExists(op.reviewId);
1599
+ // What a board is outside its versions.
1600
+ if (op.type === "set_parent")
1601
+ return this.commitBoardFacts(op.reviewId, author, {
1602
+ parentId: op.parentId,
1603
+ });
1219
1604
 
1220
- if (!this.trashTime(op.reviewId))
1221
- throw new ReviewInputError(
1222
- "Move this whiteboard to Trash before deleting it.",
1223
- 409,
1224
- );
1605
+ if (op.type === "set_dependencies")
1606
+ return this.commitBoardFacts(op.reviewId, author, {
1607
+ dependsOn: op.dependsOn,
1608
+ });
1225
1609
 
1226
- const result: Result = {
1227
- reviewId: op.reviewId,
1228
- version: this.read(op.reviewId).version,
1229
- deleted: true,
1230
- };
1610
+ if (op.type === "lifecycle")
1611
+ return this.commitBoardFacts(op.reviewId, author, { state: op.state });
1231
1612
 
1232
- this.commitCommand(
1233
- result,
1234
- () => this.removePermanently(op.reviewId),
1235
- () => this.assertMutation(op.reviewId, result.version),
1236
- );
1613
+ if (op.type === "attention")
1614
+ return this.commitBoardFacts(op.reviewId, author, { viewed: true });
1237
1615
 
1238
- return result;
1239
- }
1616
+ if (op.type === "delete")
1617
+ return this.deleteBoard(this.read(op.reviewId), author, {
1618
+ fromTrash: true,
1619
+ });
1240
1620
 
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
- };
1621
+ const id = op.type === "create" ? randomUUID() : op.reviewId;
1250
1622
 
1251
- this.commitCommand(result, () => {
1252
- this.db
1253
- .prepare(
1254
- "INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)",
1623
+ const previous = op.type === "create" ? undefined : this.read(id);
1624
+
1625
+ let snapshot: Snapshot =
1626
+ op.type === "create"
1627
+ ? createdSnapshot(
1628
+ id,
1629
+ op,
1630
+ resolvedTarget!,
1631
+ fromPullRequest &&
1632
+ (fromPullRequest.title ||
1633
+ `PR #${op.pullRequestUrl!.split("/").at(-1)}`),
1634
+ author.kind === "person",
1255
1635
  )
1256
- .run(op.reviewId);
1636
+ : structuredClone(previous!);
1257
1637
 
1258
- if (op.type === "attention")
1259
- this.db
1260
- .prepare(
1638
+ delete snapshot.trashedAt;
1639
+
1640
+ // Overlay state: a degraded read must not persist unavailability.
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)
2194
+ this.db
2195
+ .prepare(
1261
2196
  "UPDATE review_attention SET viewed_at=? WHERE review_id=?",
1262
2197
  )
1263
- .run(new Date().toISOString(), op.reviewId);
2198
+ .run(new Date().toISOString(), reviewId);
1264
2199
  else {
2200
+ // A second move to Trash keeps the first deadline.
1265
2201
  const timestamp =
1266
- op.state === "trash" ? new Date().toISOString() : null;
2202
+ fact.state === "trash" ? new Date().toISOString() : null;
1267
2203
 
1268
2204
  this.db
1269
2205
  .prepare(
1270
2206
  "UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?",
1271
2207
  )
1272
- .run(timestamp, timestamp, op.reviewId);
2208
+ .run(timestamp, timestamp, reviewId);
1273
2209
  }
1274
- });
2210
+ }
1275
2211
 
1276
- return result;
1277
- }
2212
+ if ("parentId" in fact || "dependsOn" in fact)
2213
+ result.relations = this.relations(reviewId);
2214
+ },
2215
+ rules,
2216
+ );
1278
2217
 
1279
- const id =
1280
- op.type !== "create"
1281
- ? op.reviewId
1282
- : op.kind === "scratchpad"
1283
- ? SCRATCHPAD_ID
1284
- : randomUUID();
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;
1285
2227
 
1286
- const previous = op.type === "create" ? undefined : this.read(id);
2228
+ while (this.leased(reviewId) && Date.now() < deadline)
2229
+ await new Promise((resolve) => setTimeout(resolve, DISCARD_POLL_MS));
1287
2230
 
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!);
2231
+ return this.enqueue(async () => {
2232
+ if (!this.has(reviewId)) return { deleted: false };
2233
+ const snapshot = this.read(reviewId);
1299
2234
 
1300
- delete snapshot.trashedAt;
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();
2306
+
2307
+ for (const [clientId, lease] of leases)
2308
+ if (lease.expiresAt <= now) leases.delete(clientId);
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);
2320
+
2321
+ return this.enqueue(async () => {
2322
+ const previous = this.read(reviewId);
2323
+
2324
+ assertBaseVersion(body.baseVersion, previous.version);
2325
+
2326
+ const { document, nextId, ids } = personDocument(
2327
+ body.document,
2328
+ Number(
2329
+ this.db
2330
+ .prepare("SELECT next_id FROM reviews WHERE id=?")
2331
+ .get(reviewId)!.next_id,
2332
+ ),
2333
+ );
2334
+
2335
+ const snapshot = structuredClone(previous);
1301
2336
 
1302
- // Overlay state: a degraded read must not persist unavailability.
1303
2337
  delete snapshot.sourceUnavailable;
1304
- // Each version describes only its own edit.
1305
2338
  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
+ });
1306
2354
 
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;
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
+
2454
+ if (trashed.has(board) || trashed.has(dependency)) continue;
2455
+
2456
+ dependsOn.set(board, [...(dependsOn.get(board) ?? []), dependency]);
2457
+ }
2458
+
2459
+ return { parents, children, dependsOn };
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);
1313
2744
 
1314
- let applied: Applied | undefined;
1315
- let lensTarget: { targetId: string; type: "lens" } | undefined;
2745
+ const insert = this.db.prepare(
2746
+ "INSERT INTO board_links(from_review,from_block,to_review,to_block) VALUES(?,?,?,?)",
2747
+ );
1316
2748
 
1317
- if (
1318
- (op.type === "create" || op.type === "set_target") &&
1319
- this.providers.headBranch
1320
- ) {
1321
- const pins = resolvedTarget?.pins ?? snapshot.pins;
2749
+ for (const { from, to } of boardLinks(id, document))
2750
+ insert.run(from.board, from.block, to.board, to.block ?? null);
2751
+ }
2752
+ /** Link previews by board, version and block; a version never changes. */
2753
+ private readonly linkPreviews = new Map<string, BoardLinkPreview>();
1322
2754
 
1323
- if (pins) {
1324
- const headRef =
1325
- requestedTarget?.kind === "commits"
1326
- ? requestedTarget.head
1327
- : undefined;
2755
+ private linkPreview(target: Snapshot, block?: string): BoardLinkPreview {
2756
+ const key = `${target.reviewId}@${target.version}#${block ?? ""}`;
2757
+ let preview = this.linkPreviews.get(key);
1328
2758
 
1329
- const branch = await this.providers.headBranch(pins, headRef);
2759
+ if (!preview) {
2760
+ preview = boardLinkPreview(target.document, block);
1330
2761
 
1331
- snapshot.origin = { ...snapshot.origin, branch };
1332
- }
1333
- }
2762
+ if (this.linkPreviews.size >= 500)
2763
+ this.linkPreviews.delete(this.linkPreviews.keys().next().value!);
1334
2764
 
1335
- if (!requestedTarget && fromPullRequest)
1336
- snapshot.origin = {
1337
- ...snapshot.origin,
1338
- branch: fromPullRequest.branch ?? snapshot.origin?.branch,
1339
- baseBranch: fromPullRequest.baseBranch,
1340
- };
2765
+ this.linkPreviews.set(key, preview);
2766
+ }
1341
2767
 
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
- };
2768
+ return preview;
2769
+ }
1352
2770
 
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
- };
2771
+ /** What a link's hover card shows of the board or block it links to,
2772
+ * read when the card opens rather than with every link. Only for a link
2773
+ * the version has. */
2774
+ linkPeek(
2775
+ id: string,
2776
+ to: { board: string; block?: string },
2777
+ version?: number,
2778
+ ): BoardLinkPreview {
2779
+ const linked = boardLinks(id, this.read(id, version).document).some(
2780
+ (link) => link.to.board === to.board && link.to.block === to.block,
2781
+ );
1361
2782
 
1362
- for (const block of snapshot.document)
1363
- assignFreshIds(block, (prefix) => `${prefix}-${++nextId}`);
1364
- }
2783
+ if (!linked || !this.has(to.board))
2784
+ throw new ReviewInputError("The board has no such link.", 404);
1365
2785
 
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
- );
2786
+ return this.linkPreview(this.read(to.board), to.block);
2787
+ }
1395
2788
 
1396
- const lenses = snapshot.lenses ?? [];
1397
- const lens = applyLensEdit(lenses, op.edit, () => `lens-${++nextId}`);
2789
+ /** "owner / repo" for a board in a GitHub repository, else its folder. */
2790
+ private repositoryLabel(board: Snapshot): string | undefined {
2791
+ const repositoryId = board.target?.repositoryId;
1398
2792
 
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
- }
2793
+ if (!repositoryId) return undefined;
1415
2794
 
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
- );
2795
+ try {
2796
+ return this.repositoryGroup(this.repositoryPath(repositoryId))
2797
+ ?.label.split("/")
2798
+ .join(" / ");
2799
+ } catch {
2800
+ return undefined;
2801
+ }
2802
+ }
1424
2803
 
1425
- snapshot.lastEdit = summarizeEdit(
1426
- op.edit,
1427
- applied,
1428
- previous!.document,
1429
- snapshot.document,
1430
- );
2804
+ /** A version's outgoing links and the board's current backlinks. Links to a
2805
+ * deleted board or block are marked missing. */
2806
+ links(id: string, version?: number) {
2807
+ const titles = new Map<string, Snapshot | undefined>();
1431
2808
 
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
- );
2809
+ const board = (reviewId: string) => {
2810
+ if (!titles.has(reviewId))
2811
+ titles.set(
2812
+ reviewId,
2813
+ this.has(reviewId) ? this.read(reviewId) : undefined,
2814
+ );
1439
2815
 
1440
- const newSources = new Map(
1441
- sourceReferences(snapshot.document).map((item) => [
1442
- item.id,
1443
- JSON.stringify(item.source),
1444
- ]),
1445
- );
2816
+ return titles.get(reviewId);
2817
+ };
1446
2818
 
1447
- snapshot.staleSources = snapshot.staleSources.filter(
1448
- (id) =>
1449
- oldSources.get(id) === newSources.get(id) && newSources.has(id),
1450
- );
1451
- }
2819
+ const outgoing = boardLinks(id, this.read(id, version).document).map(
2820
+ ({ from, to }) => {
2821
+ const target = board(to.board);
2822
+
2823
+ const missing =
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
+ );
1452
2859
 
1453
- break;
1454
- }
1455
- }
2860
+ const backlinks = this.db
2861
+ .prepare(
2862
+ "SELECT DISTINCT from_review,from_block,to_block FROM board_links WHERE to_review=? AND from_review<>? ORDER BY from_review",
2863
+ )
2864
+ .all(id, id)
2865
+ .map((row) => {
2866
+ const from = board(String(row.from_review));
1456
2867
 
1457
- if (
1458
- snapshot.target?.kind === "worktree" &&
1459
- op.type !== "restore" &&
1460
- !resolvedTarget
1461
- ) {
1462
- snapshot = await this.projectLiveSource(snapshot);
1463
- }
2868
+ return {
2869
+ from: {
2870
+ board: String(row.from_review),
2871
+ block: String(row.from_block),
2872
+ },
2873
+ to:
2874
+ row.to_block === null
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
+ });
1464
2885
 
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);
2886
+ return { outgoing, backlinks };
2887
+ }
2888
+ /** Boards edited by an Ask thread, most recent first. */
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);
1468
2937
 
1469
- if (
1470
- snapshot.pins &&
1471
- (!previous ||
1472
- JSON.stringify(previous.pins) !== JSON.stringify(snapshot.pins))
2938
+ return (
2939
+ (row?.path && path.basename(String(row.path))) ||
2940
+ (row?.name ? String(row.name) : "")
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=?`,
1473
2949
  )
1474
- await this.providers.validatePins(snapshot.pins);
2950
+ .get(reviewId)!;
1475
2951
 
1476
- // Advice is about what this command wrote; earlier advice stays said.
1477
- const advised = new Set(documentAdvice(previous?.document ?? []));
2952
+ return {
2953
+ reviewId,
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
+ >();
1478
2970
 
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
- ];
2971
+ if (!threadIds.length) return changed;
1489
2972
 
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
- };
2973
+ const rows = this.db
2974
+ .prepare(
2975
+ // The thread_id index finds the rows; rowid orders them by when
2976
+ // they were saved without parsing them.
2977
+ `SELECT thread_id AS thread, review_id
2978
+ FROM versions
2979
+ WHERE thread_id IN (${threadIds.map(() => "?").join(",")})
2980
+ GROUP BY thread, review_id ORDER BY MAX(rowid) DESC`,
2981
+ )
2982
+ .all(...threadIds);
1502
2983
 
1503
- if (warnings.length) result.warnings = warnings;
2984
+ const boards = new Map<
2985
+ string,
2986
+ { reviewId: string; title: string; icon: BoardIconKind } | undefined
2987
+ >();
1504
2988
 
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
- );
2989
+ for (const row of rows) {
2990
+ const reviewId = String(row.review_id);
1536
2991
 
1537
- return result;
1538
- });
2992
+ if (!boards.has(reviewId))
2993
+ boards.set(
2994
+ reviewId,
2995
+ this.has(reviewId) ? this.boardFace(reviewId) : undefined,
2996
+ );
2997
+ const board = boards.get(reviewId);
1539
2998
 
1540
- this.pending = run.catch(() => {});
2999
+ if (!board) continue;
3000
+ const thread = String(row.thread);
1541
3001
 
1542
- return run;
3002
+ changed.set(thread, [...(changed.get(thread) ?? []), board]);
3003
+ }
3004
+
3005
+ return changed;
1543
3006
  }
1544
3007
  /** Resolve a target-less PR create before it queues, when it will need it. */
1545
3008
  private startPullRequest(
@@ -1547,7 +3010,7 @@ export class ReviewStore {
1547
3010
  ): Promise<ResolvedPullRequest> | undefined {
1548
3011
  const op = command.operation;
1549
3012
 
1550
- if (op.type !== "create" || op.kind || op.target || !op.pullRequestUrl)
3013
+ if (op.type !== "create" || op.target || !op.pullRequestUrl)
1551
3014
  return undefined;
1552
3015
 
1553
3016
  if (!this.providers.resolvePullRequest)
@@ -1555,7 +3018,14 @@ export class ReviewStore {
1555
3018
  new ReviewInputError("Pull request targets are unavailable."),
1556
3019
  );
1557
3020
 
1558
- // Keep an existing review's checkout so headMoved compares like with like.
3021
+ if (!op.repositoryId)
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.
1559
3029
  const [existing] =
1560
3030
  op.reuseExisting === false
1561
3031
  ? []
@@ -1571,7 +3041,6 @@ export class ReviewStore {
1571
3041
 
1572
3042
  return this.providers.resolvePullRequest(op.pullRequestUrl, {
1573
3043
  id: op.repositoryId,
1574
- preferred: existing && this.read(existing).pins?.repositoryId,
1575
3044
  });
1576
3045
  }
1577
3046
  private startPullRequestTitle(
@@ -1620,9 +3089,8 @@ export class ReviewStore {
1620
3089
 
1621
3090
  this.assertReusableReview(reviewId);
1622
3091
 
1623
- const headMoved =
1624
- snapshot.pins?.repositoryId !== requested.repositoryId ||
1625
- snapshot.pins?.head !== requested.head;
3092
+ // Worktrees share commits, so comparing heads is enough.
3093
+ const headMoved = snapshot.pins?.head !== requested.head;
1626
3094
 
1627
3095
  const working = this.activity.read(reviewId).activities ?? [];
1628
3096
 
@@ -1709,7 +3177,7 @@ export class ReviewStore {
1709
3177
  else if (!result.attention)
1710
3178
  this.observedVersions.set(result.reviewId, result.version);
1711
3179
 
1712
- if (!result.attention || result.lifecycle)
3180
+ if ((!result.attention || result.lifecycle) && !result.relations)
1713
3181
  for (const listener of this.listeners)
1714
3182
  try {
1715
3183
  listener(result);
@@ -1739,6 +3207,42 @@ export class ReviewStore {
1739
3207
  undefined
1740
3208
  );
1741
3209
  }
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
+ }
1742
3246
  private async validateExternal(
1743
3247
  snapshot: Snapshot,
1744
3248
  previous?: Snapshot,
@@ -1747,10 +3251,11 @@ export class ReviewStore {
1747
3251
  const warnings: string[] = [];
1748
3252
 
1749
3253
  const references = (
1750
- document: Block[],
3254
+ board: Pick<Snapshot, "document" | "target" | "pins" | "lenses">,
1751
3255
  tolerant = false,
1752
- lenses: readonly Lens[] = [],
1753
3256
  ) => {
3257
+ const { document, lenses = [] } = board;
3258
+
1754
3259
  const sources = new Map<
1755
3260
  string,
1756
3261
  { source: FileLineRange; peek: boolean }
@@ -1764,7 +3269,10 @@ export class ReviewStore {
1764
3269
  sources.set(key, { source, peek: peek || (kept?.peek ?? false) });
1765
3270
  };
1766
3271
 
1767
- for (const { source, peek } of sourceReferences(document, { tolerant }))
3272
+ for (const { source, peek } of sourceReferences(document, {
3273
+ tolerant,
3274
+ repositoryId: boardRepositoryId(board),
3275
+ }))
1768
3276
  add(source, peek === true);
1769
3277
 
1770
3278
  // A lens range is a prose-like link: it must exist, not read as a peek.
@@ -1777,7 +3285,7 @@ export class ReviewStore {
1777
3285
  return { sources, resources };
1778
3286
  };
1779
3287
 
1780
- const current = references(snapshot.document, repin, snapshot.lenses);
3288
+ const current = references(snapshot, repin);
1781
3289
 
1782
3290
  // Stored content is not re-validated: an edit may fix a link that the
1783
3291
  // current rules reject.
@@ -1794,11 +3302,7 @@ export class ReviewStore {
1794
3302
  .catch(() => undefined)
1795
3303
  : undefined;
1796
3304
 
1797
- const retained = references(
1798
- previous?.document ?? [],
1799
- true,
1800
- previous?.lenses,
1801
- );
3305
+ const retained = references(previous ?? { document: [] }, true);
1802
3306
 
1803
3307
  // Independent reads of immutable commits: run them concurrently.
1804
3308
  const checks: Promise<void>[] = [];
@@ -1831,6 +3335,7 @@ export class ReviewStore {
1831
3335
  () => {
1832
3336
  if (
1833
3337
  repin &&
3338
+ !readsWhereItDid(source, previous?.pins) &&
1834
3339
  (!changed || changed[source.side].has(source.file))
1835
3340
  )
1836
3341
  warnings.push(
@@ -1872,6 +3377,167 @@ export class ReviewStore {
1872
3377
  }
1873
3378
  }
1874
3379
 
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
+
1875
3541
  /** Without a resolver, a commits target that names both commits pins them as
1876
3542
  * given; validatePins still checks they exist. */
1877
3543
  function namedCommits(target: ReviewTarget) {
@@ -1886,33 +3552,28 @@ function namedCommits(target: ReviewTarget) {
1886
3552
  * is kept so stored JSON reads as it always has. */
1887
3553
  function createdSnapshot(
1888
3554
  id: string,
1889
- op: { title?: string; kind?: "scratchpad" },
1890
- resolved: { target: ReviewTarget; pins: Pins } | undefined,
3555
+ op: { title?: string },
3556
+ resolved: { target: ReviewTarget; pins?: Pins },
1891
3557
  defaultTitle?: string,
3558
+ /** A person's new board may start untitled. */
3559
+ untitled = false,
1892
3560
  ): Snapshot {
1893
- const title = op.title ?? defaultTitle;
3561
+ const title = op.title || defaultTitle || (untitled ? "" : undefined);
1894
3562
 
1895
- if (!title) throw new ReviewInputError("Supply a title.");
3563
+ if (title === undefined) throw new ReviewInputError("Supply a title.");
1896
3564
 
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 {
3565
+ const snapshot: Snapshot = {
1908
3566
  reviewId: id,
1909
3567
  version: 0,
1910
3568
  title,
1911
- pins: resolved.pins,
1912
3569
  target: resolved.target,
1913
3570
  document: [],
1914
3571
  createdAt: "",
1915
3572
  };
3573
+
3574
+ if (resolved.pins) snapshot.pins = resolved.pins;
3575
+
3576
+ return snapshot;
1916
3577
  }
1917
3578
 
1918
3579
  export function inspectSnapshot(snapshot: Snapshot, targetId?: string) {
@@ -1940,3 +3601,6 @@ export function inspectSnapshot(snapshot: Snapshot, targetId?: string) {
1940
3601
  : undefined,
1941
3602
  }));
1942
3603
  }
3604
+
3605
+ /** How many ancestry questions git answers at once. */
3606
+ const ANCESTRY_CONCURRENCY = 8;