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.
- package/core/http/Middleware/errorHandler.ts +18 -3
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/orm/databaseErrorStatus.ts +35 -0
- package/core/orm/index.ts +1 -0
- package/library/orm/Providers/PostgresProvider.ts +2 -2
- package/library/orm/Providers/transactionOutcome.ts +11 -2
- package/library/orm/Saving/SaveExecutor.ts +2 -2
- package/library/orm/Transactions/TransactionScopeCoordinator.ts +4 -2
- package/library/orm/errors.ts +83 -0
- package/library/orm/index.ts +5 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
:
|
|
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,
|
|
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
|
|
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
|
-
|
|
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) {
|
package/library/orm/errors.ts
CHANGED
|
@@ -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(
|
package/library/orm/index.ts
CHANGED
|
@@ -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.
|
|
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",
|