@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "19.0.0",
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.0.0"
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 two failures in one code — see the note above
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
- cause: detail,
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 = (detail: string, sourceError: unknown): DbError => {
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
- cause: `${detail}: ${renderThrowable(sourceError)} [SQLSTATE ${state ?? '?????'}]`,
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: SQLSTATE_FIXES[code].replace('{constraint}', () => constraint ?? UNNAMED_CONSTRAINT),
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, dbUnavailable } from './errors';
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
- throw dbUnavailable(`statement failed: ${fragment.text.slice(0, 120)}`, error);
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
- /** `undefined` when the state is unknown or absent — the caller then reports unavailability. */
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];
@@ -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(`statement failed: ${fragment.text.slice(0, 120)}`, error);
46
+ throw driverError(statementExcerpt(fragment.text), error);
46
47
  }
47
48
  }
48
49