bazis 0.104.1 → 0.105.1

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.
@@ -10,9 +10,18 @@ export function registerNamedDependencyHint(hint: NamedDependencyHint): void {
10
10
  hints.push(hint);
11
11
  }
12
12
 
13
+ function hintFor(name: string): string {
14
+ const hint = hints.map((item) => item(name)).find((text) => text !== undefined);
15
+ return hint ? ` ${hint}` : "";
16
+ }
17
+
18
+ /** The graph-validation issue for a named dependency nobody registers, naming the service that needs it. */
19
+ export function missingNamedDependencyIssue(name: string, requester: string): string {
20
+ return `Missing dependency "${name}" for "${requester}".${hintFor(name)}`;
21
+ }
22
+
13
23
  export class NamedDependencyNotFoundError extends DiError {
14
24
  public constructor(name: string) {
15
- const hint = hints.map((item) => item(name)).find((text) => text !== undefined);
16
- super(`No provider token found for named dependency "${name}".${hint ? ` ${hint}` : ""}`);
25
+ super(`No provider token found for named dependency "${name}".${hintFor(name)}`);
17
26
  }
18
27
  }
@@ -1,4 +1,5 @@
1
1
  import { ServiceValidationError } from "../errors";
2
+ import { missingNamedDependencyIssue } from "../errors/NamedDependencyNotFoundError";
2
3
  import { isLazyDependency, isNamedDependency, isOptionalDependency } from "../provider";
