bazis 0.98.16 → 0.98.18

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.16" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.18" }, 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));
@@ -7,7 +7,8 @@ import type { DatabaseProvider, DbExecutor } from "../Providers/types";
7
7
  import { isUnknownTransactionOutcome, registerTransactionUncertainty, TransactionOutcomeUnknownError } from "../Providers/transactionOutcome";
8
8
  import { registerPostCommitFinalizer } from "../Providers/transactionCallbacks";
9
9
  import { EntityState } from "../Tracking/EntityState";
10
- import { assertUniqueTrackedKeys, holdDeletedIdentity, type ChangeTracker, type TrackedEntry } from "../Tracking/ChangeTracker";
10
+ import { assertUniqueTrackedKeys, holdDeletedIdentity, trackedEntriesOf, type ChangeTracker, type TrackedEntry } from "../Tracking/ChangeTracker";
11
+ import { NavigationFixup } from "./navigationFixup";
11
12
  import { CommandBuilder } from "./CommandBuilder";
12
13
  import { applyConventions } from "./applyConventions";
13
14
  import { ExecutionStrategy, type ExecutionStrategyOptions } from "./ExecutionStrategy";
@@ -78,6 +79,10 @@ export class SaveExecutor {
78
79
 
79
80
  private async saveCore(): Promise<number> {
80
81
  if (uncertainTrackers.has(this.tracker)) throw new TransactionOutcomeUnknownError();
82
+ // Foreign keys from navigations first, so a changed navigation on an
83
+ // Unchanged entity is detected as a Modified foreign key.
84
+ const fixup = new NavigationFixup(trackedEntriesOf(this.tracker));
85
+ fixup.applyAll();
81
86
  this.tracker.detectChanges();
82
87
  const pending = this.tracker.entriesToProcess();
83
88
  if (pending.length === 0) {
@@ -127,9 +132,14 @@ export class SaveExecutor {
127
132
  group.push(ordered[i]!);
128
133
  i += 1;
129
134
  }
135
+ // Parents are inserted first: their generated keys are known now.
136
+ for (const added of group) fixup.apply(added);
130
137
  await this.insertBatch(tx, entry.model, group);
131
138
  continue;
132
139
  }
140
+ if (entry.state === EntityState.Modified) {
141
+ for (const name of fixup.apply(entry)) entry.modifiedProperties.add(name);
142
+ }
133
143
  await this.execute(tx, entry);
134
144
  i += 1;
135
145
  }
@@ -0,0 +1,80 @@
1
+ import { EntityState } from "../Tracking/EntityState";
2
+ import type { TrackedEntry } from "../Tracking/ChangeTracker";
3
+
4
+ type Values = Record<string, unknown>;
5
+
6
+ /**
7
+ * Foreign keys from navigations, as EF Core's relationship fix-up does:
8
+ * `article.author = bob` (a `@ManyToOne`) or `bob.articles.push(article)`
9
+ * (a `@OneToMany`) set `article.authorId` from `bob`'s key. Only navigations to
10
+ * entities tracked by the same context are followed; the navigation wins over
11
+ * a different value in the foreign key property.
12
+ */
13
+ export class NavigationFixup {
14
+ /** child entity -> the parents whose collection holds it. */
15
+ private readonly parents = new Map<object, { readonly parent: TrackedEntry; readonly foreignKey: readonly string[] }[]>();
16
+
17
+ constructor(private readonly entries: ReadonlyMap<object, TrackedEntry>) {
18
+ for (const entry of entries.values()) {
19
+ for (const relation of entry.model.relations) {
20
+ if (relation.kind !== "collection") continue;
21
+ const children = (entry.entity as Values)[relation.navigationName];
22
+ if (!Array.isArray(children)) continue;
23
+ for (const child of children) {
24
+ if (child === null || typeof child !== "object" || !entries.has(child)) continue;
25
+ const list = this.parents.get(child) ?? [];
26
+ list.push({ parent: entry, foreignKey: toArray(relation.foreignKey) });
27
+ this.parents.set(child, list);
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ /** Applies the fix-up to every tracked entry (before change detection). */
34
+ applyAll(): void {
35
+ for (const entry of this.entries.values()) {
36
+ if (entry.state !== EntityState.Deleted) this.apply(entry);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Applies the fix-up to one entry and returns the foreign key properties it
42
+ * changed. Called again right before the entry's SQL, when the keys the
43
+ * database generated for its parents are known.
44
+ */
45
+ apply(entry: TrackedEntry): readonly string[] {
46
+ const changed: string[] = [];
47
+ const entity = entry.entity as Values;
48
+ for (const relation of entry.model.relations) {
49
+ if (relation.kind !== "reference") continue;
50
+ const target = entity[relation.navigationName];
51
+ if (target === null || typeof target !== "object") continue;
52
+ const targetEntry = this.entries.get(target);
53
+ if (targetEntry === undefined) continue;
54
+ this.copyKey(entity, toArray(relation.foreignKey), targetEntry, changed);
55
+ }
56
+ for (const { parent, foreignKey } of this.parents.get(entry.entity) ?? []) {
57
+ if (parent.state === EntityState.Deleted) continue;
58
+ this.copyKey(entity, foreignKey, parent, changed);
59
+ }
60
+ return changed;
61
+ }
62
+
63
+ private copyKey(entity: Values, foreignKey: readonly string[], target: TrackedEntry, changed: string[]): void {
64
+ const key = target.model.key;
65
+ if (key.length !== foreignKey.length) return;
66
+ const source = target.entity as Values;
67
+ key.forEach((property, index) => {
68
+ const name = foreignKey[index]!;
69
+ const value = source[property.propertyName];
70
+ if (entity[name] !== value) {
71
+ entity[name] = value;
72
+ changed.push(name);
73
+ }
74
+ });
75
+ }
76
+ }
77
+
78
+ function toArray(value: string | readonly string[]): readonly string[] {
79
+ return typeof value === "string" ? [value] : value;
80
+ }
@@ -342,6 +342,11 @@ export class ChangeTracker {
342
342
  }
343
343
  }
344
344
 
345
+ /** Non-barrel access to every tracked entry, for the save-time navigation fix-up. */
346
+ export function trackedEntriesOf(tracker: ChangeTracker): ReadonlyMap<object, TrackedEntry> {
347
+ return trackerEntries.get(tracker) ?? new Map();
348
+ }
349
+
345
350
  /** Non-barrel helper used only by immediate DML admission. */
346
351
  export function hasTrackedEntriesForModel(tracker: ChangeTracker, model: EntityModel): boolean {
347
352
  return [...(trackerEntries.get(tracker)?.values() ?? [])].some((entry) => entry.model === model);
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.16",
3
+ "version": "0.98.18",
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",