@dev.fast/whiteboard 0.3.4 → 0.3.5-preview.20261021.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{account-alias-CprZojXd.js → account-alias-CisiYUZT.js} +13 -8
- package/dist/agent-cli-BglNRDZN.js +2 -0
- package/dist/{agent-cli-CThN7Q59.js → agent-cli-TJ7_5_B3.js} +15 -10
- package/dist/{agent-client-BOOi7EUR.js → agent-client-H53ajvrm.js} +13 -3
- package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BCquUsB3.js} +34 -27
- package/dist/{background-server-DLSDogkI.js → background-server-BSYXD9au.js} +1 -1
- package/dist/{background-server-CkvxA-6O.js → background-server-Xom8cA_u.js} +5 -5
- package/dist/build-info.json +1 -1
- package/dist/{cli-R4yfa5DT.js → cli-BOnV4cCh.js} +1 -1
- package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-cRY5NOno.js} +70 -34
- package/dist/cli.js +4 -3
- package/dist/{client-BN_FDxFr.js → client-UH1bEG7Z.js} +260 -49
- package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-D_x3wumC.js} +6 -4
- package/dist/{headless-host-CVBOQyRS.js → headless-host-BEe-aFtB.js} +1 -1
- package/dist/{headless-host-Cog6SpS9.js → headless-host-CEb5d-A0.js} +563 -175
- package/dist/lens-selection-BxPk53c8.js +214 -0
- package/dist/{local-data-D_9tvdsp.js → local-data-mbCb1HDH.js} +2448 -347
- package/dist/{mcp-D1hziANa.js → mcp-cBtzXsmN.js} +9 -12
- package/dist/{package-paths-C--__jEv.js → package-paths-BkeQsdaw.js} +1 -1
- package/dist/profile-Dbyidrcr.js +2 -0
- package/dist/{profile-DokFw782.js → profile-duqp5uPa.js} +20 -8
- package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-BeKuMm_x.js} +4 -4
- package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-CX2fAec1.js} +1 -1
- package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CYWNTVSf.js} +3 -3
- package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-DLWluAeR.js} +5 -5
- package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-Y3S0YmJs.js} +6 -6
- package/dist/{request-origin-Zcn_CjX4.js → request-origin-icXPv6jh.js} +55 -18
- package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-DZvG5ZAh.js} +2 -2
- package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-BwH08ihj.js} +19 -17
- package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-AZ0imsmJ.js} +6 -6
- package/dist/runtime-CF4JXdb8.js +27 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +1 -1
- package/dist/{s3-DtJFWC0G.js → s3-CsG2EWED.js} +1 -1
- package/dist/{s3-config-BTO12tCC.js → s3-config-CjcLyFV5.js} +4 -2
- package/dist/{s3-config-CSy3fx8H.js → s3-config-zz4GWh2T.js} +1 -1
- package/dist/server/desktop-host.js +28 -104
- package/dist/{server-discovery-Bey8IqXH.js → server-discovery-DeVTdcqK.js} +3 -3
- package/dist/sharing/index.d.ts +484 -74
- package/dist/sharing/index.js +1 -1
- package/dist/{src-DsM3jO8G.js → src-DYZLCUP_.js} +98 -18
- package/dist/{src-BMPuxWv4.js → src-DtfSndqS.js} +269 -227
- package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B29lve9-.js} +318 -271
- package/dist/{thread-state-5pYr6_zM.js → thread-state-BPpvKFwQ.js} +12 -2
- package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-CQhyLht3.js} +10 -3
- package/instructions/authoring.md +20 -5
- package/package.json +4 -4
- package/src/agent-selection.ts +6 -0
- package/src/ask/agents.ts +13 -1
- package/src/ask/checkout-files.ts +2 -5
- package/src/ask/thread-state.ts +4 -0
- package/src/ask/thread.ts +61 -31
- package/src/ask/threads.ts +24 -3
- package/src/cli-install.ts +13 -2
- package/src/cli-runner.ts +4 -1
- package/src/cli.ts +4 -1
- package/src/diff-selection-migration.ts +48 -34
- package/src/heading-anchors.ts +42 -0
- package/src/lens-selection.ts +38 -2
- package/src/markdown.ts +42 -0
- package/src/posthog-capture-client.ts +1 -1
- package/src/review-api/README.md +52 -13
- package/src/review-api/activity.ts +1 -4
- package/src/review-api/agent-cli.ts +9 -1
- package/src/review-api/anchor-quotes.ts +14 -7
- package/src/review-api/ask-history.ts +118 -51
- package/src/review-api/authoring-tools.ts +45 -30
- package/src/review-api/azure-devops-provider.ts +2 -20
- package/src/review-api/azure-remote.ts +21 -0
- package/src/review-api/blocks/definition.ts +1 -1
- package/src/review-api/blocks/markdown.ts +2 -2
- package/src/review-api/board-order.ts +144 -0
- package/src/review-api/checkout-groups.ts +586 -0
- package/src/review-api/document-headings.ts +58 -27
- package/src/review-api/document-text.ts +31 -2
- package/src/review-api/document.ts +362 -67
- package/src/review-api/foreign-references.ts +328 -0
- package/src/review-api/github-provider.ts +51 -13
- package/src/review-api/http.ts +734 -135
- package/src/review-api/instructions.ts +0 -19
- package/src/review-api/link-preview.ts +113 -0
- package/src/review-api/local-data.ts +310 -46
- package/src/review-api/mcp.ts +7 -13
- package/src/review-api/onboarding.ts +1 -1
- package/src/review-api/profile.ts +37 -6
- package/src/review-api/public-tools.ts +24 -2
- package/src/review-api/pull-request.ts +82 -49
- package/src/review-api/request-origin.ts +10 -0
- package/src/review-api/review-progress.ts +61 -27
- package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
- package/src/review-api/store-migrations.ts +355 -0
- package/src/review-api/store-schema.ts +38 -29
- package/src/review-api/store.ts +2176 -495
- package/src/review-api/worktree-events.ts +3 -5
- package/src/review-api/worktree-source.ts +3 -7
- package/src/review-api/worktree-structural.integration.ts +72 -0
- package/src/review-stack.ts +15 -23
- package/src/runtime.ts +4 -30
- package/src/server/desktop-host.ts +6 -1
- package/src/server/desktop-server.ts +1 -36
- package/src/server/headless-host.ts +0 -2
- package/src/server/hono-http.ts +2 -1
- package/src/server/review-gateway-list.ts +3 -5
- package/src/server/review-gateway-memory.ts +1 -1
- package/src/server/review-lifecycle-telemetry.ts +2 -2
- package/src/server/review-server-core.ts +1 -5
- package/src/server/structural-comparisons.ts +47 -0
- package/src/sharing/export.ts +36 -7
- package/src/sharing/host.ts +34 -14
- package/src/sharing/import.ts +156 -24
- package/src/sharing/repository.ts +143 -24
- package/src/startup-trace.ts +3 -3
- package/src/stored-document-migration.ts +110 -19
- package/src/ui-telemetry-events.ts +44 -18
- package/dist/agent-cli-C6SPSII0.js +0 -2
- package/dist/profile-CYPmvBoA.js +0 -2
- package/dist/runtime-CKiWhGrf.js +0 -52
- package/instructions/scratchpad.md +0 -23
- package/src/review-preferences.ts +0 -88
- package/src/server/legacy-tutorial-cleanup.ts +0 -38
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import type { JsonValue } from "@dev.fast/json";
|
|
5
|
+
import { migrateStoredDocument } from "@review/stored-document-migration.js";
|
|
6
|
+
|
|
7
|
+
import { type Block, sourceReferences } from "./document.js";
|
|
8
|
+
import { snapshotForeignReferences } from "./foreign-references.js";
|
|
9
|
+
import type { StoreUpgradeContext } from "./store-migrations.js";
|
|
10
|
+
import { BOARD_REPOSITORY_ID_SQL, columnsOf } from "./store-schema.js";
|
|
11
|
+
|
|
12
|
+
// The old global scratchpad becomes a plan titled "Scratchpad", history
|
|
13
|
+
// kept, in the project of the repository its blocks quote most, else of the
|
|
14
|
+
// repository opened last. A board quotes only its own repository, so its
|
|
15
|
+
// quotes of others become snapshots.
|
|
16
|
+
|
|
17
|
+
const OLD_SCRATCHPAD_ID = "scratchpad";
|
|
18
|
+
|
|
19
|
+
const hasScratchpadVersions = ({ db }: StoreUpgradeContext) =>
|
|
20
|
+
Boolean(
|
|
21
|
+
db
|
|
22
|
+
.prepare("SELECT 1 FROM versions WHERE review_id=? LIMIT 1")
|
|
23
|
+
.get(OLD_SCRATCHPAD_ID),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
type PadSnapshot = { document: JsonValue[]; [key: string]: JsonValue };
|
|
27
|
+
|
|
28
|
+
/** A pad version's snapshot with its document in today's form (old pads
|
|
29
|
+
* held anchors as objects); undefined for one that won't parse. */
|
|
30
|
+
function parsePadVersion(text: string): PadSnapshot | undefined {
|
|
31
|
+
try {
|
|
32
|
+
// SAFETY: versions holds snapshots validated when they were saved.
|
|
33
|
+
const snapshot = JSON.parse(text) as PadSnapshot;
|
|
34
|
+
const document = migrateStoredDocument(snapshot.document);
|
|
35
|
+
|
|
36
|
+
snapshot.document = Array.isArray(document) ? document : [];
|
|
37
|
+
|
|
38
|
+
return snapshot;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Each pad version as parsed, by version, for one open. The run, inside
|
|
45
|
+
* the write lock, reads the rows again: it reuses a parse only of text that
|
|
46
|
+
* hasn't changed since. */
|
|
47
|
+
const parsedPads = new WeakMap<
|
|
48
|
+
StoreUpgradeContext,
|
|
49
|
+
Map<number, { text: string; snapshot: PadSnapshot | undefined }>
|
|
50
|
+
>();
|
|
51
|
+
|
|
52
|
+
function padVersion(
|
|
53
|
+
context: StoreUpgradeContext,
|
|
54
|
+
version: number,
|
|
55
|
+
text: string,
|
|
56
|
+
) {
|
|
57
|
+
let parsed = parsedPads.get(context);
|
|
58
|
+
|
|
59
|
+
if (!parsed) parsedPads.set(context, (parsed = new Map()));
|
|
60
|
+
const known = parsed.get(version);
|
|
61
|
+
|
|
62
|
+
if (known?.text === text) return known.snapshot;
|
|
63
|
+
|
|
64
|
+
const snapshot = parsePadVersion(text);
|
|
65
|
+
|
|
66
|
+
parsed.set(version, { text, snapshot });
|
|
67
|
+
|
|
68
|
+
return snapshot;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The pad's versions, oldest first, each parsed once per open. */
|
|
72
|
+
const scratchpadVersions = (context: StoreUpgradeContext) =>
|
|
73
|
+
context.db
|
|
74
|
+
.prepare(
|
|
75
|
+
"SELECT version,snapshot FROM versions WHERE review_id=? ORDER BY version",
|
|
76
|
+
)
|
|
77
|
+
.all(OLD_SCRATCHPAD_ID)
|
|
78
|
+
.map((row) => ({
|
|
79
|
+
version: Number(row.version),
|
|
80
|
+
snapshot: padVersion(context, Number(row.version), String(row.snapshot)),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
/** The pad's newest version that parses, reading no further back. */
|
|
84
|
+
function latestScratchpadDocument(context: StoreUpgradeContext) {
|
|
85
|
+
for (const row of context.db
|
|
86
|
+
.prepare(
|
|
87
|
+
"SELECT version,snapshot FROM versions WHERE review_id=? ORDER BY version DESC",
|
|
88
|
+
)
|
|
89
|
+
.iterate(OLD_SCRATCHPAD_ID)) {
|
|
90
|
+
const snapshot = padVersion(
|
|
91
|
+
context,
|
|
92
|
+
Number(row.version),
|
|
93
|
+
String(row.snapshot),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (snapshot) return snapshot.document;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The checkout the pad quotes most, else the one opened last. Only a
|
|
103
|
+
* checkout that still exists: a plan opens on it. */
|
|
104
|
+
function scratchpadRepository(context: StoreUpgradeContext) {
|
|
105
|
+
const exists = (repositoryId: string) => {
|
|
106
|
+
try {
|
|
107
|
+
return existsSync(context.repositoryPath(repositoryId));
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const counts = new Map<string, number>();
|
|
114
|
+
|
|
115
|
+
// SAFETY: a stored pad's blocks, in today's form.
|
|
116
|
+
const latest = latestScratchpadDocument(context) as Block[] | undefined;
|
|
117
|
+
|
|
118
|
+
for (const { source } of sourceReferences(latest ?? [], {
|
|
119
|
+
tolerant: true,
|
|
120
|
+
})) {
|
|
121
|
+
const repositoryId = source.pins?.repositoryId;
|
|
122
|
+
|
|
123
|
+
if (repositoryId && exists(repositoryId))
|
|
124
|
+
counts.set(repositoryId, (counts.get(repositoryId) ?? 0) + 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const [quoted] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
128
|
+
|
|
129
|
+
if (quoted) return quoted;
|
|
130
|
+
|
|
131
|
+
const viewed = context.db
|
|
132
|
+
.prepare(
|
|
133
|
+
`SELECT ${BOARD_REPOSITORY_ID_SQL} AS repository
|
|
134
|
+
FROM review_attention
|
|
135
|
+
JOIN reviews ON reviews.id=review_attention.review_id
|
|
136
|
+
JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
137
|
+
WHERE review_attention.viewed_at IS NOT NULL AND repository IS NOT NULL
|
|
138
|
+
ORDER BY review_attention.viewed_at DESC`,
|
|
139
|
+
)
|
|
140
|
+
.all()
|
|
141
|
+
.map(({ repository }) => String(repository));
|
|
142
|
+
|
|
143
|
+
return [...viewed, ...context.repositories().map(({ id }) => id)].find(
|
|
144
|
+
exists,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const snapshots = (
|
|
149
|
+
context: StoreUpgradeContext,
|
|
150
|
+
own: string,
|
|
151
|
+
document: JsonValue[],
|
|
152
|
+
allocate: (prefix: string) => string,
|
|
153
|
+
) =>
|
|
154
|
+
snapshotForeignReferences(
|
|
155
|
+
document,
|
|
156
|
+
own,
|
|
157
|
+
context.fileAtCommit,
|
|
158
|
+
context.repositoryName,
|
|
159
|
+
allocate,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
/** Reads the files the pad quotes, which the context keeps, before the
|
|
163
|
+
* write lock other hosts on this home wait on. */
|
|
164
|
+
export function readScratchpadQuotes(context: StoreUpgradeContext) {
|
|
165
|
+
if (!hasScratchpadVersions(context)) return;
|
|
166
|
+
|
|
167
|
+
const quoting = scratchpadRepository(context);
|
|
168
|
+
|
|
169
|
+
if (quoting)
|
|
170
|
+
for (const { snapshot } of scratchpadVersions(context))
|
|
171
|
+
if (snapshot)
|
|
172
|
+
snapshots(context, quoting, snapshot.document, (prefix) => prefix);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function planFromScratchpad(context: StoreUpgradeContext) {
|
|
176
|
+
const { db } = context;
|
|
177
|
+
|
|
178
|
+
// A pad row with no versions has nothing to keep; it stays as it is.
|
|
179
|
+
if (!hasScratchpadVersions(context)) return;
|
|
180
|
+
|
|
181
|
+
const own = scratchpadRepository(context);
|
|
182
|
+
const id = randomUUID();
|
|
183
|
+
|
|
184
|
+
db.prepare(
|
|
185
|
+
"INSERT INTO reviews(id,version,next_id,project_id) SELECT ?,version,next_id,? FROM reviews WHERE id=?",
|
|
186
|
+
).run(
|
|
187
|
+
id,
|
|
188
|
+
own ? context.projectOfRepository(own) : context.fallbackProject(),
|
|
189
|
+
OLD_SCRATCHPAD_ID,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
let nextId = Number(
|
|
193
|
+
db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id)!.next_id,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const old = scratchpadVersions(context);
|
|
197
|
+
|
|
198
|
+
// The run changes the snapshots it took; nothing parses them again.
|
|
199
|
+
parsedPads.delete(context);
|
|
200
|
+
|
|
201
|
+
// Every version moves over; one that no longer parses, as it is.
|
|
202
|
+
db.prepare("UPDATE versions SET review_id=? WHERE review_id=?").run(
|
|
203
|
+
id,
|
|
204
|
+
OLD_SCRATCHPAD_ID,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
for (const { version, snapshot } of old) {
|
|
208
|
+
if (!snapshot) continue;
|
|
209
|
+
|
|
210
|
+
delete snapshot.kind;
|
|
211
|
+
|
|
212
|
+
snapshot.reviewId = id;
|
|
213
|
+
snapshot.title = "Scratchpad";
|
|
214
|
+
|
|
215
|
+
if (own) {
|
|
216
|
+
snapshot.target = { kind: "project", repositoryId: own };
|
|
217
|
+
snapshot.document = snapshots(
|
|
218
|
+
context,
|
|
219
|
+
own,
|
|
220
|
+
snapshot.document,
|
|
221
|
+
(prefix) => `${prefix}-${++nextId}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
context.rewriteVersion(id, version, snapshot);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
db.prepare("UPDATE reviews SET next_id=? WHERE id=?").run(nextId, id);
|
|
229
|
+
|
|
230
|
+
// Tables a store makes after its upgrades may not exist yet. A thread's
|
|
231
|
+
// board is its review_id until ask-threads-by-project renames it, which
|
|
232
|
+
// a development build may have done already.
|
|
233
|
+
for (const [table, ...names] of [
|
|
234
|
+
["ask_conversations", "review_id", "anchor_board"],
|
|
235
|
+
["authoring_presences", "review_id"],
|
|
236
|
+
["review_coverage", "review_id"],
|
|
237
|
+
["review_attention", "review_id"],
|
|
238
|
+
]) {
|
|
239
|
+
const columns = columnsOf(db, table!);
|
|
240
|
+
const column = names.find((name) => columns.has(name));
|
|
241
|
+
|
|
242
|
+
if (column)
|
|
243
|
+
db.prepare(`UPDATE ${table} SET ${column}=? WHERE ${column}=?`).run(
|
|
244
|
+
id,
|
|
245
|
+
OLD_SCRATCHPAD_ID,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
db.prepare("DELETE FROM reviews WHERE id=?").run(OLD_SCRATCHPAD_ID);
|
|
250
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
|
|
4
|
+
import type { JsonValue } from "@dev.fast/json";
|
|
5
|
+
|
|
6
|
+
import { CheckoutGroups } from "./checkout-groups.js";
|
|
7
|
+
import type { ReadAtCommit } from "./foreign-references.js";
|
|
8
|
+
import {
|
|
9
|
+
planFromScratchpad,
|
|
10
|
+
readScratchpadQuotes,
|
|
11
|
+
} from "./scratchpad-plan-upgrade.js";
|
|
12
|
+
import { columnsOf, hasTable } from "./store-schema.js";
|
|
13
|
+
|
|
14
|
+
// A store's one-time upgrades, in order. PRAGMA user_version holds the last
|
|
15
|
+
// one a store has run, so each runs once per store and a later launch runs
|
|
16
|
+
// none; a new store runs them all on its first open.
|
|
17
|
+
//
|
|
18
|
+
// A shipped step never changes: a fix is a new step at the end. Each step
|
|
19
|
+
// upgrades a store as a release before user_version left it, which may
|
|
20
|
+
// already have some of it done, or lack the tables it touches; so each
|
|
21
|
+
// checks what it needs before changing anything.
|
|
22
|
+
|
|
23
|
+
/** What a step may use besides the database. */
|
|
24
|
+
export interface StoreUpgradeContext {
|
|
25
|
+
db: DatabaseSync;
|
|
26
|
+
/** Groups the registered checkouts, reading their git metadata. */
|
|
27
|
+
prepareCheckouts(): void;
|
|
28
|
+
repositories(): { id: string }[];
|
|
29
|
+
repositoryPath(repositoryId: string): string;
|
|
30
|
+
/** A checkout's name as its project labels it, else its folder. */
|
|
31
|
+
repositoryName(repositoryId: string): string;
|
|
32
|
+
/** Undefined when the file can't be read; each file is read once. */
|
|
33
|
+
fileAtCommit: ReadAtCommit;
|
|
34
|
+
/** A checkout's project, made on first use. */
|
|
35
|
+
projectOfRepository(repositoryId: string): string;
|
|
36
|
+
fallbackProject(): string;
|
|
37
|
+
/** Saves a version over what it was; the current one re-indexes its
|
|
38
|
+
* board's links, as every written version does. */
|
|
39
|
+
rewriteVersion(reviewId: string, version: number, snapshot: JsonValue): void;
|
|
40
|
+
firstRepositoryProject(): string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface StoreUpgrade {
|
|
44
|
+
version: number;
|
|
45
|
+
name: string;
|
|
46
|
+
/** It groups the checkouts, which the store can work out before it
|
|
47
|
+
* opens (`groupCheckoutsAhead`), else does before the write lock. */
|
|
48
|
+
groupsCheckouts?: true;
|
|
49
|
+
/** Slow reads to do before taking the write lock other hosts wait on. */
|
|
50
|
+
beforeLock?(context: StoreUpgradeContext): void;
|
|
51
|
+
/** For a table rebuild: foreign keys can't be turned off in a
|
|
52
|
+
* transaction. */
|
|
53
|
+
foreignKeysOff?: true;
|
|
54
|
+
run(context: StoreUpgradeContext): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Keep versions.author_kind to its values, as a CHECK would. */
|
|
58
|
+
const VERSION_AUTHOR_TRIGGERS = `
|
|
59
|
+
CREATE TRIGGER IF NOT EXISTS versions_author_kind_insert BEFORE INSERT ON versions
|
|
60
|
+
WHEN NEW.author_kind NOT IN ('person','agent','system')
|
|
61
|
+
BEGIN SELECT RAISE(ABORT, 'versions.author_kind must be person, agent or system'); END;
|
|
62
|
+
CREATE TRIGGER IF NOT EXISTS versions_author_kind_update BEFORE UPDATE OF author_kind ON versions
|
|
63
|
+
WHEN NEW.author_kind NOT IN ('person','agent','system')
|
|
64
|
+
BEGIN SELECT RAISE(ABORT, 'versions.author_kind must be person, agent or system'); END;`;
|
|
65
|
+
|
|
66
|
+
export const STORE_UPGRADES: readonly StoreUpgrade[] = [
|
|
67
|
+
{
|
|
68
|
+
version: 1,
|
|
69
|
+
name: "retired-tables",
|
|
70
|
+
// Tables earlier releases kept and nothing reads any more. Presences
|
|
71
|
+
// replaced authoring_sessions; both last minutes, so it isn't moved.
|
|
72
|
+
run: ({ db }) =>
|
|
73
|
+
db.exec(`DROP TABLE IF EXISTS receipts;
|
|
74
|
+
DROP TABLE IF EXISTS review_viewed;
|
|
75
|
+
DROP TABLE IF EXISTS authoring_drafts;
|
|
76
|
+
DROP TABLE IF EXISTS authoring_sessions;`),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
version: 2,
|
|
80
|
+
name: "archive-to-trash",
|
|
81
|
+
// Archive became Trash: archived boards get Trash's full recovery
|
|
82
|
+
// window from the upgrade, since Archive kept them forever.
|
|
83
|
+
run: ({ db }) => {
|
|
84
|
+
if (!columnsOf(db, "review_attention").has("dismissed_at")) return;
|
|
85
|
+
|
|
86
|
+
db.exec(
|
|
87
|
+
"ALTER TABLE review_attention RENAME COLUMN dismissed_at TO deleted_at",
|
|
88
|
+
);
|
|
89
|
+
db.prepare(
|
|
90
|
+
"UPDATE review_attention SET deleted_at=? WHERE deleted_at IS NOT NULL",
|
|
91
|
+
).run(new Date().toISOString());
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
version: 3,
|
|
96
|
+
name: "board-projects",
|
|
97
|
+
// Every board belongs to a project: its repository's, else the one of
|
|
98
|
+
// the board opened last. With hundreds of checkouts, grouping them
|
|
99
|
+
// reads a good deal of git metadata.
|
|
100
|
+
groupsCheckouts: true,
|
|
101
|
+
foreignKeysOff: true,
|
|
102
|
+
run: ({
|
|
103
|
+
db,
|
|
104
|
+
repositories,
|
|
105
|
+
projectOfRepository,
|
|
106
|
+
firstRepositoryProject,
|
|
107
|
+
}) => {
|
|
108
|
+
// A child's parent is found by it.
|
|
109
|
+
const parents = () =>
|
|
110
|
+
db.exec(
|
|
111
|
+
"CREATE INDEX IF NOT EXISTS reviews_parent ON reviews(parent_id)",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// A new store has the columns from the start.
|
|
115
|
+
if (columnsOf(db, "reviews").has("project_id")) return parents();
|
|
116
|
+
|
|
117
|
+
// A checkout names its project; stores from before projects lack
|
|
118
|
+
// the column.
|
|
119
|
+
if (!columnsOf(db, "repositories").has("project_id"))
|
|
120
|
+
db.exec(
|
|
121
|
+
"ALTER TABLE repositories ADD COLUMN project_id TEXT REFERENCES projects(id)",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// In creation order, which the rebuild keeps. A board whose current
|
|
125
|
+
// version is missing is kept too, in the fallback project.
|
|
126
|
+
const boards = db
|
|
127
|
+
.prepare(
|
|
128
|
+
`SELECT reviews.id, CASE WHEN json_valid(versions.snapshot) THEN COALESCE(json_extract(versions.snapshot,'$.target.repositoryId'),json_extract(versions.snapshot,'$.pins.repositoryId')) END AS repository_id,
|
|
129
|
+
review_attention.viewed_at
|
|
130
|
+
FROM reviews LEFT JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
131
|
+
LEFT JOIN review_attention ON review_attention.review_id=reviews.id
|
|
132
|
+
ORDER BY reviews.rowid`,
|
|
133
|
+
)
|
|
134
|
+
.all();
|
|
135
|
+
|
|
136
|
+
const registered = new Set(repositories().map(({ id }) => id));
|
|
137
|
+
const projects = new Map<string, string>();
|
|
138
|
+
|
|
139
|
+
for (const { id, repository_id: repository } of boards)
|
|
140
|
+
if (repository && registered.has(String(repository)))
|
|
141
|
+
projects.set(String(id), projectOfRepository(String(repository)));
|
|
142
|
+
|
|
143
|
+
// The project of the board opened last.
|
|
144
|
+
const [opened] = boards
|
|
145
|
+
.filter(({ id, viewed_at }) => viewed_at && projects.has(String(id)))
|
|
146
|
+
.sort((a, b) => String(b.viewed_at).localeCompare(String(a.viewed_at)));
|
|
147
|
+
|
|
148
|
+
let fallback = opened && projects.get(String(opened.id));
|
|
149
|
+
|
|
150
|
+
const brokenReferences = () =>
|
|
151
|
+
db
|
|
152
|
+
.prepare("PRAGMA foreign_key_check")
|
|
153
|
+
.all()
|
|
154
|
+
.filter(({ parent }) => parent === "reviews").length;
|
|
155
|
+
|
|
156
|
+
// Only references this rebuild breaks; older dangling rows stay.
|
|
157
|
+
const broken = brokenReferences();
|
|
158
|
+
|
|
159
|
+
// A child names its parent; deleting the parent moves it to top
|
|
160
|
+
// level. No board had one before projects.
|
|
161
|
+
db.exec(`CREATE TABLE reviews_with_projects(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL,
|
|
162
|
+
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
163
|
+
parent_id TEXT REFERENCES reviews(id) ON DELETE SET NULL)`);
|
|
164
|
+
|
|
165
|
+
const insert = db.prepare(
|
|
166
|
+
"INSERT INTO reviews_with_projects(id,version,next_id,project_id) SELECT id,version,next_id,? FROM reviews WHERE id=?",
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
for (const { id } of boards)
|
|
170
|
+
insert.run(
|
|
171
|
+
projects.get(String(id)) ?? (fallback ??= firstRepositoryProject()),
|
|
172
|
+
String(id),
|
|
173
|
+
);
|
|
174
|
+
db.exec(`DROP TABLE reviews;
|
|
175
|
+
ALTER TABLE reviews_with_projects RENAME TO reviews;`);
|
|
176
|
+
parents();
|
|
177
|
+
|
|
178
|
+
if (brokenReferences() > broken)
|
|
179
|
+
throw new Error("Giving boards projects broke a reference.");
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
version: 4,
|
|
184
|
+
name: "scratchpad-plan",
|
|
185
|
+
beforeLock: readScratchpadQuotes,
|
|
186
|
+
run: planFromScratchpad,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
version: 5,
|
|
190
|
+
name: "ask-threads-by-project",
|
|
191
|
+
// Threads were kept per review. Each is now anchored on the board it
|
|
192
|
+
// started on, and is in that board's project.
|
|
193
|
+
run: ({ db }) => {
|
|
194
|
+
// The Threads tab's Project filter finds a project's boards by it.
|
|
195
|
+
db.exec(
|
|
196
|
+
"CREATE INDEX IF NOT EXISTS reviews_project ON reviews(project_id)",
|
|
197
|
+
);
|
|
198
|
+
const columns = columnsOf(db, "ask_conversations");
|
|
199
|
+
|
|
200
|
+
if (columns.size === 0) return;
|
|
201
|
+
|
|
202
|
+
if (columns.has("review_id"))
|
|
203
|
+
db.exec(
|
|
204
|
+
"ALTER TABLE ask_conversations RENAME COLUMN review_id TO anchor_board",
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (!columns.has("anchor_block"))
|
|
208
|
+
db.exec("ALTER TABLE ask_conversations ADD COLUMN anchor_block TEXT");
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
version: 6,
|
|
213
|
+
name: "version-threads",
|
|
214
|
+
// A version names the Ask thread that caused it in its own column.
|
|
215
|
+
// No release wrote one into a version, so earlier versions have none.
|
|
216
|
+
run: ({ db }) => {
|
|
217
|
+
if (!columnsOf(db, "versions").has("thread_id"))
|
|
218
|
+
db.exec("ALTER TABLE versions ADD COLUMN thread_id TEXT");
|
|
219
|
+
|
|
220
|
+
db.exec(
|
|
221
|
+
"CREATE INDEX IF NOT EXISTS versions_thread_id ON versions(thread_id)",
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
version: 7,
|
|
227
|
+
name: "viewed-on-upgrade",
|
|
228
|
+
// Every board counts as viewed at its latest version, so only later
|
|
229
|
+
// changes badge it.
|
|
230
|
+
run: ({ db }) =>
|
|
231
|
+
db.exec(`INSERT OR IGNORE INTO review_attention(review_id) SELECT id FROM reviews;
|
|
232
|
+
UPDATE review_attention SET viewed_at=COALESCE(NULLIF((
|
|
233
|
+
SELECT MAX(COALESCE(review_attention.viewed_at, ''), COALESCE(CASE WHEN json_valid(versions.snapshot) THEN json_extract(versions.snapshot,'$.createdAt') END, ''))
|
|
234
|
+
FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
|
|
235
|
+
WHERE reviews.id=review_attention.review_id), ''), review_attention.viewed_at)
|
|
236
|
+
WHERE review_id IN (SELECT id FROM reviews);`),
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
version: 8,
|
|
240
|
+
name: "ask-thread-viewed",
|
|
241
|
+
// A thread keeps when it was last seen; one saved before has none.
|
|
242
|
+
run: ({ db }) => {
|
|
243
|
+
const columns = columnsOf(db, "ask_conversations");
|
|
244
|
+
|
|
245
|
+
if (columns.size > 0 && !columns.has("viewed_at"))
|
|
246
|
+
db.exec("ALTER TABLE ask_conversations ADD COLUMN viewed_at TEXT");
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
version: 9,
|
|
251
|
+
name: "version-authors",
|
|
252
|
+
// Who wrote each version, a person or an agent, in its own column.
|
|
253
|
+
// Releases didn't record it: an agent's command and a person's edit on
|
|
254
|
+
// the canvas saved alike, so earlier versions have none. Triggers keep
|
|
255
|
+
// the column to its values; a CHECK added with it would have SQLite read
|
|
256
|
+
// every version, all of their JSON, to check the empty column.
|
|
257
|
+
run: ({ db }) => {
|
|
258
|
+
if (!columnsOf(db, "versions").has("author_kind"))
|
|
259
|
+
db.exec("ALTER TABLE versions ADD COLUMN author_kind TEXT");
|
|
260
|
+
|
|
261
|
+
db.exec(VERSION_AUTHOR_TRIGGERS);
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
version: 10,
|
|
266
|
+
name: "ask-permissions",
|
|
267
|
+
// Auto mode keeps what an Ask thread may do without asking; threads
|
|
268
|
+
// saved before it lack the column.
|
|
269
|
+
run: ({ db }) => {
|
|
270
|
+
const columns = columnsOf(db, "ask_conversations");
|
|
271
|
+
|
|
272
|
+
if (columns.size > 0 && !columns.has("permissions"))
|
|
273
|
+
db.exec("ALTER TABLE ask_conversations ADD COLUMN permissions TEXT");
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
export const STORE_VERSION = STORE_UPGRADES.at(-1)!.version;
|
|
279
|
+
|
|
280
|
+
export const storeVersion = (db: DatabaseSync) =>
|
|
281
|
+
Number(db.prepare("PRAGMA user_version").get()!.user_version);
|
|
282
|
+
|
|
283
|
+
/** Runs every step the store hasn't, each in its own transaction with the
|
|
284
|
+
* version it reaches, so a failed step rolls back alone and stops the store
|
|
285
|
+
* opening, naming the step; the ones before it stay done. A store at a
|
|
286
|
+
* later version than this build knows is left as it is. */
|
|
287
|
+
export function upgradeStore(context: StoreUpgradeContext) {
|
|
288
|
+
const { db } = context;
|
|
289
|
+
|
|
290
|
+
for (const step of STORE_UPGRADES) {
|
|
291
|
+
if (storeVersion(db) >= step.version) continue;
|
|
292
|
+
|
|
293
|
+
if (step.groupsCheckouts) context.prepareCheckouts();
|
|
294
|
+
step.beforeLock?.(context);
|
|
295
|
+
|
|
296
|
+
if (step.foreignKeysOff) db.exec("PRAGMA foreign_keys=OFF");
|
|
297
|
+
|
|
298
|
+
db.exec("BEGIN IMMEDIATE");
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
// Another host on this home may have run it while this one waited.
|
|
302
|
+
if (storeVersion(db) < step.version) {
|
|
303
|
+
step.run(context);
|
|
304
|
+
db.exec(`PRAGMA user_version=${step.version}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
db.exec("COMMIT");
|
|
308
|
+
} catch (error) {
|
|
309
|
+
// SQLite may have rolled back already, on a full disk say; the error
|
|
310
|
+
// that made it is the one to report.
|
|
311
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
312
|
+
throw new Error(
|
|
313
|
+
`Could not upgrade the whiteboard store: step ${step.version} (${step.name}) failed and was rolled back. ${error instanceof Error ? error.message : String(error)}`,
|
|
314
|
+
{ cause: error },
|
|
315
|
+
);
|
|
316
|
+
} finally {
|
|
317
|
+
if (step.foreignKeysOff) db.exec("PRAGMA foreign_keys=ON");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** How the store's checkouts group, worked out before it opens, asking
|
|
323
|
+
* several repositories at once, when an upgrade still to run needs it;
|
|
324
|
+
* undefined otherwise, or when the store can't be read so, and the step
|
|
325
|
+
* works it out itself. */
|
|
326
|
+
export async function groupCheckoutsAhead(
|
|
327
|
+
databasePath: string,
|
|
328
|
+
): Promise<ReadonlyMap<string, string> | undefined> {
|
|
329
|
+
if (!existsSync(databasePath)) return undefined;
|
|
330
|
+
|
|
331
|
+
let db: DatabaseSync | undefined;
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
db = new DatabaseSync(databasePath, { timeout: 5000 });
|
|
335
|
+
const version = storeVersion(db);
|
|
336
|
+
|
|
337
|
+
if (
|
|
338
|
+
!STORE_UPGRADES.some(
|
|
339
|
+
(step) => step.groupsCheckouts && step.version > version,
|
|
340
|
+
) ||
|
|
341
|
+
!columnsOf(db, "repositories").size
|
|
342
|
+
)
|
|
343
|
+
return undefined;
|
|
344
|
+
|
|
345
|
+
const groups = new CheckoutGroups(db);
|
|
346
|
+
|
|
347
|
+
await groups.prepareConcurrently();
|
|
348
|
+
|
|
349
|
+
return groups.prepared;
|
|
350
|
+
} catch {
|
|
351
|
+
return undefined;
|
|
352
|
+
} finally {
|
|
353
|
+
db?.close();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -1,46 +1,55 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
2
|
|
|
3
|
+
/** A board version's repository in SQL over `versions.snapshot`, as
|
|
4
|
+
* `boardRepositoryId` reads it: its target's, else its pins'. */
|
|
5
|
+
export const BOARD_REPOSITORY_ID_SQL =
|
|
6
|
+
"CASE WHEN json_valid(versions.snapshot) THEN COALESCE(json_extract(versions.snapshot,'$.target.repositoryId'),json_extract(versions.snapshot,'$.pins.repositoryId')) END";
|
|
7
|
+
|
|
8
|
+
export const columnsOf = (db: DatabaseSync, table: string) =>
|
|
9
|
+
new Set(
|
|
10
|
+
db
|
|
11
|
+
.prepare("SELECT name FROM pragma_table_info(?)")
|
|
12
|
+
.all(table)
|
|
13
|
+
.map(({ name }) => String(name)),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export const hasTable = (db: DatabaseSync, table: string) =>
|
|
17
|
+
Boolean(
|
|
18
|
+
db
|
|
19
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
|
|
20
|
+
.get(table),
|
|
21
|
+
);
|
|
22
|
+
|
|
3
23
|
export function initializeReviewStoreSchema(db: DatabaseSync): void {
|
|
4
24
|
db.exec(`
|
|
5
25
|
PRAGMA journal_mode=WAL;
|
|
6
26
|
PRAGMA foreign_keys=ON;
|
|
7
|
-
CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL
|
|
27
|
+
CREATE TABLE IF NOT EXISTS reviews(id TEXT PRIMARY KEY, version INTEGER NOT NULL, next_id INTEGER NOT NULL,
|
|
28
|
+
project_id TEXT NOT NULL REFERENCES projects(id), parent_id TEXT REFERENCES reviews(id) ON DELETE SET NULL);
|
|
8
29
|
CREATE TABLE IF NOT EXISTS versions(review_id TEXT REFERENCES reviews(id), version INTEGER, snapshot TEXT NOT NULL,
|
|
30
|
+
thread_id TEXT, author_kind TEXT,
|
|
9
31
|
PRIMARY KEY(review_id,version));
|
|
10
|
-
DROP TABLE IF EXISTS receipts;
|
|
11
32
|
CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT, deleted_at TEXT);
|
|
12
|
-
CREATE TABLE IF NOT EXISTS
|
|
33
|
+
CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT NOT NULL);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
|
35
|
+
project_id TEXT REFERENCES projects(id));
|
|
13
36
|
CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY, repository_id TEXT NOT NULL REFERENCES repositories(id),
|
|
14
37
|
kind TEXT NOT NULL, mime_type TEXT NOT NULL, data BLOB NOT NULL);
|
|
15
38
|
CREATE TABLE IF NOT EXISTS review_coverage(review_id TEXT REFERENCES reviews(id), file TEXT, fingerprint TEXT NOT NULL, coverage TEXT NOT NULL,
|
|
16
39
|
PRIMARY KEY(review_id,file));
|
|
17
|
-
DROP TABLE IF EXISTS review_viewed;
|
|
18
40
|
CREATE TABLE IF NOT EXISTS comparison_stats(identity TEXT PRIMARY KEY, stats TEXT NOT NULL);
|
|
19
41
|
CREATE TABLE IF NOT EXISTS server_identity(one INTEGER PRIMARY KEY CHECK(one=1), id TEXT NOT NULL);
|
|
20
|
-
|
|
42
|
+
CREATE TABLE IF NOT EXISTS board_dependencies(review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
|
43
|
+
depends_on TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, PRIMARY KEY(review_id,depends_on));
|
|
44
|
+
CREATE INDEX IF NOT EXISTS board_dependencies_depends_on ON board_dependencies(depends_on);
|
|
45
|
+
-- A link goes with the board it is on. Where it points stays plain
|
|
46
|
+
-- text, no foreign key: a link to a deleted board stays, and reads
|
|
47
|
+
-- "Board deleted" from the deleted_boards tombstone.
|
|
48
|
+
CREATE TABLE IF NOT EXISTS board_links(from_review TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, from_block TEXT NOT NULL,
|
|
49
|
+
to_review TEXT NOT NULL, to_block TEXT);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS board_links_from ON board_links(from_review);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS board_links_to ON board_links(to_review);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS deleted_boards(id TEXT PRIMARY KEY, title TEXT NOT NULL, deleted_at TEXT NOT NULL,
|
|
53
|
+
project_id TEXT);
|
|
21
54
|
`);
|
|
22
|
-
|
|
23
|
-
db.exec("BEGIN IMMEDIATE");
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
// Migrate the former Archive timestamp without losing recoverable boards.
|
|
27
|
-
const attentionColumns = db
|
|
28
|
-
.prepare("PRAGMA table_info(review_attention)")
|
|
29
|
-
.all();
|
|
30
|
-
|
|
31
|
-
if (attentionColumns.some((column) => column.name === "dismissed_at")) {
|
|
32
|
-
db.exec(
|
|
33
|
-
"ALTER TABLE review_attention RENAME COLUMN dismissed_at TO deleted_at",
|
|
34
|
-
);
|
|
35
|
-
// Archive had no retention limit. Give migrated boards the full recovery window.
|
|
36
|
-
db.prepare(
|
|
37
|
-
"UPDATE review_attention SET deleted_at=? WHERE deleted_at IS NOT NULL",
|
|
38
|
-
).run(new Date().toISOString());
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
db.exec("COMMIT");
|
|
42
|
-
} catch (error) {
|
|
43
|
-
db.exec("ROLLBACK");
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
55
|
}
|