bazis 0.116.0 → 0.117.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.
@@ -31,7 +31,9 @@ export function holdResponseScope(
31
31
  if (signal.aborted && !body.locked) void body.cancel(signal.reason).catch(() => undefined);
32
32
  void Promise.resolve().then(dispose).then(complete, reject);
33
33
  };
34
- cleanup = finished(body, { signal }, finish);
34
+ // Types only: with the DOM lib (TypeScript's default) Response.body is the
35
+ // DOM ReadableStream, which node:stream's typings do not list.
36
+ cleanup = finished(body as unknown as Parameters<typeof finished>[0], { signal }, finish);
35
37
 
36
38
  // Qualified Bun 1.4 consumes native Blob/file bodies directly: bodyUsed is
37
39
  // true, but no JS reader is acquired and its stream never emits finished.
@@ -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.116.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.117.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");
@@ -5,6 +5,8 @@ import { ShutdownTimeoutError, StartupFailuresError, StartupTimeoutError } from
5
5
  const MAX_CAUSES = 5;
6
6
  /** Properties already printed in the header, the frames or the cause chain. */
7
7
  const SHOWN = new Set(["name", "message", "stack", "cause", "code", "errno", "errors", "failures"]);
8
+ /** Where in PostgreSQL's own sources the error was raised: nothing to act on. Hint, detail and the object names stay. */
9
+ const POSTGRES_INTERNALS = new Set(["file", "line", "routine", "severity", "severity_local"]);
8
10
  const INSPECT = { depth: null, colors: false, customInspect: false, getters: false, maxArrayLength: null, maxStringLength: null } as const;
9
11
 
10
12
  /**
@@ -34,7 +36,8 @@ export function formatStartupFailure(error: unknown): string {
34
36
  if (!(current instanceof StartupFailuresError)) lines.push(...(current.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)));
35
37
  // Timeouts already say their limit and what was pending in the message.
36
38
  const restated = current instanceof StartupTimeoutError || current instanceof ShutdownTimeoutError;
37
- const details = restated ? {} : Object.fromEntries(Object.entries(current).filter(([name]) => !SHOWN.has(name)));
39
+ const internals = current.name === "PostgresError" ? POSTGRES_INTERNALS : undefined;
40
+ const details = restated ? {} : Object.fromEntries(Object.entries(current).filter(([name]) => !SHOWN.has(name) && !internals?.has(name)));
38
41
  if (Object.keys(details).length > 0) lines.push(inspect(redactSensitive(details), INSPECT));
39
42
  current = current.cause;
40
43
  }
@@ -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
  }
@@ -5,7 +5,7 @@ import { Migrator, type MigrationResult } from "./Schema/Migrator";
5
5
  import { MigrationRunner, type Migration, type VersionedMigrationResult } from "./Schema/MigrationRunner";
6
6
  import { SchemaDiffer } from "./Schema/SchemaDiffer";
7
7
  import { SchemaAdmissionEngine, type EnsureCreatedResult } from "./Schema/SchemaAdmission";
8
- import { entityStorageKey } from "./Schema/tableKey";
8
+ import { entityStorageKey, foreignKeyName } from "./Schema/tableKey";
9
9
 
10
10
  /** Result of `ensureCreated` together with versioned migrations. */
