bazis 0.115.1 → 0.117.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.
@@ -88,6 +88,12 @@ export function normalizeResult(result: unknown, ctx: HttpContext, defaults: Res
88
88
  if (status === 204 || status === 304) {
89
89
  payload = null;
90
90
  }
91
+ // A known length survives HEAD: the server answers it with the headers of
92
+ // this response and no body, and Bun would otherwise send content-length: 0.
93
+ const length = typeof payload === "string" ? Buffer.byteLength(payload) : payload instanceof Uint8Array || payload instanceof ArrayBuffer ? payload.byteLength : undefined;
94
+ if (length !== undefined && !headers.has("content-length")) {
95
+ headers.set("content-length", String(length));
96
+ }
91
97
 
92
98
  return new Response(payload, { status, headers });
93
99
  }
@@ -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.115.1" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.117.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");
@@ -58,6 +58,8 @@ function snapshot<TContext extends DbContext>(context: ContextClass<TContext>, e
58
58
  const expected = compileExpectedSchema(new OrmModel(entities));
59
59
  if (expected.tables.length === 0 || expected.tables.length > 512) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(definition)} needs 1 to 512 entities, got ${expected.tables.length}`);
60
60
  for (const table of expected.tables) {
61
+ const decimal = table.columns.find((column) => column.physicalType.startsWith("decimal"));
62
+ if (decimal) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(definition)}: column "${decimal.column}" of "${table.table}" is decimal; owned stores do not support decimal columns`);
61
63
  const partial = table.indexes.find((index) => index.predicate !== undefined);
