@sigitex/outlaw 1.0.0 → 1.2.0
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 +355 -4
- package/package.json +7 -6
- package/src/api/DatabaseTable.ts +31 -19
- package/src/api/DatabaseView.ts +6 -5
- package/src/api/UnsupportedTransactionError.ts +7 -0
- package/src/api/api.types.ts +46 -25
- package/src/api/createDatabase.ts +31 -6
- package/src/api/index.ts +1 -0
- package/src/bun/bun.ts +120 -7
- package/src/cowboyMigration/CowboyConnection.ts +45 -13
- package/src/queryBuilder/DeleteBuilder.ts +6 -4
- package/src/queryBuilder/UpdateBuilder.ts +6 -4
package/README.md
CHANGED
|
@@ -1,9 +1,360 @@
|
|
|
1
1
|
# Outlaw
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A trigger-happy SQLite framework. Type-safe, schema-first, with automatic migrations.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```
|
|
6
|
+
bun add @sigitex/outlaw
|
|
7
|
+
```
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Attempts to mirror Sqlite syntax *very* closely. Currently works with Bun and Cloudflare Functions.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
Define your schema in code, and Outlaw's [cowboy migrations](#cowboy-migrations) automatically diff and converge your database on startup.
|
|
12
|
+
|
|
13
|
+
> **Note:** This package exports TypeScript sources directly. A TypeScript-compatible runtime or bundler (Bun, etc.) is required.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// 1. Define your schema
|
|
19
|
+
const users = createTable("users", {
|
|
20
|
+
id: integer.primaryKey.autoincrement,
|
|
21
|
+
name: text.notNull,
|
|
22
|
+
email: text.notNull.unique,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const schema = createSchema({ users })
|
|
26
|
+
|
|
27
|
+
// 2. Create a connection with auto-migration
|
|
28
|
+
const bun = new BunConnection(new Database("app.db"))
|
|
29
|
+
const connection = new CowboyConnection(bun, schema)
|
|
30
|
+
|
|
31
|
+
// 3. Use the typed database API
|
|
32
|
+
const db = createDatabase(connection, schema)
|
|
33
|
+
|
|
34
|
+
await db.users.insert({ name: "Wyatt", email: "wyatt@earp.com" }).execute()
|
|
35
|
+
|
|
36
|
+
const allUsers = await db.users.select("*").fetch()
|
|
37
|
+
const user = await db.users.select("*").where("id", 1).first()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Schema Definition
|
|
41
|
+
|
|
42
|
+
### Tables
|
|
43
|
+
|
|
44
|
+
Define tables with `createTable`. Each column uses a builder chain starting from a base type.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { createTable, text, integer, real, blob } from "@sigitex/outlaw"
|
|
48
|
+
|
|
49
|
+
const products = createTable("products", {
|
|
50
|
+
id: integer.primaryKey.autoincrement,
|
|
51
|
+
name: text.notNull,
|
|
52
|
+
description: text, // nullable by default
|
|
53
|
+
price: real.notNull,
|
|
54
|
+
image: blob,
|
|
55
|
+
sku: text.notNull.unique,
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
#### Column Types
|
|
60
|
+
|
|
61
|
+
| Builder | SQLite Type | TypeScript Type |
|
|
62
|
+
|-----------|-------------|-----------------|
|
|
63
|
+
| `text` | TEXT | `string` |
|
|
64
|
+
| `integer` | INTEGER | `number` |
|
|
65
|
+
| `real` | REAL | `number` |
|
|
66
|
+
| `blob` | BLOB | `ArrayBuffer` |
|
|
67
|
+
|
|
68
|
+
#### Column Modifiers
|
|
69
|
+
|
|
70
|
+
Modifiers are chained as properties or method calls:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
text.notNull // NOT NULL
|
|
74
|
+
integer.primaryKey // PRIMARY KEY
|
|
75
|
+
integer.primaryKey.autoincrement // PRIMARY KEY AUTOINCREMENT
|
|
76
|
+
text.unique // UNIQUE
|
|
77
|
+
text.default("'unknown'") // DEFAULT 'unknown'
|
|
78
|
+
text.check("length(name) > 0") // CHECK constraint
|
|
79
|
+
integer.foreignKey.references(other.id) // FOREIGN KEY
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Each modifier can only be used once per column -- the type system removes it after use.
|
|
83
|
+
|
|
84
|
+
#### Type Mappings
|
|
85
|
+
|
|
86
|
+
Map SQLite storage types to richer TypeScript types:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const events = createTable("events", {
|
|
90
|
+
id: integer.primaryKey.autoincrement,
|
|
91
|
+
active: integer.notNull.map.boolean, // stored as 0/1, typed as boolean
|
|
92
|
+
createdAt: integer.notNull.map.timestamp, // stored as epoch ms, typed as Date
|
|
93
|
+
scheduledFor: text.map.date, // stored as ISO string, typed as Date
|
|
94
|
+
metadata: text.map.json<{ tags: string[] }>(), // stored as JSON string, typed as object
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### Table Constraints
|
|
99
|
+
|
|
100
|
+
Add composite constraints after column definitions:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const memberships = createTable("memberships", {
|
|
104
|
+
userId: integer.notNull.foreignKey.references(users.id),
|
|
105
|
+
groupId: integer.notNull.foreignKey.references(groups.id),
|
|
106
|
+
})
|
|
107
|
+
.primaryKey("userId", "groupId")
|
|
108
|
+
.unique("userId", "groupId")
|
|
109
|
+
.check("userId != groupId")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Views
|
|
113
|
+
|
|
114
|
+
Define views from query builders on existing tables:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { createView } from "@sigitex/outlaw"
|
|
118
|
+
|
|
119
|
+
const activeUsers = createView("active_users",
|
|
120
|
+
users.select("id", "name").where("active", 1)
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Indexes
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { createIndex, createUniqueIndex } from "@sigitex/outlaw"
|
|
128
|
+
|
|
129
|
+
const emailIndex = createUniqueIndex("idx_users_email").on(users.email)
|
|
130
|
+
const nameIndex = createIndex("idx_users_name").on(users.name)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Schema
|
|
134
|
+
|
|
135
|
+
Group tables, views, and indexes into a schema:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { createSchema } from "@sigitex/outlaw"
|
|
139
|
+
|
|
140
|
+
const schema = createSchema({
|
|
141
|
+
users,
|
|
142
|
+
products,
|
|
143
|
+
memberships,
|
|
144
|
+
activeUsers,
|
|
145
|
+
emailIndex,
|
|
146
|
+
nameIndex,
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Connections
|
|
151
|
+
|
|
152
|
+
Outlaw abstracts over any SQLite connection via the `Connection` interface:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
type Connection = {
|
|
156
|
+
query<Row>(sql: string): Promise<Row[]>
|
|
157
|
+
script(statements: string[]): Promise<void>
|
|
158
|
+
transaction?<Result>(
|
|
159
|
+
work: (connection: TransactionalConnection) => Promise<Result>,
|
|
160
|
+
): Promise<Result>
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Two built-in adapters are provided:
|
|
165
|
+
|
|
166
|
+
### Bun
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { BunConnection } from "@sigitex/outlaw/bun"
|
|
170
|
+
import { Database } from "bun:sqlite"
|
|
171
|
+
|
|
172
|
+
const connection = new BunConnection(new Database("app.db"))
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Cloudflare D1
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { CloudflareConnection } from "@sigitex/outlaw/cloudflare"
|
|
179
|
+
|
|
180
|
+
// Inside a Cloudflare Worker
|
|
181
|
+
const connection = new CloudflareConnection(env.DB)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Database API
|
|
185
|
+
|
|
186
|
+
`createDatabase` returns a typed object with an accessor for each table and view in the schema.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { createDatabase } from "@sigitex/outlaw"
|
|
190
|
+
|
|
191
|
+
const db = createDatabase(connection, schema)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Every database API exposes `transaction`. Supported connections provide a fresh
|
|
195
|
+
database API bound to the transaction-scoped connection:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
await db.transaction(async (tx) => {
|
|
199
|
+
await tx.users.insert({ name: "Wyatt" }).execute()
|
|
200
|
+
|
|
201
|
+
await tx.transaction(async (nested) => {
|
|
202
|
+
await nested.products.insert({ name: "Hat" }).execute()
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Use only the callback's scoped database API until that callback ends. Bun
|
|
208
|
+
serializes root operations, uses `BEGIN IMMEDIATE`, and implements nested
|
|
209
|
+
callbacks with savepoints. Cloudflare D1 does not support interactive callback
|
|
210
|
+
transactions; `transaction` throws `UnsupportedTransactionError` before the
|
|
211
|
+
callback runs, while `script()` remains available through D1 batch.
|
|
212
|
+
|
|
213
|
+
### Select
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
// Select all columns
|
|
217
|
+
const rows = await db.users.select("*").fetch()
|
|
218
|
+
|
|
219
|
+
// Select specific columns
|
|
220
|
+
const names = await db.users.select("name", "email").fetch()
|
|
221
|
+
|
|
222
|
+
// Single result (throws if no match)
|
|
223
|
+
const user = await db.users.select("*").where("id", 1).first()
|
|
224
|
+
|
|
225
|
+
// Filtering
|
|
226
|
+
db.users.select("*")
|
|
227
|
+
.where("name", "Wyatt") // equality
|
|
228
|
+
.where("age", ">=", 21) // comparison operators
|
|
229
|
+
.where("deletedAt", "is null") // unary operators
|
|
230
|
+
|
|
231
|
+
// Sorting, pagination
|
|
232
|
+
db.users.select("*")
|
|
233
|
+
.orderBy([["name", "asc"], ["id", "desc"]])
|
|
234
|
+
.limit(10)
|
|
235
|
+
.offset(20)
|
|
236
|
+
.fetch()
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Joins
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
db.users.select("*")
|
|
243
|
+
.join(posts).on(users.id, "=", posts.userId)
|
|
244
|
+
.fetch()
|
|
245
|
+
|
|
246
|
+
db.users.select("*")
|
|
247
|
+
.leftJoin(posts).on(users.id, "=", posts.userId)
|
|
248
|
+
.fetch()
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Join types: `join`, `leftJoin`, `rightJoin`, `crossJoin`. Each accepts a table or a subquery.
|
|
252
|
+
|
|
253
|
+
### Insert
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
// Single row
|
|
257
|
+
await db.users.insert({ name: "Doc", email: "doc@ok.com" }).execute()
|
|
258
|
+
|
|
259
|
+
// With returning
|
|
260
|
+
const [inserted] = await db.users
|
|
261
|
+
.insert({ name: "Doc", email: "doc@ok.com" })
|
|
262
|
+
.returning("*")
|
|
263
|
+
.execute()
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Update
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
await db.users
|
|
270
|
+
.update({ name: "Morgan" })
|
|
271
|
+
.where("id", 3)
|
|
272
|
+
.execute()
|
|
273
|
+
|
|
274
|
+
// With returning
|
|
275
|
+
const updated = await db.users
|
|
276
|
+
.update({ name: "Morgan" })
|
|
277
|
+
.where("id", 3)
|
|
278
|
+
.returning("*")
|
|
279
|
+
.execute()
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Delete
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
await db.users
|
|
286
|
+
.delete()
|
|
287
|
+
.where("id", 3)
|
|
288
|
+
.execute()
|
|
289
|
+
|
|
290
|
+
// With returning
|
|
291
|
+
const deleted = await db.users
|
|
292
|
+
.delete()
|
|
293
|
+
.where("id", 3)
|
|
294
|
+
.returning("*")
|
|
295
|
+
.execute()
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Cowboy Migrations
|
|
299
|
+
|
|
300
|
+
Wrap any connection with `CowboyConnection` to enable automatic schema migration. On the first query, Outlaw diffs the database against your schema and applies changes -- creating missing tables, rebuilding tables whose columns have changed, and managing views and indexes.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { CowboyConnection } from "@sigitex/outlaw"
|
|
304
|
+
|
|
305
|
+
const connection = new CowboyConnection(rawConnection, schema)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Schema metadata is stored in a `cowboy_migration` table. When columns change, Outlaw uses an interim table pattern: create the new table, copy data, drop the old one, rename.
|
|
309
|
+
|
|
310
|
+
### Schema Hacks
|
|
311
|
+
|
|
312
|
+
Destructive changes (renaming or dropping tables/columns) require explicit hints via `createSchemaHacker`, so data isn't silently lost:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import { createSchemaHacker } from "@sigitex/outlaw"
|
|
316
|
+
|
|
317
|
+
const hack = createSchemaHacker()
|
|
318
|
+
|
|
319
|
+
hack.renamed.table("old_users", "users")
|
|
320
|
+
hack.renamed.column("users", "firstName", "name")
|
|
321
|
+
hack.dropped.table("legacy_data")
|
|
322
|
+
hack.dropped.column("users", "deprecated_field")
|
|
323
|
+
|
|
324
|
+
const connection = new CowboyConnection(rawConnection, schema, {
|
|
325
|
+
hacks: hack.hacks,
|
|
326
|
+
})
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Fixtures and Seeds
|
|
330
|
+
|
|
331
|
+
Pre-populate tables with `createFixture` (or its alias `createSeed`). Fixtures can use templates to provide default values and `RefBy` to reference rows in other tables.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
import { createFixture } from "@sigitex/outlaw"
|
|
335
|
+
|
|
336
|
+
// Simple fixture
|
|
337
|
+
const userFixture = createFixture(users, [
|
|
338
|
+
{ name: "Wyatt", email: "wyatt@earp.com" },
|
|
339
|
+
{ name: "Doc", email: "doc@ok.com" },
|
|
340
|
+
])
|
|
341
|
+
|
|
342
|
+
// Fixture with a template for default values
|
|
343
|
+
const postFixture = createFixture(posts,
|
|
344
|
+
{ createdAt: () => Date.now() }, // template: default for createdAt
|
|
345
|
+
[
|
|
346
|
+
{ title: "First Post", userId: users.by.id(1) }, // RefBy
|
|
347
|
+
{ title: "Second Post", userId: users.by.id(1) },
|
|
348
|
+
],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
// Pass to CowboyConnection
|
|
352
|
+
const connection = new CowboyConnection(rawConnection, schema, {
|
|
353
|
+
fixtures: { userFixture, postFixture },
|
|
354
|
+
runFixtures: true,
|
|
355
|
+
})
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## License
|
|
359
|
+
|
|
360
|
+
MIT
|
package/package.json
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
"name": "@sigitex/outlaw",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"license": "MIT",
|
|
5
|
+
"description": "A trigger-happy sqlite framework.",
|
|
5
6
|
"author": {
|
|
6
7
|
"name": "Sigitex",
|
|
7
|
-
"url": "
|
|
8
|
+
"url": "https://sigitex.com"
|
|
8
9
|
},
|
|
9
10
|
"repository": {
|
|
10
11
|
"type": "git",
|
|
11
|
-
"url": "https://github.com/sigitex/outlaw.git"
|
|
12
|
+
"url": "git+https://github.com/sigitex/outlaw.git"
|
|
12
13
|
},
|
|
13
14
|
"exports": {
|
|
14
15
|
".": {
|
|
@@ -27,12 +28,12 @@
|
|
|
27
28
|
"@semantic-release/exec": "^7.0.3",
|
|
28
29
|
"@types/bun": "^1.3.13",
|
|
29
30
|
"@types/fast-json-stable-stringify": "^2.1.2",
|
|
30
|
-
"@typescript/native-preview": "beta",
|
|
31
31
|
"husky": "^9.1.7",
|
|
32
32
|
"multi-semantic-release": "^3.1.0",
|
|
33
33
|
"oxfmt": "^0.47.0",
|
|
34
34
|
"oxlint": "^1.62.0",
|
|
35
|
-
"semantic-release": "^25.0.3"
|
|
35
|
+
"semantic-release": "^25.0.3",
|
|
36
|
+
"typescript": "^7.0.2"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"@sigitex/print": "*",
|
|
@@ -42,11 +43,11 @@
|
|
|
42
43
|
"scripts": {
|
|
43
44
|
"prepare": "husky",
|
|
44
45
|
"lint": "oxlint",
|
|
45
|
-
"check": "
|
|
46
|
+
"check": "tsc --build",
|
|
46
47
|
"test": "bun test --pass-with-no-tests --tsconfig-override tsconfig.test.json"
|
|
47
48
|
},
|
|
48
49
|
"files": [
|
|
49
50
|
"src"
|
|
50
51
|
],
|
|
51
|
-
"version": "1.
|
|
52
|
+
"version": "1.2.0"
|
|
52
53
|
}
|
package/src/api/DatabaseTable.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// oxlint-disable typescript/no-explicit-any
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
Delete,
|
|
4
|
+
Connection,
|
|
5
|
+
Insert,
|
|
6
|
+
Select,
|
|
7
|
+
TableApi,
|
|
8
|
+
Update,
|
|
9
|
+
} from "./api.types"
|
|
3
10
|
import type { TableData } from "../schemaBuilder"
|
|
4
11
|
import {
|
|
5
12
|
DeleteBuilder,
|
|
@@ -18,27 +25,32 @@ export class DatabaseTable implements TableApi<any> {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
select(all: "*"): Select<any, any>
|
|
21
|
-
select<Column extends string>(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
select<Column extends string>(
|
|
29
|
+
...columns: Column[]
|
|
30
|
+
): Select<Pick<any, any>, any>
|
|
31
|
+
select(...columns: ("*" | string)[]): Select<any, any> {
|
|
32
|
+
const selected = columns[0] === "*" || columns.length === 0 ? "*" : columns
|
|
33
|
+
return new SelectBuilder(this.connection, this.table, selected)
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
insert(
|
|
29
|
-
insert<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
insert(
|
|
34
|
-
columnsOrRow: any,
|
|
35
|
-
rows?: any,
|
|
36
|
-
): Insert<any, any, number> | Insert<any, any, number> {
|
|
37
|
-
if (rows) {
|
|
38
|
-
return new InsertBuilder(this.connection, this.table, columnsOrRow, rows)
|
|
36
|
+
insert(...rows: [Partial<any>, ...Partial<any>[]]): Insert<any, any, number>
|
|
37
|
+
insert(...rows: Record<string, unknown>[]): Insert<any, any, number> {
|
|
38
|
+
const first = rows[0]
|
|
39
|
+
if (!first) {
|
|
40
|
+
throw new Error("insert() requires at least one row")
|
|
39
41
|
}
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
+
const columns = Object.keys(first)
|
|
43
|
+
const expected = new Set(columns)
|
|
44
|
+
for (const row of rows.slice(1)) {
|
|
45
|
+
const rowColumns = Object.keys(row)
|
|
46
|
+
if (
|
|
47
|
+
rowColumns.length !== columns.length ||
|
|
48
|
+
rowColumns.some((column) => !expected.has(column))
|
|
49
|
+
) {
|
|
50
|
+
throw new Error("insert() rows must use the same columns")
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return new InsertBuilder(this.connection, this.table, columns, rows)
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
update(row: Partial<any>): Update<any, number> {
|
package/src/api/DatabaseView.ts
CHANGED
|
@@ -13,10 +13,11 @@ export class DatabaseView implements ViewApi<any> {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
select(all: "*"): Select<any, any>
|
|
16
|
-
select<Column extends string>(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
select<Column extends string>(
|
|
17
|
+
...columns: Column[]
|
|
18
|
+
): Select<Pick<any, any>, any>
|
|
19
|
+
select(...columns: ("*" | string)[]): Select<any, any> {
|
|
20
|
+
const selected = columns[0] === "*" || columns.length === 0 ? "*" : columns
|
|
21
|
+
return new SelectBuilder(this.connection, this.tableData, selected)
|
|
21
22
|
}
|
|
22
23
|
}
|
package/src/api/api.types.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// oxlint-disable typescript/consistent-type-definitions
|
|
2
1
|
// oxlint-disable typescript/no-explicit-any
|
|
2
|
+
// oxlint-disable typescript/method-signature-style
|
|
3
|
+
// oxlint-disable typescript/consistent-type-definitions
|
|
3
4
|
import type { BINARY_OPERATORS, UNARY_OPERATORS } from "../queryBuilder"
|
|
4
5
|
import type {
|
|
5
|
-
BuildColumn,
|
|
6
6
|
BuildTable,
|
|
7
7
|
BuildView,
|
|
8
8
|
ColumnRef,
|
|
9
|
+
InferColumn,
|
|
9
10
|
SchemaMembers,
|
|
10
11
|
TablesOf,
|
|
11
12
|
ViewsOf,
|
|
@@ -29,36 +30,54 @@ export type InsertRecord<R> = Partial<R> &
|
|
|
29
30
|
export type ColumnsOf<BT> =
|
|
30
31
|
BT extends BuildTable<infer Columns> ? Columns : never
|
|
31
32
|
|
|
32
|
-
export type ColumnTypeOf<BC> =
|
|
33
|
-
BC extends BuildColumn<infer Type, infer Constraints>
|
|
34
|
-
? Constraints extends "notNull" | "primaryKey"
|
|
35
|
-
? Type
|
|
36
|
-
: Type | null
|
|
37
|
-
: never
|
|
38
|
-
|
|
39
33
|
export type ColumnTypesOf<BCS> = {
|
|
40
|
-
readonly [BCK in keyof BCS]:
|
|
34
|
+
readonly [BCK in keyof BCS]: InferColumn<BCS[BCK]>
|
|
41
35
|
}
|
|
42
36
|
|
|
43
37
|
// API
|
|
44
38
|
|
|
45
39
|
export type DefaultRow = Record<string, unknown>
|
|
46
40
|
|
|
47
|
-
export type
|
|
41
|
+
export type ConnectionOperations = {
|
|
48
42
|
readonly query: <Row = DefaultRow>(sql: string) => Promise<Row[]>
|
|
49
43
|
readonly script: (statements: string[]) => Promise<void>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type TransactionWork<Result> = (
|
|
47
|
+
connection: TransactionalConnection,
|
|
48
|
+
) => Promise<Result>
|
|
49
|
+
|
|
50
|
+
export type Transaction = <Result>(
|
|
51
|
+
work: TransactionWork<Result>,
|
|
52
|
+
) => Promise<Result>
|
|
53
|
+
|
|
54
|
+
export type TransactionalConnection = ConnectionOperations & {
|
|
55
|
+
readonly transaction: Transaction
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Connection = ConnectionOperations & {
|
|
50
59
|
readonly reset?: () => Promise<void>
|
|
60
|
+
readonly transaction?: Transaction
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
export type DatabaseApi<
|
|
63
|
+
export type DatabaseApi<
|
|
64
|
+
M extends SchemaMembers,
|
|
65
|
+
C extends Connection = Connection,
|
|
66
|
+
> = {
|
|
54
67
|
readonly [K in keyof TablesOf<M>]: TableApi<ColumnsOf<TablesOf<M>[K]>>
|
|
55
68
|
} & {
|
|
56
69
|
readonly [K in keyof ViewsOf<M>]: ViewApi<ColumnsOfView<ViewsOf<M>[K]>>
|
|
57
70
|
} & {
|
|
58
|
-
readonly connection:
|
|
71
|
+
readonly connection: C
|
|
72
|
+
readonly transaction: <Result>(
|
|
73
|
+
work: (
|
|
74
|
+
database: DatabaseApi<M, TransactionalConnection>,
|
|
75
|
+
) => Promise<Result>,
|
|
76
|
+
) => Promise<Result>
|
|
59
77
|
}
|
|
60
78
|
|
|
61
|
-
export type ColumnsOfView<BV> =
|
|
79
|
+
export type ColumnsOfView<BV> =
|
|
80
|
+
BV extends BuildView<infer Columns> ? Columns : never
|
|
62
81
|
|
|
63
82
|
export type ViewApi<Columns> = {
|
|
64
83
|
/** Issue a `SELECT *` query to the API. */
|
|
@@ -78,7 +97,7 @@ export type TableApi<Columns> = {
|
|
|
78
97
|
): Select<Pick<Columns, Column>, Columns>
|
|
79
98
|
/** Issue an `INSERT` statement. */
|
|
80
99
|
insert<InsertColumns extends Partial<ColumnTypesOf<Columns>>>(
|
|
81
|
-
...rows: InsertColumns[]
|
|
100
|
+
...rows: [InsertColumns, ...InsertColumns[]]
|
|
82
101
|
): Insert<InsertColumns, Columns, number>
|
|
83
102
|
/** Issue an `UPDATE` statement. */
|
|
84
103
|
update(row: Partial<ColumnTypesOf<Columns>>): Update<Columns, number>
|
|
@@ -91,7 +110,7 @@ export interface HasWhereClause<Columns> {
|
|
|
91
110
|
/** Add a condition where the given column equals the given value. */
|
|
92
111
|
where<Column extends keyof Columns>(
|
|
93
112
|
column: Column,
|
|
94
|
-
value: Columns[Column]
|
|
113
|
+
value: InferColumn<Columns[Column]>,
|
|
95
114
|
): this
|
|
96
115
|
/** Add a unary `WHERE` condition. */
|
|
97
116
|
where<Column extends keyof Columns>(
|
|
@@ -102,13 +121,15 @@ export interface HasWhereClause<Columns> {
|
|
|
102
121
|
where<Column extends keyof Columns>(
|
|
103
122
|
column: Column,
|
|
104
123
|
operator: BinaryOperator,
|
|
105
|
-
value:
|
|
124
|
+
value: InferColumn<Columns[Column]>,
|
|
106
125
|
): this
|
|
107
126
|
}
|
|
108
127
|
|
|
109
128
|
/** Select query API. */
|
|
110
|
-
export interface Select<
|
|
111
|
-
|
|
129
|
+
export interface Select<
|
|
130
|
+
SelectColumns,
|
|
131
|
+
Columns,
|
|
132
|
+
> extends HasWhereClause<Columns> {
|
|
112
133
|
/** Issue the query, expecting an array of results. */
|
|
113
134
|
fetch(): Promise<ColumnTypesOf<SelectColumns>[]>
|
|
114
135
|
/** Issue the query, returning a single result, or throwing.. */
|
|
@@ -177,11 +198,11 @@ export interface Update<Columns, Returning> extends HasWhereClause<Columns> {
|
|
|
177
198
|
/** Execute the statement. */
|
|
178
199
|
execute(): Promise<Returning>
|
|
179
200
|
/** Specify a `RETURNING *` clause. */
|
|
180
|
-
returning(all: "*"): Update<Columns, Columns[]>
|
|
201
|
+
returning(all: "*"): Update<Columns, ColumnTypesOf<Columns>[]>
|
|
181
202
|
/** Specify a `RETURNING` clause with the chosen columns. */
|
|
182
203
|
returning<Column extends keyof Columns>(
|
|
183
|
-
columns: Column[]
|
|
184
|
-
): Update<Columns, Pick<Columns, Column
|
|
204
|
+
...columns: Column[]
|
|
205
|
+
): Update<Columns, ColumnTypesOf<Pick<Columns, Column>>[]>
|
|
185
206
|
}
|
|
186
207
|
|
|
187
208
|
/** Delete statement API. */
|
|
@@ -189,11 +210,11 @@ export interface Delete<Columns, Returning> extends HasWhereClause<Columns> {
|
|
|
189
210
|
/** Execute the statement. */
|
|
190
211
|
execute(): Promise<Returning>
|
|
191
212
|
/** Specify a `RETURNING *` clause. */
|
|
192
|
-
returning(all: "*"): Delete<Columns, Columns[]>
|
|
213
|
+
returning(all: "*"): Delete<Columns, ColumnTypesOf<Columns>[]>
|
|
193
214
|
/** Specify a `RETURNING` clause with the chosen columns. */
|
|
194
215
|
returning<Column extends keyof Columns>(
|
|
195
|
-
columns: Column[]
|
|
196
|
-
): Delete<Columns, Pick<Columns, Column
|
|
216
|
+
...columns: Column[]
|
|
217
|
+
): Delete<Columns, ColumnTypesOf<Pick<Columns, Column>>[]>
|
|
197
218
|
}
|
|
198
219
|
|
|
199
220
|
export type UnaryOperator = ElementOf<typeof UNARY_OPERATORS>
|
|
@@ -1,13 +1,38 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
BuildTable,
|
|
3
|
+
BuildView,
|
|
4
|
+
SchemaMembers,
|
|
5
|
+
Schema,
|
|
6
|
+
} from "../schemaBuilder"
|
|
7
|
+
import type {
|
|
8
|
+
Connection,
|
|
9
|
+
DatabaseApi,
|
|
10
|
+
TransactionalConnection,
|
|
11
|
+
} from "./api.types"
|
|
3
12
|
import { DatabaseTable } from "./DatabaseTable"
|
|
4
13
|
import { DatabaseView } from "./DatabaseView"
|
|
14
|
+
import { UnsupportedTransactionError } from "./UnsupportedTransactionError"
|
|
5
15
|
|
|
6
|
-
export function createDatabase<M extends SchemaMembers>(
|
|
7
|
-
connection:
|
|
16
|
+
export function createDatabase<M extends SchemaMembers, C extends Connection>(
|
|
17
|
+
connection: C,
|
|
8
18
|
schema: Schema<M>,
|
|
9
19
|
) {
|
|
10
|
-
const api = {
|
|
20
|
+
const api = {
|
|
21
|
+
connection,
|
|
22
|
+
transaction: async <Result>(
|
|
23
|
+
work: (
|
|
24
|
+
database: DatabaseApi<M, TransactionalConnection>,
|
|
25
|
+
) => Promise<Result>,
|
|
26
|
+
) => {
|
|
27
|
+
const transaction = connection.transaction
|
|
28
|
+
if (!transaction) {
|
|
29
|
+
throw new UnsupportedTransactionError()
|
|
30
|
+
}
|
|
31
|
+
return transaction((scopedConnection) =>
|
|
32
|
+
work(createDatabase(scopedConnection, schema)),
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
}
|
|
11
36
|
for (const [property, member] of Object.entries(schema.tables)) {
|
|
12
37
|
const table = member as BuildTable<unknown>
|
|
13
38
|
Object.defineProperty(api, property, {
|
|
@@ -20,5 +45,5 @@ export function createDatabase<M extends SchemaMembers>(
|
|
|
20
45
|
value: new DatabaseView(connection, view.$tableData),
|
|
21
46
|
})
|
|
22
47
|
}
|
|
23
|
-
return api as DatabaseApi<M>
|
|
48
|
+
return api as DatabaseApi<M, C>
|
|
24
49
|
}
|
package/src/api/index.ts
CHANGED
package/src/bun/bun.ts
CHANGED
|
@@ -1,27 +1,140 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
Connection,
|
|
4
|
+
DefaultRow,
|
|
5
|
+
TransactionalConnection,
|
|
6
|
+
TransactionWork,
|
|
7
|
+
} from "../api/api.types"
|
|
8
|
+
|
|
9
|
+
type TransactionState = {
|
|
10
|
+
active: boolean
|
|
11
|
+
nextSavepoint: number
|
|
12
|
+
}
|
|
3
13
|
|
|
4
14
|
export class BunConnection implements Connection {
|
|
5
15
|
private readonly db: Database
|
|
16
|
+
private pending = Promise.resolve()
|
|
6
17
|
|
|
7
18
|
constructor(db: Database) {
|
|
8
19
|
this.db = db
|
|
9
20
|
}
|
|
10
21
|
|
|
11
22
|
async query<Row = DefaultRow>(sql: string) {
|
|
12
|
-
return this.
|
|
23
|
+
return this.exclusive(() => query<Row>(this.db, sql))
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
async script(statements: string[]) {
|
|
16
|
-
this.db
|
|
27
|
+
await this.exclusive(() => script(this.db, statements, "BEGIN"))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async transaction<Result>(work: TransactionWork<Result>) {
|
|
31
|
+
return this.exclusive(async () => {
|
|
32
|
+
await run(this.db, "BEGIN IMMEDIATE")
|
|
33
|
+
const state: TransactionState = { active: true, nextSavepoint: 0 }
|
|
34
|
+
const connection = new BunTransactionConnection(this.db, state)
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const result = await work(connection)
|
|
38
|
+
connection.close()
|
|
39
|
+
await run(this.db, "COMMIT")
|
|
40
|
+
return result
|
|
41
|
+
} catch (error) {
|
|
42
|
+
connection.close()
|
|
43
|
+
await run(this.db, "ROLLBACK")
|
|
44
|
+
throw error
|
|
45
|
+
} finally {
|
|
46
|
+
state.active = false
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async exclusive<Result>(work: () => Promise<Result>) {
|
|
52
|
+
const previous = this.pending
|
|
53
|
+
let release!: () => void
|
|
54
|
+
this.pending = new Promise<void>((resolve) => {
|
|
55
|
+
release = resolve
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await previous
|
|
17
59
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
60
|
+
return await work()
|
|
61
|
+
} finally {
|
|
62
|
+
release()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class BunTransactionConnection implements TransactionalConnection {
|
|
68
|
+
private readonly db: Database
|
|
69
|
+
private readonly state: TransactionState
|
|
70
|
+
private active = true
|
|
71
|
+
|
|
72
|
+
constructor(db: Database, state: TransactionState) {
|
|
73
|
+
this.db = db
|
|
74
|
+
this.state = state
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async query<Row = DefaultRow>(sql: string) {
|
|
78
|
+
this.assertActive()
|
|
79
|
+
return query<Row>(this.db, sql)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async script(statements: string[]) {
|
|
83
|
+
this.assertActive()
|
|
84
|
+
await this.transaction(async (connection) => {
|
|
85
|
+
for (const statement of statements) {
|
|
86
|
+
await connection.query(statement)
|
|
20
87
|
}
|
|
21
|
-
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async transaction<Result>(work: TransactionWork<Result>) {
|
|
92
|
+
this.assertActive()
|
|
93
|
+
const name = `outlaw_transaction_${++this.state.nextSavepoint}`
|
|
94
|
+
await run(this.db, `SAVEPOINT ${name}`)
|
|
95
|
+
const connection = new BunTransactionConnection(this.db, this.state)
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const result = await work(connection)
|
|
99
|
+
connection.close()
|
|
100
|
+
await run(this.db, `RELEASE SAVEPOINT ${name}`)
|
|
101
|
+
return result
|
|
22
102
|
} catch (error) {
|
|
23
|
-
|
|
103
|
+
connection.close()
|
|
104
|
+
await run(this.db, `ROLLBACK TO SAVEPOINT ${name}`)
|
|
105
|
+
await run(this.db, `RELEASE SAVEPOINT ${name}`)
|
|
24
106
|
throw error
|
|
25
107
|
}
|
|
26
108
|
}
|
|
109
|
+
|
|
110
|
+
close() {
|
|
111
|
+
this.active = false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private assertActive() {
|
|
115
|
+
if (!this.active || !this.state.active) {
|
|
116
|
+
throw new Error("Transaction connection is no longer active.")
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function query<Row>(db: Database, sql: string) {
|
|
122
|
+
return db.query(sql).all() as Row[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function run(db: Database, sql: string) {
|
|
126
|
+
db.run(sql)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function script(db: Database, statements: string[], begin: "BEGIN") {
|
|
130
|
+
await run(db, begin)
|
|
131
|
+
try {
|
|
132
|
+
for (const statement of statements) {
|
|
133
|
+
await run(db, statement)
|
|
134
|
+
}
|
|
135
|
+
await run(db, "COMMIT")
|
|
136
|
+
} catch (error) {
|
|
137
|
+
await run(db, "ROLLBACK")
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
27
140
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Fixtures, Schema, SchemaMembers, Seeds } from "../schemaBuilder"
|
|
2
|
-
import type { Connection, DefaultRow } from "../api"
|
|
2
|
+
import type { Connection, DefaultRow, Transaction } from "../api"
|
|
3
3
|
import type { SchemaHack } from "./cowboyMigration.types"
|
|
4
4
|
import { CowboyMigrator } from "./CowboyMigrator"
|
|
5
5
|
import { CowboySeeder } from "./CowboySeeder"
|
|
@@ -15,36 +15,68 @@ export class CowboyConnection implements Connection {
|
|
|
15
15
|
private readonly raw: Connection
|
|
16
16
|
private readonly schema: Schema<SchemaMembers>
|
|
17
17
|
private readonly options: CowboyOptions
|
|
18
|
-
private
|
|
18
|
+
private readiness?: Promise<void>
|
|
19
|
+
readonly transaction?: Transaction
|
|
19
20
|
|
|
20
|
-
constructor(
|
|
21
|
+
constructor(
|
|
22
|
+
raw: Connection,
|
|
23
|
+
schema: Schema<SchemaMembers>,
|
|
24
|
+
options?: CowboyOptions,
|
|
25
|
+
) {
|
|
21
26
|
this.raw = raw
|
|
22
27
|
this.schema = schema
|
|
23
28
|
this.options = options ?? {}
|
|
29
|
+
const transaction = raw.transaction?.bind(raw)
|
|
30
|
+
if (transaction) {
|
|
31
|
+
this.transaction = async (work) => {
|
|
32
|
+
await this.ready()
|
|
33
|
+
return transaction(work)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
async query<Row = DefaultRow>(sql: string) {
|
|
27
|
-
|
|
28
|
-
await this.shotgun()
|
|
29
|
-
}
|
|
39
|
+
await this.ready()
|
|
30
40
|
return this.raw.query(sql) as Promise<Row[]>
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
async script(statements: string[]) {
|
|
34
|
-
|
|
35
|
-
await this.shotgun()
|
|
36
|
-
}
|
|
44
|
+
await this.ready()
|
|
37
45
|
await this.raw.script(statements)
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
async reset() {
|
|
41
|
-
|
|
49
|
+
try {
|
|
50
|
+
await this.readiness
|
|
51
|
+
} catch {
|
|
52
|
+
// A failed initialization is already reset by ready().
|
|
53
|
+
}
|
|
54
|
+
this.readiness = undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private ready() {
|
|
58
|
+
if (this.readiness) {
|
|
59
|
+
return this.readiness
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const readiness = this.shotgun().catch((error: unknown) => {
|
|
63
|
+
if (this.readiness === readiness) {
|
|
64
|
+
this.readiness = undefined
|
|
65
|
+
}
|
|
66
|
+
throw error
|
|
67
|
+
})
|
|
68
|
+
this.readiness = readiness
|
|
69
|
+
return readiness
|
|
42
70
|
}
|
|
43
71
|
|
|
44
72
|
private async shotgun() {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
73
|
+
await new CowboyMigrator(
|
|
74
|
+
this.raw,
|
|
75
|
+
this.schema,
|
|
76
|
+
this.options.hacks ?? [],
|
|
77
|
+
).migrate()
|
|
78
|
+
const hasSeeds =
|
|
79
|
+
this.options.seeds || (this.options.runFixtures && this.options.fixtures)
|
|
48
80
|
if (hasSeeds) {
|
|
49
81
|
await new CowboySeeder(
|
|
50
82
|
this.raw,
|
|
@@ -20,13 +20,15 @@ export class DeleteBuilder implements Delete<any, any> {
|
|
|
20
20
|
|
|
21
21
|
async execute(): Promise<any> {
|
|
22
22
|
const result = await this.connection.query(generateDelete(this.command))
|
|
23
|
-
return this.command.returning
|
|
23
|
+
return this.command.returning
|
|
24
|
+
? Mappings.results(this.table, result)
|
|
25
|
+
: result
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
returning(all: "*"): Delete<any, any[]>
|
|
27
|
-
returning(columns: string[]): Delete<any, Pick<any, any>[]>
|
|
28
|
-
returning(columns:
|
|
29
|
-
this.command.returning = columns === "*" ? ["*"] : columns
|
|
29
|
+
returning(...columns: string[]): Delete<any, Pick<any, any>[]>
|
|
30
|
+
returning(...columns: ("*" | string)[]): Delete<any, any[]> {
|
|
31
|
+
this.command.returning = columns[0] === "*" ? ["*"] : columns
|
|
30
32
|
return this
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -24,13 +24,15 @@ export class UpdateBuilder implements Update<any, any> {
|
|
|
24
24
|
|
|
25
25
|
async execute(): Promise<any> {
|
|
26
26
|
const result = await this.connection.query(generateUpdate(this.command))
|
|
27
|
-
return this.command.returning
|
|
27
|
+
return this.command.returning
|
|
28
|
+
? Mappings.results(this.table, result)
|
|
29
|
+
: result
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
returning(all: "*"): Update<any, any[]>
|
|
31
|
-
returning(columns: string[]): Update<any, Pick<any, any>[]>
|
|
32
|
-
returning(columns:
|
|
33
|
-
this.command.returning = columns === "*" ? ["*"] : columns
|
|
33
|
+
returning(...columns: string[]): Update<any, Pick<any, any>[]>
|
|
34
|
+
returning(...columns: ("*" | string)[]): Update<any, any[]> {
|
|
35
|
+
this.command.returning = columns[0] === "*" ? ["*"] : columns
|
|
34
36
|
return this
|
|
35
37
|
}
|
|
36
38
|
|