bazis 0.104.0 → 0.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/library/orm/DbContext.ts +1 -1
- package/library/orm/Metadata/ValueConverter.ts +6 -0
- package/library/orm/Providers/propertyConversion.ts +10 -2
- package/library/orm/Query/ImmediateMutations.ts +3 -2
- 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
|
@@ -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.0" }, 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");
|
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
|
}
|
|
@@ -13,6 +13,12 @@ export interface ValueConverter<TModel = unknown, TProvider = unknown> {
|
|
|
13
13
|
* the query fails instead of silently matching nothing.
|
|
14
14
|
*/
|
|
15
15
|
readonly comparable?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* `true`: the converter also gets `null` (both ways), for example to store
|
|
18
|
+
* `null` as `""`. By default `null` stays SQL NULL and the converter is
|
|
19
|
+
* not called, as in EF Core.
|
|
20
|
+
*/
|
|
21
|
+
readonly convertsNulls?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/** Thrown by an encrypted column that cannot be read: another key, or the stored value was changed. */
|
|
@@ -2,16 +2,24 @@ import { OrmError } from "../errors";
|
|
|
2
2
|
import type { PropertyModel } from "../Metadata/types";
|
|
3
3
|
import type { SqlDialect, SqlParam } from "./types";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Whether the converter of the property applies to the value: `null` and
|
|
7
|
+
* `undefined` stay SQL NULL unless the converter declares `convertsNulls`.
|
|
8
|
+
*/
|
|
9
|
+
export function convertsValue(property: PropertyModel, value: unknown): boolean {
|
|
10
|
+
return property.converter !== undefined && (property.converter.convertsNulls === true || (value !== null && value !== undefined));
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
/** Encodes a property value: converter -> dialect. */
|
|
6
14
|
export function encodeProperty(property: PropertyModel, value: unknown, dialect: SqlDialect): SqlParam {
|
|
7
|
-
const modelValue = property
|
|
15
|
+
const modelValue = convertsValue(property, value) ? property.converter!.toProvider(value) : value;
|
|
8
16
|
return dialect.encode(modelValue, property.type);
|
|
9
17
|
}
|
|
10
18
|
|
|
11
19
|
/** Decodes a column value: dialect -> converter. A converter failure names the entity and the property. */
|
|
12
20
|
export function decodeProperty(property: PropertyModel, value: unknown, dialect: SqlDialect, owner?: string): unknown {
|
|
13
21
|
const providerValue = dialect.decode(value, property.type);
|
|
14
|
-
if (!property.converter) return providerValue;
|
|
22
|
+
if (!property.converter || !convertsValue(property, providerValue)) return providerValue;
|
|
15
23
|
try {
|
|
16
24
|
return property.converter.fromProvider(providerValue);
|
|
17
25
|
} catch (error) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { types } from "node:util";
|
|
2
2
|
import { OrmError, OrmTrackedMutationConflictError, OrmUndeclaredConflictTargetError, OrmUnsafeImmediateMutationError } from "../errors";
|
|
3
3
|
import { isDatabaseGenerated, type EntityModel, type PropertyModel } from "../Metadata/types";
|
|
4
|
+
import { convertsValue } from "../Providers/propertyConversion";
|
|
4
5
|
import type { DbContextRuntime } from "../runtime";
|
|
5
6
|
import { hasTrackedEntriesForModel } from "../Tracking/ChangeTracker";
|
|
6
7
|
import { isPostgresJsonScalarParameter } from "../Providers/PostgresDialect";
|
|
@@ -136,11 +137,11 @@ function admitInsertValues<T extends object>(model: EntityModel, entity: T): rea
|
|
|
136
137
|
}
|
|
137
138
|
function unsetKey(value: unknown): boolean { return value === undefined || value === null || value === "" || value === 0; }
|
|
138
139
|
function encodeAssignments(runtime: DbContextRuntime, assignments: readonly AdmittedAssignment[]): readonly { readonly property: PropertyModel; readonly value: SqlParam }[] {
|
|
139
|
-
return Object.freeze(assignments.map(({ property, value }) => Object.freeze({ property, value: value === null ? null : encode(runtime, property, value) })));
|
|
140
|
+
return Object.freeze(assignments.map(({ property, value }) => Object.freeze({ property, value: value === null && !convertsValue(property, null) ? null : encode(runtime, property, value) })));
|
|
140
141
|
}
|
|
141
142
|
function encode(runtime: DbContextRuntime, property: PropertyModel, input: unknown, allowNull = false): SqlParam {
|
|
142
143
|
try {
|
|
143
|
-
const providerValue = property
|
|
144
|
+
const providerValue = convertsValue(property, input) ? property.converter!.toProvider(input) : input;
|
|
144
145
|
const safeProvider = storageValue(providerValue, property.type, allowNull);
|
|
145
146
|
return snapshotEncodedSqlParam(runtime.provider.dialect.encode(safeProvider, property.type));
|
|
146
147
|
} catch {
|
|
@@ -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.0",
|
|
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",
|