@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.
Files changed (51) hide show
  1. package/README.md +8 -6
  2. package/dist/codecs/parse-json-text.d.ts +1 -0
  3. package/dist/constants.d.ts +1 -0
  4. package/dist/migrations/rename-relations-migration.d.ts +2 -0
  5. package/dist/{run-store-migrations.d.ts → migrations/run-store-migrations.d.ts} +1 -1
  6. package/dist/migrations/v1-tables-migration.d.ts +2 -0
  7. package/dist/{build-sqlite-reader.d.ts → reads/build-sqlite-reader.d.ts} +1 -1
  8. package/dist/reads/is-latest-of-session.d.ts +3 -0
  9. package/dist/{read-item-views.d.ts → reads/read-item-views.d.ts} +3 -3
  10. package/dist/reads/read-limit-fills.d.ts +4 -0
  11. package/dist/reads/read-wakes.d.ts +4 -0
  12. package/dist/test-utils/create-claim-bench.d.ts +8 -0
  13. package/dist/types.d.ts +60 -10
  14. package/dist/with-busy-retry.d.ts +1 -0
  15. package/dist/with-transaction.d.ts +3 -0
  16. package/dist/{apply-sqlite-change.d.ts → writes/apply-sqlite-change.d.ts} +1 -1
  17. package/dist/{create-sqlite-tx.d.ts → writes/create-sqlite-tx.d.ts} +1 -1
  18. package/dist/writes/take-write-lock.d.ts +3 -0
  19. package/package.json +15 -5
  20. package/src/codecs/parse-json-text.ts +5 -0
  21. package/src/{table-codecs.ts → codecs/table-codecs.ts} +52 -15
  22. package/src/constants.ts +1 -0
  23. package/src/create-sqlite-claim-store.ts +73 -51
  24. package/src/migrations/rename-relations-migration.ts +17 -0
  25. package/src/migrations/run-store-migrations.ts +42 -0
  26. package/src/migrations/v1-tables-migration.ts +184 -0
  27. package/src/reads/build-sqlite-reader.ts +484 -0
  28. package/src/reads/is-latest-of-session.ts +21 -0
  29. package/src/{read-item-views.ts → reads/read-item-views.ts} +9 -9
  30. package/src/reads/read-limit-fills.ts +32 -0
  31. package/src/reads/read-wakes.ts +34 -0
  32. package/src/test-utils/create-claim-bench.ts +66 -0
  33. package/src/test-utils/create-test-claim-store.ts +2 -1
  34. package/src/types.ts +60 -10
  35. package/src/with-busy-retry.ts +32 -0
  36. package/src/with-transaction.ts +29 -0
  37. package/src/{apply-sqlite-change.ts → writes/apply-sqlite-change.ts} +77 -74
  38. package/src/writes/create-sqlite-tx.ts +229 -0
  39. package/src/writes/take-write-lock.ts +8 -0
  40. package/src/build-sqlite-reader.ts +0 -321
  41. package/src/create-sqlite-tx.ts +0 -134
  42. package/src/run-store-migrations.ts +0 -20
  43. /package/dist/{parse-store-row.d.ts → codecs/parse-store-row.d.ts} +0 -0
  44. /package/dist/{table-codecs.d.ts → codecs/table-codecs.d.ts} +0 -0
  45. /package/dist/{to-column-name.d.ts → codecs/to-column-name.d.ts} +0 -0
  46. /package/dist/{to-store-columns.d.ts → codecs/to-store-columns.d.ts} +0 -0
  47. /package/dist/{create-tables-migration.d.ts → migrations/create-tables-migration.d.ts} +0 -0
  48. /package/src/{parse-store-row.ts → codecs/parse-store-row.ts} +0 -0
  49. /package/src/{to-column-name.ts → codecs/to-column-name.ts} +0 -0
  50. /package/src/{to-store-columns.ts → codecs/to-store-columns.ts} +0 -0
  51. /package/src/{create-tables-migration.ts → migrations/create-tables-migration.ts} +0 -0