11
11
  export interface EnsureCreatedWithMigrationsResult extends EnsureCreatedResult {
@@ -166,7 +166,7 @@ export class DatabaseFacade {
166
166
  const constraint: ForeignKeyConstraint = {
167
167
  column: columns[0]!.columnName,
168
168
  columns: columns.map((column) => column!.columnName),
169
- name: fk.name,
169
+ name: foreignKeyName(model, fk),
170
170
  referencedTable: this.referencedTableName(target),
171
171
  referencedColumn: target.key[0].columnName,
172
172
  referencedColumns: target.key.map((property) => property.columnName),
@@ -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
  }
@@ -2,7 +2,8 @@ import type { ColumnType, EntityModel, IndexModel, PropertyModel } from "../Meta
2
2
  import type { ForeignKeyConstraint, RowLockMode, SqlDialect, SqlParam } from "./types";
3
3
  import { projectCheckAstIdentifiers, renderCheck } from "../Schema/CheckExpression";
4
4
  import { resolvedForeignKey } from "./resolvedForeignKey";
5
- import { physicalTableIdentity } from "../Schema/tableKey";
5
+ import { physicalTableIdentity, primaryKeyName } 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":
@@ -124,27 +132,29 @@ export class PostgresDialect implements SqlDialect {
124
132
  }
125
133
 
126
134
  createTableSql(model: EntityModel, foreignKeys: readonly ForeignKeyConstraint[]): string {
135
+ // The same name ensureCreated expects, not PostgreSQL's "<table>_pkey".
136
+ const primaryKey = `CONSTRAINT ${this.quoteId(primaryKeyName(model))} PRIMARY KEY`;
127
137
  const columns = model.properties.map((property) => {
128
138
  if (model.key.length === 1 && property.isKey && property.generation === "identity") {
129
139
  // BY DEFAULT allows inserting an explicit key when needed.
130
- return `${this.quoteId(property.columnName)} bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY`;
140
+ return `${this.quoteId(property.columnName)} bigint GENERATED BY DEFAULT AS IDENTITY ${primaryKey}`;
131
141
  }
132
142
  if (model.key.length === 1 && property.isKey && property.generation === "uuid") {
133
- return `${this.quoteId(property.columnName)} uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY`;
143
+ return `${this.quoteId(property.columnName)} uuid NOT NULL DEFAULT gen_random_uuid() ${primaryKey}`;
134
144
  }
135
145
  if (model.key.length === 1 && property.isKey && property.generation === "uuidV7") {
136
146
  // The ORM assigns the v7 value before INSERT, so the column has no default.
137
- return `${this.quoteId(property.columnName)} uuid NOT NULL PRIMARY KEY`;
147
+ return `${this.quoteId(property.columnName)} uuid NOT NULL ${primaryKey}`;
138
148
  }
139
149
  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))];
150
+ const parts = [this.quoteId(property.columnName), this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property), property)];
141
151
  if (property.required) {
142
152
  parts.push("NOT NULL");
143
153
  }
144
154
  return parts.join(" ");
145
155
  });
146
156
  if (model.key.length > 1 || model.key[0].generation === "none") {
147
- columns.push(`PRIMARY KEY (${model.key.map((key) => this.quoteId(key.columnName)).join(", ")})`);
157
+ columns.push(`${primaryKey} (${model.key.map((key) => this.quoteId(key.columnName)).join(", ")})`);
148
158
  }
149
159
  for (const check of model.checks) columns.push(`CONSTRAINT ${this.quoteId(check.name)} CHECK (${renderCheck(projectCheckAstIdentifiers(check.expression, (name) => model.propertyByName(name)!.columnName), (name) => this.quoteId(name))})`);
150
160
  for (const fk of foreignKeys) {
@@ -175,7 +185,7 @@ export class PostgresDialect implements SqlDialect {
175
185
  const parts = [
176
186
  `ALTER TABLE ${this.qualifyTable(model)} ADD COLUMN IF NOT EXISTS`,
177
187
  this.quoteId(property.columnName),
178
- this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property)),
188
+ this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property), property),
179
189
  ];
