@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,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
+ }
@@ -1,8 +1,8 @@
1
1
  import { buildItemViews } from '@boxxy/model';
2
- import type { Instant, ItemWithLevers, MaxAttemptsByKind } from '@boxxy/model';
2
+ import type { Instant, ItemRules, ItemWithLevers } from '@boxxy/model';
3
3
  import type { Expression, ExpressionBuilder, Kysely, SqlBool } from 'kysely';
4
- import { parseStoreRow } from './parse-store-row.ts';
5
- import type { StoreDatabase } from './types.ts';
4
+ import { parseStoreRow } from '../codecs/parse-store-row.ts';
5
+ import type { StoreDatabase } from '../types.ts';
6
6
 
7
7
  export type ItemScope = (builder: ExpressionBuilder<StoreDatabase, 'items'>) => Expression<SqlBool>;
8
8
 
@@ -11,12 +11,12 @@ export async function readItemViews(
11
11
  db: Kysely<StoreDatabase>,
12
12
  scope: ItemScope,
13
13
  now: Instant,
14
- maxAttemptsByKind: MaxAttemptsByKind,
14
+ rules: ItemRules,
15
15
  ): Promise<ItemWithLevers[]> {
16
16
  const scoped = db.selectFrom('items').select('id').where(scope);
17
17
 
18
18
  const blockers = db
19
- .selectFrom('links')
19
+ .selectFrom('relations')
20
20
  .select('source')
21
21
  .where('removed_at', 'is', null)
22
22
  .where('target', 'in', scoped);
@@ -40,8 +40,8 @@ export async function readItemViews(
40
40
 
41
41
  const inScope = new Set(scopedRows.map((row) => row.id));
42
42
 
43
- const links = await db
44
- .selectFrom('links')
43
+ const relations = await db
44
+ .selectFrom('relations')
45
45
  .selectAll()
46
46
  .where('removed_at', 'is', null)
47
47
  .where('target', 'in', loaded)
@@ -78,14 +78,14 @@ export async function readItemViews(
78
78
  const views = buildItemViews(
79
79
  {
80
80
  items: items.map((row) => parseStoreRow('items', row)),
81
- links: links.map((row) => parseStoreRow('links', row)),
81
+ relations: relations.map((row) => parseStoreRow('relations', row)),
82
82
  levers: levers.map((row) => parseStoreRow('levers', row)),
83
83
  attempts: attempts.map((row) => parseStoreRow('attempts', row)),
84
84
  liveLeases: leases.map((row) => parseStoreRow('leases', row)),
85
85
  pullRequests: pullRequests.map((row) => parseStoreRow('pull_requests', row)),
86
86
  },
87
87
  now,
88
- maxAttemptsByKind,
88
+ rules,
89
89
  );
90
90
 
91
91
  return views.filter((view) => inScope.has(view.id));
@@ -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
+ }
@@ -0,0 +1,66 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { claimNext } from '@boxxy/dispatch';
5
+ import { createTestDispatch } from '@boxxy/dispatch/test-utils/create-test-dispatch';
6
+ import type { ItemId } from '@boxxy/model';
7
+ import { createSQLiteClaimStore } from '../create-sqlite-claim-store.ts';
8
+
9
+ export interface ClaimBenchOptions {
10
+ readonly itemCount: number;
11
+ }
12
+
13
+ export interface ClaimBench extends AsyncDisposable {
14
+ // one `claimNext` call, which holds the store's lock for the whole decision; throws when it
15
+ // picks no item
16
+ readonly claim: () => Promise<ItemId>;
17
+ }
18
+
19
+ // A SQLite store in a temp folder, seeded with open items at their pipeline's first phase, and
20
+ // every limit raised to the item count, so each claim finds a free seat with no close or settle
21
+ // between claims.
22
+ export async function createClaimBench(options: ClaimBenchOptions): Promise<ClaimBench> {
23
+ const stack = new AsyncDisposableStack();
24
+
25
+ const folder = await mkdtemp(join(tmpdir(), 'boxxy-claim-bench-'));
26
+
27
+ stack.defer(() => rm(folder, { recursive: true, force: true }));
28
+
29
+ const store = await createSQLiteClaimStore(join(folder, 'store.db'));
30
+
31
+ stack.defer(() => store.close());
32
+
33
+ const dispatch = await createTestDispatch({ store });
34
+
35
+ const rules = dispatch.rules();
36
+ const limits = rules.limits.map((limit) => ({ ...limit, max: options.itemCount }));
37
+
38
+ dispatch.setRules({ ...rules, limits });
39
+
40
+ await store.exclusive((tx) => tx.syncLimits(limits, dispatch.clock.now()));
41
+
42
+ for (let index = 0; index < options.itemCount; index += 1) {
43
+ await dispatch.seedItem({});
44
+ }
45
+
46
+ return {
47
+ claim: async () => {
48
+ const claim = await claimNext({
49
+ store,
50
+ runner: dispatch.runner,
51
+ rules: dispatch.rules(),
52
+ now: dispatch.clock.now(),
53
+ newId: dispatch.newId,
54
+ backoff: dispatch.backoff,
55
+ spawn: dispatch.spawn,
56
+ });
57
+
58
+ if (claim === null) {
59
+ throw new Error('the claim picked no item');
60
+ }
61
+
62
+ return claim.attempt.item;
63
+ },
64
+ [Symbol.asyncDispose]: () => stack.disposeAsync(),
65
+ };
66
+ }
@@ -10,7 +10,8 @@ export async function createTestClaimStore(): Promise<SQLiteClaimStore> {
10
10
 
11
11
  onTestFinished(() => cleanup.disposeAsync());
12
12
 
13
- const folder = await mkdtemp(join(tmpdir(), 'boxxy-store-'));
13
+ // the process id keeps the folders of test runs that share a temp folder apart by name
14
+ const folder = await mkdtemp(join(tmpdir(), `boxxy-store-${process.pid}-`));
14
15
 
15
16
  cleanup.defer(() => rm(folder, { recursive: true, force: true }));
16
17
 
package/src/types.ts CHANGED
@@ -8,13 +8,16 @@ export interface StoreDatabase {
8
8
  title: string;
9
9
  outcome: string;
10
10
  acceptance: string;
11
+ fields: string;
12
+ labels: string;
11
13
  found_in_attempt: string | null;
12
14
  filed_by: string;
13
15
  filed_at: number;
14
16
  closed_at: number | null;
15
17
  close_reason: string | null;
18
+ kept_session: string | null;
16
19
  };
17
- links: {
20
+ relations: {
18
21
  source: string;
19
22
  target: string;
20
23
  type: string;
@@ -29,6 +32,7 @@ export interface StoreDatabase {
29
32
  until: number | null;
30
33
  expires_at: number | null;
31
34
  reason: string | null;
35
+ phase: string | null;
32
36
  actor: string;
33
37
  at: number;
34
38
  };
@@ -41,23 +45,42 @@ export interface StoreDatabase {
41
45
  recipe_hash: string;
42
46
  rules_version: string;
43
47
  pool: string;
44
- slot: number;
45
- slot_gen: number;
46
48
  lease_gen: number;
47
49
  spawn_request: string;
48
50
  spawn_request_hash: string;
49
51
  state: string;
50
52
  failure: string | null;
51
53
  atc_session: string | null;
52
- close_out: string | null;
54
+ caller: string | null;
53
55
  claimed_at: number;
54
56
  started_at: number | null;
55
57
  ended_at: number | null;
58
+ fields: string;
59
+ mode: string;
60
+ round: number;
61
+ input: string | null;
62
+ hold_gen: number;
63
+ close_out: string | null;
64
+ close_out_hash: string | null;
65
+ close_out_fact: string | null;
66
+ closing: string | null;
67
+ closing_at: number | null;
68
+ suspend_requested_at: number | null;
69
+ slept_at: number | null;
70
+ wake_requested_at: number | null;
71
+ woken_at: number | null;
72
+ outcome: string | null;
73
+ returned_outcome: string | null;
74
+ strayed_at: number | null;
75
+ session_stopped_at: number | null;
76
+ session_forgotten_at: number | null;
56
77
  };
57
78
  attempt_sessions: {
58
79
  attempt: string;
59
80
  agent_session: string;
60
81
  atc_session: string;
82
+ product: string;
83
+ source: string;
61
84
  seen_at: number;
62
85
  };
63
86
  leases: {
@@ -68,23 +91,31 @@ export interface StoreDatabase {
68
91
  taken_at: number;
69
92
  released_at: number | null;
70
93
  };
71
- pool_slots: {
72
- pool: string;
73
- slot: number;
74
- holder: string | null;
75
- gen: number;
76
- retired: number;
94
+ limits: {
95
+ name: string;
96
+ max: number;
97
+ counts: string;
98
+ };
99
+ limit_seats: {
100
+ limit: string;
101
+ attempt: string;
102
+ hold_gen: number;
103
+ over_cap: number;
104
+ taken_at: number;
105
+ released_at: number | null;
77
106
  };
78
107
  decisions: {
79
108
  id: string;
80
109
  attempt: string;
81
110
  item: string;
111
+ kind: string;
82
112
  pool: string;
83
113
  rules_version: string;
84
114
  snapshot_hash: string;
85
115
  store_version: number;
86
116
  pepys_offsets: string;
87
117
  faults: string;
118
+ limits: string;
88
119
  at: number;
89
120
  };
90
121
  evidence: {
@@ -100,6 +131,8 @@ export interface StoreDatabase {
100
131
  repo: string;
101
132
  number: number;
102
133
  attempt: string;
134
+ github_id: number;
135
+ url: string;
103
136
  state: string;
104
137
  merged_at: number | null;
105
138
  merge_commit: string | null;
@@ -110,6 +143,21 @@ export interface StoreDatabase {
110
143
  heartbeat_at: number;
111
144
  paused: number;
112
145
  };
146
+ notes: {
147
+ item: string;
148
+ seq: number;
149
+ text: string;
150
+ by: string;
151
+ at: number;
152
+ };
153
+ notices: {
154
+ id: Generated<number>;
155
+ item: string;
156
+ reason: string;
157
+ at: number;
158
+ tries: number;
159
+ sent_at: number | null;
160
+ };
113
161
  outbox: {
114
162
  seq: Generated<number>;
115
163
  fact_id: string;
@@ -119,5 +167,7 @@ export interface StoreDatabase {
119
167
  at: number;
120
168
  payload: string;
121
169
  sent_at: number | null;
170
+ parked_at: number | null;
171
+ parked_reason: string | null;
122
172
  };
123
173
  }
@@ -0,0 +1,32 @@
1
+ import { SQLiteError } from 'bun:sqlite';
2
+
3
+ const retryDelayMs = 10;
4
+
5
+ // bun:sqlite waits on a busy lock synchronously, which stalls every other store in the process for
6
+ // the whole wait, so the statement fails at once and this waits between tries without blocking.
7
+ export async function withBusyRetry(waitLimitMs: number, work: () => unknown): Promise<void> {
8
+ const startMs = performance.now();
9
+
10
+ for (;;) {
11
+ try {
12
+ await work();
13
+
14
+ return;
15
+ } catch (error) {
16
+ if (!isBusy(error)) {
17
+ throw error;
18
+ }
19
+
20
+ await Bun.sleep(retryDelayMs);
21
+
22
+ // checked after the sleep, so no try starts once the limit has passed
23
+ if (performance.now() - startMs >= waitLimitMs) {
24
+ throw error;
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ function isBusy(error: unknown): boolean {
31
+ return error instanceof SQLiteError && error.code?.startsWith('SQLITE_BUSY') === true;
32
+ }
@@ -0,0 +1,29 @@
1
+ import { sql } from 'kysely';
2
+ import type { Kysely } from 'kysely';
3
+ import type { StoreDatabase } from './types.ts';
4
+
5
+ export async function withTransaction<T>(
6
+ db: Kysely<StoreDatabase>,
7
+ begin: (db: Kysely<StoreDatabase>) => Promise<unknown>,
8
+ work: () => Promise<T>,
9
+ ): Promise<T> {
10
+ await begin(db);
11
+
12
+ try {
13
+ const result = await work();
14
+
15
+ await sql`COMMIT`.execute(db);
16
+
17
+ return result;
18
+ } catch (error) {
19
+ // SQLite ends the transaction by itself after some errors, such as a full disk, and ROLLBACK
20
+ // then fails; its error joins the first one, never replaces it
21
+ await sql`ROLLBACK`.execute(db).catch((rollbackError: unknown) => {
22
+ throw new AggregateError([error, rollbackError], 'the work failed, and so did its rollback', {
23
+ cause: error,
24
+ });
25
+ });
26
+
27
+ throw error;
28
+ }
29
+ }
@@ -1,12 +1,13 @@
1
- import { buildAppliedChange, findSlotTake, toJSONValue } from '@boxxy/model';
2
- import type { ApplyResult, RowWrite, SlotTake, StoreChange, TakenSlot } from '@boxxy/model';
1
+ import { buildAppliedChange, parseNewFact, pickSeatTake, toJSONValue } from '@boxxy/model';
2
+ import type { ApplyResult, Instant, RowWrite, SeatTake, StoreChange } from '@boxxy/model';
3
3
  import { sql } from 'kysely';
4
4
  import type { Kysely, RawBuilder } from 'kysely';
5
5
  import invariant from 'tiny-invariant';
6
6
  import { match } from 'ts-pattern';
7
- import { toStoreColumns } from './to-store-columns.ts';
8
- import type { StoreValue } from './to-store-columns.ts';
9
- import type { StoreDatabase } from './types.ts';
7
+ import { toStoreColumns } from '../codecs/to-store-columns.ts';
8
+ import type { StoreValue } from '../codecs/to-store-columns.ts';
9
+ import { buildSQLiteReader } from '../reads/build-sqlite-reader.ts';
10
+ import type { StoreDatabase } from '../types.ts';
10
11
 
11
12
  // A savepoint, not the transaction, so a refusal keeps the caller's earlier changes in the same
12
13
  // exclusive call. The caller runs one change at a time, so one savepoint name is enough.
@@ -14,12 +15,12 @@ export async function applySQLiteChange(
14
15
  db: Kysely<StoreDatabase>,
15
16
  change: StoreChange,
16
17
  ): Promise<ApplyResult> {
17
- invariant(change.facts.length > 0, 'a change writes at least one fact');
18
+ const facts = change.facts.map((fact) => parseNewFact(fact));
18
19
 
19
20
  await sql`SAVEPOINT apply_change`.execute(db);
20
21
 
21
22
  try {
22
- const result = await writeSQLiteChange(db, change);
23
+ const result = await writeSQLiteChange(db, { ...change, facts });
23
24
 
24
25
  if (result.kind !== 'applied') {
25
26
  await sql`ROLLBACK TO apply_change`.execute(db);
@@ -43,22 +44,23 @@ async function writeSQLiteChange(
43
44
  return { kind: 'stale-generation' };
44
45
  }
45
46
 
46
- const take = findSlotTake(change.writes);
47
- const taken = take === null ? null : await writeSlotTake(db, take);
48
-
49
- if (take !== null && taken === null) {
50
- return { kind: 'no-free-slot' };
51
- }
52
-
53
47
  const lastLever = await db
54
48
  .selectFrom('levers')
55
49
  .select((builder) => builder.fn.max('seq').as('seq'))
56
50
  .executeTakeFirst();
57
51
 
58
- const applied = buildAppliedChange(change, { taken, lastLeverSeq: lastLever?.seq ?? 0 });
52
+ const lastNotice = await db
53
+ .selectFrom('notices')
54
+ .select((builder) => builder.fn.max('id').as('id'))
55
+ .executeTakeFirst();
56
+
57
+ const applied = buildAppliedChange(change, {
58
+ lastLeverSeq: lastLever?.seq ?? 0,
59
+ lastNoticeId: lastNotice?.id ?? 0,
60
+ });
59
61
 
60
62
  for (const write of applied.writes) {
61
- const refusal = await writeSQLiteRow(db, write);
63
+ const refusal = await writeSQLiteRow(db, write, change.at);
62
64
 
63
65
  if (refusal !== null) {
64
66
  return refusal;
@@ -76,84 +78,53 @@ async function writeSQLiteChange(
76
78
  at: change.at,
77
79
  payload: JSON.stringify(toJSONValue(fact.payload)),
78
80
  sent_at: null,
81
+ parked_at: null,
82
+ parked_reason: null,
79
83
  })
80
84
  .execute();
81
85
  }
82
86
 
83
- const slot = taken === null ? null : { pool: taken.pool, slot: taken.slot, gen: taken.gen };
84
-
85
- return { kind: 'applied', slot };
87
+ return { kind: 'applied' };
86
88
  }
87
89
 
88
90
  async function probeLiveUnder(
89
91
  db: Kysely<StoreDatabase>,
90
92
  under: StoreChange['under'],
91
93
  ): Promise<boolean> {
92
- if (under === undefined) {
94
+ if (under === undefined || under === null) {
93
95
  return true;
94
96
  }
95
97
 
96
98
  const lease = await db
97
99
  .selectFrom('leases')
98
- .select('gen')
99
- .where('item', '=', under.item)
100
- .where('phase', '=', under.phase)
101
- .where('gen', '=', under.gen)
102
- .where('released_at', 'is', null)
100
+ .innerJoin('attempts', 'attempts.id', 'leases.attempt')
101
+ .select('attempts.hold_gen')
102
+ .where('leases.item', '=', under.item)
103
+ .where('leases.phase', '=', under.phase)
104
+ .where('leases.gen', '=', under.gen)
105
+ .where('leases.released_at', 'is', null)
103
106
  .executeTakeFirst();
104
107
 
105
- if (lease === undefined || under.slot === undefined) {
106
- return lease !== undefined;
107
- }
108
-
109
- const slot = await db
110
- .selectFrom('pool_slots')
111
- .select('gen')
112
- .where('pool', '=', under.slot.pool)
113
- .where('slot', '=', under.slot.slot)
114
- .where('gen', '=', under.slot.gen)
115
- .where('holder', 'is not', null)
116
- .executeTakeFirst();
117
-
118
- return slot !== undefined;
108
+ return lease !== undefined && (under.holdGen === undefined || lease.hold_gen === under.holdGen);
119
109
  }
120
110
 
121
- async function writeSlotTake(db: Kysely<StoreDatabase>, take: SlotTake): Promise<TakenSlot | null> {
122
- const lowest = db
123
- .selectFrom('pool_slots')
124
- .select((builder) => builder.fn.min('slot').as('slot'))
125
- .where('pool', '=', take.pool)
126
- .where('holder', 'is', null)
127
- .where('retired', '=', 0);
128
-
129
- const row = await db
130
- .updateTable('pool_slots')
131
- .set((builder) => ({ holder: take.holder, gen: builder('gen', '+', 1) }))
132
- .where('pool', '=', take.pool)
133
- .where('slot', '=', lowest)
134
- .where('holder', 'is', null)
135
- .where('retired', '=', 0)
136
- .returning(['slot', 'gen'])
137
- .executeTakeFirst();
138
-
139
- return row === undefined
140
- ? null
141
- : { holder: take.holder, pool: take.pool, slot: row.slot, gen: row.gen };
142
- }
143
-
144
- function writeSQLiteRow(db: Kysely<StoreDatabase>, write: RowWrite): Promise<ApplyResult | null> {
111
+ function writeSQLiteRow(
112
+ db: Kysely<StoreDatabase>,
113
+ write: RowWrite,
114
+ at: Instant,
115
+ ): Promise<ApplyResult | null> {
145
116
  return match(write)
146
- .with({ op: 'take-slot' }, () => Promise.resolve(null))
147
- .with({ op: 'release-slot' }, async (release) => {
148
- const result = await db
149
- .updateTable('pool_slots')
150
- .set({ holder: null })
151
- .where('pool', '=', release.pool)
152
- .where('slot', '=', release.slot)
153
- .where('holder', '=', release.holder)
154
- .executeTakeFirst();
155
-
156
- return result.numUpdatedRows === 0n ? ({ kind: 'stale-generation' } as const) : null;
117
+ .with({ op: 'take-seats' }, (seats) => writeSeatTake(db, seats.take, at))
118
+ .with({ op: 'release-seats' }, async (seats) => {
119
+ await db
120
+ .updateTable('limit_seats')
121
+ .set({ released_at: at })
122
+ .where('attempt', '=', seats.release.attempt)
123
+ .where('hold_gen', '=', seats.release.holdGen)
124
+ .where('released_at', 'is', null)
125
+ .execute();
126
+
127
+ return null;
157
128
  })
158
129
  .with({ op: 'insert', table: 'leases' }, async (insert) => {
159
130
  const columns = toStoreColumns(insert.table, insert.row);
@@ -190,6 +161,38 @@ function writeSQLiteRow(db: Kysely<StoreDatabase>, write: RowWrite): Promise<App
190
161
  .exhaustive();
191
162
  }
192
163
 
164
+ // The write lock is held for the whole transaction, so the counts read here stay true until the
165
+ // seats are inserted.
166
+ async function writeSeatTake(
167
+ db: Kysely<StoreDatabase>,
168
+ take: SeatTake,
169
+ at: Instant,
170
+ ): Promise<ApplyResult | null> {
171
+ const limits = await buildSQLiteReader(db).listLimits();
172
+
173
+ const answer = pickSeatTake(take, limits, at);
174
+
175
+ if (answer.kind !== 'take') {
176
+ return { kind: answer.kind, limit: answer.limit };
177
+ }
178
+
179
+ for (const seat of answer.seats) {
180
+ await db
181
+ .insertInto('limit_seats')
182
+ .values({
183
+ limit: seat.limit,
184
+ attempt: seat.attempt,
185
+ hold_gen: seat.holdGen,
186
+ over_cap: seat.overCap ? 1 : 0,
187
+ taken_at: seat.takenAt,
188
+ released_at: seat.releasedAt,
189
+ })
190
+ .execute();
191
+ }
192
+
193
+ return null;
194
+ }
195
+
193
196
  function buildInsert(table: string, columns: [string, StoreValue][]): RawBuilder<unknown> {
194
197
  return sql`INSERT INTO ${sql.table(table)}
195
198
  (${sql.join(columns.map(([column]) => sql.ref(column)))})