bazis 0.98.24 → 0.98.26
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/Authorization/authorizeComposer.ts +16 -3
- package/core/http/Authorization/bearerToken.ts +11 -0
- package/core/http/Middleware/errorHandler.ts +18 -3
- package/core/http/index.ts +1 -0
- 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/jwt/signing/Rs256Algorithm.ts +45 -7
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { JwtError } from "../../../library/jwt/errors";
|
|
2
|
+
import { ForbiddenError, UnauthorizedError } from "../Errors/HttpError";
|
|
2
3
|
import type { HttpMiddleware } from "../Middleware/types";
|
|
3
4
|
import type { RouteMiddlewareComposer } from "../options";
|
|
4
5
|
import { resolveAuthorizeMeta } from "./metadata";
|
|
@@ -8,7 +9,8 @@ import { resolveAuthorizeMeta } from "./metadata";
|
|
|
8
9
|
* the per-route composers. For routes that require authorization it returns
|
|
9
10
|
* middleware that runs all checks in order: any that returns `false` → `403`;
|
|
10
11
|
* a check may throw an `HttpError` itself (for example
|
|
11
|
-
* `UnauthorizedError` → `401`).
|
|
12
|
+
* `UnauthorizedError` → `401`). A `JwtError` (missing, malformed, expired or
|
|
13
|
+
* forged token) escaping a check is a 401 as well, not a server error.
|
|
12
14
|
*/
|
|
13
15
|
export function createAuthorizeComposer(): RouteMiddlewareComposer {
|
|
14
16
|
return (controllerClass, methodName) => {
|
|
@@ -20,7 +22,18 @@ export function createAuthorizeComposer(): RouteMiddlewareComposer {
|
|
|
20
22
|
const { checks } = meta.authorize;
|
|
21
23
|
const middleware: HttpMiddleware = async (ctx, next) => {
|
|
22
24
|
for (const check of checks) {
|
|
23
|
-
|
|
25
|
+
let allowed: boolean;
|
|
26
|
+
try {
|
|
27
|
+
allowed = await check(ctx);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof JwtError) {
|
|
30
|
+
const unauthorized = new UnauthorizedError();
|
|
31
|
+
Object.defineProperty(unauthorized, "cause", { value: error, configurable: true, writable: true });
|
|
32
|
+
throw unauthorized;
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
if (!allowed) {
|
|
24
37
|
throw new ForbiddenError();
|
|
25
38
|
}
|
|
26
39
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The token of an `Authorization: Bearer <token>` header, or `undefined`
|
|
5
|
+
* without the header, with another scheme or with an empty token. The scheme
|
|
6
|
+
* is case-insensitive (RFC 7235); surrounding spaces are trimmed.
|
|
7
|
+
*/
|
|
8
|
+
export function bearerToken(ctx: Pick<HttpContext, "header">): string | undefined {
|
|
9
|
+
const match = /^\s*bearer\s+(\S+)\s*$/i.exec(ctx.header("authorization") ?? "");
|
|
10
|
+
return match?.[1];
|
|
11
|
+
}
|
|
@@ -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
|
}
|
package/core/http/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export { PRINCIPAL_STATE_KEY, type RequestPrincipal } from "./HttpContext/princi
|
|
|
34
34
|
// Authorization
|
|
35
35
|
export { Authorize, AllowAnonymous } from "./Authorization/Authorize";
|
|
36
36
|
export { createAuthorizeComposer } from "./Authorization/authorizeComposer";
|
|
37
|
+
export { bearerToken } from "./Authorization/bearerToken";
|
|
37
38
|
export {
|
|
38
39
|
resolveAuthorizeMeta,
|
|
39
40
|
type AuthorizeCheck,
|
|
@@ -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.26" }, 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";
|
|
@@ -34,14 +34,19 @@ export class Rs256Algorithm implements SigningAlgorithm {
|
|
|
34
34
|
if (keys === null || typeof keys !== "object" || Array.isArray(keys)) {
|
|
35
35
|
throw new TypeError("RS256 key material must be an object");
|
|
36
36
|
}
|
|
37
|
-
this.keys = Object.freeze({ ...keys });
|
|
38
37
|
for (const field of ["publicKeyPem", "privateKeyPem", "keyId"] as const) {
|
|
39
|
-
const value =
|
|
38
|
+
const value = keys[field];
|
|
40
39
|
if (value === undefined && field !== "publicKeyPem") continue;
|
|
41
40
|
if (typeof value !== "string" || value.length === 0) {
|
|
42
41
|
throw new TypeError(`RS256 ${field} must be a non-empty string`);
|
|
43
42
|
}
|
|
44
43
|
}
|
|
44
|
+
// A wrong key format must fail here, at startup, not on the first sign-in.
|
|
45
|
+
this.keys = Object.freeze({
|
|
46
|
+
...keys,
|
|
47
|
+
publicKeyPem: checkedPem("publicKeyPem", keys.publicKeyPem),
|
|
48
|
+
...(keys.privateKeyPem === undefined ? {} : { privateKeyPem: checkedPem("privateKeyPem", keys.privateKeyPem) }),
|
|
49
|
+
});
|
|
45
50
|
this.canSign = this.keys.privateKeyPem !== undefined;
|
|
46
51
|
this.keyId = this.keys.keyId;
|
|
47
52
|
}
|
|
@@ -78,13 +83,13 @@ export class Rs256Algorithm implements SigningAlgorithm {
|
|
|
78
83
|
if (this.keys.privateKeyPem === undefined) {
|
|
79
84
|
throw new Error("RS256 private key PEM is required for signing");
|
|
80
85
|
}
|
|
81
|
-
const key = await crypto.subtle.importKey(
|
|
86
|
+
const key = await importKey("private", () => crypto.subtle.importKey(
|
|
82
87
|
"pkcs8",
|
|
83
|
-
decodePemToBuffer(this.keys.privateKeyPem),
|
|
88
|
+
decodePemToBuffer(this.keys.privateKeyPem!),
|
|
84
89
|
RSA_PARAMS,
|
|
85
90
|
false,
|
|
86
91
|
["sign"],
|
|
87
|
-
);
|
|
92
|
+
));
|
|
88
93
|
return this.cachedPrivate = this.requireStrongKey(key);
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -92,13 +97,13 @@ export class Rs256Algorithm implements SigningAlgorithm {
|
|
|
92
97
|
if (this.cachedPublic !== undefined) {
|
|
93
98
|
return this.cachedPublic;
|
|
94
99
|
}
|
|
95
|
-
const key = await crypto.subtle.importKey(
|
|
100
|
+
const key = await importKey("public", () => crypto.subtle.importKey(
|
|
96
101
|
"spki",
|
|
97
102
|
decodePemToBuffer(this.keys.publicKeyPem),
|
|
98
103
|
RSA_PARAMS,
|
|
99
104
|
true,
|
|
100
105
|
["verify"],
|
|
101
|
-
);
|
|
106
|
+
));
|
|
102
107
|
return this.cachedPublic = this.requireStrongKey(key);
|
|
103
108
|
}
|
|
104
109
|
|
|
@@ -112,6 +117,39 @@ export class Rs256Algorithm implements SigningAlgorithm {
|
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
const PEM = /^-----BEGIN ([A-Z0-9 ]+)-----\n([\s\S]*?)\n?-----END \1-----$/;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Normalizes and checks one PEM string: literal `\n` (a key stored on one line
|
|
124
|
+
* in .env or a CI secret) and CRLF become line breaks; the label must be the
|
|
125
|
+
* one Web Crypto imports (PKCS#8 private, SPKI public), with a fix otherwise.
|
|
126
|
+
*/
|
|
127
|
+
function checkedPem(field: "publicKeyPem" | "privateKeyPem", value: string): string {
|
|
128
|
+
// A backslash never occurs in base64 or PEM armor, so this cannot change a valid key.
|
|
129
|
+
const pem = value.replaceAll("\\n", "\n").replaceAll("\r\n", "\n").trim();
|
|
130
|
+
const expected = field === "publicKeyPem" ? "PUBLIC KEY" : "PRIVATE KEY";
|
|
131
|
+
const match = PEM.exec(pem);
|
|
132
|
+
const label = match?.[1] ?? /^-----BEGIN ([A-Z0-9 ]+)-----/.exec(pem)?.[1];
|
|
133
|
+
if (label === undefined) throw new TypeError(`RS256 ${field} is not a PEM: expected "-----BEGIN ${expected}-----"`);
|
|
134
|
+
if (field === "privateKeyPem" && label === "RSA PRIVATE KEY") throw new TypeError("RS256 privateKeyPem is a PKCS#1 key (BEGIN RSA PRIVATE KEY); convert it to PKCS#8: openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem");
|
|
135
|
+
if (field === "privateKeyPem" && label === "ENCRYPTED PRIVATE KEY") throw new TypeError("RS256 privateKeyPem is encrypted; remove the passphrase first: openssl pkcs8 -in key.pem -out key-plain.pem");
|
|
136
|
+
if (field === "privateKeyPem" && label.endsWith("PUBLIC KEY")) throw new TypeError("RS256 privateKeyPem contains a PUBLIC KEY; pass the private key (BEGIN PRIVATE KEY)");
|
|
137
|
+
if (field === "publicKeyPem" && label === "RSA PUBLIC KEY") throw new TypeError("RS256 publicKeyPem is a PKCS#1 public key (BEGIN RSA PUBLIC KEY); convert it to SPKI: openssl rsa -RSAPublicKey_in -in public.pem -pubout -out public-spki.pem");
|
|
138
|
+
if (field === "publicKeyPem" && label.endsWith("PRIVATE KEY")) throw new TypeError("RS256 publicKeyPem contains a private key; give verifiers only the public key: openssl pkey -in key.pem -pubout -out public.pem");
|
|
139
|
+
if (label !== expected || match === undefined || match === null) throw new TypeError(`RS256 ${field} is not a PEM: expected "-----BEGIN ${expected}-----"`);
|
|
140
|
+
if (!/^[A-Za-z0-9+/=\s]+$/.test(match[2]!)) throw new TypeError(`RS256 ${field} has an invalid base64 body`);
|
|
141
|
+
return pem;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Web Crypto says only "Invalid keyData"; name the key that failed. */
|
|
145
|
+
async function importKey(which: "private" | "public", work: () => Promise<CryptoKey>): Promise<CryptoKey> {
|
|
146
|
+
try {
|
|
147
|
+
return await work();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new Error(`RS256 ${which} key could not be imported (${error instanceof Error ? error.message : String(error)}); check that it is an RSA ${which === "private" ? "PKCS#8 private" : "SPKI public"} key in PEM`, { cause: error });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
115
153
|
/** Convenience factory for {@link Rs256Algorithm}. */
|
|
116
154
|
export function rs256(keys: Rs256KeyMaterial): Rs256Algorithm {
|
|
117
155
|
return new Rs256Algorithm(keys);
|
|
@@ -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.26",
|
|
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",
|