package/README.md CHANGED
@@ -29,14 +29,16 @@ The package runs on [Bun](https://bun.sh) 1.3 or newer.
29
29
 
30
30
  ## Features
31
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)).
32
+ - Items, levers, attempts, leases, limit seats, notes, notices and the outbox in one file, with each
33
+ change applied whole or not at all
34
+ ([one write path](../../docs/design/boxxy/slice-1.md#one-write-path)).
34
35
  - `exclusive` calls that run one at a time in a `BEGIN IMMEDIATE` transaction, and `read` calls that
35
36
  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)).
37
+ - Each item's position and state derived on each read, from the rows and the loaded rules
38
+ ([v1](../../docs/design/boxxy/v1.md)).
38
39
 
39
40
  ## Documentation
40
41
 
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.
42
+ The [v1 tables migration](./src/migrations/v1-tables-migration.ts) creates each table, and the store
43
+ section of [`02-types.ts`](../../docs/design/boxxy/sketches/v1/core/02-types.ts) gives the port, the
44
+ seat take and the seat sync.
@@ -0,0 +1 @@
1
+ export declare function parseJSONText(text: string): unknown;
@@ -0,0 +1 @@
1
+ export declare const lockWaitLimitMs = 5000;
@@ -0,0 +1,2 @@
1
+ import type { Migration } from 'kysely/migration';
2
+ export declare const renameRelationsMigration: Migration;
@@ -1,3 +1,3 @@
1
1
  import type { Kysely } from 'kysely';
2
- import type { StoreDatabase } from './types.ts';
2
+ import type { StoreDatabase } from '../types.ts';
3
3
  export declare function runStoreMigrations(db: Kysely<StoreDatabase>): Promise<void>;
@@ -0,0 +1,2 @@
1
+ import type { Migration } from 'kysely/migration';
2
+ export declare const v1TablesMigration: Migration;
@@ -1,4 +1,4 @@
1
1
  import type { StoreReader } from '@boxxy/model';
2
2
  import type { Kysely } from 'kysely';
3
- import type { StoreDatabase } from './types.ts';
3
+ import type { StoreDatabase } from '../types.ts';
4
4
  export declare function buildSQLiteReader(db: Kysely<StoreDatabase>): StoreReader;
@@ -0,0 +1,3 @@
1
+ import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
2
+ import type { StoreDatabase } from '../types.ts';
3
+ export declare function isLatestOfSession(builder: ExpressionBuilder<StoreDatabase, 'attempts'>): Expression<SqlBool>;
@@ -1,5 +1,5 @@
1
- import type { Instant, ItemWithLevers, MaxAttemptsByKind } from '@boxxy/model';
1
+ import type { Instant, ItemRules, ItemWithLevers } from '@boxxy/model';
2
2
  import type { Expression, ExpressionBuilder, Kysely, SqlBool } from 'kysely';
