@sigitex/outlaw 1.0.1 → 2.0.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
@@ -8,7 +8,7 @@ bun add @sigitex/outlaw
8
8
 
9
9
  Attempts to mirror Sqlite syntax *very* closely. Currently works with Bun and Cloudflare Functions.
10
10
 
11
- Define your schema in code, and Outlaw's [cowboy migrations](#cowboy-migrations) automatically diff and converge your database on startup -- no migration files, no CLI steps. Just change your schema and go.
11
+ Define your schema in code, and Outlaw's [cowboy migrations](#cowboy-migrations) automatically diff and converge your database on startup.
12
12
 
13
13
  > **Note:** This package exports TypeScript sources directly. A TypeScript-compatible runtime or bundler (Bun, etc.) is required.
14
14
 
@@ -155,6 +155,9 @@ Outlaw abstracts over any SQLite connection via the `Connection` interface:
155
155
  type Connection = {
156
156
  query<Row>(sql: string): Promise<Row[]>
157
157
  script(statements: string[]): Promise<void>
158
+ transaction?<Result>(
159
+ work: (connection: TransactionalConnection) => Promise<Result>,
160
+ ): Promise<Result>
158
161
  }
159
162
  ```
160
163
 
@@ -188,6 +191,25 @@ import { createDatabase } from "@sigitex/outlaw"
188
191
  const db = createDatabase(connection, schema)
189
192
  ```
190
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
+
191
213
  ### Select
192
214
 
193
215
  ```ts
@@ -216,17 +238,182 @@ db.users.select("*")
216
238
 
217
239
  ### Joins
218
240
 
241
+ Compose sources **before** selecting results. Examples in this section use a
242
+ `Connection` named `connection` and these schema definitions:
243
+
219
244
  ```ts
220
- db.users.select("*")
221
- .join(posts).on(users.id, "=", posts.userId)
245
+ const users = createTable("users", {
246
+ id: integer.primaryKey,
247
+ name: text.notNull,
248
+ managerId: integer,
249
+ })
250
+ const posts = createTable("posts", {
251
+ id: integer.primaryKey,
252
+ userId: integer.notNull,
253
+ tenantId: integer.notNull,
254
+ title: text.notNull,
255
+ published: integer.map.boolean.notNull,
256
+ createdAt: integer.map.timestamp.notNull,
257
+ })
258
+ const profiles = createTable("profiles", {
259
+ userId: integer.notNull,
260
+ tenantId: integer.notNull,
261
+ bio: text,
262
+ })
263
+ const db = createDatabase(connection, createSchema({ users, posts, profiles }))
264
+ ```
265
+
266
+ Strings work throughout joined queries. Bare names retain SQLite name resolution;
267
+ qualified strings identify a specific source. Only selected columns appear in the
268
+ result:
269
+
270
+ ```ts
271
+ await db.users
272
+ .join(posts)
273
+ .on("users.id", "=", "posts.userId")
274
+ .select("name", "title")
275
+ .where("title", "Hello")
276
+ .orderBy([["name", "asc"]])
277
+ .limit(20)
278
+ .offset(40)
222
279
  .fetch()
