@ultimat3/db 20.2.1 → 22.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/CLAUDE.md +238 -1474
- package/README.md +9 -0
- package/package.json +2 -2
- package/src/array-parameter.ts +0 -16
- package/src/bound-parameters.ts +23 -0
- package/src/branch.ts +5 -2
- package/src/bun-sql.ts +21 -0
- package/src/client.ts +8 -2
- package/src/column-alter.ts +66 -0
- package/src/ddl-errors.ts +13 -0
- package/src/generate.ts +5 -0
- package/src/generated-column.ts +30 -8
- package/src/migrate.ts +2 -1
- package/src/pg-instant.ts +54 -0
- package/src/pglite.ts +21 -6
- package/src/replica-client.ts +28 -0
- package/src/statement-funnel.ts +6 -6
- package/src/statement-split.ts +31 -1
- package/src/transaction.ts +88 -14
package/README.md
CHANGED
|
@@ -440,3 +440,12 @@ x db branch drop feature_x
|
|
|
440
440
|
half is the `drift` step of `x verify`, which hashes entity source against what `x db gen` recorded
|
|
441
441
|
and opens no database. Two questions, two owners, and no `drift` subcommand under `x db` — the
|
|
442
442
|
`DB_SUBCOMMANDS` set is `gen`, `migrate`, `reset`, `studio`, `branch`, `backfill`.
|
|
443
|
+
|
|
444
|
+
### Error classes
|
|
445
|
+
|
|
446
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
447
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
448
|
+
|
|
449
|
+
| Class | Code | Declared in |
|
|
450
|
+
|---|---|---|
|
|
451
|
+
| `DbError` | any `DbErrorCode` — `DB_ERROR_CODES`, including the SQLSTATE codes a driver error maps to (`DB_SQLSTATE_CODES`) | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "22.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/array-parameter.ts
CHANGED
|
@@ -73,19 +73,3 @@ export function pgArrayLiteral(values: readonly unknown[]): string {
|
|
|
73
73
|
);
|
|
74
74
|
return `{${values.map((value) => (Array.isArray(value) ? pgArrayLiteral(value) : element(value))).join(',')}}`;
|
|
75
75
|
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Every parameter of one statement, arrays rendered and everything else passed through untouched.
|
|
79
|
-
*
|
|
80
|
-
* A NEW ARRAY ONLY WHEN SOMETHING CHANGED. Every statement the framework runs goes through this
|
|
81
|
-
* function, and almost none of them binds an array — so the common path is one `some` over a short
|
|
82
|
-
* list and the caller's own array object, byte for byte, which is what `sendOn` had before this
|
|
83
|
-
* existed (axiom 6).
|
|
84
|
-
*
|
|
85
|
-
* A `Uint8Array` is BYTEA and is deliberately not an array here: `Array.isArray` answers `false`
|
|
86
|
-
* for a typed array, which is the behaviour this relies on rather than a special case it writes.
|
|
87
|
-
*/
|
|
88
|
-
export function encodeArrayParameters(values: readonly unknown[]): readonly unknown[] {
|
|
89
|
-
if (!values.some(Array.isArray)) return values;
|
|
90
|
-
return values.map((value) => (Array.isArray(value) ? pgArrayLiteral(value) : value));
|
|
91
|
-
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Single responsibility: every value of one statement, encoded into what `Bun.SQL` sends correctly.
|
|
2
|
+
// Two kinds need it, both measured against Postgres 17: a JS array (`array-parameter.ts`, #384),
|
|
3
|
+
// and a `Date` once the pool runs unnamed statements (`prepare: false`, `bun-sql.ts`) — with no
|
|
4
|
+
// described type to go by the driver sent `Date.prototype.toString()`, a local-zone string
|
|
5
|
+
// Postgres refuses (`22007`), so every entity write carrying a timestamp failed.
|
|
6
|
+
|
|
7
|
+
import { pgArrayLiteral } from './array-parameter';
|
|
8
|
+
|
|
9
|
+
const needsEncoding = (value: unknown): boolean => Array.isArray(value) || value instanceof Date;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A NEW ARRAY ONLY WHEN SOMETHING CHANGED — every statement in the process passes through here, so
|
|
13
|
+
* the common path is one `some` and the caller's own array, byte for byte (axiom 6). A
|
|
14
|
+
* `Uint8Array` is BYTEA, never an array: `Array.isArray` answers `false` for a typed array.
|
|
15
|
+
*/
|
|
16
|
+
export function encodeBoundParameters(values: readonly unknown[]): readonly unknown[] {
|
|
17
|
+
if (!values.some(needsEncoding)) return values;
|
|
18
|
+
return values.map((value) => {
|
|
19
|
+
if (Array.isArray(value)) return pgArrayLiteral(value);
|
|
20
|
+
if (value instanceof Date) return value.toISOString();
|
|
21
|
+
return value;
|
|
22
|
+
});
|
|
23
|
+
}
|
package/src/branch.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// it is unsure about, a data backfill, a DROP — never happens against the shared database.
|
|
4
4
|
// Branches are cheap and forgettable, so `reapBranches()` is part of the design, not an add-on.
|
|
5
5
|
|
|
6
|
-
import { systemClock } from '@ultimat3/core';
|
|
6
|
+
import { finiteCount, systemClock } from '@ultimat3/core';
|
|
7
7
|
import { baseClient, type DbClient } from './client';
|
|
8
8
|
import { branchExists, branchNameInvalid, DbError } from './errors';
|
|
9
9
|
import { identifier, literal, sql } from './sql';
|
|
@@ -168,7 +168,10 @@ export interface ReapOptions extends DropBranchOptions {
|
|
|
168
168
|
* no migration — the next `createBranch` writes the base down.
|
|
169
169
|
*/
|
|
170
170
|
export async function reapBranches(options: ReapOptions): Promise<readonly string[]> {
|
|
171
|
-
|
|
171
|
+
// Screened before anything is read: `now - NaN` is NaN and `createdAtMs > NaN` is false — the
|
|
172
|
+
// "old enough" answer — so `maxAgeMs: NaN` dropped every branch of this database.
|
|
173
|
+
const maxAgeMs = finiteCount('reapBranches', 'maxAgeMs', options.maxAgeMs, 0);
|
|
174
|
+
const cutoff = (options.now ?? systemClock.now()).getTime() - maxAgeMs;
|
|
172
175
|
const client = options.client ?? baseClient();
|
|
173
176
|
const here = await currentDatabase(client);
|
|
174
177
|
const branches = await listBranches({ ...options, client });
|
package/src/bun-sql.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { logger, renderThrowable } from '@ultimat3/core';
|
|
7
7
|
import { dbUnavailable } from './errors';
|
|
8
|
+
import type { PoolProfile } from './pool-profile';
|
|
8
9
|
|
|
9
10
|
/** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */
|
|
10
11
|
export interface BunSqlReserved {
|
|
@@ -72,3 +73,23 @@ export function bunSqlFactory(): BunSqlFactory {
|
|
|
72
73
|
}
|
|
73
74
|
return factory as BunSqlFactory;
|
|
74
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What the pool is opened with. `prepare: false` is the load-bearing key: `Bun.SQL` prepares NAMED
|
|
79
|
+
* statements by default and caches them per connection, so after a migration that adds or drops a
|
|
80
|
+
* column every warm `select *` / `returning *` — every entity read and write — answered `0A000
|
|
81
|
+
* cached plan must not change result type` on every running pod until each connection closed.
|
|
82
|
+
* Reproduced on Postgres 17 (`client-prepared.live.test.ts`); PGlite uses unnamed statements, so
|
|
83
|
+
* `x dev` never showed it. Unnamed statements are also what PgBouncer's transaction mode needs.
|
|
84
|
+
*
|
|
85
|
+
* The cost, measured 2026-09-23 against Postgres 17 on loopback, 20,000 sends a side, `max: 4`:
|
|
86
|
+
* a primary-key `select *` went from p50 283-330µs to 370-432µs, an `update … returning *` from
|
|
87
|
+
* 733-744µs to 806-864µs — one extra parse per statement. Paid for correctness across a deploy.
|
|
88
|
+
*/
|
|
89
|
+
export const bunSqlPoolOptions = (
|
|
90
|
+
profile: Pick<PoolProfile, 'max' | 'idleTimeoutMs'>,
|
|
91
|
+
): Readonly<Record<string, unknown>> => ({
|
|
92
|
+
max: profile.max,
|
|
93
|
+
idleTimeout: profile.idleTimeoutMs / 1000,
|
|
94
|
+
prepare: false,
|
|
95
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
// importing this module never opens a socket.
|
|
6
6
|
|
|
7
7
|
import { type Role, resolveRole } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type BunSqlDriver,
|
|
10
|
+
type BunSqlReserved,
|
|
11
|
+
bunSqlFactory,
|
|
12
|
+
bunSqlPoolOptions,
|
|
13
|
+
releaseReserved,
|
|
14
|
+
} from './bun-sql';
|
|
9
15
|
import { connectionUrl } from './connection-url';
|
|
10
16
|
// Deliberate cycle, the same shape as `client.ts ⇄ transaction.ts`: nothing here is referenced at
|
|
11
17
|
// module evaluation, and both sides are `function` declarations, so hoisting covers the TDZ.
|
|
@@ -68,7 +74,7 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
68
74
|
if (driver !== undefined) return driver;
|
|
69
75
|
const url = connectionUrl(options, profile);
|
|
70
76
|
const Factory = bunSqlFactory();
|
|
71
|
-
driver = new Factory(url,
|
|
77
|
+
driver = new Factory(url, bunSqlPoolOptions(profile));
|
|
72
78
|
return driver;
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Single responsibility: the attributes of an EXISTING column that move in place — its default and
|
|
2
|
+
// its nullability. Split from `generate.ts` because `diffTable` emitted SQL for a type change only:
|
|
3
|
+
// a changed or removed `.default()`, or a nullability change, emitted nothing while the snapshot
|
|
4
|
+
// beside it recorded the move, so `x verify` answered `X_DB_SCHEMA_UNMIGRATED` and its own fix,
|
|
5
|
+
// `x db gen`, produced an empty diff. A gate red forever, with the repair being the thing that failed.
|
|
6
|
+
|
|
7
|
+
import { defaultExpression, hasUnrenderedDefault } from './column-default';
|
|
8
|
+
import { columnDefaultUnsafe } from './ddl-errors';
|
|
9
|
+
import type { ColumnDescriptionLike } from './entity-shape';
|
|
10
|
+
import type { Plan } from './foreign-key-plan';
|
|
11
|
+
import type { ColumnDescription } from './introspect';
|
|
12
|
+
import { identifier } from './sql';
|
|
13
|
+
import { statementsOf } from './statement-split';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The recorded default comes out of a `.snapshot.json` anything may edit and is SPLICED into
|
|
17
|
+
* `down`, so it takes the one-expression screen `generatedClause` gives a generated column's.
|
|
18
|
+
* The wanted side is `defaultExpression`'s own rendering (`literal()` for a value), safe by
|
|
19
|
+
* construction, and screened anyway because the check costs one scan and the two sides then
|
|
20
|
+
* cannot drift apart.
|
|
21
|
+
*/
|
|
22
|
+
function screened(column: string, expression: string): string {
|
|
23
|
+
const commands = statementsOf(expression).length;
|
|
24
|
+
if (commands > 1) throw columnDefaultUnsafe(column, commands);
|
|
25
|
+
return expression;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `set default` / `drop default` and `drop not null`, each with its reverse in `down` — the shape
|
|
30
|
+
* `redefineIndex` (`index-ddl.ts`) gives a moved index. Becoming NOT NULL is deliberately NOT a
|
|
31
|
+
* bare `set not null`: rows already holding `NULL` make it fail inside `ROLE=migrate`, so it gets
|
|
32
|
+
* the expand/contract note `diffTable` writes for a NOT NULL column added to a populated table.
|
|
33
|
+
*
|
|
34
|
+
* A default this generator cannot render (`hasUnrenderedDefault`) moves nothing: dropping the one
|
|
35
|
+
* the database holds would lose a rule the entity still states, and `unrenderedOf` already reports
|
|
36
|
+
* it at the top of `up`.
|
|
37
|
+
*/
|
|
38
|
+
export function alterColumnInPlace(
|
|
39
|
+
table: string,
|
|
40
|
+
column: ColumnDescriptionLike,
|
|
41
|
+
recorded: ColumnDescription,
|
|
42
|
+
plan: Plan,
|
|
43
|
+
): void {
|
|
44
|
+
const alter = `alter table ${identifier(table).text} alter column ${identifier(column.column).text}`;
|
|
45
|
+
const wanted = hasUnrenderedDefault(column) ? recorded.default : defaultExpression(column);
|
|
46
|
+
const held = recorded.default;
|
|
47
|
+
if (wanted !== held) {
|
|
48
|
+
const set = (expression: string | null): string =>
|
|
49
|
+
expression === null
|
|
50
|
+
? `${alter} drop default;`
|
|
51
|
+
: `${alter} set default ${screened(column.column, expression)};`;
|
|
52
|
+
plan.up.push(set(wanted));
|
|
53
|
+
plan.down.push(set(held));
|
|
54
|
+
}
|
|
55
|
+
const nullable = !column.notNull;
|
|
56
|
+
if (nullable === recorded.nullable) return;
|
|
57
|
+
if (nullable) {
|
|
58
|
+
plan.up.push(`${alter} drop not null;`);
|
|
59
|
+
plan.down.push(`${alter} set not null;`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// `drop not null` in `down` is a no-op on a column that never became NOT NULL, so the reverse is
|
|
63
|
+
// right whether or not the backfill and its `set not null` were ever run.
|
|
64
|
+
plan.up.push(`-- backfill ${identifier(column.column).text}, then: ${alter} set not null;`);
|
|
65
|
+
plan.down.push(`${alter} drop not null;`);
|
|
66
|
+
}
|
package/src/ddl-errors.ts
CHANGED
|
@@ -57,3 +57,16 @@ export const generatedExpressionUnsafe = (column: string, count: number): DbErro
|
|
|
57
57
|
fix: `give "${column}" a single expression — .searchable() or generated: 'lower("title")' — then x db gen`,
|
|
58
58
|
meta: { column, count },
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A column default holding more than one command, refused before it is spliced into
|
|
63
|
+
* `alter column … set default …`. The recorded side arrives from a `.snapshot.json`; same lexer and
|
|
64
|
+
* same argument as `generatedExpressionUnsafe`.
|
|
65
|
+
*/
|
|
66
|
+
export const columnDefaultUnsafe = (column: string, count: number): DbError =>
|
|
67
|
+
new DbError({
|
|
68
|
+
code: 'X_SQL_UNSAFE',
|
|
69
|
+
cause: `the default of column "${column}" holds ${count} commands; a default is one expression`,
|
|
70
|
+
fix: `restore the migration's .snapshot.json from version control (git checkout -- migrations/), or give "${column}" a single .default(), then x db gen`,
|
|
71
|
+
meta: { column, count },
|
|
72
|
+
});
|
package/src/generate.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { systemClock } from '@ultimat3/core';
|
|
7
7
|
import { checkClauses, checkPlan, declaredChecks } from './check-ddl';
|
|
8
|
+
import { alterColumnInPlace } from './column-alter';
|
|
8
9
|
import { defaultExpression } from './column-default';
|
|
9
10
|
import { isDestructive } from './destructive';
|
|
10
11
|
import { dropOrder } from './drop-order';
|
|
@@ -194,6 +195,10 @@ function diffTable(
|
|
|
194
195
|
if (recorded !== undefined) {
|
|
195
196
|
if (retypeColumn(live, column, recorded, plan, moved, retyped) === 'rebuilt') {
|
|
196
197
|
rebuilt.add(column.column);
|
|
198
|
+
} else {
|
|
199
|
+
// After any retype, so a new default is set against the column's new type. A rebuilt
|
|
200
|
+
// column was re-added carrying its whole clause, so it has nothing left to move.
|
|
201
|
+
alterColumnInPlace(entity.table, column, recorded, plan);
|
|
197
202
|
}
|
|
198
203
|
continue;
|
|
199
204
|
}
|
package/src/generated-column.ts
CHANGED
|
@@ -48,13 +48,21 @@ export function generatedClause(column: ColumnDescriptionLike): string {
|
|
|
48
48
|
`column "${column.column}" is generated by an empty expression`,
|
|
49
49
|
`give "${column.property}" an expression, or drop the generated declaration`,
|
|
50
50
|
);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
return ` generated always as (${oneExpression(column.column, expression)}) stored`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every expression this file splices passes here — the create clause, `set expression` in both
|
|
56
|
+
* directions, and the RECORDED one a `.snapshot.json` carries into `down`. The create path alone
|
|
57
|
+
* had the screen until 2026-09-23, so a CHANGED expression (`'n * 3); drop table users; --'`)
|
|
58
|
+
* landed in the migration whole. Same lexer as `declaredChecks` (`check-ddl.ts`), so a `;` inside
|
|
59
|
+
* a string literal stays data. Measured before the first half of it:
|
|
60
|
+
* `generated always as (upper(title)); drop table users; --) stored`.
|
|
61
|
+
*/
|
|
62
|
+
function oneExpression(column: string, expression: string): string {
|
|
55
63
|
const commands = statementsOf(expression).length;
|
|
56
|
-
if (commands > 1) throw generatedExpressionUnsafe(column
|
|
57
|
-
return
|
|
64
|
+
if (commands > 1) throw generatedExpressionUnsafe(column, commands);
|
|
65
|
+
return expression;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export const isGenerated = (column: ColumnDescriptionLike): boolean =>
|
|
@@ -98,13 +106,27 @@ export function regenerate(
|
|
|
98
106
|
moved: MovedAside,
|
|
99
107
|
): Regeneration {
|
|
100
108
|
const table = live.name;
|
|
101
|
-
const
|
|
102
|
-
|
|
109
|
+
const screen = (expression: string | null | undefined): string | null =>
|
|
110
|
+
expression === null || expression === undefined
|
|
111
|
+
? null
|
|
112
|
+
: oneExpression(column.column, expression);
|
|
113
|
+
const wanted = screen(column.generated);
|
|
114
|
+
const held = screen(recorded.generated);
|
|
103
115
|
if (wanted === null && held === null) return 'unchanged';
|
|
104
116
|
// Generated -> plain: the column keeps every value it computed and simply stops being derived.
|
|
105
117
|
if (wanted === null) {
|
|
106
118
|
plan.up.push(`${alterColumn(table, column.column)} drop expression;`);
|
|
107
119
|
plan.down.push(`${alterColumn(table, column.column)} set expression as (${held ?? ''});`);
|
|
120
|
+
// A plain column at a new type needs the retype a plain column gets, `using` and all — now
|
|
121
|
+
// legal, since the expression is gone. Without it the snapshot recorded the new type over a
|
|
122
|
+
// database holding the old one, and every later generation compared new with new.
|
|
123
|
+
if (recorded.dataType !== wantedType) {
|
|
124
|
+
const retype = (type: string): string =>
|
|
125
|
+
`${alterColumn(table, column.column)} type ${type} using ` +
|
|
126
|
+
`${identifier(column.column).text}::${type};`;
|
|
127
|
+
plan.up.push(retype(wantedType));
|
|
128
|
+
plan.down.push(retype(recorded.dataType));
|
|
129
|
+
}
|
|
108
130
|
return 'altered';
|
|
109
131
|
}
|
|
110
132
|
// Plain -> generated: `set expression` needs a column that already has one, so this is the whole
|
package/src/migrate.ts
CHANGED
|
@@ -474,7 +474,8 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
|
|
|
474
474
|
// Same reason as `auditLedger`'s: `x db status` does not exist. The `down` SQL only
|
|
475
475
|
// exists in the build that shipped it, so the fix is the read that names that build.
|
|
476
476
|
`psql "$DATABASE_URL" -c "select id, app_version from ${LEDGER_TABLE} ` +
|
|
477
|
-
`order by id desc limit 5" # deploy the build that shipped
|
|
477
|
+
`order by id desc limit 5" # deploy the build that shipped the migration ` +
|
|
478
|
+
`whose id is base64 ${Buffer.from(row.id, 'utf8').toString('base64')}, ` +
|
|
478
479
|
'and roll back there — its down SQL exists nowhere else',
|
|
479
480
|
);
|
|
480
481
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Single responsibility: Postgres' TEXT form of `timestamptz` and `timestamp`, read as an instant.
|
|
2
|
+
// PGlite's own parser — and Bun.SQL's on the text protocol — read year `0099` as 1999, and under a
|
|
3
|
+
// session zone that is not UTC answered Invalid Date for an offset carrying seconds (local mean
|
|
4
|
+
// time before a zone's standard time: `-04:56:02`), which `@ultimat3/entity` then refused as
|
|
5
|
+
// `X_INVARIANT_VIOLATED` on a whole-row read. One reader, every field explicit, no `Date.parse`.
|
|
6
|
+
|
|
7
|
+
/** The two instants `new Date` can hold at the ends of its range — what `±infinity` reads as. */
|
|
8
|
+
const FAR_FUTURE_MS = 8.64e15;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `YYYY-MM-DD HH:MM:SS[.frac][±HH[:MM[:SS]]][ BC]` — the ISO DateStyle output, the default and the
|
|
12
|
+
* only one a framework connection runs under. The year is four or more digits, never two.
|
|
13
|
+
*/
|
|
14
|
+
const PG_TIMESTAMP =
|
|
15
|
+
/^(\d{4,})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(?:([+-])(\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?( BC)?$/;
|
|
16
|
+
|
|
17
|
+
const number = (text: string | undefined): number => (text === undefined ? 0 : Number(text));
|
|
18
|
+
|
|
19
|
+
function parse(text: string, zoned: boolean): Date {
|
|
20
|
+
if (text === 'infinity') return new Date(FAR_FUTURE_MS);
|
|
21
|
+
if (text === '-infinity') return new Date(-FAR_FUTURE_MS);
|
|
22
|
+
const match = PG_TIMESTAMP.exec(text);
|
|
23
|
+
// Not a form this reader knows: Invalid Date, exactly what the drivers answered — a caller that
|
|
24
|
+
// validates (every entity column does) refuses it loudly rather than storing a guess.
|
|
25
|
+
if (match === null) return new Date(Number.NaN);
|
|
26
|
+
const [, year, month, day, hour, minute, second, fraction, sign, offH, offM, offS, bc] = match;
|
|
27
|
+
// Astronomical year: 1 BC is 0, 44 BC is -43.
|
|
28
|
+
const astronomical = bc === undefined ? number(year) : 1 - number(year);
|
|
29
|
+
const millis = Math.floor(number((fraction ?? '').padEnd(3, '0').slice(0, 3)));
|
|
30
|
+
const at = new Date(0);
|
|
31
|
+
// `setUTCFullYear`, never `Date.UTC(year, …)`: the latter maps 0-99 onto 1900-1999, which is the
|
|
32
|
+
// defect this file exists for.
|
|
33
|
+
at.setUTCFullYear(astronomical, number(month) - 1, number(day));
|
|
34
|
+
at.setUTCHours(number(hour), number(minute), number(second), millis);
|
|
35
|
+
if (!zoned || sign === undefined) return at;
|
|
36
|
+
const offsetMs = ((number(offH) * 60 + number(offM)) * 60 + number(offS)) * 1_000;
|
|
37
|
+
return new Date(at.getTime() - (sign === '-' ? -offsetMs : offsetMs));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `timestamptz` text: the offset it carries decides the instant. */
|
|
41
|
+
export const parsePgTimestamptz = (text: string): Date => parse(text, true);
|
|
42
|
+
|
|
43
|
+
/** `timestamp` text: no zone, so UTC — never the host process's zone, which is what `new Date` uses. */
|
|
44
|
+
export const parsePgTimestamp = (text: string): Date => parse(text, false);
|
|
45
|
+
|
|
46
|
+
/** Postgres type oids for the two. PGlite takes its parsers keyed by oid. */
|
|
47
|
+
export const TIMESTAMPTZ_OID = 1184;
|
|
48
|
+
export const TIMESTAMP_OID = 1114;
|
|
49
|
+
|
|
50
|
+
/** What `pglite.ts` hands the embedded driver, so it never reads an instant through its own parser. */
|
|
51
|
+
export const PGLITE_INSTANT_PARSERS = Object.freeze<Record<number, (text: string) => unknown>>({
|
|
52
|
+
[TIMESTAMPTZ_OID]: parsePgTimestamptz,
|
|
53
|
+
[TIMESTAMP_OID]: parsePgTimestamp,
|
|
54
|
+
});
|
package/src/pglite.ts
CHANGED
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
// that only ever talks to a managed Postgres must not carry 26 MB of WASM it will never load.
|
|
5
5
|
|
|
6
6
|
import { statementAttribution } from './attribution';
|
|
7
|
-
import type { DbConnection, ReservableClient } from './client';
|
|
7
|
+
import type { DbClient, DbConnection, ReservableClient } from './client';
|
|
8
8
|
import { DbError, driverError } from './errors';
|
|
9
9
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
10
10
|
import { statementObserver } from './observe';
|
|
11
|
+
import { PGLITE_INSTANT_PARSERS } from './pg-instant';
|
|
11
12
|
import { createTurnQueue } from './pglite-turns';
|
|
12
13
|
import type { SqlFragment } from './sql';
|
|
13
14
|
import { statementExcerpt } from './statement-excerpt';
|
|
14
15
|
import { withStatementSpan } from './statement-span';
|
|
15
|
-
import {
|
|
16
|
+
import { liveTxConnection } from './transaction';
|
|
16
17
|
|
|
17
18
|
/** What PGlite answers with. `rows` is empty for a write, which is why the count is separate. */
|
|
18
19
|
export interface PgliteResult {
|
|
@@ -30,7 +31,10 @@ export interface PgliteDriver {
|
|
|
30
31
|
|
|
31
32
|
/** The one export taken off `@electric-sql/pglite`. */
|
|
32
33
|
export interface PgliteModule {
|
|
33
|
-
readonly PGlite: new (
|
|
34
|
+
readonly PGlite: new (
|
|
35
|
+
dataDir?: string,
|
|
36
|
+
options?: { readonly parsers?: Readonly<Record<number, (text: string) => unknown>> },
|
|
37
|
+
) => PgliteDriver;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/** Returns the module namespace. Unknown, not typed, because it is validated before use. */
|
|
@@ -107,7 +111,9 @@ export async function loadPgliteDriver(options: PgliteOptions = {}): Promise<Pgl
|
|
|
107
111
|
}
|
|
108
112
|
const PGlite = pgliteConstructor(loaded);
|
|
109
113
|
try {
|
|
110
|
-
|
|
114
|
+
// `pg-instant.ts` reads every timestamp: PGlite's own parser took year 0099 for 1999 and an
|
|
115
|
+
// offset with seconds for Invalid Date under any session zone that is not UTC.
|
|
116
|
+
return new PGlite(dataDir, { parsers: PGLITE_INSTANT_PARSERS });
|
|
111
117
|
} catch (error) {
|
|
112
118
|
throw missing(`PGlite could not open its data directory (dataDir=${dataDir})`, error);
|
|
113
119
|
}
|
|
@@ -141,6 +147,9 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
141
147
|
// would otherwise build two instances over the same data directory and orphan one of them.
|
|
142
148
|
let booting: Promise<PgliteDriver> | undefined;
|
|
143
149
|
const turns = createTurnQueue();
|
|
150
|
+
// Every reservation this client handed out — the connections a live transaction on THIS
|
|
151
|
+
// session runs on. Weak, so a released reservation is collected with its scope.
|
|
152
|
+
const issued = new WeakSet<DbClient>();
|
|
144
153
|
|
|
145
154
|
function connect(): Promise<PgliteDriver> {
|
|
146
155
|
booting ??= loadPgliteDriver(options).catch((error: unknown) => {
|
|
@@ -229,7 +238,11 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
229
238
|
// work held the session next — a stray statement in someone else's transaction, committed or
|
|
230
239
|
// rolled back with it, with no error anywhere. A closed scope falls through and takes its own
|
|
231
240
|
// turn, exactly as `client.ts`'s released pin sends a late statement back to the pool.
|
|
232
|
-
|
|
241
|
+
// And only a transaction on THIS client's session: one open on another client says nothing
|
|
242
|
+
// about who holds this queue, and skipping it put the statement inside whatever transaction
|
|
243
|
+
// this session was running — rolled back with it (`pglite-two-clients.test.ts`).
|
|
244
|
+
const live = liveTxConnection();
|
|
245
|
+
if (live !== undefined && issued.has(live)) return statement(driver, fragment);
|
|
233
246
|
return turns.run(() => statement(driver, fragment));
|
|
234
247
|
}
|
|
235
248
|
|
|
@@ -263,7 +276,7 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
263
276
|
held = false;
|
|
264
277
|
turn.release();
|
|
265
278
|
};
|
|
266
|
-
|
|
279
|
+
const connection: DbConnection = {
|
|
267
280
|
query: async <T>(fragment: SqlFragment) => (await on(fragment)).rows as readonly T[],
|
|
268
281
|
one: async <T>(fragment: SqlFragment) =>
|
|
269
282
|
((await on(fragment)).rows[0] as T | undefined) ?? null,
|
|
@@ -271,6 +284,8 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
271
284
|
release,
|
|
272
285
|
[Symbol.dispose]: release,
|
|
273
286
|
};
|
|
287
|
+
issued.add(connection);
|
|
288
|
+
return connection;
|
|
274
289
|
},
|
|
275
290
|
async ping(): Promise<void> {
|
|
276
291
|
await connect();
|
package/src/replica-client.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { type DbClient, type DbConnection, isReservable, type ReservableClient }
|
|
|
8
8
|
import { isPlainRead } from './replica-route';
|
|
9
9
|
import { markScopeWrote, replicaScope } from './replica-scope';
|
|
10
10
|
import type { SqlFragment } from './sql';
|
|
11
|
+
import { sqlState } from './sqlstate';
|
|
11
12
|
|
|
12
13
|
export interface ReplicaStats {
|
|
13
14
|
/** Statements the replica answered. */
|
|
@@ -32,6 +33,27 @@ export interface ReplicatedClient extends DbClient {
|
|
|
32
33
|
readonly stats: ReplicaStats;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Whether a replica failure says the REPLICA could not answer, as opposed to the statement being
|
|
38
|
+
* refused. No SQLSTATE at all is a failure that never reached a server (a refused socket, a closed
|
|
39
|
+
* pool) — the `X_DB_UNAVAILABLE` case. Class `08` is a connection failure, `57P0x` an operator or
|
|
40
|
+
* crash shutdown, `40001` on a standby a conflict with recovery, `25006` a write it refused, and
|
|
41
|
+
* `53300` a replica out of connections. Everything else — `22P02` a bad cast, `42601` a syntax
|
|
42
|
+
* error, `57014` the caller's own timeout — would fail identically on the primary, so it is
|
|
43
|
+
* rethrown: counted, three of them parked a healthy replica, and every one cost two round trips.
|
|
44
|
+
*/
|
|
45
|
+
function replicaUnavailable(error: unknown): boolean {
|
|
46
|
+
const state = sqlState(error);
|
|
47
|
+
if (state === undefined) return true;
|
|
48
|
+
return (
|
|
49
|
+
state.startsWith('08') ||
|
|
50
|
+
state.startsWith('57P0') ||
|
|
51
|
+
state === '40001' ||
|
|
52
|
+
state === '25006' ||
|
|
53
|
+
state === '53300'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
/** Three in a row, then a ten-second rest — an outage costs 3 doubled reads, not every read. */
|
|
36
58
|
export const BREAKER_FAILURES = 3;
|
|
37
59
|
export const BREAKER_COOLDOWN_MS = 10_000;
|
|
@@ -106,6 +128,12 @@ export function replicatedClient(
|
|
|
106
128
|
consecutiveFailures = 0;
|
|
107
129
|
return answer;
|
|
108
130
|
} catch (error) {
|
|
131
|
+
if (!replicaUnavailable(error)) {
|
|
132
|
+
// The replica answered — with a refusal of the statement — so it is up, and the run of
|
|
133
|
+
// failures the breaker is counting is over.
|
|
134
|
+
consecutiveFailures = 0;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
109
137
|
// Re-running is exactly-once, not at-least-once: only `isPlainRead` statements reach here,
|
|
110
138
|
// and a statement a standby refused (`25006`) never executed. A replica outage therefore
|
|
111
139
|
// costs latency and never an answer — which is the whole point, since a read replica is a
|
package/src/statement-funnel.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// settle paths. Split from `client.ts` at the file-size rule; `pglite.ts` holds the mirror pair
|
|
4
4
|
// (`send`/`statement`) for the embedded driver, and the two must keep answering the same way.
|
|
5
5
|
|
|
6
|
-
import { encodeArrayParameters } from './array-parameter';
|
|
7
6
|
import { statementAttribution } from './attribution';
|
|
7
|
+
import { encodeBoundParameters } from './bound-parameters';
|
|
8
8
|
import type { BunSqlDriver } from './bun-sql';
|
|
9
9
|
import { driverError } from './errors';
|
|
10
10
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
@@ -33,11 +33,11 @@ async function sendOn(
|
|
|
33
33
|
fragment: SqlFragment,
|
|
34
34
|
): Promise<unknown> {
|
|
35
35
|
try {
|
|
36
|
-
// `
|
|
37
|
-
// with commas,
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
return await driver.unsafe(fragment.text,
|
|
36
|
+
// `encodeBoundParameters`, never `fragment.values` raw: `Bun.SQL` joins a JS array's elements
|
|
37
|
+
// with commas (#384), and on the pool's unnamed statements sends a `Date` as its local-zone
|
|
38
|
+
// `toString()`. One encoder here rather than one import per call site, because this is the
|
|
39
|
+
// only place this driver's `unsafe` is called.
|
|
40
|
+
return await driver.unsafe(fragment.text, encodeBoundParameters(fragment.values));
|
|
41
41
|
} catch (error) {
|
|
42
42
|
// `driverError`, not `dbUnavailable`: the SQLSTATE has always been on this error and nothing
|
|
43
43
|
// read it, so a `23505` from two clicks racing a signup told the operator the database was
|
package/src/statement-split.ts
CHANGED
|
@@ -5,6 +5,23 @@
|
|
|
5
5
|
import { noiseAt } from './sql-scan';
|
|
6
6
|
|
|
7
7
|
const WHITESPACE = /\s/;
|
|
8
|
+
const WORD_START = /[A-Za-z_]/;
|
|
9
|
+
const WORD = /[A-Za-z0-9_$]/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The blocks a `;` does not end. A PG14+ SQL-standard function body — `begin atomic … end` — holds
|
|
13
|
+
* whole statements, so its inner `;` is data; reproduced on PGlite, cutting there sent `create
|
|
14
|
+
* function … begin atomic select 1` alone (`syntax error at end of input`) and ran the rest of the
|
|
15
|
+
* body as top-level statements. A `case … end` inside such a body is tracked too, or its `end`
|
|
16
|
+
* would close the body early. Outside an atomic body nothing is tracked, so `begin; … end;` — a
|
|
17
|
+
* transaction — splits exactly as it always did.
|
|
18
|
+
*/
|
|
19
|
+
function trackBlocks(stack: string[], word: string, previous: string): void {
|
|
20
|
+
if (word === 'atomic' && previous === 'begin') stack.push('atomic');
|
|
21
|
+
else if (stack.length === 0) return;
|
|
22
|
+
else if (word === 'case') stack.push('case');
|
|
23
|
+
else if (word === 'end') stack.pop();
|
|
24
|
+
}
|
|
8
25
|
|
|
9
26
|
const isComment = (kind: string): boolean => kind === 'line-comment' || kind === 'block-comment';
|
|
10
27
|
|
|
@@ -22,6 +39,8 @@ export function statementsOf(script: string): readonly string[] {
|
|
|
22
39
|
// Set by anything that is not whitespace and not inside a comment: what makes a chunk a
|
|
23
40
|
// statement rather than a note between two of them.
|
|
24
41
|
let content = false;
|
|
42
|
+
const blocks: string[] = [];
|
|
43
|
+
let previous = '';
|
|
25
44
|
|
|
26
45
|
const cut = (end: number): void => {
|
|
27
46
|
const text = content ? script.slice(start, end).trim() : '';
|
|
@@ -37,7 +56,18 @@ export function statementsOf(script: string): readonly string[] {
|
|
|
37
56
|
continue;
|
|
38
57
|
}
|
|
39
58
|
const char = script[index] ?? '';
|
|
40
|
-
|
|
59
|
+
// A word starts only after a non-word character, so `x_begin` is not `begin`.
|
|
60
|
+
if (WORD_START.test(char) && !WORD.test(script[index - 1] ?? ' ')) {
|
|
61
|
+
let end = index + 1;
|
|
62
|
+
while (end < script.length && WORD.test(script[end] ?? '')) end += 1;
|
|
63
|
+
const word = script.slice(index, end).toLowerCase();
|
|
64
|
+
trackBlocks(blocks, word, previous);
|
|
65
|
+
previous = word;
|
|
66
|
+
content = true;
|
|
67
|
+
index = end;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (char === ';' && blocks.length === 0) {
|
|
41
71
|
cut(index);
|
|
42
72
|
start = index + 1;
|
|
43
73
|
index += 1;
|