@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,36 @@
|
|
|
1
|
+
import { rowSchemas } from '@boxxy/model';
|
|
2
|
+
import type { TableName, Tables } from '@boxxy/model';
|
|
3
|
+
import { tableCodecs } from './table-codecs.ts';
|
|
4
|
+
import { toColumnName } from './to-column-name.ts';
|
|
5
|
+
|
|
6
|
+
export function parseStoreRow<T extends TableName>(table: T, record: object): Tables[T] {
|
|
7
|
+
const codec = tableCodecs[table];
|
|
8
|
+
|
|
9
|
+
const columns = new Map<string, unknown>(Object.entries(record));
|
|
10
|
+
const json = new Set<string>(codec.json);
|
|
11
|
+
const flags = new Set<string>(codec.flags);
|
|
12
|
+
|
|
13
|
+
const fields = Object.fromEntries(
|
|
14
|
+
codec.fields.map((field) => {
|
|
15
|
+
const value = columns.get(toColumnName(field));
|
|
16
|
+
|
|
17
|
+
return [field, parseStoreValue(value, json.has(field), flags.has(field))];
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return rowSchemas[table].parse(fields);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseStoreValue(value: unknown, isJSON: boolean, isFlag: boolean): unknown {
|
|
25
|
+
if (isJSON && typeof value === 'string') {
|
|
26
|
+
const parsed: unknown = JSON.parse(value);
|
|
27
|
+
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (isFlag) {
|
|
32
|
+
return value === 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { buildItemViews } from '@boxxy/model';
|
|
2
|
+
import type { Instant, ItemWithLevers, MaxAttemptsByKind } from '@boxxy/model';
|
|
3
|
+
import type { Expression, ExpressionBuilder, Kysely, SqlBool } from 'kysely';
|
|
4
|
+
import { parseStoreRow } from './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
|
+
maxAttemptsByKind: MaxAttemptsByKind,
|
|
15
|
+
): Promise<ItemWithLevers[]> {
|
|
16
|
+
const scoped = db.selectFrom('items').select('id').where(scope);
|
|
17
|
+
|
|
18
|
+
const blockers = db
|
|
19
|
+
.selectFrom('links')
|
|
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 links = await db
|
|
44
|
+
.selectFrom('links')
|
|
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
|
+
links: links.map((row) => parseStoreRow('links', 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
|
+
maxAttemptsByKind,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return views.filter((view) => inScope.has(view.id));
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import { Migrator } from 'kysely/migration';
|
|
3
|
+
import { createTablesMigration } from './create-tables-migration.ts';
|
|
4
|
+
import type { StoreDatabase } from './types.ts';
|
|
5
|
+
|
|
6
|
+
// Kysely runs the migrations in the order of their names, and skips each one the file has run.
|
|
7
|
+
const migrations = { '0001-create-tables': createTablesMigration };
|
|
8
|
+
|
|
9
|
+
export async function runStoreMigrations(db: Kysely<StoreDatabase>): Promise<void> {
|
|
10
|
+
const migrator = new Migrator({
|
|
11
|
+
db,
|
|
12
|
+
provider: { getMigrations: () => Promise.resolve(migrations) },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const outcome = await migrator.migrateToLatest();
|
|
16
|
+
|
|
17
|
+
if (outcome.error !== undefined) {
|
|
18
|
+
throw new Error('the store migrations failed', { cause: outcome.error });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { TableName, Tables } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
interface TableCodec<Row> {
|
|
4
|
+
readonly fields: readonly (keyof Row & string)[];
|
|
5
|
+
|
|
6
|
+
readonly json: readonly (keyof Row & string)[];
|
|
7
|
+
|
|
8
|
+
readonly flags: readonly (keyof Row & string)[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const tableCodecs: { readonly [T in TableName]: TableCodec<Tables[T]> } = {
|
|
12
|
+
items: {
|
|
13
|
+
fields: [
|
|
14
|
+
'id',
|
|
15
|
+
'kind',
|
|
16
|
+
'repo',
|
|
17
|
+
'title',
|
|
18
|
+
'outcome',
|
|
19
|
+
'acceptance',
|
|
20
|
+
'foundInAttempt',
|
|
21
|
+
'filedBy',
|
|
22
|
+
'filedAt',
|
|
23
|
+
'closedAt',
|
|
24
|
+
'closeReason',
|
|
25
|
+
],
|
|
26
|
+
json: ['acceptance'],
|
|
27
|
+
flags: [],
|
|
28
|
+
},
|
|
29
|
+
links: {
|
|
30
|
+
fields: ['source', 'target', 'type', 'addedBy', 'addedAt', 'removedAt'],
|
|
31
|
+
json: [],
|
|
32
|
+
flags: [],
|
|
33
|
+
},
|
|
34
|
+
levers: {
|
|
35
|
+
fields: ['seq', 'kind', 'item', 'until', 'expiresAt', 'reason', 'actor', 'at'],
|
|
36
|
+
json: [],
|
|
37
|
+
flags: [],
|
|
38
|
+
},
|
|
39
|
+
attempts: {
|
|
40
|
+
fields: [
|
|
41
|
+
'id',
|
|
42
|
+
'item',
|
|
43
|
+
'phase',
|
|
44
|
+
'number',
|
|
45
|
+
'runner',
|
|
46
|
+
'recipeHash',
|
|
47
|
+
'rulesVersion',
|
|
48
|
+
'pool',
|
|
49
|
+
'slot',
|
|
50
|
+
'slotGen',
|
|
51
|
+
'leaseGen',
|
|
52
|
+
'spawnRequest',
|
|
53
|
+
'spawnRequestHash',
|
|
54
|
+
'state',
|
|
55
|
+
'failure',
|
|
56
|
+
'atcSession',
|
|
57
|
+
'closeOut',
|
|
58
|
+
'claimedAt',
|
|
59
|
+
'startedAt',
|
|
60
|
+
'endedAt',
|
|
61
|
+
],
|
|
62
|
+
json: ['spawnRequest', 'closeOut'],
|
|
63
|
+
flags: [],
|
|
64
|
+
},
|
|
65
|
+
attempt_sessions: {
|
|
66
|
+
fields: ['attempt', 'agentSession', 'atcSession', 'seenAt'],
|
|
67
|
+
json: [],
|
|
68
|
+
flags: [],
|
|
69
|
+
},
|
|
70
|
+
leases: {
|
|
71
|
+
fields: ['item', 'phase', 'attempt', 'gen', 'takenAt', 'releasedAt'],
|
|
72
|
+
json: [],
|
|
73
|
+
flags: [],
|
|
74
|
+
},
|
|
75
|
+
pool_slots: {
|
|
76
|
+
fields: ['pool', 'slot', 'holder', 'gen', 'retired'],
|
|
77
|
+
json: [],
|
|
78
|
+
flags: ['retired'],
|
|
79
|
+
},
|
|
80
|
+
decisions: {
|
|
81
|
+
fields: [
|
|
82
|
+
'id',
|
|
83
|
+
'attempt',
|
|
84
|
+
'item',
|
|
85
|
+
'pool',
|
|
86
|
+
'rulesVersion',
|
|
87
|
+
'snapshotHash',
|
|
88
|
+
'storeVersion',
|
|
89
|
+
'pepysOffsets',
|
|
90
|
+
'faults',
|
|
91
|
+
'at',
|
|
92
|
+
],
|
|
93
|
+
json: ['pepysOffsets', 'faults'],
|
|
94
|
+
flags: [],
|
|
95
|
+
},
|
|
96
|
+
evidence: {
|
|
97
|
+
fields: ['factId', 'attempt', 'kind', 'ref', 'observed', 'source', 'at'],
|
|
98
|
+
json: [],
|
|
99
|
+
flags: [],
|
|
100
|
+
},
|
|
101
|
+
pull_requests: {
|
|
102
|
+
fields: ['repo', 'number', 'attempt', 'state', 'mergedAt', 'mergeCommit', 'checkedAt'],
|
|
103
|
+
json: [],
|
|
104
|
+
flags: [],
|
|
105
|
+
},
|
|
106
|
+
runners: {
|
|
107
|
+
fields: ['id', 'heartbeatAt', 'paused'],
|
|
108
|
+
json: [],
|
|
109
|
+
flags: ['paused'],
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { onTestFinished } from 'bun:test';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createSQLiteClaimStore } from '../create-sqlite-claim-store.ts';
|
|
6
|
+
import type { SQLiteClaimStore } from '../create-sqlite-claim-store.ts';
|
|
7
|
+
|
|
8
|
+
export async function createTestClaimStore(): Promise<SQLiteClaimStore> {
|
|
9
|
+
const cleanup = new AsyncDisposableStack();
|
|
10
|
+
|
|
11
|
+
onTestFinished(() => cleanup.disposeAsync());
|
|
12
|
+
|
|
13
|
+
const folder = await mkdtemp(join(tmpdir(), 'boxxy-store-'));
|
|
14
|
+
|
|
15
|
+
cleanup.defer(() => rm(folder, { recursive: true, force: true }));
|
|
16
|
+
|
|
17
|
+
const store = await createSQLiteClaimStore(join(folder, 'store.db'));
|
|
18
|
+
|
|
19
|
+
cleanup.defer(() => store.close());
|
|
20
|
+
|
|
21
|
+
return store;
|
|
22
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { formatCanonicalJson } from '@boxxy/content-hash';
|
|
2
|
+
import { toJSONValue } from '@boxxy/model';
|
|
3
|
+
import type { TableName } from '@boxxy/model';
|
|
4
|
+
import invariant from 'tiny-invariant';
|
|
5
|
+
import { tableCodecs } from './table-codecs.ts';
|
|
6
|
+
import { toColumnName } from './to-column-name.ts';
|
|
7
|
+
|
|
8
|
+
export type StoreValue = string | number | null;
|
|
9
|
+
|
|
10
|
+
// a field outside the table's codec fails, so no caller's key reaches the SQL as a column name
|
|
11
|
+
export function toStoreColumns(table: TableName, fields: object): [string, StoreValue][] {
|
|
12
|
+
const codec = tableCodecs[table];
|
|
13
|
+
|
|
14
|
+
const known = new Set<string>(codec.fields);
|
|
15
|
+
const json = new Set<string>(codec.json);
|
|
16
|
+
|
|
17
|
+
return Object.entries(fields).map(([field, value]: [string, unknown]) => {
|
|
18
|
+
invariant(known.has(field), `${table} has no field ${field}`);
|
|
19
|
+
|
|
20
|
+
return [toColumnName(field), toStoreValue(value, json.has(field))];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toStoreValue(value: unknown, isJSON: boolean): StoreValue {
|
|
25
|
+
if (value === null) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (isJSON) {
|
|
30
|
+
// an update compares the stored text, so two objects with their keys in another order match
|
|
31
|
+
return formatCanonicalJson(toJSONValue(value));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof value === 'boolean') {
|
|
35
|
+
return value ? 1 : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
invariant(
|
|
39
|
+
typeof value === 'string' || typeof value === 'number',
|
|
40
|
+
`a ${typeof value} needs a JSON column`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return value;
|
|
44
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Generated } from 'kysely';
|
|
2
|
+
|
|
3
|
+
export interface StoreDatabase {
|
|
4
|
+
items: {
|
|
5
|
+
id: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
repo: string;
|
|
8
|
+
title: string;
|
|
9
|
+
outcome: string;
|
|
10
|
+
acceptance: string;
|
|
11
|
+
found_in_attempt: string | null;
|
|
12
|
+
filed_by: string;
|
|
13
|
+
filed_at: number;
|
|
14
|
+
closed_at: number | null;
|
|
15
|
+
close_reason: string | null;
|
|
16
|
+
};
|
|
17
|
+
links: {
|
|
18
|
+
source: string;
|
|
19
|
+
target: string;
|
|
20
|
+
type: string;
|
|
21
|
+
added_by: string;
|
|
22
|
+
added_at: number;
|
|
23
|
+
removed_at: number | null;
|
|
24
|
+
};
|
|
25
|
+
levers: {
|
|
26
|
+
seq: number;
|
|
27
|
+
kind: string;
|
|
28
|
+
item: string;
|
|
29
|
+
until: number | null;
|
|
30
|
+
expires_at: number | null;
|
|
31
|
+
reason: string | null;
|
|
32
|
+
actor: string;
|
|
33
|
+
at: number;
|
|
34
|
+
};
|
|
35
|
+
attempts: {
|
|
36
|
+
id: string;
|
|
37
|
+
item: string;
|
|
38
|
+
phase: string;
|
|
39
|
+
number: number;
|
|
40
|
+
runner: string;
|
|
41
|
+
recipe_hash: string;
|
|
42
|
+
rules_version: string;
|
|
43
|
+
pool: string;
|
|
44
|
+
slot: number;
|
|
45
|
+
slot_gen: number;
|
|
46
|
+
lease_gen: number;
|
|
47
|
+
spawn_request: string;
|
|
48
|
+
spawn_request_hash: string;
|
|
49
|
+
state: string;
|
|
50
|
+
failure: string | null;
|
|
51
|
+
atc_session: string | null;
|
|
52
|
+
close_out: string | null;
|
|
53
|
+
claimed_at: number;
|
|
54
|
+
started_at: number | null;
|
|
55
|
+
ended_at: number | null;
|
|
56
|
+
};
|
|
57
|
+
attempt_sessions: {
|
|
58
|
+
attempt: string;
|
|
59
|
+
agent_session: string;
|
|
60
|
+
atc_session: string;
|
|
61
|
+
seen_at: number;
|
|
62
|
+
};
|
|
63
|
+
leases: {
|
|
64
|
+
item: string;
|
|
65
|
+
phase: string;
|
|
66
|
+
attempt: string;
|
|
67
|
+
gen: number;
|
|
68
|
+
taken_at: number;
|
|
69
|
+
released_at: number | null;
|
|
70
|
+
};
|
|
71
|
+
pool_slots: {
|
|
72
|
+
pool: string;
|
|
73
|
+
slot: number;
|
|
74
|
+
holder: string | null;
|
|
75
|
+
gen: number;
|
|
76
|
+
retired: number;
|
|
77
|
+
};
|
|
78
|
+
decisions: {
|
|
79
|
+
id: string;
|
|
80
|
+
attempt: string;
|
|
81
|
+
item: string;
|
|
82
|
+
pool: string;
|
|
83
|
+
rules_version: string;
|
|
84
|
+
snapshot_hash: string;
|
|
85
|
+
store_version: number;
|
|
86
|
+
pepys_offsets: string;
|
|
87
|
+
faults: string;
|
|
88
|
+
at: number;
|
|
89
|
+
};
|
|
90
|
+
evidence: {
|
|
91
|
+
fact_id: string;
|
|
92
|
+
attempt: string;
|
|
93
|
+
kind: string;
|
|
94
|
+
ref: string;
|
|
95
|
+
observed: string | null;
|
|
96
|
+
source: string;
|
|
97
|
+
at: number;
|
|
98
|
+
};
|
|
99
|
+
pull_requests: {
|
|
100
|
+
repo: string;
|
|
101
|
+
number: number;
|
|
102
|
+
attempt: string;
|
|
103
|
+
state: string;
|
|
104
|
+
merged_at: number | null;
|
|
105
|
+
merge_commit: string | null;
|
|
106
|
+
checked_at: number;
|
|
107
|
+
};
|
|
108
|
+
runners: {
|
|
109
|
+
id: string;
|
|
110
|
+
heartbeat_at: number;
|
|
111
|
+
paused: number;
|
|
112
|
+
};
|
|
113
|
+
outbox: {
|
|
114
|
+
seq: Generated<number>;
|
|
115
|
+
fact_id: string;
|
|
116
|
+
type: string;
|
|
117
|
+
entity: string;
|
|
118
|
+
actor: string;
|
|
119
|
+
at: number;
|
|
120
|
+
payload: string;
|
|
121
|
+
sent_at: number | null;
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/package-name.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const packageName = "@boxxy/store-sqlite";
|
package/src/package-name.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const packageName = '@boxxy/store-sqlite';
|