280
+ ```
223
281
 
224
- db.users.select("*")
225
- .leftJoin(posts).on(users.id, "=", posts.userId)
282
+ Refs and strings can be mixed. Projection aliases are optional; use them when
283
+ distinct output names are useful. `users.all` selects `users.*`, not every joined
284
+ column:
285
+
286
+ ```ts
287
+ await db.users
288
+ .leftJoin(posts)
289
+ .on(users.id, "=", posts.userId)
290
+ .select(users.id.as("userId"), posts.id.as("postId"), posts.title)
291
+ .fetch()
292
+
293
+ await db.users
294
+ .join(posts)
295
+ .on("users.id", "=", posts.userId)
296
+ .select(users.all, posts.title)
297
+ .where(posts.published, true)
298
+ .orderBy([[posts.createdAt, "desc"]])
299
+ .fetch()
300
+ ```
301
+
302
+ #### Join forms and constraints
303
+
304
+ `join`, `leftJoin`, `rightJoin`, `fullJoin`, and `crossJoin` each accept tables,
305
+ views, or SELECT subqueries. Constraints are optional for every form. Repeated
306
+ `on()` calls combine with `AND` on the latest join. `using()` accepts one or more
307
+ common column names and cannot be combined with `on()` on that same join.
308
+
309
+ ```ts
310
+ await db.users.rightJoin(posts).on(users.id, "=", posts.userId)
311
+ .select("name", "title").fetch()
312
+ await db.users.fullJoin(posts).on(users.id, "=", posts.userId)
313
+ .select("name", "title").fetch()
314
+ await db.users.crossJoin(posts).select("name", "title").fetch()
315
+ await db.users.join(posts).select("name", "title").fetch()
316
+
317
+ await db.users.crossJoin(posts)
318
+ .on(users.id, "=", posts.userId)
319
+ .on(users.name, "!=", posts.title)
320
+ .select("name", "title").fetch()
321
+
322
+ await db.posts.join(profiles).using("userId", "tenantId").select("*").fetch()
323
+ await db.posts.crossJoin(profiles).using("userId").select("userId").fetch()
324
+ await db.posts.fullJoin(profiles).using("userId").select("userId").fetch()
325
+ ```
326
+
327
+ Both operands of `on(left, operator, right)` are **column identifiers**, including
328
+ strings. By contrast, the right-hand value of `where(column, value)` or
329
+ `where(column, operator, value)` is always a **literal value**, even if it happens
330
+ to equal a column name. Literal-value ON expressions are not supported.
331
+
332
+ `select("*")` emits an actual `*`, preserving SQLite's common-column suppression
333
+ with `USING`. Qualified refs and wildcards retain each source's own columns.
334
+ Unqualified common keys from right/full joins include surviving right-side values.
335
+ For explicitly projected common columns coalesced by `FULL JOIN ... USING`, Outlaw
336
+ emits a same-name `AS` solely to prevent SQLite from retaining identifier quotes in
337
+ the output key. No source names or distinct output names are invented.
338
+
339
+ `RIGHT JOIN` and `FULL JOIN` require **SQLite 3.39.0 or newer**. Outlaw does not probe
340
+ capabilities, emulate unsupported joins, validate rows, or preflight SQL structure.
341
+ SQLite errors, including ambiguous bare identifiers, propagate from the connection.
342
+ `NATURAL` joins and comma-separated sources are deferred. Join verification uses
343
+ local Bun SQLite; no live D1 verification is claimed.
344
+
345
+ #### Self-joins, subqueries, and views
346
+
347
+ Source aliases are explicit and non-mutating:
348
+
349
+ ```ts
350
+ const managers = users.as("managers")
351
+ await db.users.leftJoin(managers)
352
+ .on(users.managerId, "=", managers.id)
353
+ .select(users.name.as("employeeName"), managers.name.as("managerName"))
354
+ .orderBy([["employeeName", "asc"]])
355
+ .fetch()
356
+ ```
357
+
358
+ Subqueries expose only their projected outputs. Aliases remain optional; both
359
+ forms execute inline as part of one SELECT. Aliasing or embedding snapshots the
360
+ query, so later changes to the original builder do not alter an embedded query:
361
+
362
+ ```ts
363
+ const publishedPosts = db.posts
364
+ .select("id", "userId", "title", "createdAt")
365
+ .where("published", true)
366
+ .as("publishedPosts")
367
+
368
+ await db.users.join(publishedPosts)
369
+ .on(users.id, "=", publishedPosts.userId)
370
+ .select("name", publishedPosts.title, publishedPosts.createdAt)
371
+ .fetch()
372
+
373
+ await db.users.join(db.posts.select("userId", "title"))
374
+ .on("users.id", "=", "userId")
375
+ .select("name", "title")
226
376
  .fetch()
