@cosmicdrift/kumiko-framework 0.42.0 → 0.44.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -0,0 +1,81 @@
1
+ // decimal-field.integration.ts — real-DB roundtrip for the numeric(p,s) column.
2
+ //
3
+ // The unit tests prove the read codec (coerceRow) and the DDL/Zod in isolation.
4
+ // Only a live Postgres can prove the *write* direction: that a JS number binds
5
+ // into a numeric(p,s) column and comes back — via pg's numeric STRING + the
6
+ // coerceRow parse — as the exact same JS number. That symmetry is the thing
7
+ // that breaks silently if either side is wrong, so it gets a real DB test.
8
+ import { afterAll, describe, expect, test } from "bun:test";
9
+ import { fetchOne, insertOne } from "../query";
10
+ import { closeDb, withTable } from "./_helpers";
11
+
12
+ afterAll(async () => {
13
+ await closeDb();
14
+ });
15
+
16
+ const decimalCols = [
17
+ { name: "sum", pgType: "numeric(14,2)" as const, notNull: true },
18
+ { name: "interest", pgType: "numeric(6,4)" as const, notNull: true },
19
+ { name: "rate", pgType: "numeric(12,2)" as const, notNull: false },
20
+ ] as const;
21
+
22
+ describe("decimal — real-DB roundtrip", () => {
23
+ test("JS number → numeric(p,s) → exact JS number", async () => {
24
+ await withTable(decimalCols, async ({ db, meta }) => {
25
+ const ins = await insertOne<{ id: string }>(db, meta, {
26
+ sum: 1000.5,
27
+ interest: 2.5,
28
+ rate: 5.83,
29
+ });
30
+ const row = await fetchOne<{ sum: number; interest: number; rate: number }>(db, meta, {
31
+ id: ins!.id,
32
+ });
33
+ expect(typeof row!.sum).toBe("number");
34
+ expect(row!.sum).toBe(1000.5);
35
+ expect(row!.interest).toBe(2.5);
36
+ expect(row!.rate).toBe(5.83);
37
+ });
38
+ });
39
+
40
+ test("negative and zero values roundtrip", async () => {
41
+ await withTable(decimalCols, async ({ db, meta }) => {
42
+ const ins = await insertOne<{ id: string }>(db, meta, {
43
+ sum: -42.99,
44
+ interest: 0,
45
+ rate: 0.01,
46
+ });
47
+ const row = await fetchOne<{ sum: number; interest: number; rate: number }>(db, meta, {
48
+ id: ins!.id,
49
+ });
50
+ expect(row!.sum).toBe(-42.99);
51
+ expect(row!.interest).toBe(0);
52
+ expect(row!.rate).toBe(0.01);
53
+ });
54
+ });
55
+
56
+ test("pg enforces the scale — a fractional value beyond scale is rounded by the column", async () => {
57
+ await withTable(decimalCols, async ({ db, meta }) => {
58
+ // 2.55556 written into numeric(6,4) → pg rounds to 4 decimals (2.5556).
59
+ // Proves the column constraint is real DB-side, not just Zod-advisory.
60
+ const ins = await insertOne<{ id: string }>(db, meta, {
61
+ sum: 100,
62
+ interest: 2.55556,
63
+ rate: null,
64
+ });
65
+ const row = await fetchOne<{ interest: number }>(db, meta, { id: ins!.id });
66
+ expect(row!.interest).toBe(2.5556);
67
+ });
68
+ });
69
+
70
+ test("null in an optional numeric column stays null", async () => {
71
+ await withTable(decimalCols, async ({ db, meta }) => {
72
+ const ins = await insertOne<{ id: string }>(db, meta, {
73
+ sum: 100,
74
+ interest: 1,
75
+ rate: null,
76
+ });
77
+ const row = await fetchOne<{ rate: number | null }>(db, meta, { id: ins!.id });
78
+ expect(row!.rate).toBeNull();
79
+ });
80
+ });
81
+ });
@@ -359,6 +359,17 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
359
359
  // Drizzle coerced to number for numberField columns — match that.
360
360
  const n = Number(value);
361
361
  if (!Number.isNaN(n)) coerced = n;