3
- import type { StoreDatabase } from './types.ts';
3
+ import type { StoreDatabase } from '../types.ts';
4
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[]>;
5
+ export declare function readItemViews(db: Kysely<StoreDatabase>, scope: ItemScope, now: Instant, rules: ItemRules): Promise<ItemWithLevers[]>;
@@ -0,0 +1,4 @@
1
+ import type { LimitFill } from '@boxxy/model';
2
+ import type { Kysely } from 'kysely';
3
+ import type { StoreDatabase } from '../types.ts';
4
+ export declare function readLimitFills(db: Kysely<StoreDatabase>): Promise<LimitFill[]>;
@@ -0,0 +1,4 @@
1
+ import type { Wake } from '@boxxy/model';
2
+ import type { Kysely } from 'kysely';
3
+ import type { StoreDatabase } from '../types.ts';
4
+ export declare function readWakes(db: Kysely<StoreDatabase>): Promise<Wake[]>;
@@ -0,0 +1,8 @@
1
+ import type { ItemId } from '@boxxy/model';
2
+ export interface ClaimBenchOptions {
3
+ readonly itemCount: number;
4
+ }
5
+ export interface ClaimBench extends AsyncDisposable {
6
+ readonly claim: () => Promise<ItemId>;
7
+ }
8
+ export declare function createClaimBench(options: ClaimBenchOptions): Promise<ClaimBench>;
package/dist/types.d.ts CHANGED
@@ -7,13 +7,16 @@ export interface StoreDatabase {
7
7
  title: string;
8
8
  outcome: string;
9
9
  acceptance: string;
10
+ fields: string;
11
+ labels: string;
10
12
  found_in_attempt: string | null;
11
13
  filed_by: string;
12
14
  filed_at: number;
13
15
  closed_at: number | null;
14
16
  close_reason: string | null;
17
+ kept_session: string | null;
15
18
  };
