@boxxy/store-sqlite 0.1.1 → 0.1.3

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 +7 -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,42 @@
1
+ import type { Kysely } from 'kysely';
2
+ import { Migrator } from 'kysely/migration';
3
+ import { lockWaitLimitMs } from '../constants.ts';
4
+ import type { StoreDatabase } from '../types.ts';
5
+ import { withTransaction } from '../with-transaction.ts';
6
+ import { takeWriteLock } from '../writes/take-write-lock.ts';
7
+ import { createTablesMigration } from './create-tables-migration.ts';
8
+ import { renameRelationsMigration } from './rename-relations-migration.ts';
9
+ import { v1TablesMigration } from './v1-tables-migration.ts';
10
+
11
+ // Kysely's SQLite migration lock does nothing, so two openers of a new file would both find nothing
12
+ // run; the write lock makes the second one wait and then find the file migrated.
13
+ export async function runStoreMigrations(db: Kysely<StoreDatabase>): Promise<void> {
14
+ await db.connection().execute((connection) =>
15
+ withTransaction(
16
+ connection,
17
+ (locked) => takeWriteLock(locked, lockWaitLimitMs),
18
+ () => runMigrator(connection),
19
+ ),
20
+ );
21
+ }
22
+
23
+ // Kysely runs the migrations in the order of their names, and skips each one the file has run.
24
+ const migrations = {
25
+ '0001-create-tables': createTablesMigration,
26
+ '0002-rename-relations': renameRelationsMigration,
27
+ '0003-v1-tables': v1TablesMigration,
28
+ };
29
+
30
+ async function runMigrator(db: Kysely<StoreDatabase>): Promise<void> {
31
+ const migrator = new Migrator({
32
+ db,
33
+ provider: { getMigrations: () => Promise.resolve(migrations) },
34
+ disableTransactions: true,
35
+ });
36
+
37
+ const outcome = await migrator.migrateToLatest();
38
+
39
+ if (outcome.error !== undefined) {
40
+ throw new Error('the store migrations failed', { cause: outcome.error });
41
+ }
42
+ }
@@ -0,0 +1,184 @@
1
+ import { sql } from 'kysely';
2
+ import type { Kysely } from 'kysely';
3
+ import type { Migration } from 'kysely/migration';
4
+
5
+ // Slice 1 never ran outside tests, so this rebuilds each changed table empty, in place of ALTER
6
+ // statements that would need an invented default for every new NOT NULL column. A file that
7
+ // already holds an item or a fact is refused, so no row is ever dropped.
8
+ const statements = [
9
+ sql`DROP TABLE items`,
10
+ sql`CREATE TABLE items (
11
+ id TEXT PRIMARY KEY,
12
+ kind TEXT NOT NULL,
13
+ repo TEXT NOT NULL,
14
+ title TEXT NOT NULL,
15
+ outcome TEXT NOT NULL,
16
+ acceptance TEXT NOT NULL,
17
+ fields TEXT NOT NULL,
18
+ labels TEXT NOT NULL,
19
+ found_in_attempt TEXT,
20
+ filed_by TEXT NOT NULL,
21
+ filed_at INTEGER NOT NULL,
22
+ closed_at INTEGER,
23
+ close_reason TEXT,
24
+ kept_session TEXT
25
+ ) STRICT`,
26
+ sql`DROP TABLE levers`,
27
+ sql`CREATE TABLE levers (
28
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ kind TEXT NOT NULL,
30
+ item TEXT NOT NULL,
31
+ until INTEGER,
32
+ expires_at INTEGER,
33
+ reason TEXT,
34
+ phase TEXT,
35
+ actor TEXT NOT NULL,
36
+ at INTEGER NOT NULL
37
+ ) STRICT`,
38
+ sql`CREATE INDEX levers_item_kind ON levers (item, kind)`,
39
+ sql`DROP TABLE attempts`,
40
+ sql`CREATE TABLE attempts (
41
+ id TEXT PRIMARY KEY,
42
+ item TEXT NOT NULL,
43
+ phase TEXT NOT NULL,
44
+ number INTEGER NOT NULL,
45
+ runner TEXT NOT NULL,
46
+ recipe_hash TEXT NOT NULL,
47
+ rules_version TEXT NOT NULL,
48
+ pool TEXT NOT NULL,
49
+ lease_gen INTEGER NOT NULL,
50
+ spawn_request TEXT NOT NULL,
51
+ spawn_request_hash TEXT NOT NULL,
52
+ state TEXT NOT NULL,
53
+ failure TEXT,
54
+ atc_session TEXT,
55
+ caller TEXT,
56
+ claimed_at INTEGER NOT NULL,
57
+ started_at INTEGER,
58
+ ended_at INTEGER,
59
+ fields TEXT NOT NULL,
60
+ mode TEXT NOT NULL,
61
+ round INTEGER NOT NULL,
62
+ input TEXT,
63
+ hold_gen INTEGER NOT NULL,
64
+ close_out TEXT,
65
+ close_out_hash TEXT,
66
+ close_out_fact TEXT,
67
+ closing TEXT,
68
+ closing_at INTEGER,
69
+ suspend_requested_at INTEGER,
70
+ slept_at INTEGER,
71
+ wake_requested_at INTEGER,
72
+ woken_at INTEGER,
73
+ outcome TEXT,
74
+ returned_outcome TEXT,
75
+ strayed_at INTEGER,
76
+ session_stopped_at INTEGER,
77
+ session_forgotten_at INTEGER,
78
+ UNIQUE (item, phase, number)
79
+ ) STRICT`,
80
+ sql`CREATE INDEX attempts_atc_session ON attempts (atc_session)`,
81
+ sql`DROP TABLE attempt_sessions`,
82
+
83
+ // a kept session binds one agent session to each later attempt, so the agent session is no key
84
+ sql`CREATE TABLE attempt_sessions (
85
+ attempt TEXT NOT NULL,
86
+ agent_session TEXT NOT NULL,
87
+ atc_session TEXT NOT NULL,
88
+ product TEXT NOT NULL,
89
+ source TEXT NOT NULL,
90
+ seen_at INTEGER NOT NULL,
91
+ PRIMARY KEY (attempt, agent_session)
92
+ ) STRICT`,
93
+ sql`CREATE INDEX attempt_sessions_agent_session ON attempt_sessions (agent_session)`,
94
+ sql`DROP TABLE pool_slots`,
95
+ sql`CREATE TABLE limits (
96
+ name TEXT PRIMARY KEY,
97
+ max INTEGER NOT NULL,
98
+ counts TEXT NOT NULL
99
+ ) STRICT`,
100
+ sql`CREATE TABLE limit_seats (
101
+ "limit" TEXT NOT NULL,
102
+ attempt TEXT NOT NULL,
103
+ hold_gen INTEGER NOT NULL,
104
+ over_cap INTEGER NOT NULL,
105
+ taken_at INTEGER NOT NULL,
106
+ released_at INTEGER
107
+ ) STRICT`,
108
+ sql`CREATE UNIQUE INDEX limit_seats_held ON limit_seats ("limit", attempt, hold_gen)
109
+ WHERE released_at IS NULL`,
110
+ sql`DROP TABLE decisions`,
111
+ sql`CREATE TABLE decisions (
112
+ id TEXT PRIMARY KEY,
113
+ attempt TEXT NOT NULL,
114
+ item TEXT NOT NULL,
115
+ kind TEXT NOT NULL,
116
+ pool TEXT NOT NULL,
117
+ rules_version TEXT NOT NULL,
118
+ snapshot_hash TEXT NOT NULL,
119
+ store_version INTEGER NOT NULL,
120
+ pepys_offsets TEXT NOT NULL,
121
+ faults TEXT NOT NULL,
122
+ limits TEXT NOT NULL,
123
+ at INTEGER NOT NULL
124
+ ) STRICT`,
125
+ sql`DROP TABLE pull_requests`,
126
+ sql`CREATE TABLE pull_requests (
127
+ repo TEXT NOT NULL,
128
+ number INTEGER NOT NULL,
129
+ attempt TEXT NOT NULL,
130
+ github_id INTEGER NOT NULL,
131
+ url TEXT NOT NULL,
132
+ state TEXT NOT NULL,
133
+ merged_at INTEGER,
134
+ merge_commit TEXT,
135
+ checked_at INTEGER NOT NULL,
136
+ PRIMARY KEY (repo, number)
137
+ ) STRICT`,
138
+ sql`CREATE TABLE notes (
139
+ item TEXT NOT NULL,
140
+ seq INTEGER NOT NULL,
141
+ text TEXT NOT NULL,
142
+ "by" TEXT NOT NULL,
143
+ at INTEGER NOT NULL,
144
+ PRIMARY KEY (item, seq)
145
+ ) STRICT`,
146
+ sql`CREATE TABLE notices (
147
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
148
+ item TEXT NOT NULL,
149
+ reason TEXT NOT NULL,
150
+ at INTEGER NOT NULL,
151
+ tries INTEGER NOT NULL,
152
+ sent_at INTEGER
153
+ ) STRICT`,
154
+ sql`CREATE INDEX notices_unsent ON notices (id) WHERE sent_at IS NULL`,
155
+ sql`DROP TABLE outbox`,
156
+ sql`CREATE TABLE outbox (
157
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
158
+ fact_id TEXT NOT NULL UNIQUE,
159
+ type TEXT NOT NULL,
160
+ entity TEXT NOT NULL,
161
+ actor TEXT NOT NULL,
162
+ at INTEGER NOT NULL,
163
+ payload TEXT NOT NULL,
164
+ sent_at INTEGER,
165
+ parked_at INTEGER,
166
+ parked_reason TEXT
167
+ ) STRICT`,
168
+ sql`CREATE INDEX outbox_unsent ON outbox (seq) WHERE sent_at IS NULL AND parked_at IS NULL`,
169
+ sql`CREATE INDEX outbox_behind ON outbox (entity) WHERE sent_at IS NULL`,
170
+ ];
171
+
172
+ export const v1TablesMigration: Migration = {
173
+ up: async (db: Kysely<unknown>) => {
174
+ const found = await sql`SELECT 1 FROM items UNION ALL SELECT 1 FROM outbox LIMIT 1`.execute(db);
175
+
176
+ if (found.rows.length > 0) {
177
+ throw new Error('the store holds slice 1 rows, which the v1 tables cannot take');
178
+ }
179
+
180
+ for (const statement of statements) {
181
+ await statement.execute(db);
182
+ }
183
+ },
184
+ };
@@ -0,0 +1,484 @@
1
+ import { formatCanonicalJson } from '@boxxy/content-hash';
2
+ import { brandSchemas, limitSeatRowSchema, outboxFactSchema, toJSONValue } from '@boxxy/model';
3
+ import type { ItemView, ItemWithLevers, StoreReader } from '@boxxy/model';
4
+ import type { Kysely } from 'kysely';
5
+ import { parseJSONText } from '../codecs/parse-json-text.ts';
6
+ import { parseStoreRow } from '../codecs/parse-store-row.ts';
7
+ import type { StoreDatabase } from '../types.ts';
8
+ import { isLatestOfSession } from './is-latest-of-session.ts';
9
+ import { readItemViews } from './read-item-views.ts';
10
+ import { readLimitFills } from './read-limit-fills.ts';
11
+ import { readWakes } from './read-wakes.ts';
12
+
13
+ export function buildSQLiteReader(db: Kysely<StoreDatabase>): StoreReader {
14
+ return {
15
+ readSnapshot: async (now, rules) => {
16
+ const views = await readItemViews(
17
+ db,
18
+ (builder) => builder('closed_at', 'is', null),
19
+ now,
20
+ rules,
21
+ );
22
+
23
+ const open = views.filter((item) => isOpen(item));
24
+
25
+ const relations = await db
26
+ .selectFrom('relations')
27
+ .selectAll()
28
+ .where('removed_at', 'is', null)
29
+ .orderBy('added_at')
30
+ .orderBy('source')
31
+ .orderBy('target')
32
+ .execute();
33
+
34
+ const version = await db
35
+ .selectFrom('outbox')
36
+ .select((builder) => builder.fn.max('seq').as('seq'))
37
+ .executeTakeFirst();
38
+
39
+ return {
40
+ storeVersion: version?.seq ?? 0,
41
+ items: open.map((item) => toItemView(item)),
42
+ relations: relations.map((row) => parseStoreRow('relations', row)),
43
+ levers: open
44
+ .flatMap((item) => item.levers)
45
+ .toSorted((first, second) => first.seq - second.seq),
46
+ limits: await readLimitFills(db),
47
+ wakes: await readWakes(db),
48
+ };
49
+ },
50
+ readItem: async (id, now, rules) => {
51
+ const views = await readItemViews(db, (builder) => builder('id', '=', id), now, rules);
52
+
53
+ const item = views.at(0);
54
+
55
+ return item === undefined ? null : toItemView(item);
56
+ },
57
+ readItemDetail: async (id, now, rules) => {
58
+ const views = await readItemViews(db, (builder) => builder('id', '=', id), now, rules);
59
+
60
+ const item = views.at(0);
61
+
62
+ if (item === undefined) {
63
+ return null;
64
+ }
65
+
66
+ const attemptsOfItem = db.selectFrom('attempts').select('id').where('item', '=', id);
67
+
68
+ const attempts = await db
69
+ .selectFrom('attempts')
70
+ .selectAll()
71
+ .where('item', '=', id)
72
+ .orderBy('claimed_at')
73
+ .orderBy('id')
74
+ .execute();
75
+
76
+ const relations = await db
77
+ .selectFrom('relations')
78
+ .selectAll()
79
+ .where('removed_at', 'is', null)
80
+ .where((builder) => builder.or([builder('source', '=', id), builder('target', '=', id)]))
81
+ .orderBy('added_at')
82
+ .orderBy('source')
83
+ .orderBy('target')
84
+ .execute();
85
+
86
+ const evidence = await db
87
+ .selectFrom('evidence')
88
+ .selectAll()
89
+ .where('attempt', 'in', attemptsOfItem)
90
+ .orderBy('at')
91
+ .orderBy('fact_id')
92
+ .execute();
93
+
94
+ const pullRequests = await db
95
+ .selectFrom('pull_requests')
96
+ .selectAll()
97
+ .where('attempt', 'in', attemptsOfItem)
98
+ .orderBy('repo')
99
+ .orderBy('number')
100
+ .execute();
101
+
102
+ const notes = await db
103
+ .selectFrom('notes')
104
+ .selectAll()
105
+ .where('item', '=', id)
106
+ .orderBy('seq')
107
+ .execute();
108
+
109
+ return {
110
+ item,
111
+ attempts: attempts.map((row) => parseStoreRow('attempts', row)),
112
+ relations: relations.map((row) => parseStoreRow('relations', row)),
113
+ evidence: evidence.map((row) => parseStoreRow('evidence', row)),
114
+ pullRequests: pullRequests.map((row) => parseStoreRow('pull_requests', row)),
115
+ notes: notes.map((row) => parseStoreRow('notes', row)),
116
+ };
117
+ },
118
+ listItems: async (filter, now, rules) => {
119
+ const repo = filter.repo;
120
+
121
+ const views = await readItemViews(
122
+ db,
123
+ (builder) => (repo === undefined ? builder.lit(true) : builder('repo', '=', repo)),
124
+ now,
125
+ rules,
126
+ );
127
+
128
+ return views.filter(
129
+ (item) => filter.state === undefined || item.derived.state === filter.state,
130
+ );
131
+ },
132
+ listOpenIssues: async (repo, now, rules) => {
133
+ const views = await readItemViews(
134
+ db,
135
+ (builder) =>
136
+ builder.and([
137
+ builder('kind', '=', 'core.issue'),
138
+ builder('repo', '=', repo),
139
+ builder('closed_at', 'is', null),
140
+ ]),
141
+ now,
142
+ rules,
143
+ );
144
+
145
+ return views.filter((item) => isOpen(item)).map((item) => toItemView(item));
146
+ },
147
+ readLastLeaseGen: async (item, phase) => {
148
+ const row = await db
149
+ .selectFrom('leases')
150
+ .select((builder) => builder.fn.max('gen').as('gen'))
151
+ .where('item', '=', item)
152
+ .where('phase', '=', phase)
153
+ .executeTakeFirst();
154
+
155
+ return row?.gen ?? 0;
156
+ },
157
+ readAttempt: async (id) => {
158
+ const row = await db
159
+ .selectFrom('attempts')
160
+ .selectAll()
161
+ .where('id', '=', id)
162
+ .executeTakeFirst();
163
+
164
+ return row === undefined ? null : parseStoreRow('attempts', row);
165
+ },
166
+ listEvidence: async (attempt) => {
167
+ const rows = await db
168
+ .selectFrom('evidence')
169
+ .selectAll()
170
+ .where('attempt', '=', attempt)
171
+ .orderBy('at')
172
+ .orderBy('fact_id')
173
+ .execute();
174
+
175
+ return rows.map((row) => parseStoreRow('evidence', row));
176
+ },
177
+ listItemAttempts: async (item) => {
178
+ const rows = await db
179
+ .selectFrom('attempts')
180
+ .selectAll()
181
+ .where('item', '=', item)
182
+ .orderBy('claimed_at')
183
+ .orderBy('id')
184
+ .execute();
185
+
186
+ return rows.map((row) => parseStoreRow('attempts', row));
187
+ },
188
+ listLiveAttempts: async () => {
189
+ const rows = await db
190
+ .selectFrom('attempts')
191
+ .selectAll()
192
+ .where('state', 'not in', ['succeeded', 'failed'])
193
+ .orderBy('claimed_at')
194
+ .orderBy('id')
195
+ .execute();
196
+
197
+ return rows.map((row) => parseStoreRow('attempts', row));
198
+ },
199
+ listStrayAttempts: async (since) => {
200
+ const rows = await db
201
+ .selectFrom('attempts')
202
+ .selectAll()
203
+ .where('strayed_at', '>=', since)
204
+ .where('session_stopped_at', 'is', null)
205
+ .orderBy('claimed_at')
206
+ .orderBy('id')
207
+ .execute();
208
+
209
+ return rows.map((row) => parseStoreRow('attempts', row));
210
+ },
211
+ listSucceededAttemptsWithoutPullRequest: async () => {
212
+ const linked = db.selectFrom('pull_requests').select('attempt');
213
+
214
+ const rows = await db
215
+ .selectFrom('attempts')
216
+ .selectAll()
217
+ .where('state', '=', 'succeeded')
218
+ .where('id', 'not in', linked)
219
+ .orderBy('claimed_at')
220
+ .orderBy('id')
221
+ .execute();
222
+
223
+ return rows.map((row) => parseStoreRow('attempts', row));
224
+ },
225
+ // oxlint-disable-next-line zgeoff/verb-contract -- the StoreReader port in @boxxy/model fixes this name
226
+ findAttemptBySession: async (session) => {
227
+ const row = await db
228
+ .selectFrom('attempts')
229
+ .selectAll()
230
+ .where('atc_session', '=', session)
231
+ .orderBy('claimed_at', 'desc')
232
+ .orderBy('id', 'desc')
233
+ .executeTakeFirst();
234
+
235
+ return row === undefined ? null : parseStoreRow('attempts', row);
236
+ },
237
+ // oxlint-disable-next-line zgeoff/verb-contract -- the StoreReader port in @boxxy/model fixes this name
238
+ findAttemptByAgentSession: async (session) => {
239
+ const row = await db
240
+ .selectFrom('attempts')
241
+ .select('id')
242
+ .where(
243
+ 'id',
244
+ 'in',
245
+ db.selectFrom('attempt_sessions').select('attempt').where('agent_session', '=', session),
246
+ )
247
+ .orderBy('claimed_at', 'desc')
248
+ .orderBy('id', 'desc')
249
+ .executeTakeFirst();
250
+
251
+ return row === undefined ? null : brandSchemas.attemptId.parse(row.id);
252
+ },
253
+ // oxlint-disable-next-line zgeoff/verb-contract -- the StoreReader port in @boxxy/model fixes this name
254
+ findSessionByCaller: async (caller) => {
255
+ // the store keeps the caller as canonical JSON, so equal callers are equal text
256
+ const stored = formatCanonicalJson(toJSONValue(caller));
257
+
258
+ const rows = await db
259
+ .selectFrom('attempts')
260
+ .select('atc_session')
261
+ .distinct()
262
+ .where('caller', '=', stored)
263
+ .where((builder) => isLatestOfSession(builder))
264
+ .limit(2)
265
+ .execute();
266
+
267
+ if (rows.length > 1) {
268
+ return 'ambiguous';
269
+ }
270
+
271
+ const session = rows.at(0)?.atc_session;
272
+
273
+ return session === undefined || session === null
274
+ ? null
275
+ : brandSchemas.atcSessionId.parse(session);
276
+ },
277
+ readPullRequestRow: async (ref) => {
278
+ const row = await db
279
+ .selectFrom('pull_requests')
280
+ .selectAll()
281
+ .where('repo', '=', ref.repo)
282
+ .where('number', '=', ref.number)
283
+ .executeTakeFirst();
284
+
285
+ return row === undefined ? null : parseStoreRow('pull_requests', row);
286
+ },
287
+ listWatchedPullRequests: async () => {
288
+ const rows = await db
289
+ .selectFrom('pull_requests')
290
+ .selectAll()
291
+ .where('state', '=', 'open')
292
+ .orderBy('repo')
293
+ .orderBy('number')
294
+ .execute();
295
+
296
+ return rows.map((row) => parseStoreRow('pull_requests', row));
297
+ },
298
+ listLimits: () => readLimitFills(db),
299
+ listLimitSeats: async () => {
300
+ const rows = await db
301
+ .selectFrom('limit_seats')
302
+ .selectAll()
303
+ .where('released_at', 'is', null)
304
+ .orderBy('limit')
305
+ .orderBy('attempt')
306
+ .execute();
307
+
308
+ return rows.map((row) =>
309
+ limitSeatRowSchema.parse({
310
+ limit: row.limit,
311
+ attempt: row.attempt,
312
+ holdGen: row.hold_gen,
313
+ overCap: row.over_cap === 1,
314
+ takenAt: row.taken_at,
315
+ releasedAt: row.released_at,
316
+ }),
317
+ );
318
+ },
319
+ listItemNotes: async (item) => {
320
+ const rows = await db
321
+ .selectFrom('notes')
322
+ .selectAll()
323
+ .where('item', '=', item)
324
+ .orderBy('seq')
325
+ .execute();
326
+
327
+ return rows.map((row) => parseStoreRow('notes', row));
328
+ },
329
+ listUnsentNotices: async (limit) => {
330
+ const rows = await db
331
+ .selectFrom('notices')
332
+ .selectAll()
333
+ .where('sent_at', 'is', null)
334
+ .orderBy('id')
335
+ .limit(limit)
336
+ .execute();
337
+
338
+ return rows.map((row) => parseStoreRow('notices', row));
339
+ },
340
+ listKeptSessions: async () => {
341
+ const rows = await db
342
+ .selectFrom('items')
343
+ .selectAll()
344
+ .where('kept_session', 'is not', null)
345
+ .orderBy('filed_at')
346
+ .orderBy('id')
347
+ .execute();
348
+
349
+ return rows
350
+ .flatMap((row) => {
351
+ const item = parseStoreRow('items', row);
352
+
353
+ return item.keptSession === null ? [] : [{ item: item.id, kept: item.keptSession }];
354
+ })
355
+ .toSorted((first, second) => first.kept.keptAt - second.kept.keptAt);
356
+ },
357
+ listSessionsToForget: async (failedEndedBefore) => {
358
+ const keptRows = await db
359
+ .selectFrom('items')
360
+ .selectAll()
361
+ .where('kept_session', 'is not', null)
362
+ .execute();
363
+
364
+ const kept = new Set(
365
+ keptRows.flatMap((row) => {
366
+ const keptSession = parseStoreRow('items', row).keptSession;
367
+
368
+ return keptSession === null ? [] : [keptSession.atcSession];
369
+ }),
370
+ );
371
+
372
+ const rows = await db
373
+ .selectFrom('attempts')
374
+ .selectAll()
375
+ .where('atc_session', 'is not', null)
376
+ .where('session_forgotten_at', 'is', null)
377
+ .where((builder) => isLatestOfSession(builder))
378
+ .where((builder) =>
379
+ builder.or([
380
+ builder('strayed_at', 'is', null),
381
+ builder('session_stopped_at', 'is not', null),
382
+ ]),
383
+ )
384
+ .where((builder) => {
385
+ const failedBefore = builder.and([
386
+ builder('state', '=', 'failed'),
387
+ builder('ended_at', '<=', failedEndedBefore),
388
+ ]);
389
+
390
+ return builder.or([builder('state', '=', 'succeeded'), failedBefore]);
391
+ })
392
+ .orderBy('claimed_at')
393
+ .orderBy('id')
394
+ .execute();
395
+
396
+ return rows
397
+ .map((row) => parseStoreRow('attempts', row))
398
+ .filter((attempt) => attempt.atcSession === null || !kept.has(attempt.atcSession));
399
+ },
400
+ readRunner: async (id) => {
401
+ const row = await db
402
+ .selectFrom('runners')
403
+ .selectAll()
404
+ .where('id', '=', id)
405
+ .executeTakeFirst();
406
+
407
+ return row === undefined ? null : parseStoreRow('runners', row);
408
+ },
409
+ readUnsentFacts: async (limit) => {
410
+ const rows = await db
411
+ .selectFrom('outbox')
412
+ .selectAll()
413
+ .where('sent_at', 'is', null)
414
+ .where('parked_at', 'is', null)
415
+ .orderBy('seq')
416
+ .limit(limit)
417
+ .execute();
418
+
419
+ return rows.map((row) =>
420
+ outboxFactSchema.parse({
421
+ seq: row.seq,
422
+ factId: row.fact_id,
423
+ type: row.type,
424
+ entity: row.entity,
425
+ actor: row.actor,
426
+ at: row.at,
427
+ payload: parseJSONText(row.payload),
428
+ sentAt: row.sent_at,
429
+ parkedAt: row.parked_at,
430
+ parkedReason: row.parked_reason,
431
+ }),
432
+ );
433
+ },
434
+ readOutboxCounts: async () => {
435
+ const waiting = await db
436
+ .selectFrom('outbox')
437
+ .select((builder) => [
438
+ builder.fn.countAll<number>().as('waiting'),
439
+ builder.fn.count<number>('parked_at').as('parked'),
440
+ ])
441
+ .where('sent_at', 'is', null)
442
+ .executeTakeFirstOrThrow();
443
+
444
+ const unsent = await db
445
+ .selectFrom('outbox')
446
+ .select((builder) => builder.fn.min('at').as('oldest'))
447
+ .where('sent_at', 'is', null)
448
+ .where('parked_at', 'is', null)
449
+ .executeTakeFirstOrThrow();
450
+
451
+ return {
452
+ unsent: waiting.waiting - waiting.parked,
453
+ parked: waiting.parked,
454
+ oldestUnsentAt: unsent.oldest === null ? null : brandSchemas.instant.parse(unsent.oldest),
455
+ };
456
+ },
457
+ readItemBehind: async (item) => {
458
+ const row = await db
459
+ .selectFrom('outbox')
460
+ .select((builder) => [
461
+ builder.fn.countAll<number>().as('facts'),
462
+ builder.fn.min('at').as('oldest'),
463
+ ])
464
+ .where('sent_at', 'is', null)
465
+ .where('entity', '=', `boxxy:item:${item}`)
466
+ .executeTakeFirstOrThrow();
467
+
468
+ return {
469
+ facts: row.facts,
470
+ oldestAt: row.oldest === null ? null : brandSchemas.instant.parse(row.oldest),
471
+ };
472
+ },
473
+ };
474
+ }
475
+
476
+ function isOpen(item: ItemView): boolean {
477
+ return item.derived.state !== 'done' && item.derived.state !== 'archived';
478
+ }
479
+
480
+ function toItemView(item: ItemWithLevers): ItemView {
481
+ const { levers: _levers, ...view } = item;
482
+
483
+ return view;
484
+ }