@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.
- package/README.md +8 -6
- package/dist/codecs/parse-json-text.d.ts +1 -0
- package/dist/constants.d.ts +1 -0
- package/dist/migrations/rename-relations-migration.d.ts +2 -0
- package/dist/{run-store-migrations.d.ts → migrations/run-store-migrations.d.ts} +1 -1
- package/dist/migrations/v1-tables-migration.d.ts +2 -0
- package/dist/{build-sqlite-reader.d.ts → reads/build-sqlite-reader.d.ts} +1 -1
- package/dist/reads/is-latest-of-session.d.ts +3 -0
- package/dist/{read-item-views.d.ts → reads/read-item-views.d.ts} +3 -3
- package/dist/reads/read-limit-fills.d.ts +4 -0
- package/dist/reads/read-wakes.d.ts +4 -0
- package/dist/test-utils/create-claim-bench.d.ts +8 -0
- package/dist/types.d.ts +60 -10
- package/dist/with-busy-retry.d.ts +1 -0
- package/dist/with-transaction.d.ts +3 -0
- package/dist/{apply-sqlite-change.d.ts → writes/apply-sqlite-change.d.ts} +1 -1
- package/dist/{create-sqlite-tx.d.ts → writes/create-sqlite-tx.d.ts} +1 -1
- package/dist/writes/take-write-lock.d.ts +3 -0
- package/package.json +15 -5
- package/src/codecs/parse-json-text.ts +5 -0
- package/src/{table-codecs.ts → codecs/table-codecs.ts} +52 -15
- package/src/constants.ts +1 -0
- package/src/create-sqlite-claim-store.ts +73 -51
- package/src/migrations/rename-relations-migration.ts +17 -0
- package/src/migrations/run-store-migrations.ts +42 -0
- package/src/migrations/v1-tables-migration.ts +184 -0
- package/src/reads/build-sqlite-reader.ts +484 -0
- package/src/reads/is-latest-of-session.ts +21 -0
- package/src/{read-item-views.ts → reads/read-item-views.ts} +9 -9
- package/src/reads/read-limit-fills.ts +32 -0
- package/src/reads/read-wakes.ts +34 -0
- package/src/test-utils/create-claim-bench.ts +66 -0
- package/src/test-utils/create-test-claim-store.ts +2 -1
- package/src/types.ts +60 -10
- package/src/with-busy-retry.ts +32 -0
- package/src/with-transaction.ts +29 -0
- package/src/{apply-sqlite-change.ts → writes/apply-sqlite-change.ts} +77 -74
- package/src/writes/create-sqlite-tx.ts +229 -0
- package/src/writes/take-write-lock.ts +8 -0
- package/src/build-sqlite-reader.ts +0 -321
- package/src/create-sqlite-tx.ts +0 -134
- package/src/run-store-migrations.ts +0 -20
- /package/dist/{parse-store-row.d.ts → codecs/parse-store-row.d.ts} +0 -0
- /package/dist/{table-codecs.d.ts → codecs/table-codecs.d.ts} +0 -0
- /package/dist/{to-column-name.d.ts → codecs/to-column-name.d.ts} +0 -0
- /package/dist/{to-store-columns.d.ts → codecs/to-store-columns.d.ts} +0 -0
- /package/dist/{create-tables-migration.d.ts → migrations/create-tables-migration.d.ts} +0 -0
- /package/src/{parse-store-row.ts → codecs/parse-store-row.ts} +0 -0
- /package/src/{to-column-name.ts → codecs/to-column-name.ts} +0 -0
- /package/src/{to-store-columns.ts → codecs/to-store-columns.ts} +0 -0
- /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
|
-
}
|
package/src/create-sqlite-tx.ts
DELETED
|
@@ -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
|
-
}
|