@boxxy/store-sqlite 0.1.0 → 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 (54) hide show
  1. package/README.md +24 -3
  2. package/dist/codecs/parse-json-text.d.ts +1 -0
  3. package/dist/codecs/parse-store-row.d.ts +2 -0
  4. package/dist/codecs/table-codecs.d.ts +10 -0
  5. package/dist/codecs/to-column-name.d.ts +1 -0
  6. package/dist/codecs/to-store-columns.d.ts +3 -0
  7. package/dist/constants.d.ts +1 -0
  8. package/dist/create-sqlite-claim-store.d.ts +5 -0
  9. package/dist/index.d.ts +1 -1
  10. package/dist/migrations/create-tables-migration.d.ts +2 -0
  11. package/dist/migrations/rename-relations-migration.d.ts +2 -0
  12. package/dist/migrations/run-store-migrations.d.ts +3 -0
  13. package/dist/migrations/v1-tables-migration.d.ts +2 -0
  14. package/dist/reads/build-sqlite-reader.d.ts +4 -0
  15. package/dist/reads/is-latest-of-session.d.ts +3 -0
  16. package/dist/reads/read-item-views.d.ts +5 -0
  17. package/dist/reads/read-limit-fills.d.ts +4 -0
  18. package/dist/reads/read-wakes.d.ts +4 -0
  19. package/dist/test-utils/create-claim-bench.d.ts +8 -0
  20. package/dist/test-utils/create-test-claim-store.d.ts +2 -0
  21. package/dist/types.d.ts +172 -0
  22. package/dist/with-busy-retry.d.ts +1 -0
  23. package/dist/with-transaction.d.ts +3 -0
  24. package/dist/writes/apply-sqlite-change.d.ts +4 -0
  25. package/dist/writes/create-sqlite-tx.d.ts +4 -0
  26. package/dist/writes/take-write-lock.d.ts +3 -0
  27. package/package.json +15 -1
  28. package/src/codecs/parse-json-text.ts +5 -0
  29. package/src/codecs/parse-store-row.ts +36 -0
  30. package/src/codecs/table-codecs.ts +148 -0
  31. package/src/codecs/to-column-name.ts +3 -0
  32. package/src/codecs/to-store-columns.ts +44 -0
  33. package/src/constants.ts +1 -0
  34. package/src/create-sqlite-claim-store.ts +114 -0
  35. package/src/index.ts +1 -1
  36. package/src/migrations/create-tables-migration.ts +146 -0
  37. package/src/migrations/rename-relations-migration.ts +17 -0
  38. package/src/migrations/run-store-migrations.ts +42 -0
  39. package/src/migrations/v1-tables-migration.ts +184 -0
  40. package/src/reads/build-sqlite-reader.ts +484 -0
  41. package/src/reads/is-latest-of-session.ts +21 -0
  42. package/src/reads/read-item-views.ts +92 -0
  43. package/src/reads/read-limit-fills.ts +32 -0
  44. package/src/reads/read-wakes.ts +34 -0
  45. package/src/test-utils/create-claim-bench.ts +66 -0
  46. package/src/test-utils/create-test-claim-store.ts +23 -0
  47. package/src/types.ts +173 -0
  48. package/src/with-busy-retry.ts +32 -0
  49. package/src/with-transaction.ts +29 -0
  50. package/src/writes/apply-sqlite-change.ts +223 -0
  51. package/src/writes/create-sqlite-tx.ts +229 -0
  52. package/src/writes/take-write-lock.ts +8 -0
  53. package/dist/package-name.d.ts +0 -1
  54. package/src/package-name.ts +0 -1
