@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,42 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import { Migrator } from 'kysely/migration';
|
|
3
|
+
import { lockWaitLimitMs } from '../constants.ts';
|
|
4
|
+
import type { StoreDatabase } from '../types.ts';
|
|
5
|
+
import { withTransaction } from '../with-transaction.ts';
|
|
6
|
+
import { takeWriteLock } from '../writes/take-write-lock.ts';
|
|
7
|
+
import { createTablesMigration } from './create-tables-migration.ts';
|
|
8
|
+
import { renameRelationsMigration } from './rename-relations-migration.ts';
|
|
9
|
+
import { v1TablesMigration } from './v1-tables-migration.ts';
|
|
10
|
+
|
|
11
|
+
// Kysely's SQLite migration lock does nothing, so two openers of a new file would both find nothing
|
|
12
|
+
// run; the write lock makes the second one wait and then find the file migrated.
|
|
13
|
+
export async function runStoreMigrations(db: Kysely<StoreDatabase>): Promise<void> {
|
|
14
|
+
await db.connection().execute((connection) =>
|
|
15
|
+
withTransaction(
|
|
16
|
+
connection,
|
|
17
|
+
(locked) => takeWriteLock(locked, lockWaitLimitMs),
|
|
18
|
+
() => runMigrator(connection),
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Kysely runs the migrations in the order of their names, and skips each one the file has run.
|
|
24
|
+
const migrations = {
|
|
25
|
+
'0001-create-tables': createTablesMigration,
|
|
26
|
+
'0002-rename-relations': renameRelationsMigration,
|
|
27
|
+
'0003-v1-tables': v1TablesMigration,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function runMigrator(db: Kysely<StoreDatabase>): Promise<void> {
|
|
31
|
+
const migrator = new Migrator({
|
|
32
|
+
db,
|
|
33
|
+
provider: { getMigrations: () => Promise.resolve(migrations) },
|
|
34
|
+
disableTransactions: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const outcome = await migrator.migrateToLatest();
|
|
38
|
+
|
|
39
|
+
if (outcome.error !== undefined) {
|
|
40
|
+
throw new Error('the store migrations failed', { cause: outcome.error });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import type { Migration } from 'kysely/migration';
|
|
4
|
+
|
|
5
|
+
// Slice 1 never ran outside tests, so this rebuilds each changed table empty, in place of ALTER
|
|
6
|
+
// statements that would need an invented default for every new NOT NULL column. A file that
|
|
7
|
+
// already holds an item or a fact is refused, so no row is ever dropped.
|
|
8
|
+
const statements = [
|
|
9
|
+
sql`DROP TABLE items`,
|
|
10
|
+
sql`CREATE TABLE items (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
kind TEXT NOT NULL,
|
|
13
|
+
repo TEXT NOT NULL,
|
|
14
|
+
title TEXT NOT NULL,
|
|
15
|
+
outcome TEXT NOT NULL,
|
|
16
|
+
acceptance TEXT NOT NULL,
|
|
17
|
+
fields TEXT NOT NULL,
|
|
18
|
+
labels TEXT NOT NULL,
|
|
19
|
+
found_in_attempt TEXT,
|
|
20
|
+
filed_by TEXT NOT NULL,
|
|
21
|
+
filed_at INTEGER NOT NULL,
|
|
22
|
+
closed_at INTEGER,
|
|
23
|
+
close_reason TEXT,
|
|
24
|
+
kept_session TEXT
|
|
25
|
+
) STRICT`,
|
|
26
|
+
sql`DROP TABLE levers`,
|
|
27
|
+
sql`CREATE TABLE levers (
|
|
28
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
kind TEXT NOT NULL,
|
|
30
|
+
item TEXT NOT NULL,
|
|
31
|
+
until INTEGER,
|
|
32
|
+
expires_at INTEGER,
|
|
33
|
+
reason TEXT,
|
|
34
|
+
phase TEXT,
|
|
35
|
+
actor TEXT NOT NULL,
|
|
36
|
+
at INTEGER NOT NULL
|
|
37
|
+
) STRICT`,
|
|
38
|
+
sql`CREATE INDEX levers_item_kind ON levers (item, kind)`,
|
|
39
|
+
sql`DROP TABLE attempts`,
|
|
40
|
+
sql`CREATE TABLE attempts (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
item TEXT NOT NULL,
|
|
43
|
+
phase TEXT NOT NULL,
|
|
44
|
+
number INTEGER NOT NULL,
|
|
45
|
+
runner TEXT NOT NULL,
|
|
46
|
+
recipe_hash TEXT NOT NULL,
|
|
47
|
+
rules_version TEXT NOT NULL,
|
|
48
|
+
pool TEXT NOT NULL,
|
|
49
|
+
lease_gen INTEGER NOT NULL,
|
|
50
|
+
spawn_request TEXT NOT NULL,
|
|
51
|
+
spawn_request_hash TEXT NOT NULL,
|
|
52
|
+
state TEXT NOT NULL,
|
|
53
|
+
failure TEXT,
|
|
54
|
+
atc_session TEXT,
|
|
55
|
+
caller TEXT,
|
|
56
|
+
claimed_at INTEGER NOT NULL,
|
|
57
|
+
started_at INTEGER,
|
|
58
|
+
ended_at INTEGER,
|
|
59
|
+
fields TEXT NOT NULL,
|
|
60
|
+
mode TEXT NOT NULL,
|
|
61
|
+
round INTEGER NOT NULL,
|
|
62
|
+
input TEXT,
|
|
63
|
+
hold_gen INTEGER NOT NULL,
|
|
64
|
+
close_out TEXT,
|
|
65
|
+
close_out_hash TEXT,
|
|
66
|
+
close_out_fact TEXT,
|
|
67
|
+
closing TEXT,
|
|
68
|
+
closing_at INTEGER,
|
|
69
|
+
suspend_requested_at INTEGER,
|
|
70
|
+
slept_at INTEGER,
|
|
71
|
+
wake_requested_at INTEGER,
|
|
72
|
+
woken_at INTEGER,
|
|
73
|
+
outcome TEXT,
|
|
74
|
+
returned_outcome TEXT,
|
|
75
|
+
strayed_at INTEGER,
|
|
76
|
+
session_stopped_at INTEGER,
|
|
77
|
+
session_forgotten_at INTEGER,
|
|
78
|
+
UNIQUE (item, phase, number)
|
|
79
|
+
) STRICT`,
|
|
80
|
+
sql`CREATE INDEX attempts_atc_session ON attempts (atc_session)`,
|
|
81
|
+
sql`DROP TABLE attempt_sessions`,
|
|
82
|
+
|
|
83
|
+
// a kept session binds one agent session to each later attempt, so the agent session is no key
|
|
84
|
+
sql`CREATE TABLE attempt_sessions (
|
|
85
|
+
attempt TEXT NOT NULL,
|
|
86
|
+
agent_session TEXT NOT NULL,
|
|
87
|
+
atc_session TEXT NOT NULL,
|
|
88
|
+
product TEXT NOT NULL,
|
|
89
|
+
source TEXT NOT NULL,
|
|
90
|
+
seen_at INTEGER NOT NULL,
|
|
91
|
+
PRIMARY KEY (attempt, agent_session)
|
|
92
|
+
) STRICT`,
|
|
93
|
+
sql`CREATE INDEX attempt_sessions_agent_session ON attempt_sessions (agent_session)`,
|
|
94
|
+
sql`DROP TABLE pool_slots`,
|
|
95
|
+
sql`CREATE TABLE limits (
|
|
96
|
+
name TEXT PRIMARY KEY,
|
|
97
|
+
max INTEGER NOT NULL,
|
|
98
|
+
counts TEXT NOT NULL
|
|
99
|
+
) STRICT`,
|
|
100
|
+
sql`CREATE TABLE limit_seats (
|
|
101
|
+
"limit" TEXT NOT NULL,
|
|
102
|
+
attempt TEXT NOT NULL,
|
|
103
|
+
hold_gen INTEGER NOT NULL,
|
|
104
|
+
over_cap INTEGER NOT NULL,
|
|
105
|
+
taken_at INTEGER NOT NULL,
|
|
106
|
+
released_at INTEGER
|
|
107
|
+
) STRICT`,
|
|
108
|
+
sql`CREATE UNIQUE INDEX limit_seats_held ON limit_seats ("limit", attempt, hold_gen)
|
|
109
|
+
WHERE released_at IS NULL`,
|
|
110
|
+
sql`DROP TABLE decisions`,
|
|
111
|
+
sql`CREATE TABLE decisions (
|
|
112
|
+
id TEXT PRIMARY KEY,
|
|
113
|
+
attempt TEXT NOT NULL,
|
|
114
|
+
item TEXT NOT NULL,
|
|
115
|
+
kind TEXT NOT NULL,
|
|
116
|
+
pool TEXT NOT NULL,
|
|
117
|
+
rules_version TEXT NOT NULL,
|
|
118
|
+
snapshot_hash TEXT NOT NULL,
|
|
119
|
+
store_version INTEGER NOT NULL,
|
|
120
|
+
pepys_offsets TEXT NOT NULL,
|
|
121
|
+
faults TEXT NOT NULL,
|
|
122
|
+
limits TEXT NOT NULL,
|
|
123
|
+
at INTEGER NOT NULL
|
|
124
|
+
) STRICT`,
|
|
125
|
+
sql`DROP TABLE pull_requests`,
|
|
126
|
+
sql`CREATE TABLE pull_requests (
|
|
127
|
+
repo TEXT NOT NULL,
|
|
128
|
+
number INTEGER NOT NULL,
|
|
129
|
+
attempt TEXT NOT NULL,
|
|
130
|
+
github_id INTEGER NOT NULL,
|
|
131
|
+
url TEXT NOT NULL,
|
|
132
|
+
state TEXT NOT NULL,
|
|
133
|
+
merged_at INTEGER,
|
|
134
|
+
merge_commit TEXT,
|
|
135
|
+
checked_at INTEGER NOT NULL,
|
|
136
|
+
PRIMARY KEY (repo, number)
|
|
137
|
+
) STRICT`,
|
|
138
|
+
sql`CREATE TABLE notes (
|
|
139
|
+
item TEXT NOT NULL,
|
|
140
|
+
seq INTEGER NOT NULL,
|
|
141
|
+
text TEXT NOT NULL,
|
|
142
|
+
"by" TEXT NOT NULL,
|
|
143
|
+
at INTEGER NOT NULL,
|
|
144
|
+
PRIMARY KEY (item, seq)
|
|
145
|
+
) STRICT`,
|
|
146
|
+
sql`CREATE TABLE notices (
|
|
147
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
148
|
+
item TEXT NOT NULL,
|
|
149
|
+
reason TEXT NOT NULL,
|
|
150
|
+
at INTEGER NOT NULL,
|
|
151
|
+
tries INTEGER NOT NULL,
|
|
152
|
+
sent_at INTEGER
|
|
153
|
+
) STRICT`,
|
|
154
|
+
sql`CREATE INDEX notices_unsent ON notices (id) WHERE sent_at IS NULL`,
|
|
155
|
+
sql`DROP TABLE outbox`,
|
|
156
|
+
sql`CREATE TABLE outbox (
|
|
157
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
158
|
+
fact_id TEXT NOT NULL UNIQUE,
|
|
159
|
+
type TEXT NOT NULL,
|
|
160
|
+
entity TEXT NOT NULL,
|
|
161
|
+
actor TEXT NOT NULL,
|
|
162
|
+
at INTEGER NOT NULL,
|
|
163
|
+
payload TEXT NOT NULL,
|
|
164
|
+
sent_at INTEGER,
|
|
165
|
+
parked_at INTEGER,
|
|
166
|
+
parked_reason TEXT
|
|
167
|
+
) STRICT`,
|
|
168
|
+
sql`CREATE INDEX outbox_unsent ON outbox (seq) WHERE sent_at IS NULL AND parked_at IS NULL`,
|
|
169
|
+
sql`CREATE INDEX outbox_behind ON outbox (entity) WHERE sent_at IS NULL`,
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
export const v1TablesMigration: Migration = {
|
|
173
|
+
up: async (db: Kysely<unknown>) => {
|
|
174
|
+
const found = await sql`SELECT 1 FROM items UNION ALL SELECT 1 FROM outbox LIMIT 1`.execute(db);
|
|
175
|
+
|
|
176
|
+
if (found.rows.length > 0) {
|
|
177
|
+
throw new Error('the store holds slice 1 rows, which the v1 tables cannot take');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const statement of statements) {
|
|
181
|
+
await statement.execute(db);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
};
|
|
@@ -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
|
+
}
|