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.
- package/core/di/errors/NamedDependencyNotFoundError.ts +11 -2
- package/core/di/internal/GraphValidator.ts +3 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/config/defineConfig.ts +10 -0
- package/library/orm/DbContext.ts +1 -1
- package/library/orm/Saving/ExecutionStrategy.ts +3 -1
- package/library/orm/Tracking/ChangeTracker.ts +5 -0
- package/library/orm/Transactions/OrmTransaction.ts +11 -0
- package/library/orm/Transactions/TransactionScopeCoordinator.ts +36 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 (
|
|
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.
|
|
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;
|
package/library/orm/DbContext.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|