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.
@@ -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.17" }, capabilities: { experimentalApi: true },
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
- private readonly onAbort = () => this.controller.abort(new OrmTransactionScopeError("ORM operation was aborted."));
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("ORM operation deadline exceeded.")), timeoutMs);
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 { EntityModel, PropertyModel } from "../Metadata/types";
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); if (!property || property.isKey || property.generation !== "none" || property.convention || descriptor.value === undefined || descriptor.value === null) unsafe();
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); if (!descriptor || descriptor.value === undefined) unsafe();
94
- if (descriptor.value === null) { if (property.required) unsafe(); out.push(Object.freeze({ property, value: null })); continue; }
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 { unsafe(); }
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
- if (new Set(properties).size !== properties.length) throw new OrmUndeclaredConflictTargetError();
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
- if (columns.some((column) => column === undefined)) throw new OrmUndeclaredConflictTargetError();
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]))) throw new OrmUndeclaredConflictTargetError();
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(new OrmTransactionScopeError());
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(new OrmTransactionScopeError());
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 new OrmTransactionScopeError();
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(new OrmTransactionScopeError());
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 new OrmTransactionScopeError(); return operation(); });
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 ??= new OrmTransactionScopeError(); });
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 new OrmTransactionScopeError();
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 ??= new OrmTransactionScopeError(); });
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 assertNotAborted = () => { if (signal?.aborted) throw new OrmTransactionScopeError("ORM transaction scope was aborted."); };
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 || !current.enrolled.has(token) || current.frame.state !== "active") throw new OrmProviderIdentityMismatchError();
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 = new OrmTransactionScopeError("ORM transaction scope was aborted.");
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 ??= new OrmTransactionScopeError(); });
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 has unresolved operations.");
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 new OrmTransactionScopeError();
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" || frame.activeChild !== undefined || !view || view.frame !== frame) throw new OrmTransactionScopeError();
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
  }
@@ -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("ORM transaction context belongs to a different provider identity."); }
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() { super("Immediate ORM mutation conflicts with tracked entities."); }
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.17",
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",