@avelonjs/postgres 0.1.0 → 0.3.1

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
@@ -40,20 +40,49 @@ const result = await db.execute<{ id: string; title: string }>(latest)
40
40
 
41
41
  ## Capabilities
42
42
 
43
- | Capability | Value | Notes |
44
- |---|---|---|
45
- | `transactions` | `true` | Interactive transactions through `db.transaction()` |
46
- | `rowSecurity` | `false` | Ward-to-RLS compilation belongs to `@avelonjs/supabase` |
47
- | `maxRelationDepth` | `8` | Application-side nested loads; measured against nested assay fixtures |
48
- | `fullTextSearch` | `false` | No portable `search()` surface in v1 |
49
- | `upsert` | `true` | Requires explicit conflict target and update list |
50
- | `returning` | `true` | Write queries may project rows |
51
- | `windowFunctions` | `true` | Informational; available through `raw()` |
52
- | `jsonOperators` | `true` | Informational; available through `raw()` |
43
+ | Capability | Value | Notes |
44
+ | ------------------ | ------- | --------------------------------------------------------------------- |
45
+ | `transactions` | `true` | Interactive transactions through `db.transaction()` |
46
+ | `rowSecurity` | `false` | Ward-to-RLS compilation belongs to `@avelonjs/supabase` |
47
+ | `maxRelationDepth` | `8` | Application-side nested loads; measured against nested assay fixtures |
48
+ | `fullTextSearch` | `false` | No portable `search()` surface in v1 |
49
+ | `upsert` | `true` | Requires explicit conflict target and update list |
50
+ | `returning` | `true` | Write queries may project rows |
51
+ | `windowFunctions` | `true` | Informational; available through `raw()` |
52
+ | `jsonOperators` | `true` | Informational; available through `raw()` |
53
+
54
+ ## Node Without Bun
55
+
56
+ The query IR algorithm, migrations, schema checks, and error mapping live behind the `@avelonjs/postgres/sql` subpath, whose import graph contains no `bun` specifier. The package root stays on Bun's SQL client. A driver on another runtime supplies its own client through `SqlRunner` and `SqlBatchRunner`; `@avelonjs/neon` uses this to run on Node.
57
+
58
+ ```ts
59
+ import { executeQueryIR, loadSchemaCache, type SqlRunner } from '@avelonjs/postgres/sql'
60
+ import type { QueryIR } from '@avelonjs/core'
61
+
62
+ declare const vendor: {
63
+ query(
64
+ text: string,
65
+ params: unknown[],
66
+ ): Promise<{ rows: Record<string, unknown>[]; rowCount: number }>
67
+ }
68
+
69
+ const runner: SqlRunner = {
70
+ unsafe: async (text, parameters) => {
71
+ const result = await vendor.query(text, parameters === undefined ? [] : [...parameters])
72
+ return { rows: result.rows, count: result.rowCount }
73
+ },
74
+ }
75
+
76
+ declare const query: QueryIR
77
+ const schema = await loadSchemaCache(runner)
78
+ const result = await executeQueryIR(runner, schema, query, 8, () => undefined)
79
+ ```
80
+
81
+ `SqlBatchRunner` adds `batch()`, which applies a fully known statement list atomically. Migrations use it so every statement of one migration, plus its history row, reaches the database in a single transaction.
53
82
 
54
83
  ## Query Compilation
55
84
 
56
- The driver validates IR shape, rejects unknown public-schema identifiers, normalizes predicates, and compiles positional SQL. Empty AND is true, empty OR is false, empty IN matches nothing unless negated, and a constant-false ward or where short-circuits without a database round trip.
85
+ The driver validates IR shape, rejects unknown public-schema identifiers, normalizes predicates, and compiles positional SQL. Empty AND is true, empty OR is false, empty IN matches nothing unless negated, and a constant-false ward or where short-circuits without a database round trip. On insert and upsert a ward the values row does not satisfy raises `Invalid` instead of returning an empty result.
57
86
 