62
64
  if (partial) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(definition)}: index "${partial.name}" on "${table.table}" has a where condition; owned stores do not support partial indexes`);
63
65
  }
@@ -1173,6 +1173,32 @@ function routeParamNames(controller: ts.ClassDeclaration, method: ts.MethodDecla
1173
1173
  return names;
1174
1174
  }
1175
1175
 
1176
+ /** Route parameters with a constraint, `:id(int)` -> id: int, from the controller and method templates. */
1177
+ function routeParamConstraints(controller: ts.ClassDeclaration, method: ts.MethodDeclaration): Map<string, string> {
1178
+ const constraints = new Map<string, string>();
1179
+ for (const decorators of [ts.getDecorators(controller) ?? [], ts.getDecorators(method) ?? []]) {
1180
+ for (const decorator of decorators) {
1181
+ const firstArg = decoratorCall(decorator)?.call?.arguments[0];
1182
+ if (!firstArg || !ts.isStringLiteralLike(firstArg)) continue;
1183
+ for (const segment of firstArg.text.split("/")) {
1184
+ const param = /^:([A-Za-z_][A-Za-z0-9_]*)\(([A-Za-z]+)\)/.exec(segment);
1185
+ if (param) constraints.set(param[1] as string, param[2] as string);
1186
+ }
1187
+ }
1188
+ }
1189
+ return constraints;
1190
+ }
1191
+
1192
+ /**
1193
+ * The router converts a constrained segment before binding, so the parameter
1194
+ * must be declared with the converted type; otherwise its TypeScript type lies.
1195
+ */
1196
+ function constraintMismatch(paramName: string, declared: string | undefined, constraint: string | undefined): string | undefined {
1197
+ const expected = constraint === "int" || constraint === "number" ? "number" : constraint === "bool" ? "boolean" : constraint === "uuid" || constraint === "alpha" ? "string" : undefined;
1198
+ if (expected === undefined || declared === undefined || declared === expected) return undefined;
1199
+ return `route parameter "${paramName}" is constrained to (${constraint}), so it arrives as a ${expected}; declare it as ${expected}`;
1200
+ }
1201
+
1176
1202
  /** Reuses the request hydration graph; no reflection metadata/runtime TS imports. */
1177
1203
  function collectGrpcBindings(source: ts.SourceFile): void {
1178
1204
  const visit = (node: ts.Node): void => {
@@ -1455,6 +1481,7 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1455
1481
  for (const { name, routes, member } of controllerActions(node)) {
1456
1482
  collectOpenApiOperation(controllerName, member);
1457
1483
  const routeParams = routeParamNames(node, routes);
1484
+ const constraints = routeParamConstraints(node, routes);
1458
1485
  const specs: HttpBindingSpec[] = [];
1459
1486
  let bodyCount = 0;
1460
1487
  let failed: string | undefined;
@@ -1471,6 +1498,12 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1471
1498
  // An explicit source: FromQuery<…>, FromHeader<…>, FromBody<…>, FromRoute<…>.
1472
1499
  const marker = typeNode ? sourceMarker(typeNode) : undefined;
1473
1500
  if (marker !== undefined) {
1501
+ const routeName = marker.kind === "FromRoute" ? marker.name ?? paramName : undefined;
1502
+ const mismatch = routeName === undefined ? undefined : constraintMismatch(routeName, marker.inner?.getText().trim(), constraints.get(routeName));
1503
+ if (mismatch !== undefined) {
1504
+ failed = mismatch;
1505
+ break;
1506
+ }
1474
1507
  const spec = markerBindingSpec(marker, paramName, optional, routeParams);
1475
1508
  if (typeof spec === "string") {
1476
1509
  failed = spec;
@@ -1494,6 +1527,11 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1494
1527
  continue;
1495
1528
  }
1496
1529
  if (routeParams.has(paramName)) {
1530
+ const mismatch = constraintMismatch(paramName, keywordType, constraints.get(paramName));
1531
+ if (mismatch !== undefined) {
1532
+ failed = mismatch;
1533
+ break;
1534
+ }
1497
1535
  // Constraint conversion happens in the router; add type only for plain :params.
1498
1536
  const type = keywordType !== undefined ? PRIMITIVE_QUERY_TYPES[keywordType] : undefined;
1499
1537
  specs.push({ source: "route", name: paramName, type: type === "string" ? undefined : type, optional });
@@ -1,9 +1,9 @@
1
1
  import { DatabaseFacade } from "./DatabaseFacade";
2
2
  import type { DbContextOptions } from "./DbContextOptions";
3
- import { EntityNotMappedError } from "./errors";
3
+ import { EntityNotMappedError, OrmError } from "./errors";
4
4
  import { DbSet } from "./Query/DbSet";
5
5
  import type { DbContextRuntime } from "./runtime";
6
- import { SaveExecutor } from "./Saving/SaveExecutor";
6
+ import { hasPendingRollback, SaveExecutor } from "./Saving/SaveExecutor";
7
7
  import { ChangeTracker } from "./Tracking/ChangeTracker";
8
8
  import { EntityState } from "./Tracking/EntityState";
9
9
  import { monitorImmediateOperation, monitorWholeOperation, observedProvider, transactionScope } from "./Transactions/TransactionScopeCoordinator";
@@ -88,6 +88,20 @@ export abstract class DbContext {
88
88
  return this.changeTracker.stateOf(entity);
89
89
  }
90
90
 
91
+ /**
92
+ * Stops tracking `entity`: its changes are no longer saved and the next
93
+ * read returns a fresh object (`Entry(e).State = Detached` in EF Core). Use
94
+ * it to drop the changes a rolled-back scope left in the context; an entity
95
+ * saved in a scope that is still open cannot be detached, because that
96
+ * scope's rollback would restore it.
97
+ */
98
+ detach(entity: object): void {
99
+ if (hasPendingRollback(this.changeTracker, entity)) {
100
+ throw new OrmError(`${entity.constructor.name} was saved in a transaction scope that is still open; detach it after the scope ends.`);
101
+ }
102
+ this.changeTracker.detach(entity);
103
+ }
104
+
91
105
  /**
92
106
  * Applies all pending changes in one transaction and returns the number of
93
107
  * processed entities. DetectChanges runs before saving, and (if enabled)
@@ -118,7 +118,7 @@ const LOGICAL_TO_STORAGE: Record<ScalarLogicalFieldType, StorageColumnType> = {
118
118
  string: "text",
119
119
  int: "integer",
120
120
  bigint: "integer",
121
- decimal: "real",
121
+ decimal: "decimal",
122
122
  bool: "boolean",
123
123
  datetime: "datetime",
124
124
  uuid: "text",
@@ -9,6 +9,7 @@ import type {
9
9
  CheckModel,
10
10
  KeyGeneration,
11
11
  PropertyModel,
12
+ StorageColumnType,
12
13
  RelationModel,
13
14
  } from "./types";
14
15
  import type { CanonicalDefault } from "../Schema/introspection";
@@ -261,6 +262,7 @@ export class ModelBuilder {
261
262
  raw.nullable === false ||
262
263
  resolved.impliedRequired === true;
263
264
  const databaseDefault = ModelBuilder.defaultFor(entityName, raw, storageType, isKey, generated, convention, required);
265
+ const digits = ModelBuilder.decimalDigits(entityName, raw, storageType);
264
266
  return {
265
267
  propertyName: raw.propertyName,
266
268
  columnName: raw.columnName ?? raw.propertyName,
@@ -272,9 +274,24 @@ export class ModelBuilder {
272
274
  convention,
273
275
  uuidVersion: raw.uuidVersion,
274
276
  databaseDefault,
277
+ ...digits,
275
278
  };
276
279
  }
277
280
 
281
+ /** `precision`/`scale` belong to `decimal`: 1–1000 digits, scale 0–precision, scale needs precision. */
282
+ private static decimalDigits(entityName: string, raw: RawProperty, type: StorageColumnType): { precision?: number; scale?: number } {
283
+ if (type === "decimal" && raw.isKey === true) throw new ModelBuildError(`Entity "${entityName}": key "${raw.propertyName}" cannot be decimal; use an integer, text or UUID key.`);
284
+ const { precision, scale } = raw;
285
+ if (precision === undefined && scale === undefined) return {};
286
+ const where = `Entity "${entityName}": property "${raw.propertyName}"`;
287
+ if (type !== "decimal") throw new ModelBuildError(`${where}: precision and scale need @Column({ type: "decimal" }).`);
288
+ if (precision === undefined) throw new ModelBuildError(`${where}: scale needs precision, for example { precision: 12, scale: 2 }.`);
289
+ if (!Number.isSafeInteger(precision) || precision < 1 || precision > 1000) throw new ModelBuildError(`${where}: precision must be an integer from 1 to 1000.`);
290
+ const digitsAfter = scale ?? 0;
291
+ if (!Number.isSafeInteger(digitsAfter) || digitsAfter < 0 || digitsAfter > precision) throw new ModelBuildError(`${where}: scale must be an integer from 0 to precision (${precision}).`);
292
+ return { precision, scale: digitsAfter };
293
+ }
294
+
278
295
  private static primaryKeyFor(
279
296
  ctor: new () => object,
280
297
  raw: NonNullable<ReturnType<typeof readRawEntity>>,
@@ -34,6 +34,13 @@ export interface ColumnOptions {
34
34
  readonly nullable?: boolean;
35
35
  /** Closed physical PostgreSQL default. It is not an application initializer or SQL fragment. */
36
36
  readonly default?: ColumnDefaultValue;
37
+ /**
38
+ * `type: "decimal"` only: the exact `numeric(precision, scale)` column. The
39
+ * value is a string (`"19.90"`); a value with more digits than declared is
40
+ * refused instead of being rounded by the database.
41
+ */
42
+ readonly precision?: number;
43
+ readonly scale?: number;
37
44
  }
38
45
  export type ColumnDefaultValue = null | boolean | number | string;
39
46
 
@@ -95,6 +102,8 @@ export interface RawProperty {
95
102
  required?: boolean;
96
103
  nullable?: boolean;
97
104
  default?: ColumnDefaultValue;
105
+ precision?: number;
106
+ scale?: number;
98
107
  index?: { unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst };
99
108
  /** `@ForeignKey(() => Principal)` on a scalar column, for the DDL FK. */
100
109
  fkTarget?: () => EntityClass;
@@ -286,6 +295,8 @@ export function Column(options: ColumnOptions = {}) {
286
295
  prop.columnName = options.name;
287
296
  prop.type = options.type;
288
297
  prop.default = options.default;
298
+ prop.precision = options.precision;
299
+ prop.scale = options.scale;
289
300
  if (options.nullable !== undefined) {
290
301
  prop.nullable = options.nullable;
291
302
  }
@@ -7,7 +7,7 @@ import type { CanonicalDefault } from "../Schema/introspection";
7
7
  * Physical column type in the database (DDL, encode/decode). The provider
8
8
  * dialect maps it to the types of a concrete DBMS (`SqlDialect.columnType`).
9
9
  */
10
- export type StorageColumnType = "integer" | "real" | "text" | "boolean" | "datetime" | "json";
10
+ export type StorageColumnType = "integer" | "real" | "decimal" | "text" | "boolean" | "datetime" | "json";
11
11
 
12
12
  /**
13
13
  * Semantic `@Column({ type })` types with automatic behavior on `SaveChanges`.
@@ -63,6 +63,9 @@ export interface PropertyModel {
63
63
  readonly convention?: PropertyConvention;
64
64
  /** UUID version for `@UUID` (default v4). */
65
65
  readonly uuidVersion?: "v4" | "v7";
66
+ /** `decimal`: total digits and digits after the point (`numeric(precision, scale)`); absent for plain `numeric`. */
67
+ readonly precision?: number;
68
+ readonly scale?: number;
66
69
  /** Closed physical database default; it never affects entity initialization. */
67
70
  readonly databaseDefault: CanonicalDefault;
68
71
  }
@@ -3,6 +3,7 @@ import type { ForeignKeyConstraint, RowLockMode, SqlDialect, SqlParam } from "./
3
3
  import { projectCheckAstIdentifiers, renderCheck } from "../Schema/CheckExpression";
4
4
  import { resolvedForeignKey } from "./resolvedForeignKey";
5
5
  import { physicalTableIdentity } from "../Schema/tableKey";
6
+ import { decimalParameter } from "./decimalValue";
6
7
 
7
8
  const jsonScalarParameters = new WeakSet<object>();
8
9
 
@@ -55,6 +56,8 @@ export class PostgresDialect implements SqlDialect {
55
56
  case "integer":
56
57
  case "real":
57
58
  return typeof value === "bigint" ? value : Number(value);
59
+ case "decimal":
60
+ return decimalParameter(value);
58
61
  case "text":
59
62
  return typeof value === "string" ? value : String(value);
60
63
  }
@@ -78,6 +81,9 @@ export class PostgresDialect implements SqlDialect {
78
81
  return typeof value === "bigint" ? value : Number(value);
79
82
  case "real":
80
83
  return Number(value);
84
+ case "decimal":
85
+ // Exact: PostgreSQL numeric stays a string, never a float.
86
+ return typeof value === "string" ? value : String(value);
81
87
  case "text":
82
88
  return typeof value === "string" ? value : String(value);
83
89
  }
@@ -106,12 +112,14 @@ export class PostgresDialect implements SqlDialect {
106
112
  return `$${index + 1}`;
107
113
  }
108
114
 
109
- columnType(type: ColumnType): string {
115
+ columnType(type: ColumnType, property?: PropertyModel): string {
110
116
  switch (type) {
111
117
  case "integer":
112
118
  return "bigint";
113
119
  case "real":
114
120
  return "double precision";
121
+ case "decimal":
122
+ return property?.precision === undefined ? "numeric" : `numeric(${property.precision}, ${property.scale ?? 0})`;
115
123
  case "boolean":
116
124
  return "boolean";
117
125
  case "datetime":
@@ -137,7 +145,7 @@ export class PostgresDialect implements SqlDialect {
137
145
  return `${this.quoteId(property.columnName)} uuid NOT NULL PRIMARY KEY`;
138
146
  }
139
147
  const foreignKey = foreignKeys.find((candidate) => (candidate.columns ?? [candidate.column]).includes(property.columnName));
140
- const parts = [this.quoteId(property.columnName), this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property))];
148
+ const parts = [this.quoteId(property.columnName), this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property), property)];
141
149
  if (property.required) {
142
150
  parts.push("NOT NULL");
143
151
  }
@@ -175,7 +183,7 @@ export class PostgresDialect implements SqlDialect {
175
183
  const parts = [
176
184
  `ALTER TABLE ${this.qualifyTable(model)} ADD COLUMN IF NOT EXISTS`,
177
185
  this.quoteId(property.columnName),
178
- this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property)),
186
+ this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property), property),
179
187
  ];
180
188
  if (property.required) {
181
189
  // An invented FK value (0/empty UUID) cannot safely backfill existing rows.
@@ -197,6 +205,7 @@ export class PostgresDialect implements SqlDialect {
197
205
  switch (type) {
198
206
  case "integer":
199
207
  case "real":
208
+ case "decimal":
200
209
  return "0";
201
210
  case "boolean":
202
211
  return "false";
@@ -209,8 +218,8 @@ export class PostgresDialect implements SqlDialect {
209
218
  }
210
219
  }
211
220
 
212
- private ddlColumnType(type: ColumnType | "uuid"): string {
213
- return type === "uuid" ? "uuid" : this.columnType(type);
221
+ private ddlColumnType(type: ColumnType | "uuid", property?: PropertyModel): string {
222
+ return type === "uuid" ? "uuid" : this.columnType(type, property);
214
223
  }
215
224
 
216
225
  private foreignKeyColumnType(fk: ForeignKeyConstraint | undefined, property: PropertyModel): ColumnType | "uuid" {
@@ -233,3 +242,4 @@ export function renderPostgresSkipLocked(dialect: SqlDialect): string {
233
242
  }
234
243
  return " SKIP LOCKED";
235
244
  }
245
+
@@ -9,10 +9,12 @@ export function canonicalPostgresColumn(type: string, defaultExpression: string
9
9
  return { physicalType, default: defaultValue.value, generation, unsupported: physicalType === "unknown" || defaultValue.unsupported || (identity !== "" && identity !== "d") };
10
10
  }
11
11
 
12
- export function canonicalPostgresType(type: string): "integer" | "real" | "text" | "boolean" | "datetime" | "json" | "uuid" | "unknown" {
12
+ export function canonicalPostgresType(type: string): "integer" | "real" | "text" | "boolean" | "datetime" | "json" | "uuid" | "decimal" | `decimal(${number},${number})` | "unknown" {
13
+ const numeric = type.trim().toLowerCase().match(/^numeric(?:\((\d+),\s*(\d+)\))?$/u);
14
+ if (numeric) return numeric[1] === undefined ? "decimal" : `decimal(${Number(numeric[1])},${Number(numeric[2])})`;
13
15
  switch (type.trim().toLowerCase()) {
14
16
  case "smallint": case "integer": case "bigint": return "integer";
15
- case "real": case "double precision": case "numeric": return "real";
17
+ case "real": case "double precision": return "real";
16
18
  case "text": case "character varying": case "character": return "text";
17
19
  case "boolean": return "boolean";
18
20
  case "timestamp with time zone": return "datetime";
@@ -25,6 +27,8 @@ export function canonicalPostgresType(type: string): "integer" | "real" | "text"
25
27
  /** Exact admission accepts only the physical spellings rendered by PostgresDialect. */
26
28
  export function exactPostgresType(type: string, canonical: IntrospectedColumn["physicalType"]): IntrospectedColumn["physicalType"] {
27
29
  const raw = type.trim().toLowerCase();
30
+ // numeric and numeric(p,s) are the spellings PostgresDialect renders for decimal.
31
+ if (/^numeric(?:\(\d+,\s*\d+\))?$/u.test(raw)) return canonical;
28
32
  switch (raw) {
29
33
  case "bigint": case "double precision": case "text": case "boolean": case "timestamp with time zone": case "jsonb": case "uuid": return canonical;
30
34
  default: return `${canonical ?? "unknown"}:${raw}`;
@@ -0,0 +1,37 @@
1
+ import { OrmError } from "../errors";
2
+ import type { PropertyModel } from "../Metadata/types";
3
+
4
+ const DECIMAL_TEXT = /^([+-]?)(\d+)(?:\.(\d+))?$/u;
5
+
6
+ /**
7
+ * A decimal is written as text so no digit is lost: a string such as "19.90"
8
+ * or "-0.5", a bigint, or a safe integer. A float like 0.1 is refused because
9
+ * it is already inexact.
10
+ */
11
+ export function decimalText(value: unknown): string | undefined {
12
+ if (typeof value === "bigint" || (typeof value === "number" && Number.isSafeInteger(value))) return String(value);
13
+ return typeof value === "string" && DECIMAL_TEXT.test(value) ? value : undefined;
14
+ }
15
+
16
+ /** The parameter for a decimal column; anything that is not an exact decimal is refused. */
17
+ export function decimalParameter(value: unknown): string {
18
+ const text = decimalText(value);
19
+ if (text !== undefined) return text;
20
+ throw new OrmError(`A decimal value must be a string such as "19.90" (or an integer), got ${typeof value === "string" ? JSON.stringify(value.slice(0, 40)) : typeof value}.`);
21
+ }
22
+
23
+ /**
24
+ * Before a write: a value with more digits than `numeric(precision, scale)`
25
+ * allows is refused, because PostgreSQL would round the fraction silently.
26
+ * Trailing zeros after the point do not count ("1.500" fits scale 2).
27
+ */
28
+ export function assertDecimalFits(owner: string, property: PropertyModel, value: unknown): void {
29
+ if (property.type !== "decimal" || property.precision === undefined || value === null || value === undefined) return;
30
+ const match = decimalParameter(value).match(DECIMAL_TEXT)!;
31
+ const whole = match[2]!.replace(/^0+(?=\d)/u, "");
32
+ const fraction = (match[3] ?? "").replace(/0+$/u, "");
33
+ const scale = property.scale ?? 0;
34
+ const where = `${owner}.${property.propertyName}`;
35
+ if (fraction.length > scale) throw new OrmError(`${where}: "${value}" has ${fraction.length} digits after the point; numeric(${property.precision}, ${scale}) keeps ${scale}. Round it in the code.`);
36
+ if ((whole === "0" ? 0 : whole.length) > property.precision - scale) throw new OrmError(`${where}: "${value}" does not fit numeric(${property.precision}, ${scale}): at most ${property.precision - scale} digits before the point.`);
37
+ }
@@ -91,7 +91,7 @@ export interface SqlDialect {
91
91
  /** PostgreSQL parameter placeholder by index (0-based): `$1`, `$2`, …. */
92
92
  parameter(index: number): string;
93
93
  /** Column type in DDL. */
94
- columnType(type: ColumnType): string;
94
+ columnType(type: ColumnType, property?: PropertyModel): string;
95
95
  /** Whether `INSERT ... RETURNING` is supported. */
96
96
  readonly supportsReturning: boolean;
97
97
  /** Encodes a property value into a query parameter for this DBMS. */
@@ -2,6 +2,7 @@ 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
4
  import { convertsValue } from "../Providers/propertyConversion";
5
+ import { assertDecimalFits, decimalText } from "../Providers/decimalValue";
5
6
  import type { DbContextRuntime } from "../runtime";
6
7
  import { hasTrackedEntriesForModel } from "../Tracking/ChangeTracker";
7
8
  import { isPostgresJsonScalarParameter } from "../Providers/PostgresDialect";
@@ -19,7 +20,7 @@ export type OrmUniqueKeySelectorV1<T extends object> = (entity: FieldSelector<T>
19
20
  export function executeImmediateUpdate<T extends object>(model: EntityModel, runtime: DbContextRuntime, plan: QueryPlan, values: OrmUpdateValuesV1<T>): Promise<OrmMutationResultV1> {
20
21
  return runtime.runImmediateOperation(async () => {
21
22
  guard(model, runtime); const admittedPlan = admitPlan(plan, "executeUpdate");
22
- const conditions = effectiveConditions(model, admittedPlan, runtime); const assignments = encodeAssignments(runtime, admitUpdateValues(model, values));
23
+ const conditions = effectiveConditions(model, admittedPlan, runtime); const assignments = encodeAssignments(runtime, model, admitUpdateValues(model, values));
23
24
  const compiled = new SqlTranslator(model, runtime.provider.dialect).immediateUpdate(assignments, conditions, (property, value) => encode(runtime, property, snapshot(value), true));
24
25
  Object.freeze(compiled.params);
25
26
  guard(model, runtime);
@@ -42,7 +43,7 @@ export function executeImmediateDelete(model: EntityModel, runtime: DbContextRun
42
43
  export function insertIfAbsent<T extends object>(model: EntityModel, runtime: DbContextRuntime, entity: T, options: { readonly conflictBy: OrmUniqueKeySelectorV1<T> }): Promise<OrmInsertIfAbsentResultV1> {
43
44
  return runtime.runImmediateOperation(async () => {
44
45
  guard(model, runtime); if (runtime.provider.dialect.name !== "postgres") unsafe();
45
- const admitted = admitInsertValues(model, entity); const columns = conflictTarget(model, options); const values = encodeAssignments(runtime, admitted);
46
+ const admitted = admitInsertValues(model, entity); const columns = conflictTarget(model, options); const values = encodeAssignments(runtime, model, admitted);
46
47
  const compiled = new SqlTranslator(model, runtime.provider.dialect).immediateInsert(values, columns);
47
48
  Object.freeze(compiled.params);
48
49
  guard(model, runtime);
@@ -136,7 +137,8 @@ function admitInsertValues<T extends object>(model: EntityModel, entity: T): rea
136
137
  return Object.freeze(out.map((value) => Object.freeze(value)));
137
138
  }
138
139
  function unsetKey(value: unknown): boolean { return value === undefined || value === null || value === "" || value === 0; }
139
- function encodeAssignments(runtime: DbContextRuntime, assignments: readonly AdmittedAssignment[]): readonly { readonly property: PropertyModel; readonly value: SqlParam }[] {
140
+ function encodeAssignments(runtime: DbContextRuntime, model: EntityModel, assignments: readonly AdmittedAssignment[]): readonly { readonly property: PropertyModel; readonly value: SqlParam }[] {
141
+ for (const { property, value } of assignments) if (!property.converter && decimalText(value) !== undefined) assertDecimalFits(model.name, property, value);
140
142
  return Object.freeze(assignments.map(({ property, value }) => Object.freeze({ property, value: value === null && !convertsValue(property, null) ? null : encode(runtime, property, value) })));
141
143
  }
142
144
  function encode(runtime: DbContextRuntime, property: PropertyModel, input: unknown, allowNull = false): SqlParam {
@@ -248,6 +250,7 @@ function storageValue(value: unknown, type: PropertyModel["type"], allowNull = f
248
250
  if (type === "boolean" && typeof safe !== "boolean") unsafe();
249
251
  if (type === "integer" && !(typeof safe === "bigint" ? safe >= -9223372036854775808n && safe <= 9223372036854775807n : typeof safe === "number" && Number.isSafeInteger(safe))) unsafe();
250
252
  if (type === "real" && !(typeof safe === "number" && Number.isFinite(safe))) unsafe();
253
+ if (type === "decimal" && decimalText(safe) === undefined) unsafe();
251
254
  if (type === "datetime" && (typeof safe !== "object" || safe === null || Object.getPrototypeOf(safe) !== capturedDatePrototype)) unsafe();
252
255
  if (type === "json" && !jsonValue(safe)) unsafe();
253
256
  return safe as SqlParam;
@@ -1,6 +1,7 @@
1
1
  import { isDatabaseGenerated, type EntityModel, type PropertyModel } from "../Metadata/types";
2
2
  import type { SqlDialect, SqlParam } from "../Providers/types";
3
3
  import { encodeProperty } from "../Providers/propertyConversion";
4
+ import { assertDecimalFits } from "../Providers/decimalValue";
4
5
  import { EntityState } from "../Tracking/EntityState";
5
6
  import type { TrackedEntry } from "../Tracking/ChangeTracker";
6
7
 
@@ -56,7 +57,7 @@ export class CommandBuilder {
56
57
  const tuples = entities.map((entity) => {
57
58
  const placeholders = columns.map((property) => {
58
59
  const placeholder = this.dialect.parameter(params.length);
59
- params.push(encodeProperty(property, entity[property.propertyName], this.dialect));
60
+ params.push(this.written(model, property, entity[property.propertyName]));
60
61
  return placeholder;
61
62
  });
62
63
  return `(${placeholders.join(", ")})`;
@@ -99,7 +100,7 @@ export class CommandBuilder {
99
100
  const params: SqlParam[] = [];
100
101
  const assignments = columns.map((property) => {
101
102
  const placeholder = this.dialect.parameter(params.length);
102
- params.push(encodeProperty(property, entity[property.propertyName], this.dialect));
103
+ params.push(this.written(model, property, entity[property.propertyName]));
103
104
  return `${this.dialect.quoteId(property.columnName)} = ${placeholder}`;
104
105
  });
105
106
  const where = this.keyPredicate(model, entity, params);
@@ -109,6 +110,12 @@ export class CommandBuilder {
109
110
  return { sql, params };
110
111
  }
111
112
 
113
+ /** A written value; a decimal that would lose digits is refused before SQL. */
114
+ private written(model: EntityModel, property: PropertyModel, value: unknown): SqlParam {
115
+ if (!property.converter) assertDecimalFits(model.name, property, value);
116
+ return encodeProperty(property, value, this.dialect);
117
+ }
118
+
112
119
  private delete(entity: Record<string, unknown>, model: EntityModel): DbCommand {
113
120
  const params: SqlParam[] = [];
114
121
  const sql = `DELETE FROM ${this.dialect.qualifyTable(model)} WHERE ${this.keyPredicate(model, entity, params)}`;
@@ -36,6 +36,11 @@ interface RollbackToken {
36
36
 
37
37
  /** Per-tracker, per-entity save baselines. It is deliberately transient and never part of TrackedEntry. */
38
38
  const rollbackTokens = new WeakMap<ChangeTracker, WeakMap<object, RollbackToken>>();
39
+
40
+ /** @internal The entity was saved in a transaction scope that has not ended yet. */
41
+ export function hasPendingRollback(tracker: ChangeTracker, entity: object): boolean {
42
+ return rollbackTokens.get(tracker)?.get(entity) !== undefined;
43
+ }
39
44
  const uncertainTrackers = new WeakSet<ChangeTracker>();
40
45
 
41
46
  export interface SaveOptions {
@@ -1,4 +1,4 @@
1
- import type { EntityModel } from "../Metadata/types";
1
+ import type { EntityModel, PropertyModel } from "../Metadata/types";
2
2
  import type { OrmModel } from "../Metadata/OrmModel";
3
3
  import type { CanonicalDefault, GenerationStrategy } from "./introspection";
4
4
  import { projectCheckAstIdentifiers, type CheckAst } from "./CheckExpression";
@@ -31,7 +31,7 @@ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"
31
31
  const columns = model.properties.map((property) => ({
32
32
  property: property.propertyName,
33
33
  column: property.columnName,
34
- physicalType: types.get(property)!,
34
+ physicalType: property.type === "decimal" ? decimalPhysicalType(property) : types.get(property)!,
35
35
  nullable: !property.required,
36
36
  default: property.databaseDefault,
37
37
  generation: property.generation === "identity" ? "identityByDefault" as const : property.generation === "uuid" ? "uuidDefault" as const : "none" as const,
@@ -43,3 +43,8 @@ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"
43
43
  });
44
44
  return Object.freeze({ schema, table, columns: Object.freeze(columns), primaryKey: Object.freeze({ name: model.keyName ?? `pk_${table}`, columns: Object.freeze(model.key.map((property) => property.columnName)) }), indexes: Object.freeze(model.indexes.map((index) => Object.freeze({ name: index.name, columns: Object.freeze([...index.columns]), unique: index.unique, method: "btree" as const, ...(index.where === undefined ? {} : { predicate: projectCheckAstIdentifiers(index.where, (name) => model.propertyByName(name)!.columnName) }) }))), foreignKeys: Object.freeze(foreignKeys), checks: Object.freeze(model.checks.map((check) => Object.freeze({ name: check.name, expression: projectCheckAstIdentifiers(check.expression, (name) => model.propertyByName(name)!.columnName) }))) });
45
45
  }
46
+
47
+ /** `decimal(12,2)` for numeric(12, 2), `decimal` for an unbounded numeric: the catalog reports the same label. */
48
+ export function decimalPhysicalType(property: Pick<PropertyModel, "precision" | "scale">): string {
49
+ return property.precision === undefined ? "decimal" : `decimal(${property.precision},${property.scale ?? 0})`;
50
+ }
@@ -94,7 +94,11 @@ function literal(value: ExpectedColumn["default"], physicalType: string): string
94
94
  }
95
95
  switch (value.kind) { case "null": return "NULL"; case "boolean": return value.value ? "TRUE" : "FALSE"; case "number": return String(value.value); case "string": return `'${value.value.replaceAll("'", "''")}'`; case "uuidV4": return "gen_random_uuid()"; default: throw new Error("Unrenderable PostgreSQL schema default."); }
