bazis 0.98.24 → 0.98.25

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.
@@ -19,6 +19,13 @@ export interface ErrorHandlerOptions {
19
19
  onUnexpectedError?: (ctx: HttpContext, error: unknown) => void;
20
20
  /** Replaces the built-in logging of unexpected errors. */
21
21
  logError?: (error: unknown) => void;
22
+ /**
23
+ * Status for a non-{@link HttpError} failure, for example
24
+ * `databaseErrorStatus` from `bazis/core/orm`; `undefined` keeps 500. The
25
+ * body stays the standard reason phrase. A mapped 4xx is the client's
26
+ * mistake and is not logged or sent to `onUnexpectedError`.
27
+ */
28
+ status?: (error: unknown) => number | undefined;
22
29
  /**
23
30
  * Logger for unexpected errors when {@link logError} is absent: one `error`
24
31
  * line with the method, path, request id and the redacted error. The server
@@ -124,8 +131,8 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
124
131
  );
125
132
  return;
126
133
  }
127
- safelyReportUnexpected(options, log, ctx, error);
128
- const [status, title] = unexpectedStatus(error);
134
+ const [status, title] = unexpectedStatus(error, options.status);
135
+ if (status >= 500) safelyReportUnexpected(options, log, ctx, error);
129
136
  ctx.response = options.exposeDetails
130
137
  ? safeJsonResponse(status, {
131
138
  error: title,
@@ -146,9 +153,17 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
146
153
  * and stays 500, like everything else. Upstream details never reach the
147
154
  * client; the log keeps the full error.
148
155
  */
149
- function unexpectedStatus(error: unknown): readonly [number, string] {
156
+ const REASONS: Readonly<Record<number, string>> = {
157
+ 400: "Bad Request", 404: "Not Found", 409: "Conflict", 410: "Gone", 412: "Precondition Failed", 422: "Unprocessable Content",
158
+ 423: "Locked", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
159
+ };
160
+
161
+ function unexpectedStatus(error: unknown, mapper?: (error: unknown) => number | undefined): readonly [number, string] {
150
162
  if (error instanceof HttpClientError && error.code !== undefined) {
151
163
  return error.code === HttpErrorCode.Timeout ? [504, "Gateway Timeout"] : [502, "Bad Gateway"];
152
164
  }
165
+ let mapped: number | undefined;
166
+ try { mapped = mapper?.(error); } catch { mapped = undefined; }
167
+ if (mapped !== undefined && Number.isInteger(mapped) && mapped >= 400 && mapped <= 599) return [mapped, REASONS[mapped] ?? (mapped >= 500 ? "Server Error" : "Client Error")];
153
168
  return [500, "Internal Server Error"];
154
169
  }
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.98.24" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.25" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -0,0 +1,35 @@
1
+ import {
2
+ CheckViolationError,
3
+ EntityNotFoundError,
4
+ ForeignKeyViolationError,
5
+ LockTimeoutError,
6
+ NotNullViolationError,
7
+ OrmTransactionScopeError,
8
+ OrmValidationError,
9
+ TransactionOutcomeUnknownError,
10
+ UniqueViolationError,
11
+ } from "../../library/orm";
12
+
13
+ /** SQLSTATEs a client may retry: serialization failure, deadlock, statement timeout. */
14
+ const RETRYABLE = new Set(["40001", "40P01", "57014"]);
15
+
16
+ /**
17
+ * HTTP status for a database error, for `http: { errorHandler: { status:
18
+ * databaseErrorStatus } }`: 409 for a unique or foreign key conflict, 422 for
19
+ * a check, not-null or entity validation failure, 404 for `first()` without a
20
+ * row, 503 when the database did not answer, a lock or the transaction timed
21
+ * out, or a retryable conflict happened. Anything else stays 500.
22
+ */
23
+ export function databaseErrorStatus(error: unknown): number | undefined {
24
+ if (error instanceof UniqueViolationError || error instanceof ForeignKeyViolationError) return 409;
25
+ if (error instanceof CheckViolationError || error instanceof NotNullViolationError || error instanceof OrmValidationError) return 422;
26
+ if (error instanceof EntityNotFoundError) return 404;
27
+ if (error instanceof LockTimeoutError || error instanceof TransactionOutcomeUnknownError) return 503;
28
+ if (error instanceof OrmTransactionScopeError && / timed out after \d+ ms/.test(error.message)) return 503;
29
+ if (typeof error === "object" && error !== null) {
30
+ const { errno, code } = error as { errno?: unknown; code?: unknown };
31
+ if (typeof errno === "string" && RETRYABLE.has(errno)) return 503;
32
+ if (typeof code === "string" && code.startsWith("ERR_POSTGRES_CONNECTION")) return 503;
33
+ }
34
+ return undefined;
35
+ }
package/core/orm/index.ts CHANGED
@@ -10,6 +10,7 @@ export { OrmConnectionLifecycle, OrmLifecycle, OrmProviderReadyLifecycle } from
10
10
  export { IRepository, repositoryFor } from "./repository";
11
11
  export { registerRepositories } from "./registerRepositories";
12
12
  export { paginate, type PageResult } from "./listQuery";
13
+ export { databaseErrorStatus } from "./databaseErrorStatus";
13
14
 
14
15
  import { registerModuleMetadataExpander, type BazisModuleRef } from "../di";
15
16
  import { OrmOwnedStoreAdmissionError, type DbContext } from "../../library/orm";
@@ -749,7 +749,7 @@ export class PostgresProvider implements DatabaseProvider {
749
749
  // Both query() and execute() can write in autocommit mode. Once handed
750
750
  // to the driver, a missing reply is not proof that replay is safe.
751
751
  if (autocommit && nativeQuery !== undefined && !isConfirmedStatementRejection(error)) {
752
- const unknown = this.uncertainty(error);
752
+ const unknown = this.uncertainty(error, "statement");
753
753
  if (owner) {
754
754
  owner.unknownOutcome ??= unknown;
755
755
  owner.state = "quarantined";
@@ -1205,7 +1205,7 @@ export class PostgresProvider implements DatabaseProvider {
1205
1205
  } catch { return false; }
1206
1206
  }
1207
1207
 
1208
- private uncertainty(cause?: unknown, phase: "commit" | "cancellation" = "commit"): TransactionOutcomeUnknownError {
1208
+ private uncertainty(cause?: unknown, phase: "commit" | "cancellation" | "statement" = "commit"): TransactionOutcomeUnknownError {
1209
1209
  const error = new TransactionOutcomeUnknownError(cause, phase); this.uncertainErrors.add(error); return error;
1210
1210
  }
1211
1211
 
@@ -5,13 +5,22 @@ import type { TransactionCallback } from "./types";
5
5
  export class TransactionOutcomeUnknownError extends DbUpdateError {
6
6
  readonly code = "ORM_TRANSACTION_OUTCOME_UNKNOWN";
7
7
  readonly outcome = "unknown";
8
- constructor(override readonly cause?: unknown, readonly phase: "commit" | "cancellation" = "commit") {
8
+ /** `statement`: a statement sent outside a transaction got no answer (a timeout or a lost connection). */
9
+ constructor(override readonly cause?: unknown, readonly phase: "commit" | "cancellation" | "statement" = "commit") {
9
10
  super(phase === "commit"
10
11
  ? "Transaction outcome is unknown. Reconcile database state and use a new DbContext before saving again."
11
- : "Transaction cancellation is unconfirmed. Reconcile database state and use a new DbContext before saving again.");
12
+ : phase === "statement"
13
+ ? `The database did not answer a statement sent outside a transaction${statementReason(cause)}. If the statement changed data, its outcome is unknown: check the data and use a new DbContext before saving again.`
14
+ : "Transaction cancellation is unconfirmed. Reconcile database state and use a new DbContext before saving again.");
12
15
  }
13
16
  }
14
17
 
18
+ function statementReason(cause: unknown): string {
19
+ if (!(cause instanceof Error) || cause.message === "") return "";
20
+ const message = cause.message.replace(/\.$/, "");
21
+ return `: ${cause.name === "Error" || cause.name.startsWith("Orm") ? message : `${cause.name}: ${message}`}`;
22
+ }
23
+
15
24
  export function isUnknownTransactionOutcome(error: unknown): boolean {
16
25
  const pending = [error];
17
26
  const seen = new Set<unknown>();
@@ -1,5 +1,5 @@
1
1
  import { Validator } from "../../validation";
2
- import { DbUpdateError, isCommittedOutcome, OrmValidationError, UniqueViolationError } from "../errors";
2
+ import { DbUpdateError, isCommittedOutcome, OrmValidationError, translateDatabaseError } from "../errors";
3
3
  import { isDatabaseGenerated, type EntityModel } from "../Metadata/types";
4
4
  import { maxRowsPerInsert } from "../Providers/limits";
5
5
  import { decodeProperty } from "../Providers/propertyConversion";
@@ -170,7 +170,7 @@ export class SaveExecutor {
170
170
  // idempotent fallback also protects custom/top-level providers.
171
171
  this.restoreGeneratedKeys(generatedKeys);
172
172
  for (const token of tokens) this.restoreRollbackToken(token);
173
- throw UniqueViolationError.from(error);
173
+ throw translateDatabaseError(error);
174
174
  }
175
175
  throw error;
176
176
  }
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { DbContext } from "../DbContext";
3
- import { ConcurrentTransactionScopeError, differentProviderMessage, OrmDatabaseTimeError, OrmProviderIdentityMismatchError, OrmTransactionScopeError } from "../errors";
3
+ import { ConcurrentTransactionScopeError, differentProviderMessage, OrmDatabaseTimeError, OrmProviderIdentityMismatchError, OrmTransactionScopeError, translateDatabaseError } from "../errors";
4
4
  import { baseProvider, withPostgresScopeOptions, createObservedProvider, postgresTransactionCapability, rootAuthority, withProviderDispatchObserver, withoutProviderDispatchObserver, type CancellableProviderDispatch } from "../Providers/ormTransactionRuntime";
5
5
  import type { DatabaseProvider } from "../Providers/types";
6
6
  import { OrmTransaction, type OrmDatabaseTimeV1, type OrmTransactionScopeOptions } from "./OrmTransaction";
@@ -86,7 +86,9 @@ export function observedProvider(context: object, provider: DatabaseProvider): D
86
86
  const authority = rootAuthority(provider);
87
87
  const fence = <T>(operation: () => Promise<T>, view = views.getStore()): Promise<T> => {
88
88
  if (uncertainContexts.has(token)) return Promise.reject(new TransactionOutcomeUnknownError());
89
- const guarded = () => operation().catch((error) => { if (postgresTransactionCapability(provider)?.isOutcomeUncertain?.(error)) uncertainContexts.add(token); throw error; });
89
+ // Known SQLSTATEs (unique, foreign key, check, not null, lock timeout)
90
+ // reach the context's caller as ORM errors; the outcome check sees the raw one.
91
+ const guarded = () => operation().catch((error) => { if (postgresTransactionCapability(provider)?.isOutcomeUncertain?.(error)) uncertainContexts.add(token); throw translateDatabaseError(error); });
90
92
  if (!view) return guarded();
91
93
  const frame = view.frame;
92
94
  if (frame.authority !== authority || !view.enrolled.has(token) || frame.state !== "active" || frame.activeChild !== undefined) {
@@ -192,6 +192,89 @@ export class UniqueViolationError extends DbUpdateError {
192
192
  }
193
193
  }
194
194
 
195
+ /** Reads a non-empty string field of a driver error. */
196
+ function driverField(cause: object, name: string): string | undefined {
197
+ const value = (cause as Record<string, unknown>)[name];
198
+ return typeof value === "string" && value !== "" ? value : undefined;
199
+ }
200
+
201
+ /** Copies the SQLSTATE so transaction-outcome checks see the same server rejection. */
202
+ function copyServerCode(target: object, cause: object): void {
203
+ for (const name of ["errno", "code"]) {
204
+ const descriptor = Object.getOwnPropertyDescriptor(cause, name);
205
+ if (descriptor && "value" in descriptor) Object.defineProperty(target, name, { value: descriptor.value, enumerable: true });
206
+ }
207
+ }
208
+
209
+ /** A foreign key constraint was violated (SQLSTATE 23503). */
210
+ export class ForeignKeyViolationError extends DbUpdateError {
211
+ readonly constraint: string | undefined;
212
+ readonly table: string | undefined;
213
+ constructor(cause: object) {
214
+ const constraint = driverField(cause, "constraint");
215
+ super(`Foreign key constraint${constraint === undefined ? "" : ` "${constraint}"`} violated: the row refers to a missing row, or a row that other rows refer to was deleted.`, { cause });
216
+ this.constraint = constraint;
217
+ this.table = driverField(cause, "table");
218
+ copyServerCode(this, cause);
219
+ }
220
+ }
221
+
222
+ /** A CHECK constraint was violated (SQLSTATE 23514). */
223
+ export class CheckViolationError extends DbUpdateError {
224
+ readonly constraint: string | undefined;
225
+ readonly table: string | undefined;
226
+ constructor(cause: object) {
227
+ const constraint = driverField(cause, "constraint");
228
+ super(constraint === undefined ? "Check constraint violated." : `Check constraint "${constraint}" violated.`, { cause });
229
+ this.constraint = constraint;
230
+ this.table = driverField(cause, "table");
231
+ copyServerCode(this, cause);
232
+ }
233
+ }
234
+
235
+ /** A NOT NULL column got NULL (SQLSTATE 23502). */
236
+ export class NotNullViolationError extends DbUpdateError {
237
+ readonly column: string | undefined;
238
+ readonly table: string | undefined;
239
+ constructor(cause: object) {
240
+ const column = driverField(cause, "column");
241
+ const table = driverField(cause, "table");
242
+ super(column === undefined ? "A NOT NULL column got NULL." : `Column "${column}"${table === undefined ? "" : ` of table "${table}"`} does not accept NULL.`, { cause });
243
+ this.column = column;
244
+ this.table = table;
245
+ copyServerCode(this, cause);
246
+ }
247
+ }
248
+
249
+ /** A lock was not granted within `lock_timeout` (SQLSTATE 55P03). */
250
+ export class LockTimeoutError extends OrmError {
251
+ constructor(cause: object) {
252
+ super("A lock was not granted within lockTimeoutMs: another transaction holds the row or table. Retry the operation, or shorten the transaction that holds the lock.", { cause });
253
+ copyServerCode(this, cause);
254
+ }
255
+ }
256
+
257
+ const TRANSLATED: Readonly<Record<string, new (cause: object) => OrmError>> = {
258
+ "23505": UniqueViolationError,
259
+ "23503": ForeignKeyViolationError,
260
+ "23514": CheckViolationError,
261
+ "23502": NotNullViolationError,
262
+ "55P03": LockTimeoutError,
263
+ };
264
+
265
+ /**
266
+ * A driver error with a known SQLSTATE as its ORM error (unique, foreign key,
267
+ * check, not-null violation, lock timeout); anything else unchanged. The
268
+ * original error is `cause`; messages carry constraint and column names only,
269
+ * never row values.
270
+ */
271
+ export function translateDatabaseError(error: unknown): unknown {
272
+ if (error === null || typeof error !== "object" || error instanceof OrmError) return error;
273
+ const errno = Object.getOwnPropertyDescriptor(error, "errno");
274
+ const Translated = errno && "value" in errno && typeof errno.value === "string" ? TRANSLATED[errno.value] : undefined;
275
+ return Translated === undefined ? error : new Translated(error);
276
+ }
277
+
195
278
  /** The entity failed validation before saving. */
196
279
  export class OrmValidationError extends DbUpdateError {
197
280
  constructor(
@@ -151,6 +151,11 @@ export {
151
151
  OrmError,
152
152
  OrmValidationError,
153
153
  UniqueViolationError,
154
+ ForeignKeyViolationError,
155
+ CheckViolationError,
156
+ NotNullViolationError,
157
+ LockTimeoutError,
158
+ translateDatabaseError,
154
159
  PostCommitError,
155
160
  isCommittedOutcome,
156
161
  OrmTransactionScopeError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.24",
3
+ "version": "0.98.25",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",