bazis 0.98.17 → 0.98.19
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/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/library/orm/Providers/PostgresProvider.ts +4 -0
- package/library/orm/Providers/operationDeadline.ts +4 -3
- package/library/orm/Query/EntityQuery.ts +2 -0
- package/library/orm/Query/ImmediateMutations.ts +65 -16
- package/library/orm/Transactions/TransactionScopeCoordinator.ts +45 -18
- package/library/orm/errors.ts +13 -5
- package/package.json +1 -1
|
@@ -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.19" }, 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");
|
|
@@ -828,6 +828,7 @@ export class PostgresProvider implements DatabaseProvider {
|
|
|
828
828
|
let result!: T;
|
|
829
829
|
let failure: unknown;
|
|
830
830
|
try {
|
|
831
|
+
this.traceSql("BEGIN", []);
|
|
831
832
|
await this.ownerCommand(owner, "BEGIN");
|
|
832
833
|
await this.configureSession(owner);
|
|
833
834
|
result = await this.transactionCallbacks.run(callbacks, () => this.ambient.run({ executor, session: owner.session, owner }, async () => {
|
|
@@ -837,6 +838,7 @@ export class PostgresProvider implements DatabaseProvider {
|
|
|
837
838
|
}));
|
|
838
839
|
operation.signal.throwIfAborted();
|
|
839
840
|
committing = true;
|
|
841
|
+
this.traceSql("COMMIT", []);
|
|
840
842
|
await this.ownerCommand(owner, "COMMIT");
|
|
841
843
|
committed = true;
|
|
842
844
|
} catch (error) {
|
|
@@ -850,6 +852,7 @@ export class PostgresProvider implements DatabaseProvider {
|
|
|
850
852
|
if (operation.signal.aborted || owner.quarantinePromise) await this.quarantineOwner(owner);
|
|
851
853
|
else {
|
|
852
854
|
const cleanup = new OperationDeadline(this.cancellationTimeoutMs);
|
|
855
|
+
this.traceSql("ROLLBACK", []);
|
|
853
856
|
try { await cleanup.wait(this.native(() => owner.session.unsafe("ROLLBACK"))); }
|
|
854
857
|
catch { await this.quarantineOwner(owner); }
|
|
855
858
|
finally { cleanup.dispose(); }
|
|
@@ -1265,6 +1268,7 @@ export class PostgresProvider implements DatabaseProvider {
|
|
|
1265
1268
|
const rollback = async () => {
|
|
1266
1269
|
// No further owner dispatch can pass the synchronous quarantine fence.
|
|
1267
1270
|
await deadline.wait(Promise.allSettled([...(owner.pending ?? [])]));
|
|
1271
|
+
this.traceSql("ROLLBACK", []);
|
|
1268
1272
|
await deadline.wait(this.native(() => { deadline.signal.throwIfAborted(); return owner.session.unsafe("ROLLBACK"); }, true));
|
|
1269
1273
|
const current = await this.readBackendIdentity(owner.session, deadline);
|
|
1270
1274
|
if (Object.entries(identity).some(([key, value]) => current[key as keyof BackendIdentity] !== value)) throw new OrmTransactionScopeError("PostgreSQL rollback backend identity changed.");
|
|
@@ -11,11 +11,12 @@ export class OperationDeadline {
|
|
|
11
11
|
readonly controller = new AbortController();
|
|
12
12
|
readonly signal = this.controller.signal;
|
|
13
13
|
private readonly timer: ReturnType<typeof setTimeout>;
|
|
14
|
-
|
|
14
|
+
// A parent deadline's reason (for example a scope timeout) survives nesting.
|
|
15
|
+
private readonly onAbort = () => this.controller.abort(this.parent?.reason instanceof OrmTransactionScopeError ? this.parent.reason : new OrmTransactionScopeError(`${this.label} was aborted.`));
|
|
15
16
|
|
|
16
|
-
constructor(timeoutMs: number, private readonly parent?: AbortSignal) {
|
|
17
|
+
constructor(timeoutMs: number, private readonly parent?: AbortSignal, private readonly label = "ORM operation") {
|
|
17
18
|
if (parent !== undefined && !(parent instanceof AbortSignal)) throw new TypeError("signal must be an AbortSignal.");
|
|
18
|
-
this.timer = setTimeout(() => this.controller.abort(new OrmTransactionScopeError(
|
|
19
|
+
this.timer = setTimeout(() => this.controller.abort(new OrmTransactionScopeError(`${label} timed out after ${timeoutMs} ms.`)), timeoutMs);
|
|
19
20
|
if (parent?.aborted) this.onAbort();
|
|
20
21
|
else parent?.addEventListener("abort", this.onAbort, { once: true });
|
|
21
22
|
}
|
|
@@ -207,6 +207,8 @@ export class EntityQuery<T extends object, TResult = T> {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
function assertSkipLocked(model: EntityModel, runtime: DbContextRuntime, plan: QueryPlan, terminal: "toList" | "count"): void {
|
|
210
|
+
// Outside a transaction PostgreSQL releases the lock right after the SELECT.
|
|
211
|
+
if (plan.rowLock && !plan.skipLocked && runtime.provider.isTransactionActive?.() !== true) throw new Error("forUpdate() locks rows only until the surrounding transaction ends; outside a transaction the lock is released right after the SELECT. Run the read and the following saveChanges() inside db.transactionScope(async () => { ... }).");
|
|
210
212
|
if (!plan.skipLocked) return;
|
|
211
213
|
if (terminal !== "toList" || runtime.provider.isTransactionActive?.() !== true || plan.conditions.length === 0 || plan.invalidRequestedLimit !== undefined || !Number.isSafeInteger(plan.limit) || plan.limit! <= 0 || plan.requestedLimit !== undefined && plan.requestedLimit !== plan.limit || plan.offset !== undefined || plan.projections.length !== 0 || plan.includes.length !== 0) throw new Error("FOR UPDATE SKIP LOCKED requires an active transaction, where, positive take, and terminal materialization.");
|
|
212
214
|
const ordered = new Set(plan.orders.map((order) => order.property));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { types } from "node:util";
|
|
2
2
|
import { OrmError, OrmTrackedMutationConflictError, OrmUndeclaredConflictTargetError, OrmUnsafeImmediateMutationError } from "../errors";
|
|
3
|
-
import type
|
|
3
|
+
import { isDatabaseGenerated, type EntityModel, type PropertyModel } from "../Metadata/types";
|
|
4
4
|
import type { DbContextRuntime } from "../runtime";
|
|
5
5
|
import { hasTrackedEntriesForModel } from "../Tracking/ChangeTracker";
|
|
6
6
|
import { isPostgresJsonScalarParameter } from "../Providers/PostgresDialect";
|
|
@@ -16,7 +16,7 @@ export type OrmUniqueKeySelectorV1<T extends object> = (entity: FieldSelector<T>
|
|
|
16
16
|
|
|
17
17
|
export function executeImmediateUpdate<T extends object>(model: EntityModel, runtime: DbContextRuntime, plan: QueryPlan, values: OrmUpdateValuesV1<T>): Promise<OrmMutationResultV1> {
|
|
18
18
|
return runtime.runImmediateOperation(async () => {
|
|
19
|
-
guard(model, runtime); const admittedPlan = admitPlan(plan);
|
|
19
|
+
guard(model, runtime); const admittedPlan = admitPlan(plan, "executeUpdate");
|
|
20
20
|
const conditions = effectiveConditions(model, admittedPlan); const assignments = encodeAssignments(runtime, admitUpdateValues(model, values));
|
|
21
21
|
const compiled = new SqlTranslator(model, runtime.provider.dialect).immediateUpdate(assignments, conditions, (property, value) => encode(runtime, property, snapshot(value), true));
|
|
22
22
|
Object.freeze(compiled.params);
|
|
@@ -28,7 +28,7 @@ export function executeImmediateUpdate<T extends object>(model: EntityModel, run
|
|
|
28
28
|
|
|
29
29
|
export function executeImmediateDelete(model: EntityModel, runtime: DbContextRuntime, plan: QueryPlan): Promise<OrmMutationResultV1> {
|
|
30
30
|
return runtime.runImmediateOperation(async () => {
|
|
31
|
-
guard(model, runtime); const admittedPlan = admitPlan(plan); const conditions = effectiveConditions(model, admittedPlan);
|
|
31
|
+
guard(model, runtime); const admittedPlan = admitPlan(plan, "executeDelete"); const conditions = effectiveConditions(model, admittedPlan);
|
|
32
32
|
const compiled = new SqlTranslator(model, runtime.provider.dialect).immediateDelete(conditions, (property, value) => encode(runtime, property, snapshot(value), true));
|
|
33
33
|
Object.freeze(compiled.params);
|
|
34
34
|
guard(model, runtime);
|
|
@@ -52,13 +52,14 @@ export function insertIfAbsent<T extends object>(model: EntityModel, runtime: Db
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function guard(model: EntityModel, runtime: DbContextRuntime): void {
|
|
55
|
-
if (hasTrackedEntriesForModel(runtime.tracker, model)) throw new OrmTrackedMutationConflictError();
|
|
55
|
+
if (hasTrackedEntriesForModel(runtime.tracker, model)) throw new OrmTrackedMutationConflictError(model.name);
|
|
56
56
|
}
|
|
57
57
|
interface AdmittedImmediatePlan { readonly explicitConditions: readonly Condition[]; readonly ignoreQueryFilters: boolean; }
|
|
58
|
-
function admitPlan(plan: QueryPlan): AdmittedImmediatePlan {
|
|
58
|
+
function admitPlan(plan: QueryPlan, terminal: "executeUpdate" | "executeDelete"): AdmittedImmediatePlan {
|
|
59
59
|
try {
|
|
60
60
|
if (types.isProxy(plan) || typeof plan !== "object" || plan === null || Object.getPrototypeOf(plan) !== Object.prototype) unsafe();
|
|
61
61
|
const fields = Object.getOwnPropertyDescriptors(plan); const names = Object.getOwnPropertyNames(plan); if (Object.getOwnPropertySymbols(plan).length !== 0) unsafe();
|
|
62
|
+
explainPlan(fields, terminal);
|
|
62
63
|
const required = ["conditions", "orders", "noTracking", "includes", "ignoreQueryFilters", "projections"];
|
|
63
64
|
const optional = ["limit", "requestedLimit", "invalidRequestedLimit", "offset", "rowLock", "skipLocked"];
|
|
64
65
|
if (names.length !== required.length || required.some((key) => !Object.prototype.hasOwnProperty.call(fields, key)) || optional.some((key) => Object.prototype.hasOwnProperty.call(fields, key))) unsafe();
|
|
@@ -70,32 +71,69 @@ function admitPlan(plan: QueryPlan): AdmittedImmediatePlan {
|
|
|
70
71
|
return Object.freeze({ explicitConditions: Object.freeze(explicitConditions), ignoreQueryFilters: fields.ignoreQueryFilters!.value });
|
|
71
72
|
} catch (error) { if (error instanceof OrmUnsafeImmediateMutationError) throw error; unsafe(); }
|
|
72
73
|
}
|
|
74
|
+
/** Names the query method that an immediate mutation does not accept; reads data descriptors only. */
|
|
75
|
+
function explainPlan(fields: Record<string, PropertyDescriptor>, terminal: string): void {
|
|
76
|
+
const valueOf = (key: string) => { const descriptor = fields[key]; return descriptor && "value" in descriptor ? descriptor.value as unknown : undefined; };
|
|
77
|
+
const length = (key: string) => { const value = valueOf(key); return Array.isArray(value) && !types.isProxy(value) ? Object.getOwnPropertyDescriptor(value, "length")?.value as unknown : undefined; };
|
|
78
|
+
if (valueOf("noTracking") !== true) unsafe(`call .asNoTracking() before ${terminal}(); immediate mutations bypass the change tracker`);
|
|
79
|
+
if (["limit", "requestedLimit", "invalidRequestedLimit", "offset"].some((key) => key in fields)) unsafe(`${terminal}() does not accept take() or skip(); narrow the rows with where(...)`);
|
|
80
|
+
if ("rowLock" in fields || "skipLocked" in fields) unsafe(`${terminal}() does not accept forUpdate()`);
|
|
81
|
+
for (const [key, method] of [["orders", "orderBy()"], ["includes", "include()"], ["projections", "select()"]] as const) {
|
|
82
|
+
const size = length(key); if (typeof size === "number" && size > 0) unsafe(`${terminal}() does not accept ${method}`);
|
|
83
|
+
}
|
|
84
|
+
if (length("conditions") === 0) unsafe(`add .where(...) before ${terminal}(); changing every row of a table is not allowed, use raw SQL for that`);
|
|
85
|
+
}
|
|
73
86
|
function count(value: unknown): number { if (!Number.isSafeInteger(value) || (value as number) < 0) throw new OrmError("ORM provider returned an invalid immediate mutation count."); return value as number; }
|
|
74
|
-
function unsafe(): never { throw new OrmUnsafeImmediateMutationError(); }
|
|
87
|
+
function unsafe(reason?: string): never { throw new OrmUnsafeImmediateMutationError(reason); }
|
|
88
|
+
function describeValue(value: unknown): string {
|
|
89
|
+
if (value === null) return "null";
|
|
90
|
+
if (Array.isArray(value)) return "array";
|
|
91
|
+
if (value instanceof Date) return "Date";
|
|
92
|
+
if (value instanceof Uint8Array) return "Uint8Array";
|
|
93
|
+
return typeof value;
|
|
94
|
+
}
|
|
95
|
+
function functionValue(name: string): never { unsafe(`"${name}" must be a value, got a function; expressions such as views + 1 are not supported, use raw SQL for them`); }
|
|
75
96
|
|
|
76
97
|
interface AdmittedAssignment { readonly property: PropertyModel; readonly value: unknown; }
|
|
77
98
|
function admitUpdateValues<T extends object>(model: EntityModel, values: OrmUpdateValuesV1<T>): readonly AdmittedAssignment[] {
|
|
78
|
-
if (!plain(values)) unsafe();
|
|
79
|
-
const descriptors = ownData(values); const keys = [...descriptors.keys()]; if (keys.length === 0) unsafe();
|
|
99
|
+
if (!plain(values)) unsafe('pass the new values as a plain object, for example { status: "archived" }');
|
|
100
|
+
const descriptors = ownData(values); const keys = [...descriptors.keys()]; if (keys.length === 0) unsafe("pass at least one property to set");
|
|
80
101
|
const out: AdmittedAssignment[] = [];
|
|
81
102
|
for (const key of keys) {
|
|
82
103
|
const descriptor = descriptors.get(key)!;
|
|
83
|
-
const property = model.propertyByName(key);
|
|
104
|
+
const property = model.propertyByName(key);
|
|
105
|
+
if (!property) unsafe(`"${key}" is not a mapped property of ${model.name}`);
|
|
106
|
+
if (property.isKey) unsafe(`"${key}" is the primary key of ${model.name} and cannot be changed`);
|
|
107
|
+
if (property.generation !== "none" || property.convention) unsafe(`"${key}" is filled automatically and cannot be set`);
|
|
108
|
+
if (descriptor.value === undefined) unsafe(`"${key}" is undefined; omit it or pass a value`);
|
|
109
|
+
if (descriptor.value === null) {
|
|
110
|
+
if (property.required) unsafe(`"${key}" is required (NOT NULL) and cannot be set to null`);
|
|
111
|
+
out.push(Object.freeze({ property, value: null })); continue;
|
|
112
|
+
}
|
|
113
|
+
if (typeof descriptor.value === "function") functionValue(key);
|
|
84
114
|
out.push(Object.freeze({ property, value: snapshot(descriptor.value) }));
|
|
85
115
|
}
|
|
86
116
|
return Object.freeze(out.map((value) => Object.freeze(value)));
|
|
87
117
|
}
|
|
88
118
|
function admitInsertValues<T extends object>(model: EntityModel, entity: T): readonly AdmittedAssignment[] {
|
|
89
|
-
if (!objectInput(entity)) unsafe();
|
|
119
|
+
if (!objectInput(entity)) unsafe("pass an entity object to insertIfAbsent()");
|
|
90
120
|
const descriptors = ownData(entity);
|
|
91
121
|
const out: AdmittedAssignment[] = [];
|
|
92
122
|
for (const property of model.properties) {
|
|
93
|
-
const descriptor = descriptors.get(property.propertyName);
|
|
94
|
-
|
|
123
|
+
const descriptor = descriptors.get(property.propertyName);
|
|
124
|
+
// An unset generated key is assigned as by saveChanges(); the caller's entity stays unchanged.
|
|
125
|
+
if (property.isKey && model.key.length === 1 && unsetKey(descriptor?.value)) {
|
|
126
|
+
if (isDatabaseGenerated(property.generation)) continue;
|
|
127
|
+
if (property.generation === "uuidV7") { out.push(Object.freeze({ property, value: Bun.randomUUIDv7() })); continue; }
|
|
128
|
+
}
|
|
129
|
+
if (!descriptor || descriptor.value === undefined) unsafe(`"${property.propertyName}" is missing; pass an entity with every mapped property of ${model.name}`);
|
|
130
|
+
if (descriptor.value === null) { if (property.required) unsafe(`"${property.propertyName}" is required (NOT NULL); got null`); out.push(Object.freeze({ property, value: null })); continue; }
|
|
131
|
+
if (typeof descriptor.value === "function") functionValue(property.propertyName);
|
|
95
132
|
out.push(Object.freeze({ property, value: snapshot(descriptor.value) }));
|
|
96
133
|
}
|
|
97
134
|
return Object.freeze(out.map((value) => Object.freeze(value)));
|
|
98
135
|
}
|
|
136
|
+
function unsetKey(value: unknown): boolean { return value === undefined || value === null || value === "" || value === 0; }
|
|
99
137
|
function encodeAssignments(runtime: DbContextRuntime, assignments: readonly AdmittedAssignment[]): readonly { readonly property: PropertyModel; readonly value: SqlParam }[] {
|
|
100
138
|
return Object.freeze(assignments.map(({ property, value }) => Object.freeze({ property, value: value === null ? null : encode(runtime, property, value) })));
|
|
101
139
|
}
|
|
@@ -104,7 +142,11 @@ function encode(runtime: DbContextRuntime, property: PropertyModel, input: unkno
|
|
|
104
142
|
const providerValue = property.converter ? property.converter.toProvider(input) : input;
|
|
105
143
|
const safeProvider = storageValue(providerValue, property.type, allowNull);
|
|
106
144
|
return snapshotEncodedSqlParam(runtime.provider.dialect.encode(safeProvider, property.type));
|
|
107
|
-
} catch {
|
|
145
|
+
} catch {
|
|
146
|
+
// Hostile inputs keep the generic message; describing them could run their traps.
|
|
147
|
+
if (typeof input === "object" && input !== null && types.isProxy(input)) unsafe();
|
|
148
|
+
unsafe(property.converter ? `"${property.propertyName}" could not be converted for its ${property.type} column` : `"${property.propertyName}" expects ${property.type}, got ${describeValue(input)}`);
|
|
149
|
+
}
|
|
108
150
|
}
|
|
109
151
|
|
|
110
152
|
function conflictTarget<T extends object>(model: EntityModel, options: { readonly conflictBy: OrmUniqueKeySelectorV1<T> }): readonly string[] {
|
|
@@ -120,11 +162,18 @@ function conflictTarget<T extends object>(model: EntityModel, options: { readonl
|
|
|
120
162
|
if (!Number.isSafeInteger(length) || length < 1 || targetDescriptors.size !== length + 1) unsafe();
|
|
121
163
|
const properties: string[] = [];
|
|
122
164
|
for (let index = 0; index < length; index += 1) { const operand = targetDescriptors.get(String(index))?.value; if (typeof operand !== "object" || operand === null || types.isProxy(operand)) unsafe(); const property = issued.get(operand as Operand); if (!property) unsafe(); properties.push(property); }
|
|
123
|
-
|
|
165
|
+
const repeated = properties.find((name, index) => properties.indexOf(name) !== index);
|
|
166
|
+
if (repeated !== undefined) throw new OrmUndeclaredConflictTargetError(`conflictBy lists "${repeated}" more than once`);
|
|
124
167
|
const columns = properties.map((name) => model.propertyByName(name)?.columnName);
|
|
125
|
-
|
|
168
|
+
const unknown = properties.find((_name, index) => columns[index] === undefined);
|
|
169
|
+
if (unknown !== undefined) throw new OrmUndeclaredConflictTargetError(`conflictBy uses "${unknown}", which is not a mapped property of ${model.name}`);
|
|
126
170
|
const candidates = [model.key.map((property) => property.columnName), ...model.indexes.filter((index) => index.unique).map((index) => index.columns)];
|
|
127
|
-
if (!candidates.some((candidate) => candidate.length === columns.length && candidate.every((column, index) => column === columns[index])))
|
|
171
|
+
if (!candidates.some((candidate) => candidate.length === columns.length && candidate.every((column, index) => column === columns[index]))) {
|
|
172
|
+
// Candidates are physical columns; the message speaks in property names, in the declared order.
|
|
173
|
+
const propertyOf = (column: string) => model.properties.find((property) => property.columnName === column)?.propertyName ?? column;
|
|
174
|
+
const options = candidates.map((candidate) => `(${candidate.map(propertyOf).join(", ")})`).join(", ");
|
|
175
|
+
throw new OrmUndeclaredConflictTargetError(`conflictBy (${properties.join(", ")}) is not the primary key or a unique index of ${model.name}. Use one of: ${options}; or declare @Index({ unique: true }) on these properties`);
|
|
176
|
+
}
|
|
128
177
|
return Object.freeze(columns as string[]);
|
|
129
178
|
}
|
|
130
179
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import type { DbContext } from "../DbContext";
|
|
3
|
-
import { ConcurrentTransactionScopeError, OrmDatabaseTimeError, OrmProviderIdentityMismatchError, OrmTransactionScopeError } from "../errors";
|
|
3
|
+
import { ConcurrentTransactionScopeError, differentProviderMessage, OrmDatabaseTimeError, OrmProviderIdentityMismatchError, OrmTransactionScopeError } 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";
|
|
@@ -47,6 +47,27 @@ const uncertainContexts = new WeakSet<object>();
|
|
|
47
47
|
const contextTokens = new WeakMap<object, object>();
|
|
48
48
|
const contextProviders = new WeakMap<object, DatabaseProvider>();
|
|
49
49
|
|
|
50
|
+
const contextName = (context: object): string => (context.constructor as { readonly name?: string } | undefined)?.name || "This DbContext";
|
|
51
|
+
const scopeClosed = () => new OrmTransactionScopeError("ORM transaction scope has already finished or is closing; await every ORM call inside the scope callback.");
|
|
52
|
+
const nestedScopeRunning = () => new OrmTransactionScopeError("A nested transaction scope is still running; await it before using the outer scope.");
|
|
53
|
+
const notEnrolled = (context: object) => new OrmTransactionScopeError(`${contextName(context)} is not part of the surrounding transaction scope. Run it through tx.use(context, work) to share the transaction, or use it after the scope.`);
|
|
54
|
+
|
|
55
|
+
/** Explains why `context` cannot run ORM work in the current scope view. */
|
|
56
|
+
function scopeRejection(context: object, view: View): OrmTransactionScopeError {
|
|
57
|
+
const provider = contextProviders.get(context);
|
|
58
|
+
if (provider && rootAuthority(provider) !== view.frame.authority) return new OrmTransactionScopeError(differentProviderMessage(contextName(context)));
|
|
59
|
+
if (!view.enrolled.has(contextToken(context))) return notEnrolled(context);
|
|
60
|
+
if (view.frame.state !== "active") return scopeClosed();
|
|
61
|
+
return view.frame.activeChild !== undefined ? nestedScopeRunning() : new OrmTransactionScopeError();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A failed operation dooms its scope; a callback that caught the error still learns why. */
|
|
65
|
+
function operationFailed(error: unknown): OrmTransactionScopeError {
|
|
66
|
+
if (error instanceof OrmTransactionScopeError) return error;
|
|
67
|
+
const reason = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
68
|
+
return new OrmTransactionScopeError(`ORM transaction scope was rolled back because an operation inside it failed: ${reason}. A failed operation marks the whole scope for rollback even when its error is caught; to continue after an expected failure, run that operation in a nested transactionScope().`, { cause: error });
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
/** Private, non-barrel signal: SaveExecutor uses it to suppress retries only
|
|
51
72
|
* in a coordinator-owned ORM scope, never in arbitrary provider transactions. */
|
|
52
73
|
export function isNewOrmTransactionScopeActive(): boolean {
|
|
@@ -69,7 +90,7 @@ export function observedProvider(context: object, provider: DatabaseProvider): D
|
|
|
69
90
|
if (!view) return guarded();
|
|
70
91
|
const frame = view.frame;
|
|
71
92
|
if (frame.authority !== authority || !view.enrolled.has(token) || frame.state !== "active" || frame.activeChild !== undefined) {
|
|
72
|
-
return Promise.reject(
|
|
93
|
+
return Promise.reject(scopeRejection(context, view));
|
|
73
94
|
}
|
|
74
95
|
return trackTask(frame, guarded);
|
|
75
96
|
};
|
|
@@ -87,7 +108,7 @@ export function monitorWholeOperation<T>(context: object, operation: () => Promi
|
|
|
87
108
|
if (uncertainContexts.has(contextToken(context))) return Promise.reject(new TransactionOutcomeUnknownError());
|
|
88
109
|
const view = views.getStore();
|
|
89
110
|
if (!view) return operation();
|
|
90
|
-
if (!view.enrolled.has(contextToken(context)) || view.frame.state !== "active" || view.frame.activeChild !== undefined) return Promise.reject(
|
|
111
|
+
if (!view.enrolled.has(contextToken(context)) || view.frame.state !== "active" || view.frame.activeChild !== undefined) return Promise.reject(scopeRejection(context, view));
|
|
91
112
|
// Save work must not inherit a forever `use` callback task: only its
|
|
92
113
|
// post-validation unwind is framework-owned during physical quarantine.
|
|
93
114
|
return trackTask(view.frame, operation, "save", true);
|
|
@@ -99,11 +120,11 @@ export function monitorImmediateOperation<T>(context: object, operation: () => T
|
|
|
99
120
|
const view = views.getStore();
|
|
100
121
|
const token = contextToken(context);
|
|
101
122
|
const run = () => Promise.resolve().then(() => {
|
|
102
|
-
if (view && (!view.enrolled.has(token) || view.frame.state !== "active" || view.frame.activeChild !== undefined)) throw
|
|
123
|
+
if (view && (!view.enrolled.has(token) || view.frame.state !== "active" || view.frame.activeChild !== undefined)) throw scopeRejection(context, view);
|
|
103
124
|
return operation();
|
|
104
125
|
});
|
|
105
126
|
if (!view) return run();
|
|
106
|
-
if (!view.enrolled.has(token) || view.frame.state !== "active" || view.frame.activeChild !== undefined) return Promise.reject(
|
|
127
|
+
if (!view.enrolled.has(token) || view.frame.state !== "active" || view.frame.activeChild !== undefined) return Promise.reject(scopeRejection(context, view));
|
|
107
128
|
return trackTask(view.frame, run, "immediate", true);
|
|
108
129
|
}
|
|
109
130
|
|
|
@@ -123,9 +144,9 @@ export function markCurrentSaveRequiresUnwind(): void {
|
|
|
123
144
|
function trackTask<T>(frame: Frame, operation: () => Promise<T>, kind: PendingTask["kind"] = "user", distinct = false): Promise<T> {
|
|
124
145
|
const parent = currentTasks.getStore();
|
|
125
146
|
if (!distinct && parent?.frame === frame && frame.pending.has(parent)) {
|
|
126
|
-
const promise = Promise.resolve().then(() => { if (frame.state !== "active") throw
|
|
147
|
+
const promise = Promise.resolve().then(() => { if (frame.state !== "active") throw scopeClosed(); return operation(); });
|
|
127
148
|
parent.attached.add(promise);
|
|
128
|
-
void promise.catch(() => { frame.rollbackOnly ??=
|
|
149
|
+
void promise.catch((error) => { frame.rollbackOnly ??= operationFailed(error); });
|
|
129
150
|
promise.finally(() => { parent.attached.delete(promise); releaseTask(parent); }).catch(() => {});
|
|
130
151
|
return Promise.race([promise, frame.abortPromise]);
|
|
131
152
|
}
|
|
@@ -134,12 +155,12 @@ function trackTask<T>(frame: Frame, operation: () => Promise<T>, kind: PendingTa
|
|
|
134
155
|
const task: PendingTask = { frame, kind, dispatches: new Set(), attached: new Set(), promise: Promise.resolve(), underlying: Promise.resolve(), abort, settled: false };
|
|
135
156
|
frame.pending.add(task);
|
|
136
157
|
const underlying = Promise.resolve().then(() => {
|
|
137
|
-
if (frame.state !== "active") throw
|
|
158
|
+
if (frame.state !== "active") throw scopeClosed();
|
|
138
159
|
return currentTasks.run(task, operation);
|
|
139
160
|
});
|
|
140
161
|
task.promise = underlying;
|
|
141
162
|
(task as { underlying: Promise<unknown> }).underlying = underlying;
|
|
142
|
-
void underlying.catch(() => { frame.rollbackOnly ??=
|
|
163
|
+
void underlying.catch((error) => { frame.rollbackOnly ??= operationFailed(error); });
|
|
143
164
|
underlying.finally(() => { task.settled = true; releaseTask(task); }).catch(() => {});
|
|
144
165
|
// Permanent rejection sink makes detached hostile user tails safe.
|
|
145
166
|
void abortProjection.catch(() => {});
|
|
@@ -154,14 +175,17 @@ export async function transactionScope<TResult>(context: DbContext, provider: Da
|
|
|
154
175
|
let signal = options?.signal;
|
|
155
176
|
if (options?.timeoutMs !== undefined) positiveTimeout(options.timeoutMs, 30_000, "timeoutMs");
|
|
156
177
|
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError("ORM transaction signal must be an AbortSignal.");
|
|
157
|
-
const
|
|
178
|
+
const abortReason = () => signal?.reason instanceof OrmTransactionScopeError ? signal.reason : new OrmTransactionScopeError("ORM transaction scope was aborted.");
|
|
179
|
+
const assertNotAborted = () => { if (signal?.aborted) throw abortReason(); };
|
|
158
180
|
assertNotAborted();
|
|
159
181
|
const token = contextToken(context);
|
|
160
182
|
if (uncertainContexts.has(token)) throw new TransactionOutcomeUnknownError();
|
|
161
183
|
const authority = rootAuthority(provider);
|
|
162
184
|
const current = views.getStore();
|
|
163
185
|
if (current) {
|
|
164
|
-
if (current.frame.authority !== authority
|
|
186
|
+
if (current.frame.authority !== authority) throw new OrmProviderIdentityMismatchError(contextName(context));
|
|
187
|
+
if (!current.enrolled.has(token)) throw notEnrolled(context);
|
|
188
|
+
if (current.frame.state !== "active") throw scopeClosed();
|
|
165
189
|
if (current.frame.activeChild !== undefined) throw new ConcurrentTransactionScopeError();
|
|
166
190
|
}
|
|
167
191
|
const base = baseProvider(provider);
|
|
@@ -171,7 +195,7 @@ export async function transactionScope<TResult>(context: DbContext, provider: Da
|
|
|
171
195
|
const uncertain = (error: unknown) => postgres.isOutcomeUncertain ? postgres.isOutcomeUncertain(error) : isUnknownTransactionOutcome(error);
|
|
172
196
|
const inheritedSignal = postgres.operationSignal?.();
|
|
173
197
|
const timeoutMs = positiveTimeout(options?.timeoutMs, postgres.operationTimeoutMs ?? 30_000, "timeoutMs");
|
|
174
|
-
const deadline = new OperationDeadline(timeoutMs, signal && inheritedSignal ? AbortSignal.any([signal, inheritedSignal]) : signal ?? inheritedSignal);
|
|
198
|
+
const deadline = new OperationDeadline(timeoutMs, signal && inheritedSignal ? AbortSignal.any([signal, inheritedSignal]) : signal ?? inheritedSignal, "ORM transaction scope");
|
|
175
199
|
signal = deadline.signal;
|
|
176
200
|
const parent = current?.frame;
|
|
177
201
|
const ownership = Symbol("orm transaction child");
|
|
@@ -210,7 +234,7 @@ export async function transactionScope<TResult>(context: DbContext, provider: Da
|
|
|
210
234
|
return withProviderDispatchObserver((dispatch) => registerDispatch(frame, dispatch), () => views.run(view, async () => {
|
|
211
235
|
const transaction = new ScopedOrmTransaction(frame);
|
|
212
236
|
const onAbort = () => {
|
|
213
|
-
const error =
|
|
237
|
+
const error = abortReason();
|
|
214
238
|
// Fence synchronously: a callback catching its aborted operation
|
|
215
239
|
// cannot enqueue another query before asynchronous close starts.
|
|
216
240
|
for (let target: Frame | undefined = frame; target; target = target.activeChildFrame) {
|
|
@@ -286,13 +310,13 @@ function registerDispatch(frame: Frame, dispatch: CancellableProviderDispatch):
|
|
|
286
310
|
const settled = Promise.resolve(dispatch.settled);
|
|
287
311
|
// Keep a rejection sink for detached driver promises while preserving the
|
|
288
312
|
// original caller-facing rejection returned by query/execute.
|
|
289
|
-
void settled.catch(() => { frame.rollbackOnly ??=
|
|
313
|
+
void settled.catch((error) => { frame.rollbackOnly ??= operationFailed(error); });
|
|
290
314
|
settled.finally(() => { task.dispatches.delete(dispatch); releaseTask(task); }).catch(() => {});
|
|
291
315
|
}
|
|
292
316
|
|
|
293
317
|
async function settleFrame(frame: Frame): Promise<void> {
|
|
294
318
|
if (frame.pending.size === 0) return;
|
|
295
|
-
frame.rollbackOnly ??= new OrmTransactionScopeError("ORM transaction scope
|
|
319
|
+
frame.rollbackOnly ??= new OrmTransactionScopeError("ORM transaction scope callback returned while ORM operations were still running, so the scope was rolled back; await every ORM call inside the scope callback.");
|
|
296
320
|
const capability = postgresTransactionCapability(frame.provider);
|
|
297
321
|
const hasDispatch = [...frame.pending].some((task) => task.dispatches.size > 0);
|
|
298
322
|
let quarantine = frame.cancellationRequested || (capability?.quarantineOnPendingDispatch && hasDispatch);
|
|
@@ -335,6 +359,7 @@ async function settleFrame(frame: Frame): Promise<void> {
|
|
|
335
359
|
}
|
|
336
360
|
}
|
|
337
361
|
|
|
362
|
+
const foreignTransaction = () => new OrmTransactionScopeError("This transaction object belongs to another transaction scope; use the object passed to the innermost transactionScope() callback.");
|
|
338
363
|
const transactionFrames = new WeakMap<ScopedOrmTransaction, Frame>();
|
|
339
364
|
class ScopedOrmTransaction extends OrmTransaction {
|
|
340
365
|
constructor(frame: Frame) { super(); transactionFrames.set(this, frame); }
|
|
@@ -343,9 +368,9 @@ class ScopedOrmTransaction extends OrmTransaction {
|
|
|
343
368
|
const frame = this.#requireCurrentFrame();
|
|
344
369
|
const token = contextToken(context);
|
|
345
370
|
const provider = contextProviders.get(context);
|
|
346
|
-
if (!provider || rootAuthority(provider) !== frame.authority) throw new OrmProviderIdentityMismatchError();
|
|
371
|
+
if (!provider || rootAuthority(provider) !== frame.authority) throw new OrmProviderIdentityMismatchError(contextName(context));
|
|
347
372
|
const parent = views.getStore();
|
|
348
|
-
if (!parent || parent.frame !== frame) throw
|
|
373
|
+
if (!parent || parent.frame !== frame) throw foreignTransaction();
|
|
349
374
|
if (uncertainContexts.has(token)) throw new TransactionOutcomeUnknownError();
|
|
350
375
|
for (let target: Frame | undefined = frame; target; target = target.parent) target.allEnrolled.add(token);
|
|
351
376
|
const enrolled = new Set(parent.enrolled); enrolled.add(token);
|
|
@@ -371,7 +396,9 @@ class ScopedOrmTransaction extends OrmTransaction {
|
|
|
371
396
|
#requireCurrentFrame(): Frame {
|
|
372
397
|
const frame = transactionFrames.get(this);
|
|
373
398
|
const view = views.getStore();
|
|
374
|
-
if (!frame || frame.state !== "active"
|
|
399
|
+
if (!frame || frame.state !== "active") throw new OrmTransactionScopeError("ORM transaction scope has already finished or is closing; use the transaction object only inside its transactionScope() callback.");
|
|
400
|
+
if (frame.activeChild !== undefined) throw nestedScopeRunning();
|
|
401
|
+
if (!view || view.frame !== frame) throw foreignTransaction();
|
|
375
402
|
return frame;
|
|
376
403
|
}
|
|
377
404
|
}
|
package/library/orm/errors.ts
CHANGED
|
@@ -119,12 +119,16 @@ export class ConcurrentTransactionScopeError extends OrmError {
|
|
|
119
119
|
|
|
120
120
|
/** Safe failure for a stale, closing, or unenrolled ORM transaction capability. */
|
|
121
121
|
export class OrmTransactionScopeError extends OrmError {
|
|
122
|
-
constructor(message = "ORM transaction scope is not active for this operation.") { super(message); }
|
|
122
|
+
constructor(message = "ORM transaction scope is not active for this operation.", options?: ErrorOptions) { super(message, options); }
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/** Contexts may share transaction work only when their configured provider is the same object. */
|
|
126
126
|
export class OrmProviderIdentityMismatchError extends OrmError {
|
|
127
|
-
constructor() { super(
|
|
127
|
+
constructor(contextName = "This DbContext") { super(differentProviderMessage(contextName)); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function differentProviderMessage(contextName: string): string {
|
|
131
|
+
return `${contextName} uses a different database provider than the surrounding transaction scope. Contexts share one transaction only when they are created with the same provider object.`;
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
/** The provider did not return an exact safe millisecond database timestamp. */
|
|
@@ -134,15 +138,19 @@ export class OrmDatabaseTimeError extends OrmError {
|
|
|
134
138
|
|
|
135
139
|
/** Immediate DML is deliberately isolated from tracked state. */
|
|
136
140
|
export class OrmTrackedMutationConflictError extends OrmError {
|
|
137
|
-
constructor() {
|
|
141
|
+
constructor(entityName?: string) {
|
|
142
|
+
super(entityName === undefined
|
|
143
|
+
? "Immediate ORM mutation conflicts with tracked entities."
|
|
144
|
+
: `Immediate ORM mutation conflicts with tracked entities: this context tracks "${entityName}" entities (loaded with tracking or saved through saveChanges()), which the mutation would make stale. Load them with .asNoTracking(), or run the mutation in a separate DbContext.`);
|
|
145
|
+
}
|
|
138
146
|
}
|
|
139
147
|
/** Immediate DML accepts only the small, closed descriptor-safe input domain. */
|
|
140
148
|
export class OrmUnsafeImmediateMutationError extends OrmError {
|
|
141
|
-
constructor() { super("Immediate ORM mutation input is unsafe or unsupported."); }
|
|
149
|
+
constructor(reason?: string) { super(reason === undefined ? "Immediate ORM mutation input is unsafe or unsupported." : `Immediate ORM mutation input is unsafe or unsupported: ${reason}.`); }
|
|
142
150
|
}
|
|
143
151
|
/** INSERT ON CONFLICT may name only an exactly declared unique target. */
|
|
144
152
|
export class OrmUndeclaredConflictTargetError extends OrmError {
|
|
145
|
-
constructor() { super("Immediate ORM mutation conflict target is not a declared unique key."); }
|
|
153
|
+
constructor(detail?: string) { super(detail === undefined ? "Immediate ORM mutation conflict target is not a declared unique key." : `Immediate ORM mutation conflict target is not a declared unique key: ${detail}.`); }
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.98.
|
|
3
|
+
"version": "0.98.19",
|
|
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",
|