effect-qb 0.12.3 → 0.13.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +372 -224
  3. package/dist/mysql.js +5037 -4962
  4. package/dist/postgres.js +4978 -4903
  5. package/package.json +8 -1
  6. package/src/internal/column-state.ts +9 -1
  7. package/src/internal/column.ts +30 -17
  8. package/src/internal/expression-ast.ts +11 -0
  9. package/src/internal/expression.ts +2 -0
  10. package/src/internal/query-factory.ts +50 -63
  11. package/src/internal/query.ts +16 -2
  12. package/src/internal/sql-expression-renderer.ts +33 -9
  13. package/src/internal/table-options.ts +2 -1
  14. package/src/internal/table.ts +4 -3
  15. package/src/mysql/column.ts +2 -1
  16. package/src/mysql/executor.ts +20 -17
  17. package/src/mysql/function/aggregate.ts +6 -0
  18. package/src/mysql/function/core.ts +4 -0
  19. package/src/mysql/function/index.ts +19 -0
  20. package/src/mysql/function/json.ts +4 -0
  21. package/src/mysql/function/string.ts +6 -0
  22. package/src/mysql/function/temporal.ts +103 -0
  23. package/src/mysql/function/window.ts +7 -0
  24. package/src/mysql/private/query.ts +13 -0
  25. package/src/mysql/query.ts +1 -26
  26. package/src/mysql.ts +2 -0
  27. package/src/postgres/column.ts +1 -1
  28. package/src/postgres/executor.ts +19 -17
  29. package/src/postgres/function/aggregate.ts +6 -0
  30. package/src/postgres/function/core.ts +4 -0
  31. package/src/postgres/function/index.ts +19 -0
  32. package/src/postgres/function/json.ts +4 -0
  33. package/src/postgres/function/string.ts +6 -0
  34. package/src/postgres/function/temporal.ts +107 -0
  35. package/src/postgres/function/window.ts +7 -0
  36. package/src/postgres/private/query.ts +13 -0
  37. package/src/postgres/query.ts +1 -26
  38. package/src/postgres.ts +2 -0
package/README.md CHANGED
@@ -2,12 +2,66 @@
2
2
 
3
3
  Type-safe SQL query construction for PostgreSQL and MySQL, with query plans that carry result shapes, nullability, dialect compatibility, and statement constraints in the type system.
4
4
 
5
+ ## Overview
6
+
7
+ `effect-qb` builds immutable query plans and pushes the interesting parts of SQL into the type system:
8
+
9
+ - exact projection shapes
10
+ - nullability and predicate-driven narrowing
11
+ - join optionality
12
+ - aggregate and grouping validation
13
+ - dialect compatibility
14
+ - statement and execution result types
15
+
16
+ The main contract is compile-time. `Query.ResultRow<typeof plan>` is the logical row type after query analysis, while `Query.RuntimeResultRow<typeof plan>` describes the conservative runtime remap shape. At runtime, the library renders SQL, executes it, normalizes raw driver values, applies schema-backed transforms where they exist, and remaps aliased columns back into nested objects.
17
+
18
+ ## Why effect-qb
19
+
20
+ Use `effect-qb` when you want SQL plans to carry more than column names:
21
+
22
+ - exact nested projection shapes
23
+ - nullability refinement from predicates
24
+ - join optionality that changes with query structure
25
+ - grouped-query validation before SQL is rendered
26
+ - dialect-locked plans, renderers, and executor error channels
27
+
28
+ It is a query-construction library, not an ORM. It does not manage migrations, model identities, or runtime row decoding.
29
+
30
+ ## Installation
31
+
32
+ If you only want the typed DSL and SQL rendering:
33
+
34
+ ```bash
35
+ bun add effect-qb
36
+ npm install effect-qb
37
+ ```
38
+
39
+ If you want to execute plans with the built-in Postgres executor:
40
+
41
+ ```bash
42
+ bun add effect-qb effect @effect/sql @effect/sql-pg
43
+ npm install effect-qb effect @effect/sql @effect/sql-pg
44
+ ```
45
+
46
+ If you want to execute plans with the built-in MySQL executor:
47
+
48
+ ```bash
49
+ bun add effect-qb effect @effect/sql @effect/sql-mysql2
50
+ npm install effect-qb effect @effect/sql @effect/sql-mysql2
51
+ ```
52
+
53
+ The built-in executors require an ambient `@effect/sql` `SqlClient`. If your app already uses Effect and `@effect/sql`, you likely already have the extra runtime packages installed.
54
+
55
+ For local development in this repository:
56
+
57
+ ```bash
58
+ bun install
59
+ ```
60
+
5
61
  ## Table of Contents
6
62
 
