@boxxy/store-sqlite 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +8 -6
  2. package/dist/codecs/parse-json-text.d.ts +1 -0
  3. package/dist/constants.d.ts +1 -0
  4. package/dist/migrations/rename-relations-migration.d.ts +2 -0
  5. package/dist/{run-store-migrations.d.ts → migrations/run-store-migrations.d.ts} +1 -1
  6. package/dist/migrations/v1-tables-migration.d.ts +2 -0
  7. package/dist/{build-sqlite-reader.d.ts → reads/build-sqlite-reader.d.ts} +1 -1
  8. package/dist/reads/is-latest-of-session.d.ts +3 -0
  9. package/dist/{read-item-views.d.ts → reads/read-item-views.d.ts} +3 -3
  10. package/dist/reads/read-limit-fills.d.ts +4 -0
  11. package/dist/reads/read-wakes.d.ts +4 -0
  12. package/dist/test-utils/create-claim-bench.d.ts +8 -0
  13. package/dist/types.d.ts +60 -10
  14. package/dist/with-busy-retry.d.ts +1 -0
  15. package/dist/with-transaction.d.ts +3 -0
  16. package/dist/{apply-sqlite-change.d.ts → writes/apply-sqlite-change.d.ts} +1 -1
  17. package/dist/{create-sqlite-tx.d.ts → writes/create-sqlite-tx.d.ts} +1 -1
  18. package/dist/writes/take-write-lock.d.ts +3 -0
  19. package/package.json +15 -5
  20. package/src/codecs/parse-json-text.ts +5 -0
  21. package/src/{table-codecs.ts → codecs/table-codecs.ts} +52 -15
  22. package/src/constants.ts +1 -0
  23. package/src/create-sqlite-claim-store.ts +73 -51
  24. package/src/migrations/rename-relations-migration.ts +17 -0
  25. package/src/migrations/run-store-migrations.ts +42 -0
  26. package/src/migrations/v1-tables-migration.ts +184 -0
  27. package/src/reads/build-sqlite-reader.ts +484 -0
  28. package/src/reads/is-latest-of-session.ts +21 -0
  29. package/src/{read-item-views.ts → reads/read-item-views.ts} +9 -9
  30. package/src/reads/read-limit-fills.ts +32 -0
  31. package/src/reads/read-wakes.ts +34 -0
  32. package/src/test-utils/create-claim-bench.ts +66 -0
  33. package/src/test-utils/create-test-claim-store.ts +2 -1
  34. package/src/types.ts +60 -10
  35. package/src/with-busy-retry.ts +32 -0
  36. package/src/with-transaction.ts +29 -0
  37. package/src/{apply-sqlite-change.ts → writes/apply-sqlite-change.ts} +77 -74
  38. package/src/writes/create-sqlite-tx.ts +229 -0
  39. package/src/writes/take-write-lock.ts +8 -0
  40. package/src/build-sqlite-reader.ts +0 -321
  41. package/src/create-sqlite-tx.ts +0 -134
  42. package/src/run-store-migrations.ts +0 -20
  43. /package/dist/{parse-store-row.d.ts → codecs/parse-store-row.d.ts} +0 -0
  44. /package/dist/{table-codecs.d.ts → codecs/table-codecs.d.ts} +0 -0
  45. /package/dist/{to-column-name.d.ts → codecs/to-column-name.d.ts} +0 -0
  46. /package/dist/{to-store-columns.d.ts → codecs/to-store-columns.d.ts} +0 -0
  47. /package/dist/{create-tables-migration.d.ts → migrations/create-tables-migration.d.ts} +0 -0
  48. /package/src/{parse-store-row.ts → codecs/parse-store-row.ts} +0 -0
  49. /package/src/{to-column-name.ts → codecs/to-column-name.ts} +0 -0
  50. /package/src/{to-store-columns.ts → codecs/to-store-columns.ts} +0 -0
  51. /package/src/{create-tables-migration.ts → migrations/create-tables-migration.ts} +0 -0