377
+
378
+ const userPosts = createView("user_posts",
379
+ users.leftJoin(posts).on(users.id, "=", posts.userId)
380
+ .select(users.name.as("userName"), posts.title),
381
+ )
382
+ ```
383
+
384
+ Schema-level queries need no connection. Add `userPosts` to `createSchema` to query
385
+ the view through the database API; it exposes only `userName` and nullable `title`.
386
+
387
+ #### Result types and duplicate names
388
+
389
+ Results stay flat. Left joins add `null` to incoming columns; right joins add
390
+ `null` to all accumulated left sources; full joins add it to both sides. Selected
391
+ properties remain required, never optional. For example, selecting `users.name`
392
+ and `posts.title` after a left join yields `{ name: string; title: string | null }`.
393
+
394
+ Mappings follow the selected source and output alias, including through subqueries
395
+ and views. Outer-join SQL nulls stay null; mapped values decode once at execution.
396
+ Filters use the referenced source's storage mapping.
397
+
398
+ Duplicate output names are allowed without aliases, nesting, or automatic
399
+ renaming. The connection's object-row behavior determines which duplicate value
400
+ survives; ordinary collisions have conservative union types. Incompatible mapping
401
+ collisions stay undecoded and are typed as `unknown`. Explicit distinct aliases
402
+ preserve precise field types when both values are needed.
403
+
404
+ #### Migrating the old join chain
405
+
406
+ `db.users.select(...).join(posts).on(...)` is removed, including schema-level
407
+ join-after-select calls. Move `select(...)` after source composition:
408
+
409
+ ```ts
410
+ await db.users.join(posts).on(users.id, "=", posts.userId)
411
+ .select(users.name, posts.title).fetch()
227
412
  ```
228
413
 
229
- Join types: `join`, `leftJoin`, `rightJoin`, `crossJoin`. Each accepts a table or a subquery.
414
+ Joined columns are no longer implicitly appended to the projection. Select the
415
+ outputs needed explicitly, or use `*`/qualified wildcards. Existing single-table
416
+ SELECT and unrelated INSERT/UPDATE/DELETE chains retain their interfaces.
230
417
 
231
418
  ### Insert
232
419
 
package/package.json CHANGED
@@ -5,11 +5,11 @@
5
5
  "description": "A trigger-happy sqlite framework.",
6
6
  "author": {
7
7
  "name": "Sigitex",
8
- "url": "http://github.com/sigitex"
8
+ "url": "https://sigitex.com"
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "https://github.com/sigitex/outlaw.git"
12
+ "url": "git+https://github.com/sigitex/outlaw.git"
13
13
  },
14
14
  "exports": {
15
15
  ".": {
@@ -28,12 +28,12 @@
28
28
  "@semantic-release/exec": "^7.0.3",
29
29
  "@types/bun": "^1.3.13",
30
30
  "@types/fast-json-stable-stringify": "^2.1.2",
31
- "@typescript/native-preview": "beta",
32
31
  "husky": "^9.1.7",
33
32
  "multi-semantic-release": "^3.1.0",
34
33
  "oxfmt": "^0.47.0",
35
34
  "oxlint": "^1.62.0",
36
- "semantic-release": "^25.0.3"
35
+ "semantic-release": "^25.0.3",
36
+ "typescript": "^7.0.2"
37
37
  },
38
38
  "dependencies": {
39
39
  "@sigitex/print": "*",
@@ -43,11 +43,11 @@
43
43
  "scripts": {
44
44
  "prepare": "husky",
45
45
  "lint": "oxlint",
46
- "check": "tsgo --build",
46
+ "check": "tsc --build",
47
47
  "test": "bun test --pass-with-no-tests --tsconfig-override tsconfig.test.json"
48
48
  },
49
49
  "files": [
50
50
  "src"
51
51
  ],
52
- "version": "1.0.1"
52
+ "version": "2.0.0"
53
53
  }
@@ -1,44 +1,70 @@
1
1
  // oxlint-disable typescript/no-explicit-any
2
- import type { Delete, Connection, Insert, Select, TableApi, Update } from "./api.types"
2
+ import type { Delete, Connection, Insert, Update } from "./api.types"
3
3
  import type { TableData } from "../schemaBuilder"
4
- import {
5
- DeleteBuilder,
6
- InsertBuilder,
7
- SelectBuilder,
8
- UpdateBuilder,
9
- } from "../queryBuilder"
10
-
11
- export class DatabaseTable implements TableApi<any> {
12
- private connection: Connection
4
+ import { DeleteBuilder, InsertBuilder, UpdateBuilder } from "../queryBuilder"
5
+ import { QuerySourceBuilder } from "../queryBuilder/QuerySourceBuilder"
6
+ import type { QuerySource } from "../queryBuilder/QuerySource"
7
+ import type { Projection } from "../queryBuilder/Projection"
8
+
9
+ export class DatabaseTable {
10
+ private readonly connection: Connection
13
11
  private table: TableData
12
+ private readonly source: QuerySource.Data
14
13
 
15
14
  constructor(connection: Connection, table: TableData) {
16
15
  this.connection = connection
17
16
  this.table = table
17
+ this.source = { kind: "table", name: table.name, tableData: table }
18
+ }
19
+
20
+ select(...columns: Projection.Input[]) {
21
+ return new QuerySourceBuilder(this.source, this.connection).select(
22
+ ...columns,
23
+ )
24
+ }
25
+
26
+ join(target: QuerySource.Input) {
27
+ return new QuerySourceBuilder(this.source, this.connection).join(target)
28
+ }
29
+
30
+ leftJoin(target: QuerySource.Input) {
31
+ return new QuerySourceBuilder(this.source, this.connection).leftJoin(target)
18
32
  }
19
33
 
20
- 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)
26
- }
27
-
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)
34
+ rightJoin(target: QuerySource.Input) {
35
+ return new QuerySourceBuilder(this.source, this.connection).rightJoin(
36
+ target,
37
+ )
38
+ }
39
+
40
+ fullJoin(target: QuerySource.Input) {
41
+ return new QuerySourceBuilder(this.source, this.connection).fullJoin(target)
42
+ }
43
+
44
+ crossJoin(target: QuerySource.Input) {
45
+ return new QuerySourceBuilder(this.source, this.connection).crossJoin(
46
+ target,
47
+ )
48
+ }
49
+
50
+ insert(...rows: [Partial<any>, ...Partial<any>[]]): Insert<any, any, number>
51
+ insert(...rows: Record<string, unknown>[]): Insert<any, any, number> {
52
+ const first = rows[0]
53
+ if (!first) {
54
+ throw new Error("insert() requires at least one row")
55
+ }
56
+ const columns = Object.keys(first)
57
+ const expected = new Set(columns)
58
+ for (const row of rows.slice(1)) {
59
+ const rowColumns = Object.keys(row)
60
+ if (
61
+ rowColumns.length !== columns.length ||
62
+ rowColumns.some((column) => !expected.has(column))
63
+ ) {
64
+ throw new Error("insert() rows must use the same columns")
65
+ }
39
66
  }
40
- const row = columnsOrRow as Record<string, unknown>
41
- return new InsertBuilder(this.connection, this.table, Object.keys(row), [row])
67
+ return new InsertBuilder(this.connection, this.table, columns, rows)
42
68
  }
43
69
 
44
70
  update(row: Partial<any>): Update<any, number> {
@@ -1,22 +1,45 @@
1
- // oxlint-disable typescript/no-explicit-any
2
- import type { Connection, Select, ViewApi } from "./api.types"
1
+ import type { Connection } from "./api.types"
3
2
  import type { TableData } from "../schemaBuilder"
4
- import { SelectBuilder } from "../queryBuilder"
3
+ import { QuerySourceBuilder } from "../queryBuilder/QuerySourceBuilder"
4
+ import type { QuerySource } from "../queryBuilder/QuerySource"
5
+ import type { Projection } from "../queryBuilder/Projection"
5
6
 
6
- export class DatabaseView implements ViewApi<any> {
7
- private connection: Connection
8
- private tableData: TableData
7
+ export class DatabaseView {
8
+ private readonly connection: Connection
9
+ private readonly source: QuerySource.Data
9
10
 
10
11
  constructor(connection: Connection, tableData: TableData) {
11
12
  this.connection = connection
12
- this.tableData = tableData
13
+ this.source = { kind: "table", name: tableData.name, tableData }
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(...columns: Projection.Input[]) {
17
+ return new QuerySourceBuilder(this.source, this.connection).select(
18
+ ...columns,
19
+ )
20
+ }
21
+
22
+ join(target: QuerySource.Input) {
23
+ return new QuerySourceBuilder(this.source, this.connection).join(target)
24
+ }
25
+
26
+ leftJoin(target: QuerySource.Input) {
27
+ return new QuerySourceBuilder(this.source, this.connection).leftJoin(target)
28
+ }
29
+
30
+ rightJoin(target: QuerySource.Input) {
31
+ return new QuerySourceBuilder(this.source, this.connection).rightJoin(
32
+ target,
33
+ )
34
+ }
35
+
36
+ fullJoin(target: QuerySource.Input) {
37
+ return new QuerySourceBuilder(this.source, this.connection).fullJoin(target)
38
+ }
39
+
40
+ crossJoin(target: QuerySource.Input) {
41
+ return new QuerySourceBuilder(this.source, this.connection).crossJoin(
42
+ target,
43
+ )
21
44
  }
22
45
  }
@@ -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
+ }