7
- - [Overview](#overview)
8
- - [Why effect-qb](#why-effect-qb)
9
- - [Installation](#installation)
10
63
  - [Choose An Entrypoint](#choose-an-entrypoint)
64
+ - [Postgres Function](#postgres-function)
11
65
  - [Quick Start](#quick-start)
12
66
  - [Execution Model](#execution-model)
13
67
  - [Feature Map](#feature-map)
@@ -57,63 +111,53 @@ Type-safe SQL query construction for PostgreSQL and MySQL, with query plans that
57
111
  - [Limitations](#limitations)
58
112
  - [Contributing](#contributing)
59
113
 
60
- ## Overview
61
-
62
- `effect-qb` builds immutable query plans and pushes the interesting parts of SQL into the type system:
63
-
64
- - exact projection shapes
65
- - nullability and predicate-driven narrowing
66
- - join optionality
67
- - aggregate and grouping validation
68
- - dialect compatibility
69
- - statement and execution result types
114
+ ## Choose An Entrypoint
70
115
 
71
- The main contract is compile-time. `Query.ResultRow<typeof plan>` is the logical row type after query analysis, while `Query.RuntimeResultRow<typeof plan>` describes the conservative runtime remap shape. At runtime, the library renders SQL, executes it, and remaps aliased columns back into nested objects. It does not build or validate query-result schemas.
116
+ Available entrypoints:
72
117
 
73
- ## Why effect-qb
118
+ - `effect-qb/postgres`
119
+ - `effect-qb/mysql`
74
120
 
75
- Use `effect-qb` when you want SQL plans to carry more than column names:
121
+ Use `effect-qb/postgres` when you want explicit Postgres branding throughout the plan, renderer, executor, datatypes, and errors.
76
122
 
77
- - exact nested projection shapes
78
- - nullability refinement from predicates
79
- - join optionality that changes with query structure
80
- - grouped-query validation before SQL is rendered
81
- - dialect-locked plans, renderers, and executor error channels
123
+ That entrypoint also exposes `Postgres.Function` for typed SQL functions and JSON helpers.
82
124
 
83
- It is a query-construction library, not an ORM. It does not manage migrations, model identities, or runtime row decoding.
125
+ Use `effect-qb/mysql` when you want the MySQL-specific DSL, renderer, executor, datatypes, and errors. It also exposes `Mysql.Function` for typed SQL functions and JSON helpers.
84
126
 
85
- ## Installation
127
+ ## Postgres Function
86
128
 
87
- Install the library:
129
+ `effect-qb/postgres` exposes `Postgres.Function` for typed SQL expressions. The helpers are expressions, so they compose like other query values and keep their result types.
88
130
 
89
- ```bash
90
- bun add effect-qb
91
- ```
131
+ ```ts
132
+ import { Column as C, Function as F, Query as Q, Table } from "effect-qb/postgres"
92
133
 
93
- For local development in this repository:
134
+ const users = Table.make("users", {
135
+ id: C.uuid().pipe(C.primaryKey),
136
+ email: C.text(),
137
+ bio: C.text().pipe(C.nullable)
138
+ })
94
139
 
95
- ```bash
96
- bun install
140
+ const userSummary = Q.select({
141
+ email: F.lower(users.email),
142
+ bio: F.coalesce(users.bio, "anonymous"),
143
+ seenAt: F.currentTimestamp()
144
+ }).pipe(
145
+ Q.from(users)
146
+ )
97
147
  ```
98
148
 
99
- ## Choose An Entrypoint
100
-
101
- Available entrypoints:
102
-
103
- - `effect-qb`
104
- - `effect-qb/postgres`
105
- - `effect-qb/mysql`
106
-
107
- Use `effect-qb` when Postgres defaults are acceptable and you want the shortest imports.
149
+ `Postgres.Function` currently covers:
108
150
 
109
- Use `effect-qb/postgres` when you want explicit Postgres branding throughout the plan, renderer, executor, datatypes, and errors.
110
-
111
- Use `effect-qb/mysql` when you want the MySQL-specific DSL, renderer, executor, datatypes, and errors.
151
+ - scalar helpers like `coalesce`, `lower`, `upper`, and `concat`
152
+ - aggregate helpers like `count`, `max`, and `min`
153
+ - window helpers like `over`, `rowNumber`, `rank`, and `denseRank`
154
+ - temporal helpers like `now`, `currentDate`, `currentTime`, `currentTimestamp`, `localTime`, and `localTimestamp`
155
+ - JSON helpers via `Function.json`
112
156
 
113
157
  ## Quick Start
114
158
 
115
159
  ```ts
116
- import { Column as C, Query as Q, Renderer, Table } from "effect-qb"
160
+ import { Column as C, Function as F, Query as Q, Renderer, Table } from "effect-qb/postgres"
117
161
 
118
162
  const users = Table.make("users", {
119
163
  id: C.uuid().pipe(C.primaryKey),
@@ -129,7 +173,7 @@ const posts = Table.make("posts", {
129
173
  const postsPerUser = Q.select({
130
174
  userId: users.id,
131
175
  email: users.email,
132
- postCount: Q.count(posts.id)
176
+ postCount: F.count(posts.id)
133
177
  }).pipe(
134
178
  Q.from(users),
135
179
  Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
@@ -157,18 +201,18 @@ The runtime model is intentionally small:
157
201
 
158
202
  1. build a typed plan
159
203
  2. render SQL plus bind params
160
- 3. execute the statement
161
- 4. remap flat aliases like `profile__email` back into nested objects
204
+ 3. normalize raw driver values into canonical `effect-qb` runtime values
205
+ 4. apply propagated runtime schemas where they exist
206
+ 5. remap flat aliases like `profile__email` back into nested objects
162
207
 
163
- What it does not do is decode rows against a runtime schema. `Q.ResultRow<typeof plan>` is the logical static result, while `Q.RuntimeResultRow<typeof plan>` is the conservative runtime shape.
208
+ For the logical/static row split, see `ResultRow vs RuntimeResultRow` below.
164
209
 
165
210
  ```ts
166
211
  import * as SqlClient from "@effect/sql/SqlClient"
167
212
  import * as Effect from "effect/Effect"
168
- import * as Postgres from "effect-qb/postgres"
213
+ import { Executor as PostgresExecutor } from "effect-qb/postgres"
169
214
 
170
- const renderer = Postgres.Renderer.make()
171
- const executor = Postgres.Executor.fromSqlClient(renderer)
215
+ const executor = PostgresExecutor.make()
172
216
 
173
217
  const rowsEffect = executor.execute(postsPerUser)
174
218
 
@@ -177,7 +221,7 @@ const rows = Effect.runSync(
177
221
  )
178
222
  ```
179
223
 
180
- If you want runtime validation, add it after execution.
224
+ Schema-backed columns and preserved projections can enforce runtime transforms during execution. Arbitrary query plans still do not become one big derived query schema automatically.
181
225
 
182
226
  ## Feature Map
183
227
 
@@ -186,6 +230,7 @@ The rest of this README goes deeper, but the main surface area is:
186
230
  - table builders with keys, indexes, nullability, defaults, and schema-backed JSON columns
187
231
  - select plans with joins, CTEs, derived tables, `values(...)`, `unnest(...)`, subqueries, and set operators
188
232
  - mutation plans for `insert`, `update`, `delete`, `returning`, and conflict handling
233
+ - typed SQL functions via `Postgres.Function` and `Mysql.Function`
189
234
  - renderers and executors for Postgres and MySQL
190
235
  - type-level checks for missing sources, grouped selections, dialect compatibility, and JSON mutation compatibility
191
236
 
@@ -193,65 +238,92 @@ Dialect-specific capabilities are called out later. Postgres currently has the w
193
238
 
194
239
  ## Effect Schema Integration
195
240
 
196
- `effect-qb` is tightly integrated with `effect/Schema`.
241
+ `effect-qb` uses `effect/Schema` as the runtime contract for columns and tables.
197
242
 
198
243
  That integration shows up in three places:
199
244
 
200
- - built-in columns are backed by Effect Schema primitives like `Schema.String`, `Schema.UUID`, and `Schema.Date`
201
- - custom and JSON columns can be defined directly from your own Effect Schemas
202
- - every table derives runtime `select`, `insert`, and `update` schemas from its column definitions
245
+ - column definitions carry runtime schemas
246
+ - tables derive `select`, `insert`, and `update` schemas from those columns
247
+ - built-in executors apply schema-backed transforms when selected projections carry a runtime schema
203
248
 
204
- There is no separate `C.schema(...)` helper. The schema-backed entrypoints are:
249
+ The schema-aware column APIs are:
205
250
 
251
+ - built-in columns like `C.uuid()`, `C.text()`, `C.number()`, `C.date()`, and `C.timestamp()`
252
+ - `C.schema(schema)` to replace a column's runtime schema without changing its SQL type or key/default metadata
206
253
  - `C.custom(schema, dbType)` for arbitrary non-JSON columns
207
254
  - `C.json(schema)` for JSON columns
208
- - `column.schema` when you need the underlying Effect Schema attached to a column definition
209
255
 
210
- This means the same table definition drives:
256
+ ### Defaults And Generated Columns
257
+
258
+ `C.default(expr)` and `C.generated(expr)` only affect write-shape:
259
+
260
+ - `C.default(expr)` keeps a column selectable and updatable, but optional on insert because the database may fill it
261
+ - `C.generated(expr)` omits a column from insert and update because the database owns the value
262
+ - both helpers keep the expression around for DDL rendering
211
263
 
212
- - SQL construction
213
- - static TypeScript row and payload types
214
- - runtime validation for table-shaped inputs
264
+ The important rule for `C.schema(...)` is that the schema must accept the column's current runtime output, not the raw driver value.
265
+
266
+ - `C.date()` produces a canonical `LocalDateString`, so `C.date().pipe(C.schema(Schema.DateFromString))` is valid
267
+ - `C.int().pipe(C.schema(Schema.DateFromString))` is rejected because the column runtime type is `number`, not `string`
215
268
 
216
269
  Example:
217
270
 
218
271
  ```ts
219
272
  import * as Schema from "effect/Schema"
220
- import { Column as C, Table } from "effect-qb"
273
+ import { Column as C, Executor, Function as F, Query as Q, Table } from "effect-qb/postgres"
221
274
 
222
- const UserProfile = Schema.Struct({
223
- displayName: Schema.String,
224
- bio: Schema.NullOr(Schema.String)
275
+ const users = Table.make("users", {
276
+ id: C.uuid().pipe(C.primaryKey, C.generated(Q.literal("generated-user-id"))),
277
+ happenedOn: C.date().pipe(C.schema(Schema.DateFromString)),
278
+ profile: C.json(Schema.Struct({
279
+ visits: Schema.NumberFromString
280
+ })),
281
+ createdAt: C.timestamp().pipe(C.default(F.localTimestamp()))
225
282
  })
226
283
 
227
- const users = Table.make("users", {
228
- id: C.uuid().pipe(C.primaryKey, C.generated),
229
- email: C.text(),
230
- profile: C.json(UserProfile),
231
- createdAt: C.timestamp().pipe(C.hasDefault),
232
- status: C.custom(
233
- Schema.Literal("active", "disabled"),
234
- { dialect: "postgres", kind: "text" } as const
235
- )
284
+ type UserSelect = Table.SelectOf<typeof users>
285
+ type UserInsert = Table.InsertOf<typeof users>
286
+ type UserUpdate = Table.UpdateOf<typeof users>
287
+
288
+ const decoded = Schema.decodeUnknownSync(users.schemas.select)({
289
+ id: "11111111-1111-1111-1111-111111111111",
290
+ happenedOn: "2026-03-20",
291
+ profile: {
292
+ visits: "42"
293
+ },
294
+ createdAt: "2026-03-20T10:00:00"
236
295
  })
296
+
297
+ decoded.happenedOn
298
+ // Date
299
+
300
+ decoded.profile.visits
301
+ // number
302
+
303
+ const plan = Q.select({
304
+ happenedOn: users.happenedOn,
305
+ profile: users.profile
306
+ }).pipe(
307
+ Q.from(users)
308
+ )
309
+
310
+ const rowsEffect = Executor.make().execute(plan)
237
311
  ```
238
312
 
239
313
  From that one definition you get:
240
314
 
241
- - bound SQL columns like `users.email`
242
- - a schema for reading table rows: `users.schemas.select`
243
- - a schema for inserts that respects generated/default/nullable columns: `users.schemas.insert`
244
- - a schema for updates that excludes primary keys and generated columns: `users.schemas.update`
315
+ - bound SQL columns like `users.profile`
316
+ - derived table schemas: `users.schemas.select`, `users.schemas.insert`, `users.schemas.update`
317
+ - static helper types that line up with those schemas
318
+ - executor-side transforms for schema-backed projections
245
319
 
246
- The helper types line up with those schemas:
320
+ That last point matters. Built-in executors do not just remap aliases. They first normalize raw driver values into canonical `effect-qb` runtime values, then apply propagated schemas where they exist. So a selected `users.happenedOn` can become a `Date`, and a selected `users.profile` can decode `"42"` into `42` via `Schema.NumberFromString`.
247
321
 
248
- ```ts
249
- type UserSelect = Table.SelectOf<typeof users>
250
- type UserInsert = Table.InsertOf<typeof users>
251
- type UserUpdate = Table.UpdateOf<typeof users>
252
- ```
322
+ The boundary is still important:
253
323
 
254
- One important boundary: table schemas are runtime schemas for table-shaped data, while query plans remain schema-free at execution time. `effect-qb` derives and exposes Effect Schemas for tables and JSON columns, but `executor.execute(plan)` still remaps rows without decoding arbitrary query results through `effect/Schema`.
324
+ - table schemas are full `effect/Schema` values for table-shaped data
325
+ - schema-backed columns and preserved projections can enforce runtime transforms on reads
326
+ - arbitrary query plans do not automatically become one big derived query schema
255
327
 
256
328
  ## Core Concepts
257
329
 
@@ -263,10 +335,10 @@ Every table exposes derived Effect Schemas:
263
335
  import * as Schema from "effect/Schema"
264
336
 
265
337
  const users = Table.make("users", {
266
- id: C.uuid().pipe(C.primaryKey, C.generated),
338
+ id: C.uuid().pipe(C.primaryKey, C.generated(Q.literal("generated-user-id"))),
267
339
  email: C.text().pipe(C.unique),
268
340
  bio: C.text().pipe(C.nullable),
269
- createdAt: C.timestamp().pipe(C.hasDefault)
341
+ createdAt: C.timestamp().pipe(C.default(F.localTimestamp()))
270
342
  })
271
343
 
272
344
  Schema.isSchema(users.schemas.select)
@@ -288,7 +360,7 @@ Tables are typed sources, not loose name strings. Columns carry DB types, nullab
288
360
 
289
361
  ```ts
290
362
  import * as Schema from "effect/Schema"
291
- import { Column as C, Table } from "effect-qb"
363
+ import { Column as C, Table } from "effect-qb/postgres"
292
364
 
293
365
  const users = Table.make("users", {
294
366
  id: C.uuid().pipe(C.primaryKey),
@@ -311,6 +383,41 @@ const events = analytics.table("events", {
311
383
  })
312
384
  ```
313
385
 
386
+ ### Table Options
387
+
388
+ Table-level options live on the table definition itself. They are pipeable and render into DDL:
389
+
390
+ - `Table.primaryKey(...)` for table-level composite keys
391
+ - `Table.unique(...)` for table-level unique constraints
392
+ - `Table.index(...)` for table-level indexes
393
+ - `Table.foreignKey(...)` for table-level foreign keys
394
+ - `Table.check(...)` for expression-only check constraints
395
+
396
+ ```ts
397
+ import { Column as C, Query as Q, Table } from "effect-qb/postgres"
398
+
399
+ const orgs = Table.make("orgs", {
400
+ id: C.uuid().pipe(C.primaryKey),
401
+ slug: C.text().pipe(C.unique)
402
+ })
403
+
404
+ const membershipsBase = Table.make("memberships", {
405
+ id: C.uuid().pipe(C.primaryKey),
406
+ orgId: C.uuid(),
407
+ role: C.text(),
408
+ note: C.text().pipe(C.nullable)
409
+ })
410
+
411
+ const memberships = membershipsBase.pipe(
412
+ Table.foreignKey("orgId", () => orgs, "id"),
413
+ Table.unique(["orgId", "role"] as const),
414
+ Table.index(["role", "orgId"] as const),
415
+ Table.check("role_not_empty", Q.neq(membershipsBase.role, Q.literal("")))
416
+ )
417
+ ```
418
+
419
+ The `check` helper must receive an expression. Raw SQL strings are not accepted.
420
+
314
421
  ### Plans, Not Strings
315
422
 
316
423
  `effect-qb` does not build rows from ad hoc string fragments. It builds typed plans. Partial plans are allowed while assembling a query, but rendering and execution require a complete plan.
@@ -378,13 +485,13 @@ const docs = Table.make("docs", {
378
485
  }))
379
486
  })
380
487
 
381
- const cityPath = Q.json.path(
382
- Q.json.key("profile"),
383
- Q.json.key("address"),
384
- Q.json.key("city")
488
+ const cityPath = F.json.path(
489
+ F.json.key("profile"),
490
+ F.json.key("address"),
491
+ F.json.key("city")
385
492
  )
386
493
 
387
- const city = Q.json.get(docs.payload, cityPath)
494
+ const city = F.json.get(docs.payload, cityPath)
388
495
  type City = Q.OutputOfExpression<typeof city, {
389
496
  readonly docs: {
390
497
  readonly name: "docs"
@@ -396,17 +503,11 @@ type City = Q.OutputOfExpression<typeof city, {
396
503
 
397
504
  ### Dialect-specific Entrypoints
398
505
 
399
- The root entrypoint defaults to the Postgres-flavored `Query` and `Table` DSLs:
400
-
401
- ```ts
402
- import { Query as Q, Table } from "effect-qb"
403
- ```
404
-
405
506
  Dialect entrypoints expose dialect-specific builders:
406
507
 
407
508
  ```ts
408
- import * as Postgres from "effect-qb/postgres"
409
- import * as Mysql from "effect-qb/mysql"
509
+ import { Query as PostgresQuery } from "effect-qb/postgres"
510
+ import { Query as MysqlQuery } from "effect-qb/mysql"
410
511
  ```
411
512
 
412
513
  This matters for:
@@ -450,16 +551,14 @@ Projection typing is local. You usually do not need to define row interfaces you
450
551
  `from(...)` and joins make referenced sources available to the plan. Derived tables, CTEs, and correlated sources stay typed.
451
552
 
452
553
  ```ts
453
- const activePosts = Q.as(
454
- Q.select({
554
+ const activePosts = Q.select({
455
555
  userId: posts.userId,
456
556
  title: posts.title
457
557
  }).pipe(
458
558
  Q.from(posts),
459
- Q.where(Q.isNotNull(posts.title))
460
- ),
461
- "active_posts"
462
- )
559
+ Q.where(Q.isNotNull(posts.title)),
560
+ Q.as("active_posts")
561
+ )
463
562
 
464
563
  const usersWithPosts = Q.select({
465
564
  userId: users.id,
@@ -478,9 +577,9 @@ type UsersWithPostsRow = Q.ResultRow<typeof usersWithPosts>
478
577
 
479
578
  The same source story applies to:
480
579
 
481
- - `Q.with(subquery, alias)`
482
- - `Q.withRecursive(subquery, alias)`
483
- - `Q.lateral(subquery, alias)`
580
+ - `subquery.pipe(Q.with("alias"))`
581
+ - `subquery.pipe(Q.withRecursive("alias"))`
582
+ - `subquery.pipe(Q.lateral("alias"))`
484
583
  - `Q.values(...)`
485
584
  - `Q.unnest(...)`
486
585
 
@@ -491,7 +590,7 @@ Predicates do more than render SQL. They can narrow result types.
491
590
  ```ts
492
591
  const allPosts = Q.select({
493
592
  title: posts.title,
494
- upperTitle: Q.upper(posts.title)
593
+ upperTitle: F.upper(posts.title)
495
594
  }).pipe(
496
595
  Q.from(posts)
497
596
  )
@@ -504,7 +603,7 @@ type AllPostsRow = Q.ResultRow<typeof allPosts>
504
603
 
505
604
  const titledPosts = Q.select({
506
605
  title: posts.title,
507
- upperTitle: Q.upper(posts.title)
606
+ upperTitle: F.upper(posts.title)
508
607
  }).pipe(
509
608
  Q.from(posts),
510
609
  Q.where(Q.isNotNull(posts.title))
@@ -530,6 +629,7 @@ The expression surface is large, but the important point is that result-shaping
530
629
 
531
630
  ```ts
532
631
  import * as Schema from "effect/Schema"
632
+ import { Column as C, Function as F, Query as Q, Table } from "effect-qb/postgres"
533
633
 
534
634
  const docs = Table.make("docs", {
535
635
  id: C.uuid().pipe(C.primaryKey),
@@ -542,17 +642,17 @@ const docs = Table.make("docs", {
542
642
  }))
543
643
  })
544
644
 
545
- const cityPath = Q.json.path(
546
- Q.json.key("profile"),
547
- Q.json.key("address"),
548
- Q.json.key("city")
645
+ const cityPath = F.json.path(
646
+ F.json.key("profile"),
647
+ F.json.key("address"),
648
+ F.json.key("city")
549
649
  )
550
650
 
551
651
  const shapedDocs = Q.select({
552
652
  title: Q.case()
553
653
  .when(Q.isNull(posts.title), "missing")
554
- .else(Q.upper(posts.title)),
555
- profileCity: Q.json.text(docs.payload, cityPath),
654
+ .else(F.upper(posts.title)),
655
+ profileCity: F.json.text(docs.payload, cityPath),
556
656
  titleAsText: Q.cast(posts.title, Q.type.text())
557
657
  }).pipe(
558
658
  Q.from(posts),
@@ -562,12 +662,12 @@ const shapedDocs = Q.select({
562
662
 
563
663
  The same JSON path object can be reused across:
564
664
 
565
- - `Q.json.get(...)`
566
- - `Q.json.text(...)`
567
- - `Q.json.set(...)`
568
- - `Q.json.insert(...)`
569
- - `Q.json.delete(...)`
570
- - `Q.json.pathExists(...)`
665
+ - `Function.json.get(...)`
666
+ - `Function.json.text(...)`
667
+ - `Function.json.set(...)`
668
+ - `Function.json.insert(...)`
669
+ - `Function.json.delete(...)`
670
+ - `Function.json.pathExists(...)`
571
671
 
572
672
  Comparison and cast safety are dialect-aware. Incompatible operands are rejected unless you make the conversion explicit with `Q.cast(...)`.
573
673
 
@@ -576,13 +676,26 @@ Comparison and cast safety are dialect-aware. Incompatible operands are rejected
576
676
  Grouped queries are checked structurally, not just by source provenance.
577
677
 
578
678
  ```ts
679
+ const invalidPostsPerUser = Q.select({
680
+ userId: users.id,
681
+ title: posts.title,
682
+ postCount: F.count(posts.id)
683
+ }).pipe(
684
+ Q.from(users),
685
+ Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
686
+ Q.groupBy(users.id)
687
+ )
688
+
689
+ type InvalidPostsPerUser = Q.CompletePlan<typeof invalidPostsPerUser>
690
+ // {
691
+ // __effect_qb_error__: "effect-qb: invalid grouped selection"
692
+ // __effect_qb_hint__:
693
+ // "Scalar selections must be covered by groupBy(...) when aggregates are present"
694
+ // }
695
+
579
696
  const postsPerUser = Q.select({
580
697
  userId: users.id,
581
- postCount: Q.count(posts.id),
582
- rowNumber: Q.over(Q.rowNumber(), {
583
- partitionBy: [users.id],
584
- orderBy: [{ value: users.id, direction: "asc" }]
585
- })
698
+ postCount: F.count(posts.id)
586
699
  }).pipe(
587
700
  Q.from(users),
588
701
  Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
@@ -593,11 +706,10 @@ type PostsPerUserRow = Q.ResultRow<typeof postsPerUser>
593
706
  // {
594
707
  // userId: string
595
708
  // postCount: number
596
- // rowNumber: number
597
709
  // }
598
710
  ```
599
711
 
600
- Scalar selections must be covered by `groupBy(...)` when aggregates are present. Invalid grouped selections are rejected at the complete-plan boundary.
712
+ This catches invalid grouped queries before rendering, then the fixed plan keeps only grouped or aggregate selections.
601
713
 
602
714
  ### Combining Queries
603
715
 
@@ -654,15 +766,15 @@ const recentUsers = Q.select({
654
766
  Postgres-only `distinct on` is available from the Postgres entrypoint:
655
767
 
656
768
  ```ts
657
- import * as Postgres from "effect-qb/postgres"
769
+ import { Query as Q } from "effect-qb/postgres"
658
770
 
659
- const recentEmails = Postgres.Query.select({
771
+ const recentEmails = Q.select({
660
772
  id: users.id,
661
773
  email: users.email
662
774
  }).pipe(
663
- Postgres.Query.from(users),
664
- Postgres.Query.distinctOn(users.email),
665
- Postgres.Query.orderBy(users.email)
775
+ Q.from(users),
776
+ Q.distinctOn(users.email),
777
+ Q.orderBy(users.email)
666
778
  )
667
779
  ```
668
780
 
@@ -679,28 +791,38 @@ const insertUser = Q.insert(users, {
679
791
  })
680
792
  ```
681
793
 
794
+ If every writable column is optional because of `C.default(...)`, `C.generated(...)`, or nullability, `Q.insert(table)` is the default-only form.
795
+
682
796
  Composable sources are available when the input rows come from elsewhere:
683
797
 
684
798
  ```ts
685
799
  const pendingUsers = Q.values([
686
800
  { id: "user-1", email: "alice@example.com" },
687
801
  { id: "user-2", email: "bob@example.com" }
688
- ], "pending_users")
802
+ ]).pipe(
803
+ Q.as("pending_users")
804
+ )
689
805
 
690
- const insertMany = Q.insertFrom(users, pendingUsers)
806
+ const insertMany = Q.insert(users).pipe(
807
+ Q.from(pendingUsers)
808
+ )
691
809
  ```
692
810
 
693
- `insertFrom(...)` also accepts `select(...)`, `unnest(...)`, and other compatible sources.
811
+ `from(...)` also accepts `select(...)`, `unnest(...)`, and other compatible sources.
694
812
 
695
813
  ### Update
696
814
 
697
- Updates stay expression-aware and can use joined sources where the dialect supports it.
815
+ Updates stay expression-aware and can use `from(...)` sources where the dialect supports it.
698
816
 
699
817
  ```ts
700
- const updateUsers = Q.innerJoin(posts, Q.eq(posts.userId, users.id))(
701
- Q.update(users, {
702
- email: "has-posts@example.com"
703
- })
818
+ const updateUsers = Q.update(users, {
819
+ email: "author@example.com"
820
+ }).pipe(
821
+ Q.from(posts),
822
+ Q.where(Q.and(
823
+ Q.eq(posts.userId, users.id),
824
+ Q.eq(posts.title, "hello")
825
+ ))
704
826
  )
705
827
  ```
706
828
 
@@ -721,19 +843,25 @@ const deleteUser = Q.delete(users).pipe(
721
843
  Conflict handling is modeled as a composable modifier instead of a string escape hatch.
722
844
 
723
845
  ```ts
724
- const insertOrIgnore = Q.onConflict(["id"] as const, {
725
- action: "doNothing"
726
- })(Q.insert(users, {
846
+ const insertOrIgnore = Q.insert(users, {
727
847
  id: "user-1",
728
848
  email: "alice@example.com"
729
- }))
849
+ }).pipe(
850
+ Q.onConflict(["id"] as const, {
851
+ action: "doNothing"
852
+ })
853
+ )
730
854
 
731
- const upsertUser = Q.upsert(users, {
855
+ const upsertUser = Q.insert(users, {
732
856
  id: "user-1",
733
857
  email: "alice@example.com"
734
- }, ["id"] as const, {
735
- email: "alice@example.com"
736
- })
858
+ }).pipe(
859
+ Q.onConflict(["id"] as const, {
860
+ update: {
861
+ email: Q.excluded(users.email)
862
+ }
863
+ })
864
+ )
737
865
  ```
738
866
 
739
867
  Conflict targets are checked against the target table.
@@ -743,13 +871,15 @@ Conflict targets are checked against the target table.
743
871
  Mutation plans can project typed rows with `returning(...)`.
744
872
 
745
873
  ```ts
746
- const insertedUser = Q.returning({
747
- id: users.id,
748
- email: users.email
749
- })(Q.insert(users, {
874
+ const insertedUser = Q.insert(users, {
750
875
  id: "user-1",
751
876
  email: "alice@example.com"
752
- }))
877
+ }).pipe(
878
+ Q.returning({
879
+ id: users.id,
880
+ email: users.email
881
+ })
882
+ )
753
883
 
754
884
  type InsertedUserRow = Q.ResultRow<typeof insertedUser>
755
885
  // {
@@ -763,15 +893,15 @@ type InsertedUserRow = Q.ResultRow<typeof insertedUser>
763
893
  Write plans can feed later reads in the same statement:
764
894
 
765
895
  ```ts
766
- const insertedUsers = Q.with(
896
+ const insertedUsers = Q.insert(users, {
897
+ id: "user-1",
898
+ email: "alice@example.com"
899
+ }).pipe(
767
900
  Q.returning({
768
901
  id: users.id,
769
902
  email: users.email
770
- })(Q.insert(users, {
771
- id: "user-1",
772
- email: "alice@example.com"
773
- })),
774
- "inserted_users"
903
+ }),
904
+ Q.with("inserted_users")
775
905
  )
776
906
 
777
907
  const insertedUsersPlan = Q.select({
@@ -789,9 +919,9 @@ This is one of the places where the capability model matters: write-bearing nest
789
919
  ### Renderer
790
920
 
791
921
  ```ts
792
- import * as Postgres from "effect-qb/postgres"
922
+ import { Renderer as PostgresRenderer } from "effect-qb/postgres"
793
923
 
794
- const rendered = Postgres.Renderer.make().render(postsPerUser)
924
+ const rendered = PostgresRenderer.make().render(postsPerUser)
795
925
 
796
926
  rendered.sql
797
927
  rendered.params
@@ -810,24 +940,26 @@ They do not carry a query-result schema.
810
940
  ### Executor
811
941
 
812
942
  ```ts
813
- import * as Postgres from "effect-qb/postgres"
943
+ import { Executor as PostgresExecutor, Query as Q } from "effect-qb/postgres"
814
944
 
815
- const renderer = Postgres.Renderer.make()
816
- const executor = Postgres.Executor.fromSqlClient(renderer)
945
+ const executor = PostgresExecutor.make()
817
946
 
818
947
  const rowsEffect = executor.execute(postsPerUser)
819
948
 
820
- type Rows = Postgres.Query.ResultRows<typeof postsPerUser>
821
- type Error = Postgres.Executor.PostgresQueryError<typeof postsPerUser>
949
+ type Rows = Q.ResultRows<typeof postsPerUser>
950
+ type Error = PostgresExecutor.PostgresQueryError<typeof postsPerUser>
822
951
  ```
823
952
 
953
+ Pass `{ renderer }`, `{ driver }`, or both when you need to customize execution.
954
+
824
955
  Execution is:
825
956
 
826
957
  1. render the plan
827
- 2. execute SQL
828
- 3. remap flat aliases into nested objects
958
+ 2. normalize raw driver values into canonical runtime values
959
+ 3. apply schema-backed transforms where they exist
960
+ 4. remap flat aliases into nested objects
829
961
 
830
- There is no query-result schema decode stage.
962
+ There is no automatically derived whole-query schema.
831
963
 
832
964
  ### Query-sensitive Error Channels
833
965
 
@@ -841,10 +973,10 @@ Those types are narrower than the raw dialect error catalogs. For example, known
841
973
  ### Transaction Helpers
842
974
 
843
975
  ```ts
844
- import { Executor } from "effect-qb"
976
+ import { Executor as PostgresExecutor } from "effect-qb/postgres"
845
977
 
846
- const transactional = Executor.withTransaction(rowsEffect)
847
- const savepoint = Executor.withSavepoint(rowsEffect)
978
+ const transactional = PostgresExecutor.withTransaction(rowsEffect)
979
+ const savepoint = PostgresExecutor.withSavepoint(rowsEffect)
848
980
  ```
849
981
 
850
982
  These preserve the original effect type parameters and add the ambient SQL transaction boundary.
@@ -865,8 +997,8 @@ The unusual part is that these are not separate features bolted together. The bu
865
997
  Both dialect entrypoints expose an `Errors` module:
866
998
 
867
999
  ```ts
868
- import * as Postgres from "effect-qb/postgres"
869
- import * as Mysql from "effect-qb/mysql"
1000
+ import { Errors as PostgresErrors } from "effect-qb/postgres"
1001
+ import { Errors as MysqlErrors } from "effect-qb/mysql"
870
1002
  ```
871
1003
 
872
1004
  The catalogs are backed by official vendor references:
@@ -879,7 +1011,7 @@ That means the tags and descriptor metadata are systematic, not handwritten one-
879
1011
  Postgres errors normalize around SQLSTATE codes:
880
1012
 
881
1013
  ```ts
882
- const descriptor = Postgres.Errors.getPostgresErrorDescriptor("23505")
1014
+ const descriptor = PostgresErrors.getPostgresErrorDescriptor("23505")
883
1015
  descriptor.tag
884
1016
  // "@postgres/integrity-constraint-violation/unique-violation"
885
1017
  descriptor.classCode
@@ -887,7 +1019,7 @@ descriptor.className
887
1019
  descriptor.condition
888
1020
  descriptor.primaryFields
889
1021
 
890
- const postgresError = Postgres.Errors.normalizePostgresDriverError({
1022
+ const postgresError = PostgresErrors.normalizePostgresDriverError({
891
1023
  code: "23505",
892
1024
  message: "duplicate key value violates unique constraint",
893
1025
  constraint: "users_email_key"
@@ -901,7 +1033,7 @@ postgresError.constraintName
901
1033
  MySQL errors normalize around official symbols and documented numbers:
902
1034
 
903
1035
  ```ts
904
- const descriptor = Mysql.Errors.getMysqlErrorDescriptor("ER_DUP_ENTRY")
1036
+ const descriptor = MysqlErrors.getMysqlErrorDescriptor("ER_DUP_ENTRY")
905
1037
  descriptor.tag
906
1038
  // "@mysql/server/dup-entry"
907
1039
  descriptor.category
@@ -909,7 +1041,7 @@ descriptor.number
909
1041
  descriptor.sqlState
910
1042
  descriptor.messageTemplate
911
1043
 
912
- const mysqlError = Mysql.Errors.normalizeMysqlDriverError({
1044
+ const mysqlError = MysqlErrors.normalizeMysqlDriverError({
913
1045
  code: "ER_DUP_ENTRY",
914
1046
  errno: 1062,
915
1047
  sqlState: "23000",
@@ -958,7 +1090,7 @@ const descriptors =
958
1090
 
959
1091
  Executors narrow their error channels based on what the plan is allowed to do.
960
1092
 
961
- This happens in the built-in `fromDriver(...)` and `fromSqlClient(...)` paths. They normalize the raw failure first, then decide whether the plan should expose the full dialect error surface or the read-only narrowed surface.
1093
+ This happens in the built-in `Executor.make(...)` and `Executor.driver(...)` paths. They normalize the raw failure first, then decide whether the plan should expose the full dialect error surface or the read-only narrowed surface.
962
1094
 
963
1095
  That matters most for read-only plans. If a raw driver error clearly requires write capabilities, the executor does not surface it directly on a read query. It wraps it in a query-requirements error instead:
964
1096
 
@@ -979,11 +1111,13 @@ If the plan really is write-bearing, including write CTEs, the original normaliz
979
1111
  This is reflected at the type level too:
980
1112
 
981
1113
  ```ts
1114
+ import { Executor as PostgresExecutor } from "effect-qb/postgres"
1115
+
982
1116
  type ReadError =
983
- Postgres.Executor.PostgresQueryError<typeof readPlan>
1117
+ PostgresExecutor.PostgresQueryError<typeof readPlan>
984
1118
 
985
1119
  type WriteError =
986
- Postgres.Executor.PostgresQueryError<typeof writePlan>
1120
+ PostgresExecutor.PostgresQueryError<typeof writePlan>
987
1121
  ```
988
1122
 
989
1123
  For a read-only plan, `ReadError` is the narrowed read-query surface. For a write-bearing plan, `WriteError` is the full normalized Postgres executor error surface. The MySQL executor follows the same rule.
@@ -992,10 +1126,10 @@ You can also inspect requirements directly:
992
1126
 
993
1127
  ```ts
994
1128
  const postgresRequirements =
995
- Postgres.Errors.requirements_of_postgres_error(postgresError)
1129
+ PostgresErrors.requirements_of_postgres_error(postgresError)
996
1130
 
997
1131
  const mysqlRequirements =
998
- Mysql.Errors.requirements_of_mysql_error(mysqlError)
1132
+ MysqlErrors.requirements_of_mysql_error(mysqlError)
999
1133
  ```
1000
1134
 
1001
1135
  ### Matching Errors In Application Code
@@ -1017,15 +1151,15 @@ const rows = executor.execute(plan).pipe(
1017
1151
  Use the dialect guards for precise narrowing inside shared helpers:
1018
1152
 
1019
1153
  ```ts
1020
- if (Postgres.Errors.hasSqlState(error, "23505")) {
1154
+ if (PostgresErrors.hasSqlState(error, "23505")) {
1021
1155
  error.constraintName
1022
1156
  }
1023
1157
 
1024
- if (Mysql.Errors.hasSymbol(error, "ER_DUP_ENTRY")) {
1158
+ if (MysqlErrors.hasSymbol(error, "ER_DUP_ENTRY")) {
1025
1159
  error.number
1026
1160
  }
1027
1161
 
1028
- if (Mysql.Errors.hasNumber(error, "1062")) {
1162
+ if (MysqlErrors.hasNumber(error, "1062")) {
1029
1163
  error.symbol
1030
1164
  }
1031
1165
  ```
@@ -1076,22 +1210,22 @@ The same branded error shape applies when `where(...)`, joins, or projections re
1076
1210
  Predicates refine result types, not just SQL.
1077
1211
 
1078
1212
  ```ts
1079
- const filteredPosts = Q.select({
1213
+ const helloPosts = Q.select({
1080
1214
  title: posts.title,
1081
- upperTitle: Q.upper(posts.title)
1215
+ upperTitle: F.upper(posts.title)
1082
1216
  }).pipe(
1083
1217
  Q.from(posts),
1084
- Q.where(Q.isNotNull(posts.title))
1218
+ Q.where(Q.eq(posts.title, "hello"))
1085
1219
  )
1086
1220
 
1087
- type FilteredPostsRow = Q.ResultRow<typeof filteredPosts>
1221
+ type HelloPostsRow = Q.ResultRow<typeof helloPosts>
1088
1222
  // {
1089
1223
  // title: string
1090
1224
  // upperTitle: string
1091
1225
  // }
1092
1226
  ```
1093
1227
 
1094
- This is one of the biggest differences between `ResultRow` and a hand-written row interface.
1228
+ Equality against a non-null literal narrows too. You do not need `isNotNull(...)` to get non-null output.
1095
1229
 
1096
1230
  ### Join Optionality
1097
1231
 
@@ -1111,9 +1245,24 @@ type MaybePostsRow = Q.ResultRow<typeof maybePosts>
1111
1245
  // userId: string
1112
1246
  // postId: string | null
1113
1247
  // }
1248
+
1249
+ const titledPosts = Q.select({
1250
+ userId: users.id,
1251
+ postId: posts.id
1252
+ }).pipe(
1253
+ Q.from(users),
1254
+ Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
1255
+ Q.where(Q.isNotNull(posts.title))
1256
+ )
1257
+
1258
+ type TitledPostsRow = Q.ResultRow<typeof titledPosts>
1259
+ // {
1260
+ // userId: string
1261
+ // postId: string
1262
+ // }
1114
1263
  ```
1115
1264
 
1116
- Add `where(Q.isNotNull(posts.id))` and the logical row type becomes non-null for `postId`.
1265
+ Any non-null proof on the joined table can promote the whole joined source, not just the join key.
1117
1266
 
1118
1267
  ### Grouped Query Validation
1119
1268
 
@@ -1123,7 +1272,7 @@ Grouped queries are checked structurally:
1123
1272
  const invalidGroupedPlan = Q.select({
1124
1273
  userId: users.id,
1125
1274
  title: posts.title,
1126
- postCount: Q.count(posts.id)
1275
+ postCount: F.count(posts.id)
1127
1276
  }).pipe(
1128
1277
  Q.from(users),
1129
1278
  Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
@@ -1145,29 +1294,24 @@ This catches invalid grouped queries before rendering.
1145
1294
  Plans, tables, renderers, and executors are dialect-branded.
1146
1295
 
1147
1296
  ```ts
1148
- import * as Mysql from "effect-qb/mysql"
1149
- import * as Postgres from "effect-qb/postgres"
1297
+ import { Column as MysqlColumn, Query as MysqlQuery, Table as MysqlTable } from "effect-qb/mysql"
1298
+ import { Executor as PostgresExecutor } from "effect-qb/postgres"
1150
1299
 
1151
- const mysqlUsers = Mysql.Table.make("users", {
1152
- id: Mysql.Column.uuid().pipe(Mysql.Column.primaryKey)
1300
+ const mysqlUsers = MysqlTable.make("users", {
1301
+ id: MysqlColumn.uuid().pipe(MysqlColumn.primaryKey)
1153
1302
  })
1154
1303
 
1155
- const mysqlPlan = Mysql.Query.select({
1304
+ const mysqlPlan = MysqlQuery.select({
1156
1305
  id: mysqlUsers.id
1157
1306
  }).pipe(
1158
- Mysql.Query.from(mysqlUsers)
1307
+ MysqlQuery.from(mysqlUsers)
1159
1308
  )
1160
1309
 
1161
- type WrongDialect =
1162
- Postgres.Query.DialectCompatiblePlan<typeof mysqlPlan, "postgres">
1163
- // {
1164
- // __effect_qb_error__:
1165
- // "effect-qb: plan dialect is not compatible with the target renderer or executor"
1166
- // __effect_qb_plan_dialect__: "mysql"
1167
- // __effect_qb_target_dialect__: "postgres"
1168
- // __effect_qb_hint__:
1169
- // "Use the matching dialect module or renderer/executor"
1170
- // }
1310
+ const postgresExecutor = PostgresExecutor.make()
1311
+
1312
+ // @ts-expect-error mysql plans are not dialect-compatible with the postgres executor
1313
+ postgresExecutor.execute(mysqlPlan)
1314
+ // effect-qb: plan dialect is not compatible with the target renderer or executor
1171
1315
  ```
1172
1316
 
1173
1317
  ### JSON Schema Compatibility In Mutations
@@ -1176,6 +1320,7 @@ Schema-backed JSON columns are checked on insert and update.
1176
1320
 
1177
1321
  ```ts
1178
1322
  import * as Schema from "effect/Schema"
1323
+ import { Column as C, Function as F, Query as Q, Table } from "effect-qb/postgres"
1179
1324
 
1180
1325
  const docs = Table.make("docs", {
1181
1326
  id: C.uuid().pipe(C.primaryKey),
@@ -1191,13 +1336,13 @@ const docs = Table.make("docs", {
1191
1336
  }))
1192
1337
  })
1193
1338
 
1194
- const cityPath = Q.json.path(
1195
- Q.json.key("profile"),
1196
- Q.json.key("address"),
1197
- Q.json.key("city")
1339
+ const cityPath = F.json.path(
1340
+ F.json.key("profile"),
1341
+ F.json.key("address"),
1342
+ F.json.key("city")
1198
1343
  )
1199
1344
 
1200
- const incompatibleObject = Q.json.buildObject({
1345
+ const incompatibleObject = F.json.buildObject({
1201
1346
  profile: {
1202
1347
  address: {
1203
1348
  postcode: "1000"
@@ -1207,7 +1352,7 @@ const incompatibleObject = Q.json.buildObject({
1207
1352
  note: null
1208
1353
  })
1209
1354
 
1210
- const deletedRequiredField = Q.json.delete(docs.payload, cityPath)
1355
+ const deletedRequiredField = F.json.delete(docs.payload, cityPath)
1211
1356
 
1212
1357
  Q.insert(docs, {
1213
1358
  id: "doc-1",
@@ -1243,7 +1388,7 @@ That makes invalid plans easier to inspect in editor tooltips and type aliases.
1243
1388
 
1244
1389
  ### PostgreSQL
1245
1390
 
1246
- - default root entrypoint
1391
+ - `effect-qb/postgres`
1247
1392
  - `distinctOn(...)`
1248
1393
  - wider JSON operator surface, including `json.pathMatch(...)`
1249
1394
  - schema-qualified tables default to `public`
@@ -1273,7 +1418,7 @@ Current practical limits:
1273
1418
  - some features are dialect-specific by design
1274
1419
  - JSON support is not identical between Postgres and MySQL
1275
1420
  - admin and DDL workflows are not the focus of this README
1276
- - runtime execution is schema-free, so the database is expected to honor the query contract
1421
+ - runtime schema enforcement follows propagated column and projection schemas, not full query-derived schemas
1277
1422
 
1278
1423
  ## Contributing
1279
1424
 
@@ -1282,13 +1427,16 @@ Useful commands:
1282
1427
  ```bash
1283
1428
  bun test
1284
1429
  bun run test:types
1430
+ bun run test:integration
1431
+ bun run release
1285
1432
  ```
1286
1433
 
1287
1434
  Useful places to start:
1288
1435
 
1289
- - [src/index.ts](./src/index.ts)
1436
+ - [src/postgres.ts](./src/postgres.ts)
1290
1437
  - [src/internal/query-factory.ts](./src/internal/query-factory.ts)
1291
- - [test/query.behavior.test.ts](./test/query.behavior.test.ts)
1292
- - [test/types/query-composition-types.ts](./test/types/query-composition-types.ts)
1438
+ - [src/postgres/private/query.ts](./src/postgres/private/query.ts)
1439
+ - [test/public/behavior/query.behavior.test.ts](./test/public/behavior/query.behavior.test.ts)
1440
+ - [test/public/types/query-composition-types.ts](./test/public/types/query-composition-types.ts)
1293
1441
 
1294
1442
  The codebase is organized around typed plans, dialect-specialized entrypoints, and behavior-first tests.