180
190
  if (property.required) {
181
191
  // An invented FK value (0/empty UUID) cannot safely backfill existing rows.
@@ -197,6 +207,7 @@ export class PostgresDialect implements SqlDialect {
197
207
  switch (type) {
198
208
  case "integer":
199
209
  case "real":
210
+ case "decimal":
200
211
  return "0";
201
212
  case "boolean":
202
213
  return "false";
@@ -209,8 +220,8 @@ export class PostgresDialect implements SqlDialect {
209
220
  }
210
221
  }
211
222
 
212
- private ddlColumnType(type: ColumnType | "uuid"): string {
213
- return type === "uuid" ? "uuid" : this.columnType(type);
223
+ private ddlColumnType(type: ColumnType | "uuid", property?: PropertyModel): string {
224
+ return type === "uuid" ? "uuid" : this.columnType(type, property);
214
225
  }
215
226
 
216
227
  private foreignKeyColumnType(fk: ForeignKeyConstraint | undefined, property: PropertyModel): ColumnType | "uuid" {
@@ -233,3 +244,4 @@ export function renderPostgresSkipLocked(dialect: SqlDialect): string {
233
244
  }
234
245
  return " SKIP LOCKED";
235
246
  }
247
+
@@ -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)}`;
@@ -1,10 +1,10 @@
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";
5
5
  import { crossUnitForeignKey, physicalColumnTypes } from "./physicalColumnTypes";
6
6
  import { SchemaAdmissionError } from "../errors";
7
- import { physicalTableIdentity } from "./tableKey";
7
+ import { foreignKeyName, physicalTableIdentity, primaryKeyName } from "./tableKey";
8
8
 
9
9
  export interface ExpectedColumn { readonly property: string; readonly column: string; readonly physicalType: string; readonly nullable: boolean; readonly default: CanonicalDefault; readonly generation: GenerationStrategy }
10
10
  export interface ExpectedTable { readonly schema: string; readonly table: string; readonly columns: readonly ExpectedColumn[]; readonly primaryKey: { readonly name: string; readonly columns: readonly string[] }; readonly indexes: readonly { readonly name: string; readonly columns: readonly string[]; readonly unique: boolean; readonly method: "btree"; readonly predicate?: CheckAst }[]; readonly foreignKeys: readonly { readonly name: string; readonly columns: readonly string[]; readonly target: { readonly schema: string; readonly table: string }; readonly targetColumns: readonly string[]; readonly onDelete: string; readonly onUpdate: string }[]; readonly checks: readonly { readonly name: string; readonly expression: unknown }[] }
@@ -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,
@@ -39,7 +39,12 @@ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"
39
39
  const foreignKeys = model.foreignKeys.map((foreignKey) => {
40
40
  const target = byCtor.get(foreignKey.target());
41
41
  if (!target) throw crossUnitForeignKey(model, foreignKey.properties, foreignKey.target());
42
- return { name: foreignKey.name ?? `fk_${table}_${foreignKey.properties.join("_")}`, columns: foreignKey.properties.map((name) => model.propertyByName(name)!.columnName), target: { schema: schemaOf(target), table: physicalTableIdentity(target).table }, targetColumns: target.key.map((property) => property.columnName), onDelete: foreignKey.onDelete, onUpdate: foreignKey.onUpdate };
42
+ return { name: foreignKeyName(model, foreignKey), columns: foreignKey.properties.map((name) => model.propertyByName(name)!.columnName), target: { schema: schemaOf(target), table: physicalTableIdentity(target).table }, targetColumns: target.key.map((property) => property.columnName), onDelete: foreignKey.onDelete, onUpdate: foreignKey.onUpdate };
43
43
  });
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) }))) });
44
+ return Object.freeze({ schema, table, columns: Object.freeze(columns), primaryKey: Object.freeze({ name: primaryKeyName(model), 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
+ }
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})`;
45
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(); }
@@ -24,3 +24,13 @@ export function entityStorageKey(model: EntityModel): string {
24
24
  export function introspectedTableKey(schema: string, table: string): string {
25
25
  return schema === "public" ? table : `${schema}.${table}`;
26
26
  }
27
+
28
+ /** `pk_<table>` unless the key is named; ensureCreated and migrate() name the primary key the same way. */
29
+ export function primaryKeyName(model: EntityModel): string {
30
+ return model.keyName ?? `pk_${physicalTableIdentity(model).table}`;
31
+ }
32
+
33
+ /** `fk_<table>_<properties>` unless the foreign key is named, for every way a table is created. */
34
+ export function foreignKeyName(model: EntityModel, foreignKey: { readonly name?: string; readonly properties: readonly string[] }): string {
35
+ return foreignKey.name ?? `fk_${physicalTableIdentity(model).table}_${foreignKey.properties.join("_")}`;
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.116.0",
3
+ "version": "0.117.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",