16
- links: {
19
+ relations: {
17
20
  source: string;
18
21
  target: string;
19
22
  type: string;
@@ -28,6 +31,7 @@ export interface StoreDatabase {
28
31
  until: number | null;
29
32
  expires_at: number | null;
30
33
  reason: string | null;
34
+ phase: string | null;
31
35
  actor: string;
32
36
  at: number;
33
37
  };
@@ -40,23 +44,42 @@ export interface StoreDatabase {
40
44
  recipe_hash: string;
41
45
  rules_version: string;
42
46
  pool: string;
43
- slot: number;
44
- slot_gen: number;
45
47
  lease_gen: number;
46
48
  spawn_request: string;
47
49
  spawn_request_hash: string;
48
50
  state: string;
49
51
  failure: string | null;
50
52
  atc_session: string | null;
51
- close_out: string | null;
53
+ caller: string | null;
52
54
  claimed_at: number;
53
55
  started_at: number | null;
54
56
  ended_at: number | null;
57
+ fields: string;
58
+ mode: string;
59
+ round: number;
60
+ input: string | null;
61
+ hold_gen: number;
62
+ close_out: string | null;
63
+ close_out_hash: string | null;
64
+ close_out_fact: string | null;
65
+ closing: string | null;
66
+ closing_at: number | null;
67
+ suspend_requested_at: number | null;
68
+ slept_at: number | null;
69
+ wake_requested_at: number | null;
70
+ woken_at: number | null;
71
+ outcome: string | null;
72
+ returned_outcome: string | null;
73
+ strayed_at: number | null;
74
+ session_stopped_at: number | null;
75
+ session_forgotten_at: number | null;
55
76
  };
56
77
  attempt_sessions: {
57
78
  attempt: string;
58
79
  agent_session: string;
59
80
  atc_session: string;
81
+ product: string;
82
+ source: string;
60
83
  seen_at: number;
61
84
  };
62
85
  leases: {
@@ -67,23 +90,31 @@ export interface StoreDatabase {
67
90
  taken_at: number;
68
91
  released_at: number | null;
69
92
  };
70
- pool_slots: {
71
- pool: string;
72
- slot: number;
73
- holder: string | null;
74
- gen: number;
75
- retired: number;
93
+ limits: {
94
+ name: string;
95
+ max: number;
96
+ counts: string;
97
+ };
98
+ limit_seats: {
99
+ limit: string;
100
+ attempt: string;
101
+ hold_gen: number;
102
+ over_cap: number;
103
+ taken_at: number;
104
+ released_at: number | null;
76
105
  };
77
106
  decisions: {
78
107
  id: string;
79
108
  attempt: string;
80
109
  item: string;
110
+ kind: string;
81
111
  pool: string;
82
112
  rules_version: string;
83
113
  snapshot_hash: string;
84
114
  store_version: number;
85
115
  pepys_offsets: string;
86
116
  faults: string;
117
+ limits: string;
87
118
  at: number;
88
119
  };
89
120
  evidence: {
@@ -99,6 +130,8 @@ export interface StoreDatabase {
99
130
  repo: string;
100
131
  number: number;
101
132
  attempt: string;
133
+ github_id: number;
134
+ url: string;
102
135
  state: string;
103
136
  merged_at: number | null;
104
137
  merge_commit: string | null;
@@ -109,6 +142,21 @@ export interface StoreDatabase {
109
142
  heartbeat_at: number;
110
143
  paused: number;
111
144
  };
145
+ notes: {
146
+ item: string;
147
+ seq: number;
148
+ text: string;
149
+ by: string;
150
+ at: number;
151
+ };
152
+ notices: {
153
+ id: Generated<number>;
154
+ item: string;
155
+ reason: string;
156
+ at: number;
157
+ tries: number;
158
+ sent_at: number | null;
159
+ };
112
160
  outbox: {
113
161
  seq: Generated<number>;
114
162
  fact_id: string;
@@ -118,5 +166,7 @@ export interface StoreDatabase {
118
166
  at: number;
119
167
  payload: string;
120
168
  sent_at: number | null;
169
+ parked_at: number | null;
170
+ parked_reason: string | null;
121
171
  };
122
172
  }
@@ -0,0 +1 @@
1
+ export declare function withBusyRetry(waitLimitMs: number, work: () => unknown): Promise<void>;
@@ -0,0 +1,3 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { StoreDatabase } from './types.ts';
3
+ export declare function withTransaction<T>(db: Kysely<StoreDatabase>, begin: (db: Kysely<StoreDatabase>) => Promise<unknown>, work: () => Promise<T>): Promise<T>;
@@ -1,4 +1,4 @@
1
1
  import type { ApplyResult, StoreChange } from '@boxxy/model';
2
2
  import type { Kysely } from 'kysely';
3
- import type { StoreDatabase } from './types.ts';
3
+ import type { StoreDatabase } from '../types.ts';
4
4
  export declare function applySQLiteChange(db: Kysely<StoreDatabase>, change: StoreChange): Promise<ApplyResult>;
@@ -1,4 +1,4 @@
1
1
  import type { StoreTx } from '@boxxy/model';
2
2
  import type { Kysely } from 'kysely';
3
- import type { StoreDatabase } from './types.ts';
3
+ import type { StoreDatabase } from '../types.ts';
4
4
  export declare function createSQLiteTx(db: Kysely<StoreDatabase>): StoreTx;
@@ -0,0 +1,3 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { StoreDatabase } from '../types.ts';
3
+ export declare function takeWriteLock(db: Kysely<StoreDatabase>, waitLimitMs: number): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxxy/store-sqlite",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
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",
@@ -26,20 +26,30 @@
26
26
  "access": "public"
27
27
  },
28
28
  "scripts": {
29
+ "bench": "rig bench",
29
30
  "build": "rm -rf dist && tsrs -p tsconfig.build.json",
30
31
  "typecheck": "tsrs --noEmit"
31
32
  },
32
33
  "dependencies": {
33
- "@boxxy/content-hash": "0.1.0",
34
- "@boxxy/model": "0.1.2",
34
+ "@boxxy/content-hash": "0.1.4",
35
+ "@boxxy/model": "0.1.4",
35
36
  "@zgeoff/kysely-bun-sqlite": "0.3.2",
36
- "kysely": "0.29.7",
37
+ "kysely": "0.29.8",
37
38
  "tiny-invariant": "1.3.3",
38
39
  "ts-pattern": "5.9.2",
39
40
  "zod": "4.6.7"
40
41
  },
41
42
  "devDependencies": {
42
- "@boxxy/dispatch": "0.1.1"
43
+ "@boxxy/dispatch": "0.1.4",
44
+ "@zgeoff/rig": "0.2.3"
45
+ },
46
+ "peerDependencies": {
47
+ "@boxxy/dispatch": "0.1.4"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@boxxy/dispatch": {
51
+ "optional": true
52
+ }
43
53
  },
44
54
  "engines": {
45
55
  "bun": ">=1.3",
@@ -0,0 +1,5 @@
1
+ export function parseJSONText(text: string): unknown {
2
+ const parsed: unknown = JSON.parse(text);
3
+
4
+ return parsed;
5
+ }
@@ -17,22 +17,25 @@ export const tableCodecs: { readonly [T in TableName]: TableCodec<Tables[T]> } =
17
17
  'title',
18
18
  'outcome',
19
19
  'acceptance',
20
+ 'fields',
21
+ 'labels',
20
22
  'foundInAttempt',
21
23
  'filedBy',
22
24
  'filedAt',
23
25
  'closedAt',
24
26
  'closeReason',
27
+ 'keptSession',
25
28
  ],
26
- json: ['acceptance'],
29
+ json: ['acceptance', 'fields', 'labels', 'keptSession'],
27
30
  flags: [],
28
31
  },
29
- links: {
32
+ relations: {
30
33
  fields: ['source', 'target', 'type', 'addedBy', 'addedAt', 'removedAt'],
31
34
  json: [],
32
35
  flags: [],
33
36
  },
34
37
  levers: {
35
- fields: ['seq', 'kind', 'item', 'until', 'expiresAt', 'reason', 'actor', 'at'],
38
+ fields: ['seq', 'kind', 'item', 'until', 'expiresAt', 'reason', 'phase', 'actor', 'at'],
36
39
  json: [],
37
40
  flags: [],
38
41
  },
@@ -46,24 +49,41 @@ export const tableCodecs: { readonly [T in TableName]: TableCodec<Tables[T]> } =
46
49
  'recipeHash',
47
50
  'rulesVersion',
48
51
  'pool',
49
- 'slot',
50
- 'slotGen',
51
52
  'leaseGen',
52
53
  'spawnRequest',
53
54
  'spawnRequestHash',
54
55
  'state',
55
56
  'failure',
56
57
  'atcSession',
57
- 'closeOut',
58
+ 'caller',
58
59
  'claimedAt',
59
60
  'startedAt',
60
61
  'endedAt',
62
+ 'fields',
63
+ 'mode',
64
+ 'round',
65
+ 'input',
66
+ 'holdGen',
67
+ 'closeOut',
68
+ 'closeOutHash',
69
+ 'closeOutFact',
70
+ 'closing',
71
+ 'closingAt',
72
+ 'suspendRequestedAt',
73
+ 'sleptAt',
74
+ 'wakeRequestedAt',
75
+ 'wokenAt',
76
+ 'outcome',
77
+ 'returnedOutcome',
78
+ 'strayedAt',
79
+ 'sessionStoppedAt',
80
+ 'sessionForgottenAt',
61
81
  ],
62
- json: ['spawnRequest', 'closeOut'],
82
+ json: ['spawnRequest', 'caller', 'fields', 'input', 'closeOut'],
63
83
  flags: [],
64
84
  },
65
85
  attempt_sessions: {
66
- fields: ['attempt', 'agentSession', 'atcSession', 'seenAt'],
86
+ fields: ['attempt', 'agentSession', 'atcSession', 'product', 'source', 'seenAt'],
67
87
  json: [],
68
88
  flags: [],
69
89
  },
@@ -72,25 +92,22 @@ export const tableCodecs: { readonly [T in TableName]: TableCodec<Tables[T]> } =
72
92
  json: [],
73
93
  flags: [],
74
94
  },
75
- pool_slots: {
76
- fields: ['pool', 'slot', 'holder', 'gen', 'retired'],
77
- json: [],
78
- flags: ['retired'],
79
- },
80
95
  decisions: {
81
96
  fields: [
82
97
  'id',
83
98
  'attempt',
84
99
  'item',
100
+ 'kind',
85
101
  'pool',
86
102
  'rulesVersion',
87
103
  'snapshotHash',
88
104
  'storeVersion',
89
105
  'pepysOffsets',
90
106
  'faults',
107
+ 'limits',
91
108
  'at',
92
109
  ],
93
- json: ['pepysOffsets', 'faults'],
110
+ json: ['pepysOffsets', 'faults', 'limits'],
94
111
  flags: [],
95
112
  },
96
113
  evidence: {
@@ -99,7 +116,17 @@ export const tableCodecs: { readonly [T in TableName]: TableCodec<Tables[T]> } =
99
116
  flags: [],
100
117
  },
101
118
  pull_requests: {
102
- fields: ['repo', 'number', 'attempt', 'state', 'mergedAt', 'mergeCommit', 'checkedAt'],
119
+ fields: [
120
+ 'repo',
121
+ 'number',
122
+ 'attempt',
123
+ 'githubId',
124
+ 'url',
125
+ 'state',
126
+ 'mergedAt',
127
+ 'mergeCommit',
128
+ 'checkedAt',
129
+ ],
103
130
  json: [],
104
131
  flags: [],
105
132
  },
@@ -108,4 +135,14 @@ export const tableCodecs: { readonly [T in TableName]: TableCodec<Tables[T]> } =
108
135
  json: [],
109
136
  flags: ['paused'],
110
137
  },
