@boxxy/store-sqlite 0.1.0 → 0.1.1
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 +22 -3
- package/dist/apply-sqlite-change.d.ts +4 -0
- package/dist/build-sqlite-reader.d.ts +4 -0
- package/dist/create-sqlite-claim-store.d.ts +5 -0
- package/dist/create-sqlite-tx.d.ts +4 -0
- package/dist/create-tables-migration.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/parse-store-row.d.ts +2 -0
- package/dist/read-item-views.d.ts +5 -0
- package/dist/run-store-migrations.d.ts +3 -0
- package/dist/table-codecs.d.ts +10 -0
- package/dist/test-utils/create-test-claim-store.d.ts +2 -0
- package/dist/to-column-name.d.ts +1 -0
- package/dist/to-store-columns.d.ts +3 -0
- package/dist/types.d.ts +122 -0
- package/package.json +13 -1
- package/src/apply-sqlite-change.ts +220 -0
- package/src/build-sqlite-reader.ts +321 -0
- package/src/create-sqlite-claim-store.ts +92 -0
- package/src/create-sqlite-tx.ts +134 -0
- package/src/create-tables-migration.ts +146 -0
- package/src/index.ts +1 -1
- package/src/parse-store-row.ts +36 -0
- package/src/read-item-views.ts +92 -0
- package/src/run-store-migrations.ts +20 -0
- package/src/table-codecs.ts +111 -0
- package/src/test-utils/create-test-claim-store.ts +22 -0
- package/src/to-column-name.ts +3 -0
- package/src/to-store-columns.ts +44 -0
- package/src/types.ts +123 -0
- package/dist/package-name.d.ts +0 -1
- package/src/package-name.ts +0 -1
|
@@ -0,0 +1,321 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import type { ClaimStore } from '@boxxy/model';
|
|
3
|
+
import { buildSQLiteDialect } from '@zgeoff/kysely-bun-sqlite';
|
|
4
|
+
import { Kysely, sql } from 'kysely';
|
|
5
|
+
import type { RawBuilder } from 'kysely';
|
|
6
|
+
import { buildSQLiteReader } from './build-sqlite-reader.ts';
|
|
7
|
+
import { createSQLiteTx } from './create-sqlite-tx.ts';
|
|
8
|
+
import { runStoreMigrations } from './run-store-migrations.ts';
|
|
9
|
+
import type { StoreDatabase } from './types.ts';
|
|
10
|
+
|
|
11
|
+
export interface SQLiteClaimStore extends ClaimStore {
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function createSQLiteClaimStore(path: string): Promise<SQLiteClaimStore> {
|
|
16
|
+
await using opened = new AsyncDisposableStack();
|
|
17
|
+
|
|
18
|
+
const writer = openStoreHandle(path);
|
|
19
|
+
|
|
20
|
+
opened.defer(() => {
|
|
21
|
+
writer.close();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const reader = openStoreHandle(path);
|
|
25
|
+
|
|
26
|
+
opened.defer(() => {
|
|
27
|
+
reader.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const writerDB = new Kysely<StoreDatabase>({ dialect: buildSQLiteDialect(writer) });
|
|
31
|
+
const readerDB = new Kysely<StoreDatabase>({ dialect: buildSQLiteDialect(reader) });
|
|
32
|
+
|
|
33
|
+
opened.defer(() => readerDB.destroy());
|
|
34
|
+
opened.defer(() => writerDB.destroy());
|
|
35
|
+
|
|
36
|
+
await runStoreMigrations(writerDB);
|
|
37
|
+
|
|
38
|
+
const handles = opened.move();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
// Kysely holds the one connection of a handle from acquire to release, so two calls on this
|
|
42
|
+
// store run one at a time, and BEGIN IMMEDIATE makes another process's writer wait.
|
|
43
|
+
exclusive: (work) =>
|
|
44
|
+
writerDB
|
|
45
|
+
.connection()
|
|
46
|
+
.execute((db) => withTransaction(db, sql`BEGIN IMMEDIATE`, () => work(createSQLiteTx(db)))),
|
|
47
|
+
|
|
48
|
+
// a second handle, so a read sees the last commit and never a running exclusive call's writes
|
|
49
|
+
read: (work) =>
|
|
50
|
+
readerDB
|
|
51
|
+
.connection()
|
|
52
|
+
.execute((db) => withTransaction(db, sql`BEGIN`, () => work(buildSQLiteReader(db)))),
|
|
53
|
+
|
|
54
|
+
close: () => handles.disposeAsync(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function openStoreHandle(path: string): Database {
|
|
59
|
+
const handle = new Database(path, { create: true });
|
|
60
|
+
|
|
61
|
+
handle.run('PRAGMA journal_mode = WAL');
|
|
62
|
+
handle.run('PRAGMA synchronous = NORMAL');
|
|
63
|
+
handle.run('PRAGMA busy_timeout = 5000');
|
|
64
|
+
|
|
65
|
+
return handle;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function withTransaction<T>(
|
|
69
|
+
db: Kysely<StoreDatabase>,
|
|
70
|
+
begin: RawBuilder<unknown>,
|
|
71
|
+
work: () => Promise<T>,
|
|
72
|
+
): Promise<T> {
|
|
73
|
+
await begin.execute(db);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const result = await work();
|
|
77
|
+
|
|
78
|
+
await sql`COMMIT`.execute(db);
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// SQLite ends the transaction by itself after some errors, such as a full disk, and ROLLBACK
|
|
83
|
+
// then fails; its error joins the first one, never replaces it
|
|
84
|
+
await sql`ROLLBACK`.execute(db).catch((rollbackError: unknown) => {
|
|
85
|
+
throw new AggregateError([error, rollbackError], 'the work failed, and so did its rollback', {
|
|
86
|
+
cause: error,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import type { Migration } from 'kysely/migration';
|
|
4
|
+
|
|
5
|
+
// a STRICT table refuses a value of the wrong type, so a bad write fails at the store
|
|
6
|
+
const statements = [
|
|
7
|
+
sql`CREATE TABLE items (
|
|
8
|
+
id TEXT PRIMARY KEY,
|
|
9
|
+
kind TEXT NOT NULL,
|
|
10
|
+
repo TEXT NOT NULL,
|
|
11
|
+
title TEXT NOT NULL,
|
|
12
|
+
outcome TEXT NOT NULL,
|
|
13
|
+
acceptance TEXT NOT NULL,
|
|
14
|
+
found_in_attempt TEXT,
|
|
15
|
+
filed_by TEXT NOT NULL,
|
|
16
|
+
filed_at INTEGER NOT NULL,
|
|
17
|
+
closed_at INTEGER,
|
|
18
|
+
close_reason TEXT
|
|
19
|
+
) STRICT`,
|
|
20
|
+
sql`CREATE TABLE links (
|
|
21
|
+
source TEXT NOT NULL,
|
|
22
|
+
target TEXT NOT NULL,
|
|
23
|
+
type TEXT NOT NULL,
|
|
24
|
+
added_by TEXT NOT NULL,
|
|
25
|
+
added_at INTEGER NOT NULL,
|
|
26
|
+
removed_at INTEGER,
|
|
27
|
+
PRIMARY KEY (source, target, type, added_at)
|
|
28
|
+
) STRICT`,
|
|
29
|
+
sql`CREATE UNIQUE INDEX links_live ON links (source, target, type) WHERE removed_at IS NULL`,
|
|
30
|
+
sql`CREATE TABLE levers (
|
|
31
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
kind TEXT NOT NULL,
|
|
33
|
+
item TEXT NOT NULL,
|
|
34
|
+
until INTEGER,
|
|
35
|
+
expires_at INTEGER,
|
|
36
|
+
reason TEXT,
|
|
37
|
+
actor TEXT NOT NULL,
|
|
38
|
+
at INTEGER NOT NULL
|
|
39
|
+
) STRICT`,
|
|
40
|
+
sql`CREATE INDEX levers_item_kind ON levers (item, kind)`,
|
|
41
|
+
sql`CREATE TABLE attempts (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
item TEXT NOT NULL,
|
|
44
|
+
phase TEXT NOT NULL,
|
|
45
|
+
number INTEGER NOT NULL,
|
|
46
|
+
runner TEXT NOT NULL,
|
|
47
|
+
recipe_hash TEXT NOT NULL,
|
|
48
|
+
rules_version TEXT NOT NULL,
|
|
49
|
+
pool TEXT NOT NULL,
|
|
50
|
+
slot INTEGER NOT NULL,
|
|
51
|
+
slot_gen INTEGER NOT NULL,
|
|
52
|
+
lease_gen INTEGER NOT NULL,
|
|
53
|
+
spawn_request TEXT NOT NULL,
|
|
54
|
+
spawn_request_hash TEXT NOT NULL,
|
|
55
|
+
state TEXT NOT NULL,
|
|
56
|
+
failure TEXT,
|
|
57
|
+
atc_session TEXT,
|
|
58
|
+
close_out TEXT,
|
|
59
|
+
claimed_at INTEGER NOT NULL,
|
|
60
|
+
started_at INTEGER,
|
|
61
|
+
ended_at INTEGER,
|
|
62
|
+
UNIQUE (item, phase, number)
|
|
63
|
+
) STRICT`,
|
|
64
|
+
sql`CREATE INDEX attempts_atc_session ON attempts (atc_session)`,
|
|
65
|
+
sql`CREATE TABLE attempt_sessions (
|
|
66
|
+
attempt TEXT NOT NULL,
|
|
67
|
+
agent_session TEXT NOT NULL UNIQUE,
|
|
68
|
+
atc_session TEXT NOT NULL,
|
|
69
|
+
seen_at INTEGER NOT NULL,
|
|
70
|
+
PRIMARY KEY (attempt, agent_session)
|
|
71
|
+
) STRICT`,
|
|
72
|
+
sql`CREATE TABLE leases (
|
|
73
|
+
item TEXT NOT NULL,
|
|
74
|
+
phase TEXT NOT NULL,
|
|
75
|
+
attempt TEXT NOT NULL,
|
|
76
|
+
gen INTEGER NOT NULL,
|
|
77
|
+
taken_at INTEGER NOT NULL,
|
|
78
|
+
released_at INTEGER,
|
|
79
|
+
PRIMARY KEY (item, phase, gen)
|
|
80
|
+
) STRICT`,
|
|
81
|
+
sql`CREATE UNIQUE INDEX leases_live ON leases (item, phase) WHERE released_at IS NULL`,
|
|
82
|
+
sql`CREATE TABLE pool_slots (
|
|
83
|
+
pool TEXT NOT NULL,
|
|
84
|
+
slot INTEGER NOT NULL,
|
|
85
|
+
holder TEXT,
|
|
86
|
+
gen INTEGER NOT NULL,
|
|
87
|
+
retired INTEGER NOT NULL,
|
|
88
|
+
PRIMARY KEY (pool, slot)
|
|
89
|
+
) STRICT`,
|
|
90
|
+
sql`CREATE TABLE decisions (
|
|
91
|
+
id TEXT PRIMARY KEY,
|
|
92
|
+
attempt TEXT NOT NULL,
|
|
93
|
+
item TEXT NOT NULL,
|
|
94
|
+
pool TEXT NOT NULL,
|
|
95
|
+
rules_version TEXT NOT NULL,
|
|
96
|
+
snapshot_hash TEXT NOT NULL,
|
|
97
|
+
store_version INTEGER NOT NULL,
|
|
98
|
+
pepys_offsets TEXT NOT NULL,
|
|
99
|
+
faults TEXT NOT NULL,
|
|
100
|
+
at INTEGER NOT NULL
|
|
101
|
+
) STRICT`,
|
|
102
|
+
sql`CREATE TABLE evidence (
|
|
103
|
+
fact_id TEXT PRIMARY KEY,
|
|
104
|
+
attempt TEXT NOT NULL,
|
|
105
|
+
kind TEXT NOT NULL,
|
|
106
|
+
ref TEXT NOT NULL,
|
|
107
|
+
observed TEXT,
|
|
108
|
+
source TEXT NOT NULL,
|
|
109
|
+
at INTEGER NOT NULL,
|
|
110
|
+
UNIQUE (attempt, kind, ref)
|
|
111
|
+
) STRICT`,
|
|
112
|
+
sql`CREATE TABLE pull_requests (
|
|
113
|
+
repo TEXT NOT NULL,
|
|
114
|
+
number INTEGER NOT NULL,
|
|
115
|
+
attempt TEXT NOT NULL,
|
|
116
|
+
state TEXT NOT NULL,
|
|
117
|
+
merged_at INTEGER,
|
|
118
|
+
merge_commit TEXT,
|
|
119
|
+
checked_at INTEGER NOT NULL,
|
|
120
|
+
PRIMARY KEY (repo, number)
|
|
121
|
+
) STRICT`,
|
|
122
|
+
sql`CREATE TABLE runners (
|
|
123
|
+
id TEXT PRIMARY KEY,
|
|
124
|
+
heartbeat_at INTEGER NOT NULL,
|
|
125
|
+
paused INTEGER NOT NULL
|
|
126
|
+
) STRICT`,
|
|
127
|
+
sql`CREATE TABLE outbox (
|
|
128
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
129
|
+
fact_id TEXT NOT NULL UNIQUE,
|
|
130
|
+
type TEXT NOT NULL,
|
|
131
|
+
entity TEXT NOT NULL,
|
|
132
|
+
actor TEXT NOT NULL,
|
|
133
|
+
at INTEGER NOT NULL,
|
|
134
|
+
payload TEXT NOT NULL,
|
|
135
|
+
sent_at INTEGER
|
|
136
|
+
) STRICT`,
|
|
137
|
+
sql`CREATE INDEX outbox_unsent ON outbox (seq) WHERE sent_at IS NULL`,
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
export const createTablesMigration: Migration = {
|
|
141
|
+
up: async (db: Kysely<unknown>) => {
|
|
142
|
+
for (const statement of statements) {
|
|
143
|
+
await statement.execute(db);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { createSQLiteClaimStore, type SQLiteClaimStore } from './create-sqlite-claim-store.ts';
|