@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 CHANGED
@@ -1,9 +1,360 @@
1
1
  # Outlaw
2
2
 
3
- It's sqlite.
3
+ A trigger-happy SQLite framework. Type-safe, schema-first, with automatic migrations.
4
4
 
5
- `bun add @sigitex/outlaw`
5
+ ```
6
+ bun add @sigitex/outlaw
7
+ ```
6
8
 
7
- > **Note:** This package currently exports TypeScript sources directly. A TypeScript-compatible runtime or bundler (Bun, etc.) is required.
9
+ Attempts to mirror Sqlite syntax *very* closely. Currently works with Bun and Cloudflare Functions.
8
10
 
9
- - TODO: conform to "adapts-to" stuff
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": "http://github.com/sigitex"
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": "tsgo --build",
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.0.0"
52
+ "version": "1.2.0"
52
53
  }
@@ -1,5 +1,12 @@
1
1
  // oxlint-disable typescript/no-explicit-any
2
- import type { Delete, Connection, Insert, Select, TableApi, Update } from "./api.types"
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>(columns: Column[]): Select<Pick<any, any>, any>
22
- select(
23
- columns: "*" | string[],
24
- ): Select<any, any> | Select<Pick<any, any>, any> {
25
- return new SelectBuilder(this.connection, this.table, columns)
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(row: Partial<any>): Insert<any, any, number>
29
- insert<Column extends string | number | symbol>(
30
- columns: Column[],
31
- rows: Pick<any, Column>[],
32
- ): Insert<Column, any, number>
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 row = columnsOrRow as Record<string, unknown>
41
- return new InsertBuilder(this.connection, this.table, Object.keys(row), [row])
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> {
@@ -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>(columns: Column[]): Select<Pick<any, any>, any>
17
- select(
18
- columns: "*" | string[],
19
- ): Select<any, any> | Select<Pick<any, any>, any> {
20
- return new SelectBuilder(this.connection, this.tableData, columns)
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
  }
@@ -0,0 +1,7 @@
1
+ export class UnsupportedTransactionError extends Error {
2
+ override readonly name = "UnsupportedTransactionError"
3
+
4
+ constructor() {
5
+ super("This connection does not support transactions.")
6
+ }
7
+ }
@@ -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]: ColumnTypeOf<BCS[BCK]>
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 Connection = {
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<M extends SchemaMembers> = {
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: 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> = BV extends BuildView<infer Columns> ? Columns : never
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: ColumnTypeOf<Columns[Column]>,
124
+ value: InferColumn<Columns[Column]>,
106
125
  ): this
107
126
  }
108
127
 
109
128
  /** Select query API. */
110
- export interface Select<SelectColumns, Columns>
111
- extends HasWhereClause<Columns> {
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 { BuildTable, BuildView, SchemaMembers, Schema } from "../schemaBuilder"
2
- import type { Connection, DatabaseApi } from "./api.types"
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: Connection,
16
+ export function createDatabase<M extends SchemaMembers, C extends Connection>(
17
+ connection: C,
8
18
  schema: Schema<M>,
9
19
  ) {
10
- const api = { connection }
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
@@ -2,3 +2,4 @@ export * from "./api.types"
2
2
  export * from "./DatabaseTable"
3
3
  export * from "./DatabaseView"
4
4
  export * from "./createDatabase"
5
+ export * from "./UnsupportedTransactionError"
package/src/bun/bun.ts CHANGED
@@ -1,27 +1,140 @@
1
1
  import type { Database } from "bun:sqlite"
2
- import type { DefaultRow, Connection } from "../api/api.types"
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.db.query(sql).all() as Row[]
23
+ return this.exclusive(() => query<Row>(this.db, sql))
13
24
  }
14
25
 
15
26
  async script(statements: string[]) {
16
- this.db.run("BEGIN")
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
- for (const s of statements) {
19
- this.db.run(s)
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
- this.db.run("COMMIT")
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
- this.db.run("ROLLBACK")
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 shotFirst = false
18
+ private readiness?: Promise<void>
19
+ readonly transaction?: Transaction
19
20
 
20
- constructor(raw: Connection, schema: Schema<SchemaMembers>, options?: CowboyOptions) {
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
- if (!this.shotFirst) {
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
- if (!this.shotFirst) {
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
- this.shotFirst = false
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
- this.shotFirst = true
46
- await new CowboyMigrator(this.raw, this.schema, this.options.hacks ?? []).migrate()
47
- const hasSeeds = this.options.seeds || (this.options.runFixtures && this.options.fixtures)
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 ? Mappings.results(this.table, result) : result
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: any): Delete<any, any[]> | Delete<any, Pick<any, any>[]> {
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 ? Mappings.results(this.table, result) : result
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: any): Update<any, any[]> | Update<any, Pick<any, any>[]> {
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