138
+ notes: {
139
+ fields: ['item', 'seq', 'text', 'by', 'at'],
140
+ json: [],
141
+ flags: [],
142
+ },
143
+ notices: {
144
+ fields: ['id', 'item', 'reason', 'at', 'tries', 'sentAt'],
145
+ json: [],
146
+ flags: [],
147
+ },
111
148
  };
@@ -0,0 +1 @@
1
+ export const lockWaitLimitMs = 5000;
@@ -1,12 +1,15 @@
1
1
  import { Database } from 'bun:sqlite';
2
- import type { ClaimStore } from '@boxxy/model';
2
+ import type { ClaimStore, StoreReader } from '@boxxy/model';
3
3
  import { buildSQLiteDialect } from '@zgeoff/kysely-bun-sqlite';
4
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';
5
+ import { lockWaitLimitMs } from './constants.ts';
6
+ import { runStoreMigrations } from './migrations/run-store-migrations.ts';
7
+ import { buildSQLiteReader } from './reads/build-sqlite-reader.ts';
9
8
  import type { StoreDatabase } from './types.ts';
9
+ import { withBusyRetry } from './with-busy-retry.ts';
10
+ import { withTransaction } from './with-transaction.ts';
11
+ import { createSQLiteTx } from './writes/create-sqlite-tx.ts';
12
+ import { takeWriteLock } from './writes/take-write-lock.ts';
10
13
 
