@boxxy/store-sqlite 0.0.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 CHANGED
@@ -5,17 +5,38 @@
5
5
  <a href="https://www.npmjs.com/package/@boxxy/store-sqlite"><img src="https://img.shields.io/npm/v/@boxxy/store-sqlite" alt="npm version"></a>
6
6
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
7
7
  </p>
8
+
9
+ <p>
10
+ <a href="../../docs/design/boxxy/README.md">Documentation</a>
11
+ </p>
8
12
  </div>
9
13
 
10
- boxxy's store on SQLite: the schema, the migrations and the transactions.
14
+ boxxy's store on SQLite: the schema, the migrations and the transactions behind the `ClaimStore`
15
+ port.
11
16
 
12
- This version is a placeholder that exports only its package name, as `packageName`. The
13
- [boxxy README](https://github.com/zgeoff/boxxy#workspace) lists the workspace's packages.
17
+ `createSQLiteClaimStore(path)` opens one SQLite file, creates it when it's missing, runs the
18
+ migrations the file hasn't run, and returns the store with a `close` function.
14
19
 
15
20
  ## Install
16
21
 
22
+ Install the package from [npm](https://www.npmjs.com/package/@boxxy/store-sqlite) with Bun:
23
+
17
24
  ```bash
18
25
  bun add @boxxy/store-sqlite
19
26
  ```
20
27
 
21
28
  The package runs on [Bun](https://bun.sh) 1.3 or newer.
29
+
30
+ ## Features
31
+
32
+ - Items, levers, attempts, leases, pool slots and the outbox in one file, with each change applied
33
+ whole or not at all ([one write path](../../docs/design/boxxy/slice-1.md#one-write-path)).
34
+ - `exclusive` calls that run one at a time in a `BEGIN IMMEDIATE` transaction, and `read` calls that
35
+ see the last commit ([store rules](../../docs/design/boxxy/slice-1.md#store-rules)).
36
+ - Item state derived on each read, from the rows and the loaded rules
37
+ ([derived item state](../../docs/design/boxxy/slice-1.md#derived-item-state)).
38
+
39
+ ## Documentation
40
+
41
+ [The store schema](../../docs/design/boxxy/slice-1.md#the-store-schema) lists each table, and
42
+ [`02-ports.ts`](../../docs/design/boxxy/sketches/02-ports.ts) gives the shape of the port.
@@ -0,0 +1,4 @@
1
+ import type { ApplyResult, StoreChange } from '@boxxy/model';
2
+ import type { Kysely } from 'kysely';
3
+ import type { StoreDatabase } from './types.ts';
4
+ export declare function applySQLiteChange(db: Kysely<StoreDatabase>, change: StoreChange): Promise<ApplyResult>;
@@ -0,0 +1,4 @@
1
+ import type { StoreReader } from '@boxxy/model';
2
+ import type { Kysely } from 'kysely';
3
+ import type { StoreDatabase } from './types.ts';
4
+ export declare function buildSQLiteReader(db: Kysely<StoreDatabase>): StoreReader;
@@ -0,0 +1,5 @@
1
+ import type { ClaimStore } from '@boxxy/model';
2
+ export interface SQLiteClaimStore extends ClaimStore {
3
+ close: () => Promise<void>;
4
+ }
5
+ export declare function createSQLiteClaimStore(path: string): Promise<SQLiteClaimStore>;
@@ -0,0 +1,4 @@
1
+ import type { StoreTx } from '@boxxy/model';
2
+ import type { Kysely } from 'kysely';
3
+ import type { StoreDatabase } from './types.ts';
4
+ export declare function createSQLiteTx(db: Kysely<StoreDatabase>): StoreTx;
@@ -0,0 +1,2 @@
1
+ import type { Migration } from 'kysely/migration';
2
+ export declare const createTablesMigration: Migration;
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { packageName } from './package-name.ts';
1
+ export { createSQLiteClaimStore, type SQLiteClaimStore } from './create-sqlite-claim-store.ts';
@@ -0,0 +1,2 @@
1
+ import type { TableName, Tables } from '@boxxy/model';
2
+ export declare function parseStoreRow<T extends TableName>(table: T, record: object): Tables[T];
@@ -0,0 +1,5 @@
1
+ import type { Instant, ItemWithLevers, MaxAttemptsByKind } from '@boxxy/model';
2
+ import type { Expression, ExpressionBuilder, Kysely, SqlBool } from 'kysely';
3
+ import type { StoreDatabase } from './types.ts';
4
+ export type ItemScope = (builder: ExpressionBuilder<StoreDatabase, 'items'>) => Expression<SqlBool>;
5
+ export declare function readItemViews(db: Kysely<StoreDatabase>, scope: ItemScope, now: Instant, maxAttemptsByKind: MaxAttemptsByKind): Promise<ItemWithLevers[]>;
@@ -0,0 +1,3 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { StoreDatabase } from './types.ts';
3
+ export declare function runStoreMigrations(db: Kysely<StoreDatabase>): Promise<void>;
@@ -0,0 +1,10 @@
1
+ import type { TableName, Tables } from '@boxxy/model';
2
+ interface TableCodec<Row> {
3
+ readonly fields: readonly (keyof Row & string)[];
4
+ readonly json: readonly (keyof Row & string)[];
5
+ readonly flags: readonly (keyof Row & string)[];
6
+ }
7
+ export declare const tableCodecs: {
8
+ readonly [T in TableName]: TableCodec<Tables[T]>;
9
+ };
10
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { SQLiteClaimStore } from '../create-sqlite-claim-store.ts';
2
+ export declare function createTestClaimStore(): Promise<SQLiteClaimStore>;
@@ -0,0 +1 @@
1
+ export declare function toColumnName(field: string): string;
@@ -0,0 +1,3 @@
1
+ import type { TableName } from '@boxxy/model';
2
+ export type StoreValue = string | number | null;
3
+ export declare function toStoreColumns(table: TableName, fields: object): [string, StoreValue][];
@@ -0,0 +1,122 @@
1
+ import type { Generated } from 'kysely';
2
+ export interface StoreDatabase {
3
+ items: {
4
+ id: string;
5
+ kind: string;
6
+ repo: string;
7
+ title: string;
8
+ outcome: string;
9
+ acceptance: string;
10
+ found_in_attempt: string | null;
11
+ filed_by: string;
12
+ filed_at: number;
13
+ closed_at: number | null;
14
+ close_reason: string | null;
15
+ };
16
+ links: {
17
+ source: string;
18
+ target: string;
19
+ type: string;
20
+ added_by: string;
21
+ added_at: number;
22
+ removed_at: number | null;
23
+ };
24
+ levers: {
25
+ seq: number;
26
+ kind: string;
27
+ item: string;
28
+ until: number | null;
29
+ expires_at: number | null;
30
+ reason: string | null;
31
+ actor: string;
32
+ at: number;
33
+ };
34
+ attempts: {
35
+ id: string;
36
+ item: string;
37
+ phase: string;
38
+ number: number;
39
+ runner: string;
40
+ recipe_hash: string;
41
+ rules_version: string;
42
+ pool: string;
43
+ slot: number;
44
+ slot_gen: number;
45
+ lease_gen: number;
46
+ spawn_request: string;
47
+ spawn_request_hash: string;
48
+ state: string;
49
+ failure: string | null;
50
+ atc_session: string | null;
51
+ close_out: string | null;
52
+ claimed_at: number;
53
+ started_at: number | null;
54
+ ended_at: number | null;
55
+ };
56
+ attempt_sessions: {
57
+ attempt: string;
58
+ agent_session: string;
59
+ atc_session: string;
60
+ seen_at: number;
61
+ };
62
+ leases: {
63
+ item: string;
64
+ phase: string;
65
+ attempt: string;
66
+ gen: number;
67
+ taken_at: number;
68
+ released_at: number | null;
69
+ };
70
+ pool_slots: {
71
+ pool: string;
72
+ slot: number;
73
+ holder: string | null;
74
+ gen: number;
75
+ retired: number;
76
+ };
77
+ decisions: {
78
+ id: string;
79
+ attempt: string;
80
+ item: string;
81
+ pool: string;
82
+ rules_version: string;
83
+ snapshot_hash: string;
84
+ store_version: number;
85
+ pepys_offsets: string;
86
+ faults: string;
87
+ at: number;
88
+ };
89
+ evidence: {
90
+ fact_id: string;
91
+ attempt: string;
92
+ kind: string;
93
+ ref: string;
94
+ observed: string | null;
95
+ source: string;
96
+ at: number;
97
+ };
98
+ pull_requests: {
99
+ repo: string;
100
+ number: number;
101
+ attempt: string;
102
+ state: string;
103
+ merged_at: number | null;
104
+ merge_commit: string | null;
105
+ checked_at: number;
106
+ };
107
+ runners: {
108
+ id: string;
109
+ heartbeat_at: number;
110
+ paused: number;
111
+ };
112
+ outbox: {
113
+ seq: Generated<number>;
114
+ fact_id: string;
115
+ type: string;
116
+ entity: string;
117
+ actor: string;
118
+ at: number;
119
+ payload: string;
120
+ sent_at: number | null;
121
+ };
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxxy/store-sqlite",
3
- "version": "0.0.0",
3
+ "version": "0.1.1",
4
4
  "description": "boxxy's store on SQLite",
5
5
  "homepage": "https://github.com/zgeoff/boxxy/tree/main/adapters/store-sqlite#readme",
6
6
  "bugs": "https://github.com/zgeoff/boxxy/issues",
@@ -29,6 +29,18 @@
29
29
  "build": "rm -rf dist && tsrs -p tsconfig.build.json",
30
30
  "typecheck": "tsrs --noEmit"
31
31
  },
32
+ "dependencies": {
33
+ "@boxxy/content-hash": "0.1.0",
34
+ "@boxxy/model": "0.1.2",
35
+ "@zgeoff/kysely-bun-sqlite": "0.3.2",
36
+ "kysely": "0.29.7",
37
+ "tiny-invariant": "1.3.3",
38
+ "ts-pattern": "5.9.2",
39
+ "zod": "4.6.7"
40
+ },
41
+ "devDependencies": {
42
+ "@boxxy/dispatch": "0.1.1"
43
+ },
32
44
  "engines": {
33
45
  "bun": ">=1.3",
34
46
  "node": ">=24"
@@ -0,0 +1,220 @@
1
+ import { buildAppliedChange, findSlotTake, toJSONValue } from '@boxxy/model';
2
+ import type { ApplyResult, RowWrite, SlotTake, StoreChange, TakenSlot } from '@boxxy/model';
3
+ import { sql } from 'kysely';
4
+ import type { Kysely, RawBuilder } from 'kysely';
5
+ import invariant from 'tiny-invariant';
6
+ import { match } from 'ts-pattern';
7
+ import { toStoreColumns } from './to-store-columns.ts';
8
+ import type { StoreValue } from './to-store-columns.ts';
9
+ import type { StoreDatabase } from './types.ts';
10
+
11
+ // A savepoint, not the transaction, so a refusal keeps the caller's earlier changes in the same
12
+ // exclusive call. The caller runs one change at a time, so one savepoint name is enough.
13
+ export async function applySQLiteChange(
14
+ db: Kysely<StoreDatabase>,
15
+ change: StoreChange,
16
+ ): Promise<ApplyResult> {
17
+ invariant(change.facts.length > 0, 'a change writes at least one fact');
18
+
19
+ await sql`SAVEPOINT apply_change`.execute(db);
20
+
21
+ try {
22
+ const result = await writeSQLiteChange(db, change);
23
+
24
+ if (result.kind !== 'applied') {
25
+ await sql`ROLLBACK TO apply_change`.execute(db);
26
+ }
27
+
28
+ await sql`RELEASE apply_change`.execute(db);
29
+
30
+ return result;
31
+ } catch (error) {
32
+ await removeSavepoint(db, error);
33
+
34
+ throw error;
35
+ }
36
+ }
37
+
38
+ async function writeSQLiteChange(
39
+ db: Kysely<StoreDatabase>,
40
+ change: StoreChange,
41
+ ): Promise<ApplyResult> {
42
+ if (!(await probeLiveUnder(db, change.under))) {
43
+ return { kind: 'stale-generation' };
44
+ }
45
+
46
+ const take = findSlotTake(change.writes);
47
+ const taken = take === null ? null : await writeSlotTake(db, take);
48
+
49
+ if (take !== null && taken === null) {
50
+ return { kind: 'no-free-slot' };
51
+ }
52
+
53
+ const lastLever = await db
54
+ .selectFrom('levers')
55
+ .select((builder) => builder.fn.max('seq').as('seq'))
56
+ .executeTakeFirst();
57
+
58
+ const applied = buildAppliedChange(change, { taken, lastLeverSeq: lastLever?.seq ?? 0 });
59
+
60
+ for (const write of applied.writes) {
61
+ const refusal = await writeSQLiteRow(db, write);
62
+
63
+ if (refusal !== null) {
64
+ return refusal;
65
+ }
66
+ }
67
+
68
+ for (const fact of applied.facts) {
69
+ await db
70
+ .insertInto('outbox')
71
+ .values({
72
+ fact_id: fact.id,
73
+ type: fact.type,
74
+ entity: fact.entity,
75
+ actor: fact.actor,
76
+ at: change.at,
77
+ payload: JSON.stringify(toJSONValue(fact.payload)),
78
+ sent_at: null,
79
+ })
80
+ .execute();
81
+ }
82
+
83
+ const slot = taken === null ? null : { pool: taken.pool, slot: taken.slot, gen: taken.gen };
84
+
85
+ return { kind: 'applied', slot };
86
+ }
87
+
88
+ async function probeLiveUnder(
89
+ db: Kysely<StoreDatabase>,
90
+ under: StoreChange['under'],
91
+ ): Promise<boolean> {
92
+ if (under === undefined) {
93
+ return true;
94
+ }
95
+
96
+ const lease = await db
97
+ .selectFrom('leases')
98
+ .select('gen')
99
+ .where('item', '=', under.item)
100
+ .where('phase', '=', under.phase)
101
+ .where('gen', '=', under.gen)
102
+ .where('released_at', 'is', null)
103
+ .executeTakeFirst();
104
+
105
+ if (lease === undefined || under.slot === undefined) {
106
+ return lease !== undefined;
107
+ }
108
+
109
+ const slot = await db
110
+ .selectFrom('pool_slots')
111
+ .select('gen')
112
+ .where('pool', '=', under.slot.pool)
113
+ .where('slot', '=', under.slot.slot)
114
+ .where('gen', '=', under.slot.gen)
115
+ .where('holder', 'is not', null)
116
+ .executeTakeFirst();
117
+
118
+ return slot !== undefined;
119
+ }
120
+
121
+ async function writeSlotTake(db: Kysely<StoreDatabase>, take: SlotTake): Promise<TakenSlot | null> {
122
+ const lowest = db
123
+ .selectFrom('pool_slots')
124
+ .select((builder) => builder.fn.min('slot').as('slot'))
125
+ .where('pool', '=', take.pool)
126
+ .where('holder', 'is', null)
127
+ .where('retired', '=', 0);
128
+
129
+ const row = await db
130
+ .updateTable('pool_slots')
131
+ .set((builder) => ({ holder: take.holder, gen: builder('gen', '+', 1) }))
132
+ .where('pool', '=', take.pool)
133
+ .where('slot', '=', lowest)
134
+ .where('holder', 'is', null)
135
+ .where('retired', '=', 0)
136
+ .returning(['slot', 'gen'])
137
+ .executeTakeFirst();
138
+
139
+ return row === undefined
140
+ ? null
141
+ : { holder: take.holder, pool: take.pool, slot: row.slot, gen: row.gen };
142
+ }
143
+
144
+ function writeSQLiteRow(db: Kysely<StoreDatabase>, write: RowWrite): Promise<ApplyResult | null> {
145
+ return match(write)
146
+ .with({ op: 'take-slot' }, () => Promise.resolve(null))
147
+ .with({ op: 'release-slot' }, async (release) => {
148
+ const result = await db
149
+ .updateTable('pool_slots')
150
+ .set({ holder: null })
151
+ .where('pool', '=', release.pool)
152
+ .where('slot', '=', release.slot)
153
+ .where('holder', '=', release.holder)
154
+ .executeTakeFirst();
155
+
156
+ return result.numUpdatedRows === 0n ? ({ kind: 'stale-generation' } as const) : null;
157
+ })
158
+ .with({ op: 'insert', table: 'leases' }, async (insert) => {
159
+ const columns = toStoreColumns(insert.table, insert.row);
160
+
161
+ // ON CONFLICT names the partial unique index, so SQLite skips a second live lease, not throws
162
+ const result = await sql`${buildInsert(insert.table, columns)}
163
+ ON CONFLICT (item, phase) WHERE released_at IS NULL DO NOTHING`.execute(db);
164
+
165
+ return result.numAffectedRows === 0n ? ({ kind: 'lease-held' } as const) : null;
166
+ })
167
+ .with({ op: 'insert' }, async (insert) => {
168
+ await buildInsert(insert.table, toStoreColumns(insert.table, insert.row)).execute(db);
169
+
170
+ return null;
171
+ })
172
+ .with({ op: 'update' }, async (update) => {
173
+ const set = toStoreColumns(update.table, update.set);
174
+
175
+ const guards = [
176
+ ...toStoreColumns(update.table, update.key),
177
+ ...toStoreColumns(update.table, update.expect),
178
+ ];
179
+
180
+ invariant(set.length > 0, 'an update sets at least one field');
181
+
182
+ const result = await sql`UPDATE ${sql.table(update.table)}
183
+ SET ${sql.join(set.map(([column, value]) => sql`${sql.ref(column)} = ${value}`))}
184
+ WHERE ${buildGuards(guards)}`.execute(db);
185
+
186
+ return result.numAffectedRows === 0n
187
+ ? ({ kind: 'guard-mismatch', table: update.table, key: toJSONValue(update.key) } as const)
188
+ : null;
189
+ })
190
+ .exhaustive();
191
+ }
192
+
193
+ function buildInsert(table: string, columns: [string, StoreValue][]): RawBuilder<unknown> {
194
+ return sql`INSERT INTO ${sql.table(table)}
195
+ (${sql.join(columns.map(([column]) => sql.ref(column)))})
196
+ VALUES (${sql.join(columns.map(([, value]) => value))})`;
197
+ }
198
+
199
+ // IS compares NULL as a value, so an expect of null matches a NULL column
200
+ function buildGuards(guards: [string, StoreValue][]): RawBuilder<unknown> {
201
+ invariant(guards.length > 0, 'an update names its key');
202
+
203
+ return sql.join(
204
+ guards.map(([column, value]) => sql`${sql.ref(column)} IS ${value}`),
205
+ sql` AND `,
206
+ );
207
+ }
208
+
209
+ // SQLite rolls the whole transaction back by itself after some errors, such as a full disk, and the
210
+ // savepoint is gone; the rollback's own error then joins the first one, never replaces it.
211
+ async function removeSavepoint(db: Kysely<StoreDatabase>, cause: unknown): Promise<void> {
212
+ try {
213
+ await sql`ROLLBACK TO apply_change`.execute(db);
214
+ await sql`RELEASE apply_change`.execute(db);
215
+ } catch (rollbackError) {
216
+ throw new AggregateError([cause, rollbackError], 'the change failed, and so did its rollback', {
217
+ cause: rollbackError,
218
+ });
219
+ }
220
+ }