@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.
- package/README.md +24 -3
- package/dist/codecs/parse-json-text.d.ts +1 -0
- package/dist/codecs/parse-store-row.d.ts +2 -0
- package/dist/codecs/table-codecs.d.ts +10 -0
- package/dist/codecs/to-column-name.d.ts +1 -0
- package/dist/codecs/to-store-columns.d.ts +3 -0
- package/dist/constants.d.ts +1 -0
- package/dist/create-sqlite-claim-store.d.ts +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/migrations/create-tables-migration.d.ts +2 -0
- package/dist/migrations/rename-relations-migration.d.ts +2 -0
- package/dist/migrations/run-store-migrations.d.ts +3 -0
- package/dist/migrations/v1-tables-migration.d.ts +2 -0
- package/dist/reads/build-sqlite-reader.d.ts +4 -0
- package/dist/reads/is-latest-of-session.d.ts +3 -0
- package/dist/reads/read-item-views.d.ts +5 -0
- 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/test-utils/create-test-claim-store.d.ts +2 -0
- package/dist/types.d.ts +172 -0
- package/dist/with-busy-retry.d.ts +1 -0
- package/dist/with-transaction.d.ts +3 -0
- package/dist/writes/apply-sqlite-change.d.ts +4 -0
- package/dist/writes/create-sqlite-tx.d.ts +4 -0
- package/dist/writes/take-write-lock.d.ts +3 -0
- package/package.json +15 -1
- package/src/codecs/parse-json-text.ts +5 -0
- package/src/codecs/parse-store-row.ts +36 -0
- package/src/codecs/table-codecs.ts +148 -0
- package/src/codecs/to-column-name.ts +3 -0
- package/src/codecs/to-store-columns.ts +44 -0
- package/src/constants.ts +1 -0
- package/src/create-sqlite-claim-store.ts +114 -0
- package/src/index.ts +1 -1
- package/src/migrations/create-tables-migration.ts +146 -0
- 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/reads/read-item-views.ts +92 -0
- 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 +23 -0
- package/src/types.ts +173 -0
- package/src/with-busy-retry.ts +32 -0
- package/src/with-transaction.ts +29 -0
- package/src/writes/apply-sqlite-change.ts +223 -0
- package/src/writes/create-sqlite-tx.ts +229 -0
- package/src/writes/take-write-lock.ts +8 -0
- package/dist/package-name.d.ts +0 -1
- 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
|
+
}
|