58
87
  ```ts
59
88
  import { compilePostgres, combinedPredicate } from '@avelonjs/postgres'
@@ -104,9 +133,7 @@ const db = createPostgresDatabase({
104
133
  migrations: [
105
134
  {
106
135
  id: '20260804_create_posts',
107
- up: [
108
- 'CREATE TABLE posts (id text PRIMARY KEY, title text NOT NULL)',
109
- ],
136
+ up: ['CREATE TABLE posts (id text PRIMARY KEY, title text NOT NULL)'],
110
137
  down: ['DROP TABLE posts'],
111
138
  },
112
139
  ],
@@ -121,13 +148,13 @@ await db.status()
121
148
 
122
149
  Vendor SQLSTATE values never leave the driver. Unique violations become `Conflict`, unknown tables/columns/routines become `Invalid`, and connection failures become `Unavailable`. Every other code becomes `DriverFault`.
123
150
 
124
- | SQLSTATE | Framework error | Meaning |
125
- |---|---|---|
126
- | `23505` | `Conflict` | unique_violation |
127
- | `42P01` | `Invalid` | undefined_table |
128
- | `42703` | `Invalid` | undefined_column |
129
- | `42883` | `Invalid` | undefined_function |
130
- | `08006` | `Unavailable` | connection_failure |
151
+ | SQLSTATE | Framework error | Meaning |
152
+ | -------- | --------------- | ------------------ |
153
+ | `23505` | `Conflict` | unique_violation |
154
+ | `42P01` | `Invalid` | undefined_table |
155
+ | `42703` | `Invalid` | undefined_column |
156
+ | `42883` | `Invalid` | undefined_function |
157
+ | `08006` | `Unavailable` | connection_failure |
131
158
 
132
159
  ## Live Conformance
133
160
 
@@ -141,40 +168,47 @@ bun test
141
168
 
142
169
  ## Method Reference
143
170
 
144
- | Method | Signature | Description |
145
- |---|---|---|
146
- | `createPostgresDatabase` | `(options?: PostgresDatabaseOptions) => PostgresDatabase` | Constructs a driver from options or `POSTGRES_URL` / `DATABASE_URL`. |
147
- | `postgresDatabaseCapabilities` | `{ transactions: true, rowSecurity: false, maxRelationDepth: 8, fullTextSearch: false, upsert: true, returning: true, windowFunctions: true, jsonOperators: true }` | Exact capability declaration for the Postgres driver. |
148
- | `PostgresDatabaseOptions` | `interface` | Connection URL, instance name, and optional migrations. |
149
- | `PostgresDatabase.execute` | `(query: QueryIR) => Promise<QueryResult>` | Validates, compiles, and executes one query IR operation. |
150
- | `PostgresDatabase.rpc` | `(routine: string, args: Readonly<Record<string, unknown>>) => Promise<T>` | Invokes a Postgres routine; missing routines raise `Invalid`. |
151
- | `PostgresDatabase.transaction` | `(callback: (tx: DatabaseTransaction) => Promise<T>) => Promise<T>` | Runs the callback atomically and returns its result. |
152
- | `PostgresDatabase.plan` | `() => Promise<MigrationPlan>` | Returns pending migration identifiers and SQL steps. |
153
- | `PostgresDatabase.apply` | `() => Promise<readonly MigrationStatus[]>` | Applies pending migrations inside transactions. |
154
- | `PostgresDatabase.rollback` | `(steps?: number) => Promise<readonly MigrationStatus[]>` | Rolls back the newest applied migration batches. |
155
- | `PostgresDatabase.status` | `() => Promise<readonly MigrationStatus[]>` | Lists applied and pending migration states. |
156
- | `PostgresDatabase.raw` | `() => SQL` | Returns the Bun SQL client at the vendor boundary. |
157
- | `PostgresDatabase.resetFixtures` | `() => Promise<void>` | Recreates assay tables and `assay_echo` for live conformance. |
158
- | `PostgresDatabase.close` | `() => Promise<void>` | Closes the underlying SQL client pool. |
159
- | `compilePostgres` | `(ir: QueryIR, predicate?: Predicate) => CompiledSql` | Compiles IR to parameterized SQL without executing it. |
160
- | `compileSqlPredicate` | `(predicate: Predicate, bind: (value: unknown) => string) => string` | Compiles a normalized predicate to a SQL boolean expression. |
161
- | `CompiledSql` | `interface` | Parameterized `text` plus positional `parameters`. |
162
- | `normalizePredicate` | `(predicate: Predicate) => Predicate` | Applies empty-list and constant identities. |
163
- | `combinedPredicate` | `(ir: Pick<QueryIR, 'where' \| 'ward'>) => Predicate` | ANDs where and ward, then normalizes. |
164
- | `mapPostgresError` | `(error: unknown, operation: string) => never` | Maps vendor failures into framework errors. |
165
- | `POSTGRES_ERROR_MAP` | `readonly { sqlstate, framework, meaning }[]` | SQLSTATE values this driver maps into the taxonomy. |
166
- | `validateQueryIR` | `(ir: QueryIR, maxRelationDepth: number) => void` | Rejects malformed IR before compilation. |
167
- | `assertIdentifier` | `(value: unknown, path: string) => asserts value is string` | Rejects identifiers that are not simple SQL names. |
168
- | `assertQueryAgainstSchema` | `(cache: SchemaCache, query: QueryIR) => void` | Rejects unknown public-schema tables and columns. |
169
- | `loadSchemaCache` | `(sql: SQL) => Promise<SchemaCache>` | Loads public base tables and their columns. |
170
- | `SchemaCache` | `type` | `Map` of table name to column set. |
171
- | `planMigrations` | `(sql: SQL, migrations: readonly PostgresMigration[]) => Promise<MigrationPlan>` | Builds a pending plan from registered migrations and history. |
172
- | `applyMigrations` | `(sql: SQL, migrations: readonly PostgresMigration[]) => Promise<readonly MigrationStatus[]>` | Applies pending migrations inside transactions. |
173
- | `rollbackMigrations` | `(sql: SQL, migrations: readonly PostgresMigration[], steps?: number) => Promise<readonly MigrationStatus[]>` | Rolls back the newest applied migration batches. |
174
- | `statusMigrations` | `(sql: SQL, migrations: readonly PostgresMigration[]) => Promise<readonly MigrationStatus[]>` | Returns applied/pending status for every registered migration. |
175
- | `PostgresMigration` | `interface` | Driver-owned `id`, `up`, and `down` SQL pair. |
176
- | `resetAssayFixtures` | `(sql: SQL) => Promise<void>` | Provisions empty assay fixtures on a SQL client. |
177
- | `ASSAY_FIXTURE_SQL` | `string` | SQL that drops and recreates the database conformance fixtures. |
171
+ | Method | Signature | Description |
172
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
173
+ | `createPostgresDatabase` | `(options?: PostgresDatabaseOptions) => PostgresDatabase` | Constructs a driver from options or `POSTGRES_URL` / `DATABASE_URL`. |
174
+ | `postgresDatabaseCapabilities` | `{ transactions: true, rowSecurity: false, maxRelationDepth: 8, fullTextSearch: false, upsert: true, returning: true, windowFunctions: true, jsonOperators: true }` | Exact capability declaration for the Postgres driver. |
175
+ | `PostgresDatabaseOptions` | `interface` | Connection URL, instance name, and optional migrations. |
176
+ | `PostgresDatabase.execute` | `(query: QueryIR) => Promise<QueryResult>` | Validates, compiles, and executes one query IR operation. |
177
+ | `PostgresDatabase.rpc` | `(routine: string, args: Readonly<Record<string, unknown>>) => Promise<T>` | Invokes a Postgres routine; missing routines raise `Invalid`. |
178
+ | `PostgresDatabase.transaction` | `(callback: (tx: DatabaseTransaction) => Promise<T>) => Promise<T>` | Runs the callback atomically and returns its result. |
179
+ | `PostgresDatabase.plan` | `() => Promise<MigrationPlan>` | Returns pending migration identifiers and SQL steps. |
180
+ | `PostgresDatabase.apply` | `() => Promise<readonly MigrationStatus[]>` | Applies pending migrations inside transactions. |
181
+ | `PostgresDatabase.rollback` | `(steps?: number) => Promise<readonly MigrationStatus[]>` | Rolls back the newest applied migration batches. |
182
+ | `PostgresDatabase.status` | `() => Promise<readonly MigrationStatus[]>` | Lists applied and pending migration states. |
183
+ | `PostgresDatabase.raw` | `() => SQL` | Returns the Bun SQL client at the vendor boundary. |
184
+ | `PostgresDatabase.resetFixtures` | `() => Promise<void>` | Recreates assay tables and `assay_echo` for live conformance. |
185
+ | `PostgresDatabase.close` | `() => Promise<void>` | Closes the underlying SQL client pool. |
186
+ | `compilePostgres` | `(ir: QueryIR, predicate?: Predicate) => CompiledSql` | Compiles IR to parameterized SQL without executing it. |
187
+ | `compileSqlPredicate` | `(predicate: Predicate, bind: (value: unknown) => string) => string` | Compiles a normalized predicate to a SQL boolean expression. |
188
+ | `CompiledSql` | `interface` | Parameterized `text` plus positional `parameters`. |
189
+ | `normalizePredicate` | `(predicate: Predicate) => Predicate` | Applies empty-list and constant identities. |
190
+ | `combinedPredicate` | `(ir: Pick<QueryIR, 'where' \| 'ward'>) => Predicate` | ANDs where and ward, then normalizes. |
191
+ | `mapPostgresError` | `(error: unknown, operation: string) => never` | Maps vendor failures into framework errors. |
192
+ | `POSTGRES_ERROR_MAP` | `readonly { sqlstate, framework, meaning }[]` | SQLSTATE values this driver maps into the taxonomy. |
193
+ | `validateQueryIR` | `(ir: QueryIR, maxRelationDepth: number) => void` | Rejects malformed IR before compilation. |
194
+ | `assertIdentifier` | `(value: unknown, path: string) => asserts value is string` | Rejects identifiers that are not simple SQL names. |
195
+ | `assertQueryAgainstSchema` | `(cache: SchemaCache, query: QueryIR) => void` | Rejects unknown public-schema tables and columns. |
196
+ | `loadSchemaCache` | `(sql: SQL) => Promise<SchemaCache>` | Loads public base tables and their columns. |
197
+ | `executeQueryIR` | `(runner: SqlRunner, schema: SchemaCache, ir: QueryIR, maxRelationDepth: number, onRoundTrip: RoundTripCounter) => Promise<QueryResult>` | Runs one query IR operation, relation loads included, against any client. |
198
+ | `executeRpc` | `(runner: SqlRunner, routine: string, args: Readonly<Record<string, unknown>>, onRoundTrip: RoundTripCounter) => Promise<unknown>` | Invokes a routine with a single jsonb argument against any client. |
199
+ | `RoundTripCounter` | `() => void` | Called once per statement so a driver can keep its round-trip counter. |
200
+ | `SqlRunner` | `interface` | `unsafe(text, parameters?)` returning normalized rows; the seam every client implements. |
201
+ | `SqlBatchRunner` | `interface` | A `SqlRunner` that also applies a known statement list atomically through `batch()`. |
202
+ | `SqlRows` | `interface` | Normalized `rows` plus the affected-row `count`. |
203
+ | `SchemaCache` | `type` | `Map` of table name to column set. |
204
+ | `planMigrations` | `(sql: SQL, migrations: readonly PostgresMigration[]) => Promise<MigrationPlan>` | Builds a pending plan from registered migrations and history. |
205
+ | `applyMigrations` | `(sql: SQL, migrations: readonly PostgresMigration[]) => Promise<readonly MigrationStatus[]>` | Applies pending migrations inside transactions. |
206
+ | `rollbackMigrations` | `(sql: SQL, migrations: readonly PostgresMigration[], steps?: number) => Promise<readonly MigrationStatus[]>` | Rolls back the newest applied migration batches. |
207
+ | `statusMigrations` | `(sql: SQL, migrations: readonly PostgresMigration[]) => Promise<readonly MigrationStatus[]>` | Returns applied/pending status for every registered migration. |
208
+ | `PostgresMigration` | `interface` | Driver-owned `id`, `up`, and `down` SQL pair. |
209
+ | `resetAssayFixtures` | `(sql: SQL) => Promise<void>` | Provisions empty assay fixtures on a SQL client. |
210
+ | `ASSAY_FIXTURE_SQL` | `string` | SQL that drops and recreates the database conformance fixtures. |
211
+ | `ASSAY_FIXTURE_STATEMENTS` | `readonly string[]` | The same fixtures as one statement per entry, for clients that reject multi-statement text. |
178
212
 
179
213
  ## Testing
180
214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avelonjs/postgres",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "Postgres database driver that compiles Avelon QueryIR to SQL.",
6
6
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  "LICENSE"
31
31
  ],
32
32
  "exports": {
33
- ".": "./src/index.ts"
33
+ ".": "./src/index.ts",
34
+ "./sql": "./src/sql.ts"
34
35
  },
35
36
  "scripts": {
36
37
  "test": "bun test",
@@ -0,0 +1,32 @@
1
+ import type { SQL } from 'bun'
2
+ import type { SqlBatchRunner, SqlRows } from './sql-client'
3
+
4
+ function toSqlRows(result: unknown): SqlRows {
5
+ if (!Array.isArray(result)) return { rows: [], count: 0 }
6
+ const count = Reflect.get(result, 'count')
7
+ return {
8
+ rows: result.map((row) => ({ ...(row as Record<string, unknown>) })),
9
+ count: typeof count === 'number' ? count : result.length,
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Adapts Bun's SQL client to the vendor-neutral runner.
15
+ *
16
+ * Written as an explicit object because Bun's `begin` resolves to `SQL.ContextCallbackResult<T>`
17
+ * and `unsafe` returns `SQL.Query<T>`, neither of which matches the runner interface structurally.
18
+ */
19
+ export function bunSqlRunner(sql: SQL): SqlBatchRunner {
20
+ return {
21
+ unsafe: async (text, parameters) =>
22
+ toSqlRows(await sql.unsafe(text, parameters === undefined ? undefined : [...parameters])),
23
+ batch: async (statements) => {
24
+ await sql.begin(async (tx) => {
25
+ for (const statement of statements) await tx.unsafe(statement.text, statement.parameters)
26
+ })
27
+ },
28
+ close: async () => {
29
+ await sql.close()
30
+ },
31
+ }
32
+ }
package/src/compile.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { Invalid, type CompareOp, type OrderTerm, type Predicate, type QueryIR } from '@avelonjs/core'
1
+ import {
2
+ Invalid,
3
+ evaluatePredicate,
4
+ rowAllowedByWard,
5
+ type CompareOp,
6
+ type OrderTerm,
7
+ type Predicate,
8
+ type QueryIR,
9
+ } from '@avelonjs/core'
2
10
  import { combinedPredicate } from './normalize'
3
11
  import { assertIdentifier } from './validate'
4
12
 
@@ -46,16 +54,55 @@ function writeRows(ir: QueryIR): { rows: Row[]; columns: string[] } {
46
54
  return { rows, columns }
47
55
  }
48
56
 
49
- function ensureUnscopedWritePredicate(predicate: Predicate, mode: 'insert' | 'upsert'): void {
50
- if (predicate.kind !== 'const' || !predicate.value) {
57
+ /** A `where` on an insert or upsert has no row to scope, so only a constant true is documented. */
58
+ function ensureUnscopedWriteWhere(ir: QueryIR, mode: 'insert' | 'upsert'): void {
59
+ const where = combinedPredicate({ where: ir.where })
60
+ if (where.kind !== 'const' || !where.value) {
51
61
  invalid(
52
- `${mode} with a non-constant where or ward has no documented row scope.`,
62
+ `${mode} with a non-constant where has no documented row scope.`,
53
63
  'where',
54
64
  `${mode} predicates are not documented`,
55
65
  )
56
66
  }
57
67
  }
58
68
 
69
+ function refuseWardedRow(table: string): never {
70
+ invalid(
71
+ `The ward on ${table} refuses this row.`,
72
+ 'ward',
73
+ `The ward on ${table} refuses this row.`,
74
+ )
75
+ }
76
+
77
+ /**
78
+ * Refuses an insert or upsert whose row the ward would hide.
79
+ *
80
+ * The values carry the whole row, so this is the same check `MemoryDatabase` runs and the same
81
+ * answer a Postgres `WITH CHECK` policy would give. A ward column the values omit reads as unknown
82
+ * and is refused, matching row security treating unknown as not visible.
83
+ */
84
+ function ensureWardAdmitsWrite(ir: QueryIR): void {
85
+ const ward = ir.ward
86
+ if (ward === undefined) return
87
+ for (const row of writeRows(ir).rows) {
88
+ if (!rowAllowedByWard(ward, row)) refuseWardedRow(ir.table)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Refuses an update that hands the row to another ward scope.
94
+ *
95
+ * Only the assigned columns are known here, so the ward is refused when those columns alone make
96
+ * it false. Columns the update leaves alone read as unknown and keep the row eligible; the ward in
97
+ * the `WHERE` clause still decides which rows the statement reaches.
98
+ */
99
+ function ensureWardAdmitsUpdate(ir: QueryIR): void {
100
+ const ward = ir.ward
101
+ if (ward === undefined) return
102
+ const row = writeRows(ir).rows[0] as Row
103
+ if (evaluatePredicate(ward, row) === false) refuseWardedRow(ir.table)
104
+ }
105
+
59
106
  /** Compiles a normalized predicate into a SQL boolean expression. */
60
107
  export function compileSqlPredicate(
61
108
  predicate: Predicate,
@@ -103,7 +150,10 @@ export function compilePostgres(ir: QueryIR, predicate = combinedPredicate(ir)):
103
150
  parameters.push(value)
104
151
  return `$${parameters.length}`
105
152
  }
106
- const where = compileSqlPredicate(predicate, bind)
153
+ // Insert and upsert carry no row scope, so their ward is checked against the values instead of
154
+ // compiled into SQL. Binding it here would number the value placeholders past the ward's.
155
+ const rowScoped = ir.mode !== 'insert' && ir.mode !== 'upsert'
156
+ const where = rowScoped ? compileSqlPredicate(predicate, bind) : 'TRUE'
107
157
  const whereClause = where === 'TRUE' ? '' : ` WHERE ${where}`
108
158
  const order = compileSqlOrder(ir.order)
109
159
  const limit = ir.limit === undefined ? '' : ` LIMIT ${bind(ir.limit)}`
@@ -121,7 +171,8 @@ export function compilePostgres(ir: QueryIR, predicate = combinedPredicate(ir)):
121
171
  parameters,
122
172
  }
123
173
  case 'insert': {
124
- ensureUnscopedWritePredicate(predicate, 'insert')
174
+ ensureUnscopedWriteWhere(ir, 'insert')
175
+ ensureWardAdmitsWrite(ir)
125
176
  const { rows, columns } = writeRows(ir)
126
177
  const values = rows
127
178
  .map((row) => `(${columns.map((column) => bind(row[column])).join(', ')})`)
@@ -132,6 +183,7 @@ export function compilePostgres(ir: QueryIR, predicate = combinedPredicate(ir)):
132
183
  }
133
184
  }
134
185
  case 'update': {
186
+ ensureWardAdmitsUpdate(ir)
135
187
  const row = writeRows(ir).rows[0] as Row
136
188
  const assignments = Object.keys(row).map(
137
189
  (column) => `${quoteIdentifier(column)} = ${bind(row[column])}`,
@@ -147,9 +199,11 @@ export function compilePostgres(ir: QueryIR, predicate = combinedPredicate(ir)):
147
199
  parameters,
148
200
  }
149
201
  case 'upsert': {
150
- ensureUnscopedWritePredicate(predicate, 'upsert')
202
+ ensureUnscopedWriteWhere(ir, 'upsert')
203
+ ensureWardAdmitsWrite(ir)
151
204
  const conflict = ir.conflict
152
- if (conflict === undefined) invalid('upsert requires conflict.', 'conflict', 'Required for upsert')
205
+ if (conflict === undefined)
206
+ invalid('upsert requires conflict.', 'conflict', 'Required for upsert')
153
207
  const { rows, columns } = writeRows(ir)
154
208
  const values = rows
155
209
  .map((row) => `(${columns.map((column) => bind(row[column])).join(', ')})`)