@@ -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
+ }
@@ -0,0 +1,21 @@
1
+ import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
2
+ import type { StoreDatabase } from '../types.ts';
3
+
4
+ // no later attempt carries the same atc session; claim time, then id, orders the attempts
5
+ export function isLatestOfSession(
6
+ builder: ExpressionBuilder<StoreDatabase, 'attempts'>,
7
+ ): Expression<SqlBool> {
8
+ const later = builder
9
+ .selectFrom('attempts as later')
10
+ .select('later.id')
11
+ .whereRef('later.atc_session', '=', 'attempts.atc_session')
12
+ .where((inner) => {
13
+ const claimedAt = inner.ref('attempts.claimed_at');
14
+ const sameTime = inner('later.claimed_at', '=', claimedAt);
15
+ const higherId = inner('later.id', '>', inner.ref('attempts.id'));
16
+
17
+ return inner.or([inner('later.claimed_at', '>', claimedAt), inner.and([sameTime, higherId])]);
18
+ });
19
+
20
+ return builder.not(builder.exists(later));
21
+ }
@@ -0,0 +1,92 @@
1
+ import { buildItemViews } from '@boxxy/model';
2
+ import type { Instant, ItemRules, ItemWithLevers } from '@boxxy/model';
3
+ import type { Expression, ExpressionBuilder, Kysely, SqlBool } from 'kysely';
4
+ import { parseStoreRow } from '../codecs/parse-store-row.ts';
5
+ import type { StoreDatabase } from '../types.ts';
6
+
7
+ export type ItemScope = (builder: ExpressionBuilder<StoreDatabase, 'items'>) => Expression<SqlBool>;
8
+
9
+ // a target's state needs each blocker's own state, so this loads the blockers too
10
+ export async function readItemViews(
11
+ db: Kysely<StoreDatabase>,
12
+ scope: ItemScope,
13
+ now: Instant,
14
+ rules: ItemRules,
15
+ ): Promise<ItemWithLevers[]> {
16
+ const scoped = db.selectFrom('items').select('id').where(scope);
17
+
18
+ const blockers = db
19
+ .selectFrom('relations')
20
+ .select('source')
21
+ .where('removed_at', 'is', null)
22
+ .where('target', 'in', scoped);
23
+
24
+ const loaded = db
25
+ .selectFrom('items')
26
+ .select('id')
27
+ .where((builder) => builder.or([builder('id', 'in', scoped), builder('id', 'in', blockers)]));
28
+
29
+ const attemptsOfLoaded = db.selectFrom('attempts').select('id').where('item', 'in', loaded);
30
+
31
+ const items = await db
32
+ .selectFrom('items')
33
+ .selectAll()
34
+ .where('id', 'in', loaded)
35
+ .orderBy('filed_at')
36
+ .orderBy('id')
37
+ .execute();
38
+
39
+ const scopedRows = await scoped.execute();
40
+
41
+ const inScope = new Set(scopedRows.map((row) => row.id));
42
+
43
+ const relations = await db
44
+ .selectFrom('relations')
45
+ .selectAll()
46
+ .where('removed_at', 'is', null)
47
+ .where('target', 'in', loaded)
48
+ .execute();
49
+
50
+ const levers = await db
51
+ .selectFrom('levers')
52
+ .selectAll()
53
+ .where('item', 'in', loaded)
54
+ .orderBy('seq')
55
+ .execute();
56
+
57
+ const attempts = await db
58
+ .selectFrom('attempts')
59
+ .selectAll()
60
+ .where('item', 'in', loaded)
61
+ .orderBy('claimed_at')
62
+ .orderBy('id')
63
+ .execute();
64
+
65
+ const leases = await db
66
+ .selectFrom('leases')
67
+ .selectAll()
68
+ .where('released_at', 'is', null)
69
+ .where('item', 'in', loaded)
70
+ .execute();
71
+
72
+ const pullRequests = await db
73
+ .selectFrom('pull_requests')
74
+ .selectAll()
75
+ .where('attempt', 'in', attemptsOfLoaded)
76
+ .execute();
77
+
78
+ const views = buildItemViews(
79
+ {
80
+ items: items.map((row) => parseStoreRow('items', row)),
81
+ relations: relations.map((row) => parseStoreRow('relations', row)),
82
+ levers: levers.map((row) => parseStoreRow('levers', row)),
83
+ attempts: attempts.map((row) => parseStoreRow('attempts', row)),
84
+ liveLeases: leases.map((row) => parseStoreRow('leases', row)),
85
+ pullRequests: pullRequests.map((row) => parseStoreRow('pull_requests', row)),
86
+ },
87
+ now,
88
+ rules,
89
+ );
90
+
91
+ return views.filter((view) => inScope.has(view.id));
92
+ }
@@ -0,0 +1,32 @@
1
+ import { limitSchema } from '@boxxy/model';
2
+ import type { LimitFill } from '@boxxy/model';
3
+ import type { Kysely } from 'kysely';
4
+ import { parseJSONText } from '../codecs/parse-json-text.ts';
5
+ import type { StoreDatabase } from '../types.ts';
6
+
7
+ // the limits of the last sync, by name, each with its held seats counted
8
+ export async function readLimitFills(db: Kysely<StoreDatabase>): Promise<LimitFill[]> {
9
+ const rows = await db
10
+ .selectFrom('limits')
11
+ .selectAll()
12
+ .select((builder) =>
13
+ builder
14
+ .selectFrom('limit_seats')
15
+ .select(builder.fn.countAll<number>().as('held'))
16
+ .whereRef('limit_seats.limit', '=', 'limits.name')
17
+ .where('limit_seats.released_at', 'is', null)
18
+ .as('held'),
19
+ )
20
+ .orderBy('name')
21
+ .execute();
22
+
23
+ return rows.map((row) => {
24
+ const limit = limitSchema.parse({
25
+ name: row.name,
26
+ max: row.max,
27
+ counts: parseJSONText(row.counts),
28
+ });
29
+
30
+ return { name: limit.name, max: limit.max, counts: limit.counts, held: row.held ?? 0 };
31
+ });
32
+ }
@@ -0,0 +1,34 @@
1
+ import type { Wake } from '@boxxy/model';
2
+ import type { Kysely } from 'kysely';
3
+ import { parseStoreRow } from '../codecs/parse-store-row.ts';
4
+ import type { StoreDatabase } from '../types.ts';
5
+
6
+ // the sleeping attempts whose wake was asked for, oldest request first
7
+ export async function readWakes(db: Kysely<StoreDatabase>): Promise<Wake[]> {
8
+ const rows = await db
9
+ .selectFrom('attempts')
10
+ .selectAll()
11
+ .where('state', '=', 'sleeping')
12
+ .where('wake_requested_at', 'is not', null)
13
+ .orderBy('wake_requested_at')
14
+ .orderBy('claimed_at')
15
+ .orderBy('id')
16
+ .execute();
17
+
18
+ return rows.flatMap((row) => {
19
+ const attempt = parseStoreRow('attempts', row);
20
+
21
+ return attempt.wakeRequestedAt === null
22
+ ? []
23
+ : [
24
+ {
25
+ attempt: attempt.id,
26
+ item: attempt.item,
27
+ phase: attempt.phase,
28
+ pool: attempt.pool,
29
+ fields: attempt.fields,
30
+ requestedAt: attempt.wakeRequestedAt,
31
+ },
32
+ ];
33
+ });
34
+ }