96
96
  }
97
- function type(value: string): string { return value === "integer" ? "bigint" : value === "real" ? "double precision" : value === "datetime" ? "timestamptz" : value === "json" ? "jsonb" : value; }
97
+ function type(value: string): string {
98
+ const decimal = value.match(/^decimal(?:\((\d+),(\d+)\))?$/u);
99
+ if (decimal) return decimal[1] === undefined ? "numeric" : `numeric(${decimal[1]}, ${decimal[2]})`;
100
+ return value === "integer" ? "bigint" : value === "real" ? "double precision" : value === "datetime" ? "timestamptz" : value === "json" ? "jsonb" : value;
101
+ }
98
102
  function q(value: string): string { return `"${value.replaceAll('"', '""')}"`; }
99
103
  function tableName(table: Pick<ExpectedTable, "schema" | "table">): string { return `${q(table.schema)}.${q(table.table)}`; }
100
104
  function action(value: string): string { return value === "noAction" ? "NO ACTION" : value === "setNull" ? "SET NULL" : value.toUpperCase(); }
@@ -243,6 +243,14 @@ export class ChangeTracker {
243
243
  this.entries.set(entry.entity, entry);
244
244
  }
245
245
 
246
+ /** Stops tracking one entity: it becomes Detached and leaves the identity map. */
247
+ detach(entity: object): void {
248
+ const entry = this.entries.get(entity);
249
+ if (!entry) return;
250
+ this.unindexByKey(entry);
251
+ this.entries.delete(entity);
252
+ }
253
+
246
254
  clear(): void {
247
255
  this.entries.clear();
248
256
  this.identity.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.115.1",
3
+ "version": "0.117.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",