@cosmicdrift/kumiko-framework 0.43.0 → 0.45.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 +1 -1
- package/src/bun-db/__tests__/decimal-field.integration.test.ts +81 -0
- package/src/bun-db/query.ts +11 -0
- package/src/db/__tests__/decimal-field.test.ts +109 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +8 -0
- package/src/db/dialect.ts +19 -0
- package/src/db/entity-table-meta.ts +15 -1
- package/src/db/table-builder.ts +43 -33
- package/src/engine/factories.ts +16 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/schema-builder.ts +14 -0
- package/src/engine/types/fields.ts +25 -0
- package/src/engine/types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -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" }];
|
package/src/db/table-builder.ts
CHANGED
|
@@ -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: "
|
|
292
|
+
: F extends { type: "decimal" }
|
|
287
293
|
? F extends { required: true }
|
|
288
|
-
? { readonly [P in K]: Col<number> }
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
: F extends { type: "
|
|
301
|
-
?
|
|
302
|
-
{ readonly [P in K]: Col<
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
: F extends { type: "
|
|
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
|
|
310
|
-
|
|
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<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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,
|
package/src/engine/factories.ts
CHANGED
|
@@ -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 } {
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|