@@ -0,0 +1,229 @@
1
+ import { formatCanonicalJson } from '@boxxy/content-hash';
2
+ import { collectSeatSync, toJSONValue } from '@boxxy/model';
3
+ import type {
4
+ FactId,
5
+ Instant,
6
+ Limit,
7
+ PullRequestRef,
8
+ RunnerId,
9
+ StoreChange,
10
+ StoreReader,
11
+ StoreTx,
12
+ } from '@boxxy/model';
13
+ import type { Kysely } from 'kysely';
14
+ import { buildSQLiteReader } from '../reads/build-sqlite-reader.ts';
15
+ import type { StoreDatabase } from '../types.ts';
16
+ import { applySQLiteChange } from './apply-sqlite-change.ts';
17
+
18
+ // A call started beside a change would run its statements inside the change's savepoint, and a
19
+ // refusal would roll them back after the call resolved, so every call waits for the one before it.
20
+ export function createSQLiteTx(db: Kysely<StoreDatabase>): StoreTx {
21
+ const inTurn = makeTurnRunner();
22
+ const reader = buildSQLiteReader(db);
23
+
24
+ return {
25
+ readSnapshot: inTurn(reader.readSnapshot),
26
+ readItem: inTurn(reader.readItem),
27
+ readItemDetail: inTurn(reader.readItemDetail),
28
+ listItems: inTurn(reader.listItems),
29
+ listOpenIssues: inTurn(reader.listOpenIssues),
30
+ readLastLeaseGen: inTurn(reader.readLastLeaseGen),
31
+ readAttempt: inTurn(reader.readAttempt),
32
+ listEvidence: inTurn(reader.listEvidence),
33
+ listItemAttempts: inTurn(reader.listItemAttempts),
34
+ listLiveAttempts: inTurn(reader.listLiveAttempts),
35
+ listStrayAttempts: inTurn(reader.listStrayAttempts),
36
+ listSucceededAttemptsWithoutPullRequest: inTurn(reader.listSucceededAttemptsWithoutPullRequest),
37
+ findAttemptBySession: inTurn(reader.findAttemptBySession),
38
+ findAttemptByAgentSession: inTurn(reader.findAttemptByAgentSession),
39
+ findSessionByCaller: inTurn(reader.findSessionByCaller),
40
+ readPullRequestRow: inTurn(reader.readPullRequestRow),
41
+ listWatchedPullRequests: inTurn(reader.listWatchedPullRequests),
42
+ listLimits: inTurn(reader.listLimits),
43
+ listLimitSeats: inTurn(reader.listLimitSeats),
44
+ listItemNotes: inTurn(reader.listItemNotes),
45
+ listUnsentNotices: inTurn(reader.listUnsentNotices),
46
+ listKeptSessions: inTurn(reader.listKeptSessions),
47
+ listSessionsToForget: inTurn(reader.listSessionsToForget),
48
+ readRunner: inTurn(reader.readRunner),
49
+ readUnsentFacts: inTurn(reader.readUnsentFacts),
50
+ readOutboxCounts: inTurn(reader.readOutboxCounts),
51
+ readItemBehind: inTurn(reader.readItemBehind),
52
+ applyChange: inTurn((change: StoreChange) => applySQLiteChange(db, change)),
53
+ markFactsSent: inTurn((ids: readonly FactId[], at: Instant) => writeFactsSent(db, ids, at)),
54
+ parkFacts: inTurn((ids: readonly FactId[], reason: string, at: Instant) =>
55
+ writeFactsParked(db, ids, reason, at),
56
+ ),
57
+ unparkFacts: inTurn(() => removeFactParks(db)),
58
+ writeHeartbeat: inTurn((runner: RunnerId, at: Instant) => writeRunnerBeat(db, runner, at)),
59
+ syncLimits: inTurn((limits: readonly Limit[], at: Instant) =>
60
+ writeLimits(db, reader, limits, at),
61
+ ),
62
+ markPullRequestChecked: inTurn((ref: PullRequestRef, at: Instant) =>
63
+ writePullRequestChecked(db, ref, at),
64
+ ),
65
+ markNoticeTried: inTurn((id: number, at: Instant, sent: boolean) =>
66
+ writeNoticeTry(db, id, at, sent),
67
+ ),
68
+ };
69
+ }
70
+
71
+ function makeTurnRunner() {
72
+ let turn: Promise<unknown> = Promise.resolve();
73
+
74
+ return <Args extends unknown[], Result>(run: (...args: Args) => Promise<Result>) =>
75
+ async (...args: Args): Promise<Result> => {
76
+ const previous = turn;
77
+ const next = Promise.withResolvers<void>();
78
+
79
+ turn = next.promise;
80
+
81
+ await previous;
82
+
83
+ try {
84
+ return await run(...args);
85
+ } finally {
86
+ next.resolve();
87
+ }
88
+ };
89
+ }
90
+
91
+ async function writeFactsSent(
92
+ db: Kysely<StoreDatabase>,
93
+ ids: readonly FactId[],
94
+ at: Instant,
95
+ ): Promise<void> {
96
+ if (ids.length === 0) {
97
+ return;
98
+ }
99
+
100
+ await db
101
+ .updateTable('outbox')
102
+ .set({ sent_at: at })
103
+ .where('fact_id', 'in', ids)
104
+ .where('sent_at', 'is', null)
105
+ .where('parked_at', 'is', null)
106
+ .execute();
107
+ }
108
+
109
+ async function writeFactsParked(
110
+ db: Kysely<StoreDatabase>,
111
+ ids: readonly FactId[],
112
+ reason: string,
113
+ at: Instant,
114
+ ): Promise<void> {
115
+ if (ids.length === 0) {
116
+ return;
117
+ }
118
+
119
+ await db
120
+ .updateTable('outbox')
121
+ .set({ parked_at: at, parked_reason: reason })
122
+ .where('fact_id', 'in', ids)
123
+ .where('sent_at', 'is', null)
124
+ .execute();
125
+ }
126
+
127
+ async function removeFactParks(db: Kysely<StoreDatabase>): Promise<number> {
128
+ const result = await db
129
+ .updateTable('outbox')
130
+ .set({ parked_at: null, parked_reason: null })
131
+ .where('sent_at', 'is', null)
132
+ .where('parked_at', 'is not', null)
133
+ .executeTakeFirst();
134
+
135
+ return Number(result.numUpdatedRows);
136
+ }
137
+
138
+ async function writeRunnerBeat(
139
+ db: Kysely<StoreDatabase>,
140
+ runner: RunnerId,
141
+ at: Instant,
142
+ ): Promise<void> {
143
+ await db
144
+ .insertInto('runners')
145
+ .values({ id: runner, heartbeat_at: at, paused: 0 })
146
+ .onConflict((conflict) => conflict.column('id').doUpdateSet({ heartbeat_at: at }))
147
+ .execute();
148
+ }
149
+
150
+ async function writeLimits(
151
+ db: Kysely<StoreDatabase>,
152
+ reader: StoreReader,
153
+ limits: readonly Limit[],
154
+ at: Instant,
155
+ ): Promise<void> {
156
+ const sync = collectSeatSync({
157
+ limits,
158
+ held: await reader.listLimitSeats(),
159
+ attempts: await reader.listLiveAttempts(),
160
+ at,
161
+ });
162
+
163
+ await db.deleteFrom('limits').execute();
164
+
165
+ for (const limit of limits) {
166
+ await db
167
+ .insertInto('limits')
168
+ .values({
169
+ name: limit.name,
170
+ max: limit.max,
171
+ counts: formatCanonicalJson(toJSONValue(limit.counts)),
172
+ })
173
+ .execute();
174
+ }
175
+
176
+ for (const seat of sync.release) {
177
+ await db
178
+ .updateTable('limit_seats')
179
+ .set({ released_at: at })
180
+ .where('limit', '=', seat.limit)
181
+ .where('attempt', '=', seat.attempt)
182
+ .where('hold_gen', '=', seat.holdGen)
183
+ .where('released_at', 'is', null)
184
+ .execute();
185
+ }
186
+
187
+ for (const seat of sync.take) {
188
+ await db
189
+ .insertInto('limit_seats')
190
+ .values({
191
+ limit: seat.limit,
192
+ attempt: seat.attempt,
193
+ hold_gen: seat.holdGen,
194
+ over_cap: seat.overCap ? 1 : 0,
195
+ taken_at: seat.takenAt,
196
+ released_at: seat.releasedAt,
197
+ })
198
+ .execute();
199
+ }
200
+ }
201
+
202
+ async function writePullRequestChecked(
203
+ db: Kysely<StoreDatabase>,
204
+ ref: PullRequestRef,
205
+ at: Instant,
206
+ ): Promise<void> {
207
+ await db
208
+ .updateTable('pull_requests')
209
+ .set({ checked_at: at })
210
+ .where('repo', '=', ref.repo)
211
+ .where('number', '=', ref.number)
212
+ .execute();
213
+ }
214
+
215
+ async function writeNoticeTry(
216
+ db: Kysely<StoreDatabase>,
217
+ id: number,
218
+ at: Instant,
219
+ sent: boolean,
220
+ ): Promise<void> {
221
+ await db
222
+ .updateTable('notices')
223
+ .set((builder) => ({
224
+ tries: builder('tries', '+', 1),
225
+ ...(sent ? { sent_at: at } : {}),
226
+ }))
227
+ .where('id', '=', id)
228
+ .execute();
229
+ }
@@ -0,0 +1,8 @@
1
+ import { sql } from 'kysely';
2
+ import type { Kysely } from 'kysely';
3
+ import type { StoreDatabase } from '../types.ts';
4
+ import { withBusyRetry } from '../with-busy-retry.ts';
5
+
6
+ export async function takeWriteLock(db: Kysely<StoreDatabase>, waitLimitMs: number): Promise<void> {
7
+ await withBusyRetry(waitLimitMs, () => sql`BEGIN IMMEDIATE`.execute(db));
8
+ }
@@ -1,321 +0,0 @@
1
- import { brandSchemas, outboxFactSchema } from '@boxxy/model';
2
- import type { ItemView, ItemWithLevers, PoolName, StoreReader } from '@boxxy/model';
3
- import type { Kysely } from 'kysely';
4
- import { parseStoreRow } from './parse-store-row.ts';
5
- import { readItemViews } from './read-item-views.ts';
6
- import type { StoreDatabase } from './types.ts';
7
-
8
- export function buildSQLiteReader(db: Kysely<StoreDatabase>): StoreReader {
9
- return {
10
- readSnapshot: async (now, maxAttemptsByKind) => {
11
- const views = await readItemViews(
12
- db,
13
- (builder) => builder('closed_at', 'is', null),
14
- now,
15
- maxAttemptsByKind,
16
- );
17
-
18
- const open = views.filter((item) => isOpen(item));
19
-
20
- const links = await db
21
- .selectFrom('links')
22
- .selectAll()
23
- .where('removed_at', 'is', null)
24
- .orderBy('added_at')
25
- .orderBy('source')
26
- .orderBy('target')
27
- .execute();
28
-
29
- const version = await db
30
- .selectFrom('outbox')
31
- .select((builder) => builder.fn.max('seq').as('seq'))
32
- .executeTakeFirst();
33
-
34
- return {
35
- storeVersion: version?.seq ?? 0,
36
- items: open.map((item) => toItemView(item)),
37
- links: links.map((row) => parseStoreRow('links', row)),
38
- levers: open
39
- .flatMap((item) => item.levers)
40
- .toSorted((first, second) => first.seq - second.seq),
41
- freeSlots: await readFreeSlots(db),
42
- };
43
- },
44
- readItem: async (id, now, maxAttemptsByKind) => {
45
- const views = await readItemViews(
46
- db,
47
- (builder) => builder('id', '=', id),
48
- now,
49
- maxAttemptsByKind,
50
- );
51
-
52
- const item = views.at(0);
53
-
54
- return item === undefined ? null : toItemView(item);
55
- },
56
- readItemDetail: async (id, now, maxAttemptsByKind) => {
57
- const views = await readItemViews(
58
- db,
59
- (builder) => builder('id', '=', id),
60
- now,
61
- maxAttemptsByKind,
62
- );
63
-
64
- const item = views.at(0);
65
-
66
- if (item === undefined) {
67
- return null;
68
- }
69
-
70
- const attemptsOfItem = db.selectFrom('attempts').select('id').where('item', '=', id);
71
-
72
- const attempts = await db
73
- .selectFrom('attempts')
74
- .selectAll()
75
- .where('item', '=', id)
76
- .orderBy('claimed_at')
77
- .orderBy('id')
78
- .execute();
79
-
80
- const links = await db
81
- .selectFrom('links')
82
- .selectAll()
83
- .where('removed_at', 'is', null)
84
- .where((builder) => builder.or([builder('source', '=', id), builder('target', '=', id)]))
85
- .orderBy('added_at')
86
- .orderBy('source')
87
- .orderBy('target')
88
- .execute();
89
-
90
- const evidence = await db
91
- .selectFrom('evidence')
92
- .selectAll()
93
- .where('attempt', 'in', attemptsOfItem)
94
- .orderBy('at')
95
- .orderBy('fact_id')
96
- .execute();
97
-
98
- const pullRequests = await db
99
- .selectFrom('pull_requests')
100
- .selectAll()
101
- .where('attempt', 'in', attemptsOfItem)
102
- .orderBy('repo')
103
- .orderBy('number')
104
- .execute();
105
-
106
- return {
107
- item,
108
- attempts: attempts.map((row) => parseStoreRow('attempts', row)),
109
- links: links.map((row) => parseStoreRow('links', row)),
110
- evidence: evidence.map((row) => parseStoreRow('evidence', row)),
111
- pullRequests: pullRequests.map((row) => parseStoreRow('pull_requests', row)),
112
- };
113
- },
114
- listItems: async (filter, now, maxAttemptsByKind) => {
115
- const repo = filter.repo;
116
-
117
- const views = await readItemViews(
118
- db,
119
- (builder) => (repo === undefined ? builder.lit(true) : builder('repo', '=', repo)),
120
- now,
121
- maxAttemptsByKind,
122
- );
123
-
124
- return views.filter(
125
- (item) => filter.state === undefined || item.derived.state === filter.state,
126
- );
127
- },
128
- listOpenIssues: async (repo, now, maxAttemptsByKind) => {
129
- const views = await readItemViews(
130
- db,
131
- (builder) =>
132
- builder.and([
133
- builder('kind', '=', 'core.issue'),
134
- builder('repo', '=', repo),
135
- builder('closed_at', 'is', null),
136
- ]),
137
- now,
138
- maxAttemptsByKind,
139
- );
140
-
141
- return views.filter((item) => isOpen(item)).map((item) => toItemView(item));
142
- },
143
- readLastLeaseGen: async (item, phase) => {
144
- const row = await db
145
- .selectFrom('leases')
146
- .select((builder) => builder.fn.max('gen').as('gen'))
147
- .where('item', '=', item)
148
- .where('phase', '=', phase)
149
- .executeTakeFirst();
150
-
151
- return row?.gen ?? 0;
152
- },
153
- readAttempt: async (id) => {
154
- const row = await db
155
- .selectFrom('attempts')
156
- .selectAll()
157
- .where('id', '=', id)
158
- .executeTakeFirst();
159
-
160
- return row === undefined ? null : parseStoreRow('attempts', row);
161
- },
162
- listEvidence: async (attempt) => {
163
- const rows = await db
164
- .selectFrom('evidence')
165
- .selectAll()
166
- .where('attempt', '=', attempt)
167
- .orderBy('at')
168
- .orderBy('fact_id')
169
- .execute();
170
-
171
- return rows.map((row) => parseStoreRow('evidence', row));
172
- },
173
- listLiveAttempts: async () => {
174
- const rows = await db
175
- .selectFrom('attempts')
176
- .selectAll()
177
- .where('state', 'in', ['starting', 'running', 'reconciling'])
178
- .orderBy('claimed_at')
179
- .orderBy('id')
180
- .execute();
181
-
182
- return rows.map((row) => parseStoreRow('attempts', row));
183
- },
184
- listSucceededAttemptsWithoutPr: async () => {
185
- const linked = db.selectFrom('pull_requests').select('attempt');
186
-
187
- const rows = await db
188
- .selectFrom('attempts')
189
- .selectAll()
190
- .where('state', '=', 'succeeded')
191
- .where('id', 'not in', linked)
192
- .orderBy('claimed_at')
193
- .orderBy('id')
194
- .execute();
195
-
196
- return rows.map((row) => parseStoreRow('attempts', row));
197
- },
198
- // oxlint-disable-next-line zgeoff/verb-contract -- the StoreReader port in @boxxy/model fixes this name
199
- findAttemptBySession: async (session) => {
200
- const row = await db
201
- .selectFrom('attempts')
202
- .selectAll()
203
- .where('atc_session', '=', session)
204
- .orderBy('claimed_at')
205
- .orderBy('id')
206
- .executeTakeFirst();
207
-
208
- return row === undefined ? null : parseStoreRow('attempts', row);
209
- },
210
- // oxlint-disable-next-line zgeoff/verb-contract -- the StoreReader port in @boxxy/model fixes this name
211
- findAttemptByAgentSession: async (session) => {
212
- const row = await db
213
- .selectFrom('attempt_sessions')
214
- .select('attempt')
215
- .where('agent_session', '=', session)
216
- .executeTakeFirst();
217
-
218
- return row === undefined ? null : brandSchemas.attemptId.parse(row.attempt);
219
- },
220
- listWatchedPullRequests: async () => {
221
- const rows = await db
222
- .selectFrom('pull_requests')
223
- .selectAll()
224
- .where('state', '=', 'open')
225
- .orderBy('repo')
226
- .orderBy('number')
227
- .execute();
228
-
229
- return rows.map((row) => parseStoreRow('pull_requests', row));
230
- },
231
- listPoolSlots: async () => {
232
- const rows = await db
233
- .selectFrom('pool_slots')
234
- .selectAll()
235
- .orderBy('pool')
236
- .orderBy('slot')
237
- .execute();
238
-
239
- return rows.map((row) => parseStoreRow('pool_slots', row));
240
- },
241
- readUnsentFacts: async (limit) => {
242
- const rows = await db
243
- .selectFrom('outbox')
244
- .selectAll()
245
- .where('sent_at', 'is', null)
246
- .orderBy('seq')
247
- .limit(limit)
248
- .execute();
249
-
250
- return rows.map((row) =>
251
- outboxFactSchema.parse({
252
- seq: row.seq,
253
- factId: row.fact_id,
254
- type: row.type,
255
- entity: row.entity,
256
- actor: row.actor,
257
- at: row.at,
258
- payload: parseJSONText(row.payload),
259
- sentAt: row.sent_at,
260
- }),
261
- );
262
- },
263
- readOutboxLag: async () => {
264
- const row = await db
265
- .selectFrom('outbox')
266
- .select((builder) => [
267
- builder.fn.countAll<number>().as('unsent'),
268
- builder.fn.min('at').as('oldest'),
269
- ])
270
- .where('sent_at', 'is', null)
271
- .executeTakeFirstOrThrow();
272
-
273
- const oldest = row.oldest === null ? null : brandSchemas.instant.parse(row.oldest);
274
-
275
- return { unsent: row.unsent, oldestUnsentAt: oldest };
276
- },
277
- readRunner: async (id) => {
278
- const row = await db
279
- .selectFrom('runners')
280
- .selectAll()
281
- .where('id', '=', id)
282
- .executeTakeFirst();
283
-
284
- return row === undefined ? null : parseStoreRow('runners', row);
285
- },
286
- };
287
- }
288
-
289
- function isOpen(item: ItemView): boolean {
290
- return item.derived.state !== 'done' && item.derived.state !== 'archived';
291
- }
292
-
293
- function toItemView(item: ItemWithLevers): ItemView {
294
- const { levers: _levers, ...view } = item;
295
-
296
- return view;
297
- }
298
-
299
- async function readFreeSlots(db: Kysely<StoreDatabase>): Promise<Record<PoolName, number>> {
300
- const rows = await db
301
- .selectFrom('pool_slots')
302
- .select(['pool', 'holder'])
303
- .where('retired', '=', 0)
304
- .execute();
305
-
306
- const free: Record<PoolName, number> = {};
307
-
308
- for (const row of rows) {
309
- const pool = brandSchemas.poolName.parse(row.pool);
310
-
311
- free[pool] = (free[pool] ?? 0) + (row.holder === null ? 1 : 0);
312
- }
313
-
314
- return free;
315
- }
316
-
317
- function parseJSONText(text: string): unknown {
318
- const parsed: unknown = JSON.parse(text);
319
-
320
- return parsed;
321
- }
@@ -1,134 +0,0 @@
1
- import type { FactId, Instant, PullRequestRef, RunnerId, StoreChange, StoreTx } from '@boxxy/model';
2
- import type { Kysely } from 'kysely';
3
- import { applySQLiteChange } from './apply-sqlite-change.ts';
4
- import { buildSQLiteReader } from './build-sqlite-reader.ts';
5
- import type { StoreDatabase } from './types.ts';
6
-
7
- type PoolCounts = Parameters<StoreTx['syncPoolSlots']>[0];
8
-
9
- // A call started beside a change would run its statements inside the change's savepoint, and a
10
- // refusal would roll them back after the call resolved, so every call waits for the one before it.
11
- export function createSQLiteTx(db: Kysely<StoreDatabase>): StoreTx {
12
- const inTurn = makeTurnRunner();
13
- const reader = buildSQLiteReader(db);
14
-
15
- return {
16
- readSnapshot: inTurn(reader.readSnapshot),
17
- readItem: inTurn(reader.readItem),
18
- readItemDetail: inTurn(reader.readItemDetail),
19
- listItems: inTurn(reader.listItems),
20
- listOpenIssues: inTurn(reader.listOpenIssues),
21
- readLastLeaseGen: inTurn(reader.readLastLeaseGen),
22
- readAttempt: inTurn(reader.readAttempt),
23
- listEvidence: inTurn(reader.listEvidence),
24
- listLiveAttempts: inTurn(reader.listLiveAttempts),
25
- listSucceededAttemptsWithoutPr: inTurn(reader.listSucceededAttemptsWithoutPr),
26
- findAttemptBySession: inTurn(reader.findAttemptBySession),
27
- findAttemptByAgentSession: inTurn(reader.findAttemptByAgentSession),
28
- listWatchedPullRequests: inTurn(reader.listWatchedPullRequests),
29
- listPoolSlots: inTurn(reader.listPoolSlots),
30
- readUnsentFacts: inTurn(reader.readUnsentFacts),
31
- readOutboxLag: inTurn(reader.readOutboxLag),
32
- readRunner: inTurn(reader.readRunner),
33
- applyChange: inTurn((change: StoreChange) => applySQLiteChange(db, change)),
34
- markFactsSent: inTurn((ids: FactId[], at: Instant) => writeFactsSent(db, ids, at)),
35
- writeHeartbeat: inTurn((runner: RunnerId, at: Instant) => writeRunnerBeat(db, runner, at)),
36
- syncPoolSlots: inTurn((pools: PoolCounts) => writePoolSlots(db, pools)),
37
- markPullRequestChecked: inTurn((ref: PullRequestRef, at: Instant) =>
38
- writePullRequestChecked(db, ref, at),
39
- ),
40
- };
41
- }
42
-
43
- function makeTurnRunner() {
44
- let turn: Promise<unknown> = Promise.resolve();
45
-
46
- return <Args extends unknown[], Result>(run: (...args: Args) => Promise<Result>) =>
47
- async (...args: Args): Promise<Result> => {
48
- const previous = turn;
49
- const next = Promise.withResolvers<void>();
50
-
51
- turn = next.promise;
52
-
53
- await previous;
54
-
55
- try {
56
- return await run(...args);
57
- } finally {
58
- next.resolve();
59
- }
60
- };
61
- }
62
-
63
- async function writeFactsSent(
64
- db: Kysely<StoreDatabase>,
65
- ids: FactId[],
66
- at: Instant,
67
- ): Promise<void> {
68
- if (ids.length === 0) {
69
- return;
70
- }
71
-
72
- await db
73
- .updateTable('outbox')
74
- .set({ sent_at: at })
75
- .where('fact_id', 'in', ids)
76
- .where('sent_at', 'is', null)
77
- .execute();
78
- }
79
-
80
- async function writeRunnerBeat(
81
- db: Kysely<StoreDatabase>,
82
- runner: RunnerId,
83
- at: Instant,
84
- ): Promise<void> {
85
- await db
86
- .insertInto('runners')
87
- .values({ id: runner, heartbeat_at: at, paused: 0 })
88
- .onConflict((conflict) => conflict.column('id').doUpdateSet({ heartbeat_at: at }))
89
- .execute();
90
- }
91
-
92
- async function writePoolSlots(db: Kysely<StoreDatabase>, pools: PoolCounts): Promise<void> {
93
- for (const pool of pools) {
94
- const rows = Array.from({ length: pool.slots }, (_, index) => ({
95
- pool: pool.name,
96
- slot: index + 1,
97
- holder: null,
98
- gen: 0,
99
- retired: 0,
100
- }));
101
-
102
- if (rows.length > 0) {
103
- await db
104
- .insertInto('pool_slots')
105
- .values(rows)
106
- .onConflict((conflict) => conflict.columns(['pool', 'slot']).doNothing())
107
- .execute();
108
- }
109
- }
110
-
111
- await db.updateTable('pool_slots').set({ retired: 1 }).execute();
112
-
113
- for (const pool of pools) {
114
- await db
115
- .updateTable('pool_slots')
116
- .set({ retired: 0 })
117
- .where('pool', '=', pool.name)
118
- .where('slot', '<=', pool.slots)
119
- .execute();
120
- }
121
- }
122
-
123
- async function writePullRequestChecked(
124
- db: Kysely<StoreDatabase>,
125
- ref: PullRequestRef,
126
- at: Instant,
127
- ): Promise<void> {
128
- await db
129
- .updateTable('pull_requests')
130
- .set({ checked_at: at })
131
- .where('repo', '=', ref.repo)
132
- .where('number', '=', ref.number)
133
- .execute();
134
- }