3
4
  import type {
4
5
  KeyedDependency,
@@ -97,7 +98,8 @@ export class GraphValidator {
97
98
  const isOptional = isOptionalDependency(dependency);
98
99
  const inner = isLazy || isOptional ? dependency.inner : dependency;
99
100
  // An optional dependency on a name nothing registered is simply absent.
100
- if (isOptional && isNamedDependency(inner) && !this.context.hasName(inner.name)) {
101
+ if (isNamedDependency(inner) && !this.context.hasName(inner.name)) {
102
+ if (!isOptional) issues.add(missingNamedDependencyIssue(inner.name, tokenToDebugName(registration.token)));
101
103
  continue;
102
104
  }
103
105
  const descriptor = this.context.describeDependency(inner);
@@ -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.104.1" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.105.1" }, 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");
@@ -7,6 +7,16 @@ import { ENV_PREFIX } from "./sources";
7
7
  import { isSensitiveKey, redactSensitiveText } from "../../../library/redaction";
8
8
  import { ConfigValidationError, type ConfigIssue, type ConfigIssueKind } from "./ConfigValidationError";
9
9
  import { callerFile } from "./callerFile";
10
+ import { registerNamedDependencyHint } from "../../di/errors/NamedDependencyNotFoundError";
11
+
12
+ // `ConfigView<T>` is injected by the name of its config; a config that no
13
+ // module declares has no provider, and the start says where to declare it.
14
+ registerNamedDependencyHint((name) => {
15
+ const match = /^Config:(.+)$/.exec(name);
16
+ if (!match) return undefined;
17
+ const declaration = match[1] === "default" ? "defineConfig({ … })" : `defineConfig("${match[1]}", …)`;
18
+ return `The config ${declaration} is not declared in the application: add it to \`config\` of the module that uses it, or to runApp({ config }).`;
19
+ });
10
20
 
11
21
  type Stand = "development" | "test" | "production";
12
22
  type Primitive = string | number | boolean;
@@ -102,6 +102,6 @@ export abstract class DbContext {
102
102
 
103
103
  /** Executes a composable ORM transaction scope for this provider identity. */
104
104
  transactionScope<TResult>(work: (transaction: OrmTransaction) => Promise<TResult>, options?: OrmTransactionScopeOptions): Promise<TResult> {
105
- return transactionScope(this, this.#options.provider, work, options);
105
+ return transactionScope(this, this.#options.provider, work, options, this.#options.executionStrategy);
106
106
  }
107
107
  }
@@ -56,7 +56,8 @@ export class ExecutionStrategy {
56
56
  return false;
57
57
  }
58
58
 
59
- async execute<T>(operation: () => Promise<T>): Promise<T> {
59
+ /** Runs `operation`, again after a transient failure; `beforeRetry` runs before each new attempt. */
60
+ async execute<T>(operation: () => Promise<T>, beforeRetry?: (error: unknown) => void): Promise<T> {
60
61
  let attempt = 0;
61
62
  for (;;) {
62
63
  try {
@@ -67,6 +68,7 @@ export class ExecutionStrategy {
67
68
  }
68
69
  const delay = this.baseDelayMs * 2 ** attempt;
69
70
  await new Promise((resolve) => setTimeout(resolve, delay));
71
+ beforeRetry?.(error);
70
72
  attempt += 1;
71
73
  }
72
74
  }
@@ -347,6 +347,11 @@ export function trackedEntriesOf(tracker: ChangeTracker): ReadonlyMap<object, Tr
347
347
  return trackerEntries.get(tracker) ?? new Map();
348
348
  }
349
349
 
350
+ /** Non-barrel helper: how many entities the tracker holds. */
351
+ export function trackedEntryCount(tracker: ChangeTracker): number {
352
+ return trackerEntries.get(tracker)?.size ?? 0;
353
+ }
354
+
350
355
  /** Non-barrel helper used only by immediate DML admission. */
351
356
  export function hasTrackedEntriesForModel(tracker: ChangeTracker, model: EntityModel): boolean {
352
357
  return [...(trackerEntries.get(tracker)?.values() ?? [])].some((entry) => entry.model === model);
@@ -1,4 +1,5 @@
1
1
  import type { DbContext } from "../DbContext";
2
+ import type { ExecutionStrategyOptions } from "../Saving/ExecutionStrategy";
2
3
 
3
4
  /** A database wall-clock value obtained from the active database session. */
4
5
  export interface OrmDatabaseTimeV1 {
@@ -15,6 +16,16 @@ export interface OrmTransactionScopeOptions {
15
16
  readonly signal?: AbortSignal;
16
17
  /** Total work budget in milliseconds, including admission and COMMIT; positive integer. */
17
18
  readonly timeoutMs?: number;
19
+ /**
20
+ * Runs the whole scope again after a transient failure (deadlock,
21
+ * serialization failure, lost connection): `true` takes the context's
22
+ * `executionStrategy` (or 3 retries from 50 ms), an object sets its own.
23
+ * Every attempt starts from a clean context: what the failed attempt
24
+ * loaded or changed is detached, so the callback must read what it
25
+ * changes. Allowed only for the outermost scope of a context that tracks
26
+ * nothing yet; a COMMIT without acknowledgement is never retried.
27
+ */
28
+ readonly retry?: boolean | ExecutionStrategyOptions;
18
29
  }
19
30
 
20
31
  /** Opaque transaction capability. It deliberately exposes no executor or completion authority. */
@@ -7,6 +7,8 @@ import { OrmTransaction, type OrmDatabaseTimeV1, type OrmTransactionScopeOptions
7
7
 
8
8
  import { isUnknownTransactionOutcome, TransactionOutcomeUnknownError } from "../Providers/transactionOutcome";
9
9
  import { OperationDeadline, positiveTimeout } from "../Providers/operationDeadline";
10
+ import { ExecutionStrategy, type ExecutionStrategyOptions } from "../Saving/ExecutionStrategy";
11
+ import { trackedEntryCount } from "../Tracking/ChangeTracker";
10
12
 
11
13
  interface Frame {
12
14
  readonly allEnrolled: Set<object>;
@@ -20,6 +22,8 @@ interface Frame {
20
22
  activeChildFrame?: Frame;
21
23
  rollbackOnly?: unknown;
22
24
  cancellationRequested?: boolean;
25
+ /** Outermost frame of a retried scope: every context of the attempt, cleared before the next one. */
26
+ readonly retryContexts?: Set<DbContext>;
23
27
  readonly pending: Set<PendingTask>;
24
28
  readonly abortPromise: Promise<never>;
25
29
  abort(error: OrmTransactionScopeError): void;
@@ -173,7 +177,31 @@ function releaseTask(task: PendingTask): void {
173
177
  if (task.settled && task.dispatches.size === 0 && task.attached.size === 0) task.frame.pending.delete(task);
174
178
  }
175
179
 
176
- export async function transactionScope<TResult>(context: DbContext, provider: DatabaseProvider, work: (transaction: OrmTransaction) => Promise<TResult>, options?: OrmTransactionScopeOptions): Promise<TResult> {
180
+ export async function transactionScope<TResult>(context: DbContext, provider: DatabaseProvider, work: (transaction: OrmTransaction) => Promise<TResult>, options?: OrmTransactionScopeOptions, contextStrategy?: ExecutionStrategyOptions): Promise<TResult> {
181
+ const retry = options?.retry;
182
+ if (retry === undefined || retry === false) return runScope(context, provider, work, options);
183
+ if (views.getStore() !== undefined || provider.isTransactionActive?.() === true) {
184
+ throw new OrmTransactionScopeError("transactionScope({ retry }) must be the outermost scope: a nested scope is part of the outer transaction, so retry the outer one.");
185
+ }
186
+ assertRetryable(context);
187
+ const contexts = new Set<DbContext>([context]);
188
+ const strategy = new ExecutionStrategy(retry === true ? contextStrategy ?? {} : retry);
189
+ return strategy.execute(() => runScope(context, provider, work, options, contexts), () => {
190
+ // The rolled-back attempt left its loaded and changed entities behind;
191
+ // the next attempt reads them again instead of applying its changes twice.
192
+ for (const each of contexts) each.changeTracker.clear();
193
+ contexts.clear();
194
+ contexts.add(context);
195
+ });
196
+ }
197
+
198
+ function assertRetryable(context: DbContext): void {
199
+ const tracked = trackedEntryCount(context.changeTracker);
200
+ if (tracked === 0) return;
201
+ throw new OrmTransactionScopeError(`transactionScope({ retry }) needs ${contextName(context)} without tracked entities: every attempt starts from a clean context, and the ${tracked} entities tracked before the scope would lose their changes. Start the scope before loading anything, or create a new scope for it.`);
202
+ }
203
+
204
+ async function runScope<TResult>(context: DbContext, provider: DatabaseProvider, work: (transaction: OrmTransaction) => Promise<TResult>, options: OrmTransactionScopeOptions | undefined, retryContexts?: Set<DbContext>): Promise<TResult> {
177
205
  let signal = options?.signal;
178
206
  if (options?.timeoutMs !== undefined) positiveTimeout(options.timeoutMs, 30_000, "timeoutMs");
179
207
  if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError("ORM transaction signal must be an AbortSignal.");
@@ -230,7 +258,7 @@ export async function transactionScope<TResult>(context: DbContext, provider: Da
230
258
  void abortPromise.catch(() => {});
231
259
  let cancelDrain!: () => void;
232
260
  const cancellationPromise = new Promise<"cancelled">((resolve) => { cancelDrain = () => resolve("cancelled"); });
233
- const frame: Frame = { allEnrolled: new Set([token]), id: Symbol("orm transaction"), authority, provider: base, parent, enrolled: parent ? current!.enrolled : new Set([token]), state: "active", pending: new Set(), abortPromise, abort, cancellationPromise, cancelDrain }; childFrame = frame;
261
+ const frame: Frame = { allEnrolled: new Set([token]), id: Symbol("orm transaction"), authority, provider: base, parent, enrolled: parent ? current!.enrolled : new Set([token]), state: "active", pending: new Set(), abortPromise, abort, cancellationPromise, cancelDrain, retryContexts: parent ? undefined : retryContexts }; childFrame = frame;
234
262
  if (parent) parent.activeChildFrame = frame;
235
263
  const view: View = { frame, enrolled: frame.enrolled };
236
264
  return withProviderDispatchObserver((dispatch) => registerDispatch(frame, dispatch), () => views.run(view, async () => {
@@ -374,6 +402,12 @@ class ScopedOrmTransaction extends OrmTransaction {
374
402
  const parent = views.getStore();
375
403
  if (!parent || parent.frame !== frame) throw foreignTransaction();
376
404
  if (uncertainContexts.has(token)) throw new TransactionOutcomeUnknownError();
405
+ let root = frame;
406
+ while (root.parent) root = root.parent;
407
+ if (root.retryContexts && !root.retryContexts.has(context)) {
408
+ assertRetryable(context);
409
+ root.retryContexts.add(context);
410
+ }
377
411
  for (let target: Frame | undefined = frame; target; target = target.parent) target.allEnrolled.add(token);
378
412
  const enrolled = new Set(parent.enrolled); enrolled.add(token);
379
413
  return views.run({ frame, enrolled }, () => trackTask(frame, () => work(context)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.104.1",
3
+ "version": "0.105.1",
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",