@ultimat3/db 19.0.0 → 19.1.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/package.json +2 -2
- package/src/errors.ts +30 -7
- package/src/pglite.ts +8 -2
- package/src/sqlstate.ts +13 -1
- package/src/statement-funnel.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.1.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": "19.
|
|
34
|
+
"@ultimat3/core": "19.1.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/errors.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { type DbSqlStateCode, sqlState, sqlStateCode } from './sqlstate';
|
|
|
19
19
|
*/
|
|
20
20
|
export const DB_OWNED_ERROR_CODES = [
|
|
21
21
|
'X_DB_UNAVAILABLE',
|
|
22
|
+
'X_DB_SCHEMA_STALE',
|
|
23
|
+
'X_DB_STATEMENT_FAILED',
|
|
22
24
|
'X_DB_UNIQUE_VIOLATION',
|
|
23
25
|
'X_DB_FOREIGN_KEY_VIOLATION',
|
|
24
26
|
'X_DB_SERIALIZATION_FAILURE',
|
|
@@ -61,6 +63,8 @@ export type DbErrorCode = (typeof DB_ERROR_CODES)[number];
|
|
|
61
63
|
|
|
62
64
|
export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
|
|
63
65
|
X_DB_UNAVAILABLE: 'cannot reach the database',
|
|
66
|
+
X_DB_SCHEMA_STALE: 'the statement names a table or column the database does not have',
|
|
67
|
+
X_DB_STATEMENT_FAILED: 'the database refused the statement',
|
|
64
68
|
X_DB_UNIQUE_VIOLATION: 'a unique constraint rejected the row',
|
|
65
69
|
X_DB_FOREIGN_KEY_VIOLATION: 'a foreign key constraint rejected the row',
|
|
66
70
|
X_DB_SERIALIZATION_FAILURE: 'the transaction lost a serialization race',
|
|
@@ -127,7 +131,9 @@ export const DB_ERROR_RETRY = {
|
|
|
127
131
|
// a typo or a renamed code is a build error rather than a classification for a code nothing throws.
|
|
128
132
|
//
|
|
129
133
|
// Left to the fail-closed default, deliberately, each for its own reason:
|
|
130
|
-
// X_DB_UNAVAILABLE
|
|
134
|
+
// X_DB_UNAVAILABLE a connection failure — see the note above
|
|
135
|
+
// X_DB_SCHEMA_STALE `42P01`/`42703`: the migration is the fix, and no attempt runs it
|
|
136
|
+
// X_DB_STATEMENT_FAILED the server's verdict on the SQL; the same SQL gets the same verdict
|
|
131
137
|
// X_DB_STATEMENT_TIMEOUT `57014`, and this package's fix for it is "add the index": an edit.
|
|
132
138
|
// The queued-behind-a-lock case has its own code, above
|
|
133
139
|
// X_DB_UNIQUE_VIOLATION the same row, the same constraint, the same refusal
|
|
@@ -171,7 +177,9 @@ export class DbError extends UltimateError {
|
|
|
171
177
|
export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
|
|
172
178
|
new DbError({
|
|
173
179
|
code: 'X_DB_UNAVAILABLE',
|
|
174
|
-
|
|
180
|
+
// The driver's own words ride along when there are any: `ECONNREFUSED 127.0.0.1:5432` is the
|
|
181
|
+
// half of "cannot reach the database" an operator acts on, and it was dropped on the floor.
|
|
182
|
+
cause: sourceError === undefined ? detail : `${detail}: ${renderThrowable(sourceError)}`,
|
|
175
183
|
fix: 'set DATABASE_URL to a reachable Postgres url, or run `x dev` to use the embedded PGlite',
|
|
176
184
|
sourceError,
|
|
177
185
|
});
|
|
@@ -186,6 +194,9 @@ export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
|
|
|
186
194
|
* of one; `driverError` substitutes the placeholder when the driver reported none.
|
|
187
195
|
*/
|
|
188
196
|
const SQLSTATE_FIXES = Object.freeze<Record<DbSqlStateCode, string>>({
|
|
197
|
+
X_DB_SCHEMA_STALE:
|
|
198
|
+
'x db gen "<what changed>" && x db migrate # the entity is ahead of the schema; ' +
|
|
199
|
+
'if the migration already exists, only the migrate half is due',
|
|
189
200
|
X_DB_UNIQUE_VIOLATION:
|
|
190
201
|
'upsertAll(rows, { onConflict: [...] }) over the columns {constraint} covers — ' +
|
|
191
202
|
'or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is',
|
|
@@ -219,19 +230,31 @@ const UNNAMED_CONSTRAINT = 'the constraint named in cause';
|
|
|
219
230
|
* given where it is true, and a new SQLSTATE arrives as a new row here rather than as a new
|
|
220
231
|
* `catch` at a call site.
|
|
221
232
|
*/
|
|
222
|
-
export const driverError = (
|
|
223
|
-
const code = sqlStateCode(sourceError);
|
|
224
|
-
if (code === undefined) return dbUnavailable(detail, sourceError);
|
|
233
|
+
export const driverError = (statement: string, sourceError: unknown): DbError => {
|
|
225
234
|
const state = sqlState(sourceError);
|
|
235
|
+
// No SQLSTATE means the failure never reached a server — a refused socket, a closed pool, a
|
|
236
|
+
// driver that would not load. THAT is unavailability, and it is the only thing that is.
|
|
237
|
+
if (state === undefined) return dbUnavailable(`statement failed: ${statement}`, sourceError);
|
|
238
|
+
// A state the table does not classify still proves the server answered: it read the statement
|
|
239
|
+
// and refused it. `X_DB_STATEMENT_FAILED` says so and carries the server's own words, where
|
|
240
|
+
// `X_DB_UNAVAILABLE` said "set DATABASE_URL" to a developer whose database was fine.
|
|
241
|
+
const code = sqlStateCode(sourceError) ?? 'X_DB_STATEMENT_FAILED';
|
|
226
242
|
const constraint = stringField(sourceError, 'constraint');
|
|
227
243
|
return new DbError({
|
|
228
244
|
code,
|
|
229
|
-
|
|
245
|
+
// The server's SQLSTATE and message FIRST, the statement after: a cause is rendered on one
|
|
246
|
+
// line and cut when long, and a column list of any width put the one thing an author needs —
|
|
247
|
+
// `column "host_id" does not exist` — past the cut. The statement is `statementExcerpt`'d by
|
|
248
|
+
// the caller, so the whole line has a known ceiling.
|
|
249
|
+
cause: `[SQLSTATE ${state}] ${renderThrowable(sourceError)} — statement: ${statement}`,
|
|
230
250
|
// A FUNCTION as the replacement, never the string: `String.replace` expands `$&`, `` $` ``,
|
|
231
251
|
// `$'` and `$$` inside a replacement literal, and a constraint name is the server's, not
|
|
232
252
|
// ours — `$` is legal in a Postgres identifier, so `posts_$&_key` would splice the matched
|
|
233
253
|
// `{constraint}` back into the fix line an author is meant to paste.
|
|
234
|
-
fix:
|
|
254
|
+
fix:
|
|
255
|
+
code === 'X_DB_STATEMENT_FAILED'
|
|
256
|
+
? `psql "$DATABASE_URL" -c "<the statement in cause>" # SQLSTATE ${state} is the server's verdict on it: fix the SQL or the data it names, not the connection`
|
|
257
|
+
: SQLSTATE_FIXES[code].replace('{constraint}', () => constraint ?? UNNAMED_CONSTRAINT),
|
|
235
258
|
meta: {
|
|
236
259
|
sqlState: state,
|
|
237
260
|
...(constraint === undefined ? {} : { constraint }),
|
package/src/pglite.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import { statementAttribution } from './attribution';
|
|
7
7
|
import type { DbConnection, ReservableClient } from './client';
|
|
8
|
-
import { DbError,
|
|
8
|
+
import { DbError, driverError } from './errors';
|
|
9
9
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
10
10
|
import { statementObserver } from './observe';
|
|
11
11
|
import { createTurnQueue } from './pglite-turns';
|
|
12
12
|
import type { SqlFragment } from './sql';
|
|
13
|
+
import { statementExcerpt } from './statement-excerpt';
|
|
13
14
|
import { withStatementSpan } from './statement-span';
|
|
14
15
|
import { inLiveTx } from './transaction';
|
|
15
16
|
|
|
@@ -142,7 +143,12 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
142
143
|
try {
|
|
143
144
|
return await driver.query(fragment.text, fragment.values);
|
|
144
145
|
} catch (error) {
|
|
145
|
-
|
|
146
|
+
// `driverError`, as `statement-funnel.ts` already does for Bun's driver: this site passed
|
|
147
|
+
// every failure to `dbUnavailable`, so under `x dev` — which IS this driver when no
|
|
148
|
+
// `DATABASE_URL` is set — a `select` naming a column whose migration had not run answered
|
|
149
|
+
// "cannot reach the database" with the fix "set DATABASE_URL", against a database that was
|
|
150
|
+
// answering fine (measured 2026-09-05). PGlite carries the SQLSTATE on `code`.
|
|
151
|
+
throw driverError(statementExcerpt(fragment.text), error);
|
|
146
152
|
}
|
|
147
153
|
}
|
|
148
154
|
|
package/src/sqlstate.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { stringField } from '@ultimat3/core';
|
|
|
14
14
|
export const SQLSTATE = Object.freeze({
|
|
15
15
|
/** `undefined_table` — the ledger's absence is a class, not a message to match on. */
|
|
16
16
|
undefinedTable: '42P01',
|
|
17
|
+
/** `undefined_column` — an entity edited before the migration that adds the column ran. */
|
|
18
|
+
undefinedColumn: '42703',
|
|
17
19
|
uniqueViolation: '23505',
|
|
18
20
|
foreignKeyViolation: '23503',
|
|
19
21
|
serializationFailure: '40001',
|
|
@@ -70,6 +72,7 @@ export function sqlState(error: unknown): string | undefined {
|
|
|
70
72
|
|
|
71
73
|
/** The codes a SQLSTATE can classify into. `errors.ts` owns their titles and their fixes. */
|
|
72
74
|
export type DbSqlStateCode =
|
|
75
|
+
| 'X_DB_SCHEMA_STALE'
|
|
73
76
|
| 'X_DB_UNIQUE_VIOLATION'
|
|
74
77
|
| 'X_DB_FOREIGN_KEY_VIOLATION'
|
|
75
78
|
| 'X_DB_SERIALIZATION_FAILURE'
|
|
@@ -84,6 +87,11 @@ export type DbSqlStateCode =
|
|
|
84
87
|
* class 53, insufficient resources, and both are answered by asking for fewer connections.
|
|
85
88
|
*/
|
|
86
89
|
export const DB_SQLSTATE_CODES: Readonly<Record<string, DbSqlStateCode>> = Object.freeze({
|
|
90
|
+
// Both class 42 "the schema does not have what this statement names": a table or a column
|
|
91
|
+
// declared in code whose migration has not run. One code, because the instruction is one
|
|
92
|
+
// instruction — generate the migration and apply it — and the cause names which it was.
|
|
93
|
+
[SQLSTATE.undefinedTable]: 'X_DB_SCHEMA_STALE',
|
|
94
|
+
[SQLSTATE.undefinedColumn]: 'X_DB_SCHEMA_STALE',
|
|
87
95
|
[SQLSTATE.uniqueViolation]: 'X_DB_UNIQUE_VIOLATION',
|
|
88
96
|
[SQLSTATE.foreignKeyViolation]: 'X_DB_FOREIGN_KEY_VIOLATION',
|
|
89
97
|
[SQLSTATE.serializationFailure]: 'X_DB_SERIALIZATION_FAILURE',
|
|
@@ -94,7 +102,11 @@ export const DB_SQLSTATE_CODES: Readonly<Record<string, DbSqlStateCode>> = Objec
|
|
|
94
102
|
[SQLSTATE.outOfMemory]: 'X_DB_POOL_EXHAUSTED',
|
|
95
103
|
} as const);
|
|
96
104
|
|
|
97
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* `undefined` when the state is absent or not in the table. What the caller does with that is
|
|
107
|
+
* `driverError`'s decision, not this file's: a state the table does not name still proves the
|
|
108
|
+
* statement REACHED a server, which is the opposite of unavailable.
|
|
109
|
+
*/
|
|
98
110
|
export function sqlStateCode(error: unknown): DbSqlStateCode | undefined {
|
|
99
111
|
const state = sqlState(error);
|
|
100
112
|
return state === undefined ? undefined : DB_SQLSTATE_CODES[state];
|
package/src/statement-funnel.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { driverError } from './errors';
|
|
|
10
10
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
11
11
|
import { statementObserver } from './observe';
|
|
12
12
|
import type { SqlFragment } from './sql';
|
|
13
|
+
import { statementExcerpt } from './statement-excerpt';
|
|
13
14
|
import { withStatementSpan } from './statement-span';
|
|
14
15
|
|
|
15
16
|
export function rowsOf<T>(result: unknown): readonly T[] {
|
|
@@ -42,7 +43,7 @@ async function sendOn(
|
|
|
42
43
|
// read it, so a `23505` from two clicks racing a signup told the operator the database was
|
|
43
44
|
// unreachable and paged on-call for an outage that never happened. Everything the table does
|
|
44
45
|
// not classify is still `X_DB_UNAVAILABLE`, byte for byte.
|
|
45
|
-
throw driverError(
|
|
46
|
+
throw driverError(statementExcerpt(fragment.text), error);
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|