11
14
  export interface SQLiteClaimStore extends ClaimStore {
12
15
  close: () => Promise<void>;
@@ -15,78 +18,97 @@ export interface SQLiteClaimStore extends ClaimStore {
15
18
  export async function createSQLiteClaimStore(path: string): Promise<SQLiteClaimStore> {
16
19
  await using opened = new AsyncDisposableStack();
17
20
 
18
- const writer = openStoreHandle(path);
21
+ const writer = new Database(path, { readwrite: true, create: true });
19
22
 
20
23
  opened.defer(() => {
21
24
  writer.close();
22
25
  });
23
26
 
24
- const reader = openStoreHandle(path);
27
+ writer.run('PRAGMA busy_timeout = 0');
25
28
 
26
- opened.defer(() => {
27
- reader.close();
29
+ // The file keeps its journal mode, so a read handle never sets it. SQLite fails these at once,
30
+ // whatever the busy timeout, while another handle switches the file to WAL or recovers it.
31
+ await withBusyRetry(lockWaitLimitMs, () => {
32
+ writer.run('PRAGMA journal_mode = WAL');
33
+ writer.run('PRAGMA synchronous = NORMAL');
28
34
  });
29
35
 
30
36
  const writerDB = new Kysely<StoreDatabase>({ dialect: buildSQLiteDialect(writer) });
31
- const readerDB = new Kysely<StoreDatabase>({ dialect: buildSQLiteDialect(reader) });
32
37
 
33
- opened.defer(() => readerDB.destroy());
34
38
  opened.defer(() => writerDB.destroy());
35
39
 
36
40
  await runStoreMigrations(writerDB);
37
41
 
38
42
  const handles = opened.move();
39
43
 
44
+ const reads = new Set<Promise<unknown>>();
45
+
46
+ let isClosed = false;
47
+
40
48
  return {
41
49
  // 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.
50
+ // store run one at a time, and the write lock makes another store's writer wait.
43
51
  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(),
52
+ writerDB.connection().execute((db) =>
53
+ withTransaction(
54
+ db,
55
+ (locked) => takeWriteLock(locked, lockWaitLimitMs),
56
+ () => work(createSQLiteTx(db)),
57
+ ),
58
+ ),
59
+
60
+ read: async (work) => {
61
+ if (isClosed) {
62
+ throw new Error('the store is closed');
63
+ }
64
+
65
+ const reading = withReadHandle(path, work);
66
+
67
+ reads.add(reading);
68
+
69
+ try {
70
+ return await reading;
71
+ } finally {
72
+ reads.delete(reading);
73
+ }
74
+ },
75
+
76
+ close: async () => {
77
+ isClosed = true;
78
+
79
+ await Promise.allSettled(reads);
80
+ await handles.disposeAsync();
81
+ },
55
82
  };
56
83
  }
57
84
 
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');
85
+ // A handle of its own per call, so a read sees the last commit and never a running exclusive
86
+ // call's writes, and a read started inside another read never waits for the outer one's handle.
87
+ async function withReadHandle<T>(
88
+ path: string,
89
+ work: (reader: StoreReader) => Promise<T>,
90
+ ): Promise<T> {
91
+ await using opened = new AsyncDisposableStack();
64
92
 
65
- return handle;
66
- }
93
+ // without create, a missing file fails the open, where the default makes an empty file
94
+ const handle = new Database(path, { readwrite: true, create: false });
67
95
 
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);
96
+ opened.defer(() => {
97
+ handle.close();
98
+ });
74
99
 
75
- try {
76
- const result = await work();
100
+ handle.run(`PRAGMA busy_timeout = ${lockWaitLimitMs}`);
101
+ handle.run('PRAGMA synchronous = NORMAL');
77
102
 
78
- await sql`COMMIT`.execute(db);
103
+ const readerDB = new Kysely<StoreDatabase>({ dialect: buildSQLiteDialect(handle) });
79
104
 
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
- });
105
+ opened.defer(() => readerDB.destroy());
89
106
 
90
- throw error;
91
- }
107
+ return await readerDB.connection().execute((db) =>
108
+ withTransaction(
109
+ db,
110
+ (connection) => sql`BEGIN`.execute(connection),
111
+ () => work(buildSQLiteReader(db)),
112
+ ),
113
+ );
92
114
  }
@@ -0,0 +1,17 @@
1
+ import { sql } from 'kysely';
2
+ import type { Kysely } from 'kysely';
3
+ import type { Migration } from 'kysely/migration';
4
+
5
+ const statements = [
6
+ sql`ALTER TABLE links RENAME TO relations`,
7
+ sql`DROP INDEX links_live`,
8
+ sql`CREATE UNIQUE INDEX relations_live ON relations (source, target, type) WHERE removed_at IS NULL`,
9
+ ];
10
+
11
+ export const renameRelationsMigration: Migration = {
12
+ up: async (db: Kysely<unknown>) => {
13
+ for (const statement of statements) {
14
+ await statement.execute(db);
15
+ }
16
+ },
17
+ };