362
+ } else if (
363
+ typeof pgType === "string" &&
364
+ pgType.startsWith("numeric") &&
365
+ (typeof value === "string" || typeof value === "bigint")
366
+ ) {
367
+ // pg returns numeric(p,s) as a string to preserve precision; the
368
+ // decimal-field contract surfaces it as JS number (safe ≤ 2^53). A
369
+ // non-numeric string would be DB corruption — leave it untouched so it
370
+ // surfaces loud downstream rather than becoming a silent NaN.
371
+ const n = Number(value);
372
+ if (!Number.isNaN(n)) coerced = n;
362
373
  }
363
374
  const fieldName = info.fieldOf(key);
364
375
  if (fieldName !== key) changed = true;
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createDecimalField, createEntity } from "../../engine/factories";
3
+ import { fieldToZod } from "../../engine/schema-builder";
4
+ import { buildEntityTableMeta } from "../entity-table-meta";
5
+ import { coerceRow, extractTableInfo } from "../query";
6
+ import { renderTableDdl } from "../render-ddl";
7
+ import { buildEntityTable } from "../table-builder";
8
+
9
+ // decimal field — Postgres numeric(precision, scale). A new framework
10
+ // primitive (not a port), so these are correctness-of-the-primitive checks:
11
+ // column mapping, DDL, the pg-string→number read codec, and the write-boundary
12
+ // Zod bounds. The read codec is the load-bearing one — pg returns numeric as a
13
+ // STRING, and a missed conversion feeds consumers a string that silently
14
+ // breaks arithmetic.
15
+
16
+ const entity = createEntity({
17
+ table: "read_decimal_probe",
18
+ fields: {
19
+ sum: createDecimalField({ precision: 14, scale: 2, required: true }),
20
+ interest: createDecimalField({ precision: 6, scale: 4, required: true }),
21
+ rate: createDecimalField({ precision: 12, scale: 2 }),
22
+ },
23
+ });
24
+
25
+ describe("decimal field — column + DDL", () => {
26
+ test("maps to numeric(precision,scale) with required→NOT NULL", () => {
27
+ const cols = new Map(
28
+ buildEntityTableMeta("decimalProbe", entity).columns.map((c) => [c.name, c]),
29
+ );
30
+ expect(cols.get("sum")?.pgType).toBe("numeric(14,2)");
31
+ expect(cols.get("interest")?.pgType).toBe("numeric(6,4)");
32
+ expect(cols.get("rate")?.pgType).toBe("numeric(12,2)");
33
+ expect(cols.get("sum")?.notNull).toBe(true);
34
+ expect(cols.get("rate")?.notNull).toBe(false);
35
+ });
36
+
37
+ test("renders real numeric(p,s) DDL", () => {
38
+ const ddl = renderTableDdl(buildEntityTableMeta("decimalProbe", entity)).join("\n");
39
+ expect(ddl).toContain('"interest" numeric(6,4) NOT NULL');
40
+ expect(ddl).toContain('"rate" numeric(12,2)');
41
+ });
42
+ });
43
+
44
+ describe("decimal field — read codec (pg numeric string → JS number)", () => {
45
+ const info = extractTableInfo(buildEntityTable("decimalProbe", entity));
46
+
47
+ test("parses numeric strings to exact numbers", () => {
48
+ // Record<string, unknown> so coerceRow's pass-through return type doesn't
49
+ // pin the keys to `string` — the runtime value is the coerced number.
50
+ const input: Record<string, unknown> = { sum: "1000.50", interest: "2.5000", rate: "5.83" };
51
+ const row = coerceRow(input, info);
52
+ expect(row["sum"]).toBe(1000.5);
53
+ expect(row["interest"]).toBe(2.5);
54
+ expect(row["rate"]).toBe(5.83);
55
+ expect(typeof row["rate"]).toBe("number");
56
+ });
57
+
58
+ test("null passes through untouched", () => {
59
+ expect(coerceRow({ rate: null }, info).rate).toBeNull();
60
+ });
61
+
62
+ test("already-number values are left as-is", () => {
63
+ expect(coerceRow({ interest: 2.5 }, info).interest).toBe(2.5);
64
+ });
65
+ });
66
+
67
+ describe("decimal field — write-boundary Zod bounds", () => {
68
+ const schema = fieldToZod(createDecimalField({ precision: 6, scale: 2 }), []);
69
+
70
+ test("accepts values within precision and scale", () => {
71
+ for (const v of [5.83, 2.5, 1000.5, -42.99, 0, 9999.99]) {
72
+ expect(schema.safeParse(v).success).toBe(true);
73
+ }
74
+ });
75
+
76
+ test("rejects more decimal places than scale", () => {
77
+ expect(schema.safeParse(1.234).success).toBe(false);
78
+ });
79
+
80
+ test("rejects values exceeding the precision−scale integer digits", () => {
81
+ // precision 6, scale 2 → at most 4 integer digits → |value| < 10000.
82
+ expect(schema.safeParse(10000).success).toBe(false);
83
+ expect(schema.safeParse(-10000).success).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("decimal field — factory", () => {
88
+ test("requires precision and scale, defaults to optional", () => {
89
+ const f = createDecimalField({ precision: 8, scale: 3 });
90
+ expect(f.type).toBe("decimal");
91
+ expect(f.precision).toBe(8);
92
+ expect(f.scale).toBe(3);
93
+ expect(f.required).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("known limitation: precision past 2^53 (same trade-off as bigInt number-mode)", () => {
98
+ test("a numeric string beyond Number.MAX_SAFE_INTEGER loses precision on read", () => {
99
+ const e = createEntity({
100
+ table: "read_decimal_big",
101
+ fields: { big: createDecimalField({ precision: 20, scale: 0 }) },
102
+ });
103
+ const info = extractTableInfo(buildEntityTable("decimalBig", e));
104
+ // 2^53 + 1 → surfaced as JS number rounds to 2^53. Documented, not a bug:
105
+ // keep precision − scale ≤ 15 to stay exact (see DecimalFieldDef doc).
106
+ const input: Record<string, unknown> = { big: "9007199254740993" };
107
+ expect(coerceRow(input, info)["big"]).toBe(2 ** 53);
108
+ });
109
+ });
@@ -22,6 +22,7 @@ const entityWithDefaults = createEntity({
22
22
  tags: { type: "multiSelect", options: ["a", "b"] },
23
23
  attempt: { type: "number", required: true, default: 1 },
24
24
  bytes: { type: "bigInt", default: 0 },
25
+ rate: { type: "decimal", precision: 6, scale: 4, required: true, default: 1.5 },
25
26
  price: { type: "money" },
26
27
  meta: { type: "embedded", fields: {} },
27
28
  startedAt: { type: "timestamp", required: true },
@@ -59,6 +60,13 @@ describe("buildEntityTable ↔ buildEntityTableMeta lock-step", () => {
59
60
  expect(cols.get("bytes")?.defaultSql).toBe("0");
60
61
  expect(cols.get("title")?.defaultSql).toBe("'untitled'");
61
62
  expect(cols.get("active")?.defaultSql).toBe("true");
63
+ expect(cols.get("rate")?.defaultSql).toBe("1.5");
64
+ });
65
+
66
+ test("decimal field maps to numeric(precision,scale) on both paths", () => {
67
+ const cols = new Map((fromBuilder?.columns ?? []).map((c) => [c.name, c]));
68
+ expect(cols.get("rate")?.pgType).toBe("numeric(6,4)");
69
+ expect(cols.get("rate")?.notNull).toBe(true);
62
70
  });
63
71
  });
64
72
 
package/src/db/dialect.ts CHANGED
@@ -54,7 +54,15 @@ export type SchemaTable = EntityTableMeta & {
54
54
  readonly [field: string]: unknown;
55
55
  };
56
56
 
57
+ function isNumericPgType(t: PgType): t is `numeric(${number},${number})` {
58
+ return t.startsWith("numeric(");
59
+ }
60
+
57
61
  export function pgTypeToSqlType(pgType: PgType): string {
62
+ // numeric(p,s) carries its precision/scale in the type string — already
63
+ // valid SQL. The guard narrows it out so the switch below stays exhaustive
64
+ // over the fixed members (a forgotten member still fails the type-check).
65
+ if (isNumericPgType(pgType)) return pgType;
58
66
  switch (pgType) {
59
67
  case "uuid":
60
68
  return "uuid";
@@ -271,6 +279,17 @@ export function instant(
271
279
  return buildColumn(name, pgType) as ColumnBuilder<Temporal.Instant>;
272
280
  }
273
281
 
282
+ // Real numeric(precision, scale) column — exact decimal storage (interest
283
+ // rates, percentages, ratios). pg returns numeric as a STRING; coerceRow
284
+ // parses it back to JS number on read (safe ≤ 2^53). Precision/scale live in
285
+ // the pgType string, so DDL render + coercion need no extra metadata.
286
+ export const decimalColumn = (
287
+ name: string,
288
+ precision: number,
289
+ scale: number,
290
+ ): ColumnBuilder<number> =>
291
+ buildColumn(name, `numeric(${precision},${scale})`) as ColumnBuilder<number>;
292
+
274
293
  // moneyAmount kept as a customType-style API but produces a bigint column.
275
294
  // bigintJsMode "bigint" — entity-table-meta renders money as bigint, and the
276
295
  // table-builder↔meta lockstep guard fails on a number-mode column. (Precision
@@ -40,7 +40,10 @@ export type PgType =
40
40
  | "bigserial"
41
41
  | "jsonb"
42
42
  | "timestamptz"
43
- | "timestamptz(3)";
43
+ | "timestamptz(3)"
44
+ // Exact decimal — precision/scale are encoded in the type string so the
45
+ // DDL renderer and read-coercion need no side-channel metadata.
46
+ | `numeric(${number},${number})`;
44
47
 
45
48
  export type ColumnMeta = {
46
49
  readonly name: string; // snake_case PG column name
@@ -207,6 +210,17 @@ function fieldToColumnMeta(
207
210
  },
208
211
  ];
209
212
  }
213
+ case "decimal": {
214
+ const def = fieldDefaultLiteral(field);
215
+ return [
216
+ {
217
+ name: snake,
218
+ pgType: `numeric(${field.precision},${field.scale})`,
219
+ notNull: field.required === true,
220
+ ...(def !== undefined && { defaultSql: def }),
221
+ },
222
+ ];
223
+ }
210
224
  case "reference":
211
225
  if (field.multiple === true) {
212
226
  return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'[]'::jsonb" }];
@@ -14,6 +14,7 @@ import {
14
14
  boolean,
15
15
  type ColumnBuilder,
16
16
  type ColumnHandle,
17
+ decimalColumn,
17
18
  type IndexBuilderWithCols,
18
19
  index,
19
20
  instant,
@@ -104,6 +105,11 @@ function fieldToColumns(
104
105
  const col = field.default !== undefined ? base.default(field.default) : base;
105
106
  return { [name]: field.required ? col.notNull() : col };
106
107
  }
108
+ case "decimal": {
109
+ const base = decimalColumn(snakeName, field.precision, field.scale);
110
+ const col = field.default !== undefined ? base.default(field.default) : base;
111
+ return { [name]: field.required ? col.notNull() : col };
112
+ }
107
113
  case "bigInt": {
108
114
  // 64-bit-Integer fuer Audit-Counter, Byte-Sizes, Cumulative-Sums.
109
115
  // mode:"number" liefert JS-`number` (sicher bis 2^53 ≈ 9 PB) statt
@@ -283,42 +289,46 @@ type ColumnsForField<K extends string, F extends FieldDefinition> = F extends {
283
289
  ? F extends { required: true }
284
290
  ? { readonly [P in K]: Col<number> }
285
291
  : { readonly [P in K]: NullCol<number> }
286
- : F extends { type: "money" }
292
+ : F extends { type: "decimal" }
287
293
  ? F extends { required: true }
288
- ? { readonly [P in K]: Col<number> } & {
289
- readonly [P in `${K}Currency`]: Col<string>;
290
- }
291
- : { readonly [P in K]: NullCol<number> } & {
292
- readonly [P in `${K}Currency`]: Col<string>;
293
- }
294
- : F extends { type: "reference"; multiple: true }
295
- ? { readonly [P in K]: Col<readonly string[]> }
296
- : F extends { type: "reference" }
297
- ? F extends { required: true }
298
- ? { readonly [P in K]: Col<string> }
299
- : { readonly [P in K]: NullCol<string> }
300
- : F extends { type: "embedded" }
301
- ? // jsonb default `{}`, immer notNull
302
- { readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
303
- : F extends { type: "date" | "timestamp" }
304
- ? F extends { required: true }
305
- ? { readonly [P in K]: Col<Temporal.Instant> }
306
- : { readonly [P in K]: NullCol<Temporal.Instant> }
307
- : F extends { type: "locatedTimestamp" }
294
+ ? { readonly [P in K]: Col<number> }
295
+ : { readonly [P in K]: NullCol<number> }
296
+ : F extends { type: "money" }
297
+ ? F extends { required: true }
298
+ ? { readonly [P in K]: Col<number> } & {
299
+ readonly [P in `${K}Currency`]: Col<string>;
300
+ }
301
+ : { readonly [P in K]: NullCol<number> } & {
302
+ readonly [P in `${K}Currency`]: Col<string>;
303
+ }
304
+ : F extends { type: "reference"; multiple: true }
305
+ ? { readonly [P in K]: Col<readonly string[]> }
306
+ : F extends { type: "reference" }
307
+ ? F extends { required: true }
308
+ ? { readonly [P in K]: Col<string> }
309
+ : { readonly [P in K]: NullCol<string> }
310
+ : F extends { type: "embedded" }
311
+ ? // jsonb default `{}`, immer notNull
312
+ { readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
313
+ : F extends { type: "date" | "timestamp" }
308
314
  ? F extends { required: true }
309
- ? { readonly [P in `${K}Utc`]: Col<Temporal.Instant> } & {
310
- readonly [P in `${K}Tz`]: Col<string>;
311
- }
312
- : { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
313
- readonly [P in `${K}Tz`]: NullCol<string>;
314
- }
315
- : F extends { type: "file" | "image" }
315
+ ? { readonly [P in K]: Col<Temporal.Instant> }
316
+ : { readonly [P in K]: NullCol<Temporal.Instant> }
317
+ : F extends { type: "locatedTimestamp" }
316
318
  ? F extends { required: true }
317
- ? { readonly [P in K]: Col<string> }
318
- : { readonly [P in K]: NullCol<string> }
319
- : F extends { type: "files" | "images" }
320
- ? Record<never, never>
321
- : never;
319
+ ? { readonly [P in `${K}Utc`]: Col<Temporal.Instant> } & {
320
+ readonly [P in `${K}Tz`]: Col<string>;
321
+ }
322
+ : { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
323
+ readonly [P in `${K}Tz`]: NullCol<string>;
324
+ }
325
+ : F extends { type: "file" | "image" }
326
+ ? F extends { required: true }
327
+ ? { readonly [P in K]: Col<string> }
328
+ : { readonly [P in K]: NullCol<string> }
329
+ : F extends { type: "files" | "images" }
330
+ ? Record<never, never>
331
+ : never;
322
332
 
323
333
  type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
324
334
  k: infer I,
@@ -2,6 +2,7 @@ import type {
2
2
  BigIntFieldDef,
3
3
  BooleanFieldDef,
4
4
  DateFieldDef,
5
+ DecimalFieldDef,
5
6
  EmbeddedFieldDef,
6
7
  EntityDefinition,
7
8
  EntityIndexDef,
@@ -140,6 +141,21 @@ export function createBigIntField<R extends true | false = false>(
140
141
  } as BigIntFieldDef & { required: R }; // @cast-boundary engine-payload
141
142
  }
142
143
 
144
+ // Exact decimal column — numeric(precision, scale). precision/scale are
145
+ // required (no truncating default). See DecimalFieldDef for the precision
146
+ // caveat (surfaced as JS number, safe ≤ 2^53).
147
+ export function createDecimalField<R extends true | false = false>(
148
+ config: { precision: number; scale: number } & Partial<
149
+ Omit<DecimalFieldDef, "type" | "precision" | "scale" | "required">
150
+ > & { required?: R },
151
+ ): DecimalFieldDef & { required: R } {
152
+ return {
153
+ type: "decimal",
154
+ required: false,
155
+ ...config,
156
+ } as DecimalFieldDef & { required: R }; // @cast-boundary engine-payload
157
+ }
158
+
143
159
  export function createMoneyField<R extends true | false = false>(
144
160
  overrides?: Partial<Omit<MoneyFieldDef, "type" | "required">> & { required?: R },
145
161
  ): MoneyFieldDef & { required: R } {
@@ -80,6 +80,7 @@ export {
80
80
  createBigIntField,
81
81
  createBooleanField,
82
82
  createDateField,
83
+ createDecimalField,
83
84
  createEmbeddedField,
84
85
  createEntity,
85
86
  createFileField,
@@ -236,6 +237,7 @@ export type {
236
237
  CustomScreenDefinition,
237
238
  CustomScreenRoute,
238
239
  DateFieldDef,
240
+ DecimalFieldDef,
239
241
  DeleteContext,
240
242
  EditExtensionSection,
241
243
  EditFieldSpec,
@@ -60,6 +60,20 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
60
60
  const schema = z.number();
61
61
  return field.default !== undefined ? schema.default(field.default) : schema;
62
62
  }
63
+ case "decimal": {
64
+ // Stored as numeric(precision, scale), surfaced as JS number. Bound the
65
+ // value at the write boundary so an over-range or over-scale input fails
66
+ // loud here instead of being silently rounded/rejected by Postgres.
67
+ const limit = 10 ** (field.precision - field.scale);
68
+ const schema = z
69
+ .number()
70
+ .gt(-limit)
71
+ .lt(limit)
72
+ .refine((n) => Number(n.toFixed(field.scale)) === n, {
73
+ message: `at most ${field.scale} decimal places`,
74
+ });
75
+ return field.default !== undefined ? schema.default(field.default) : schema;
76
+ }
63
77
  case "bigInt": {
64
78
  // JS-`number`-Round-trip via mode:"number"; sicher bis 2^53.
65
79
  // safe-integer-Cap ist explizit damit ein Caller, der einen
@@ -223,6 +223,30 @@ export type BigIntFieldDef = {
223
223
  readonly access?: FieldAccess;
224
224
  } & PiiAnnotations;
225
225
 
226
+ /**
227
+ * Exact decimal — Postgres `numeric(precision, scale)`. For values that need
228
+ * fractional precision the integer `number` field (32-bit int) and `money`
229
+ * field (BIGINT minor units + currency) can't hold: interest rates,
230
+ * percentages, ratios, measurements.
231
+ *
232
+ * `precision` = total significant digits, `scale` = digits after the decimal
233
+ * point (both required — no silent default that could truncate). pg returns
234
+ * `numeric` as a string to preserve precision; the read-codec surfaces it as
235
+ * a JS `number` (safe ≤ 2^53, same trade-off as `bigInt` mode:"number" — a
236
+ * value past that boundary loses precision, so keep `precision - scale` ≤ 15).
237
+ */
238
+ export type DecimalFieldDef = {
239
+ readonly type: "decimal";
240
+ readonly precision: number;
241
+ readonly scale: number;
242
+ readonly required?: boolean;
243
+ readonly sortable?: boolean;
244
+ readonly filterable?: boolean;
245
+ readonly sensitive?: boolean;
246
+ readonly default?: number;
247
+ readonly access?: FieldAccess;
248
+ } & PiiAnnotations;
249
+
226
250
  export type MoneyFieldDef = {
227
251
  readonly type: "money";
228
252
  readonly required?: boolean;
@@ -440,6 +464,7 @@ export type FieldDefinition =
440
464
  | MultiSelectFieldDef
441
465
  | NumberFieldDef
442
466
  | BigIntFieldDef
467
+ | DecimalFieldDef
443
468
  | MoneyFieldDef
444
469
  | ReferenceFieldDef
445
470
  | EmbeddedFieldDef
@@ -79,6 +79,7 @@ export type {
79
79
  BigIntFieldDef,
80
80
  BooleanFieldDef,
81
81
  DateFieldDef,
82
+ DecimalFieldDef,
82
83
  DefaultCurrency,
83
84
  EmbeddedFieldDef,
84
85
  EmbeddedSubFieldDef,