@cosmicdrift/kumiko-framework 0.151.1 → 0.152.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.151.1",
3
+ "version": "0.152.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>",
@@ -193,7 +193,7 @@
193
193
  "zod": "^4.4.3"
194
194
  },
195
195
  "devDependencies": {
196
- "@cosmicdrift/kumiko-dispatcher-live": "0.151.1",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.152.0",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -0,0 +1,58 @@
1
+ // Prod-relevant bug: NumberFieldDef mapped unconditionally to a Postgres
2
+ // `integer` column regardless of the `integer` flag. `createNumberField()`
3
+ // (no `integer: true`) accepts fractional values at the Zod boundary but
4
+ // the column rejected them at the DB with "invalid input syntax for type
5
+ // integer" — Monte-Carlo-style stats fields (phronexsis simulation results)
6
+ // hit this on every non-integer result. Fix: `integer: true` → `integer`
7
+ // column, otherwise → `double precision` (fractional values round-trip).
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
11
+ import { selectMany } from "../../db/query";
12
+ import { buildEntityTable } from "../../db/table-builder";
13
+ import { createEntity, createNumberField } from "../../engine";
14
+ import { setupTestStack, type TestStack, unsafeCreateEntityTable } from "../../stack";
15
+
16
+ const statsEntity = createEntity({
17
+ table: "nff_stats",
18
+ fields: {
19
+ expectedValue: createNumberField({ sortable: true }),
20
+ wholeCount: createNumberField({ integer: true }),
21
+ },
22
+ });
23
+ const statsTable = buildEntityTable("nff-stats", statsEntity);
24
+
25
+ let stack: TestStack;
26
+
27
+ beforeAll(async () => {
28
+ stack = await setupTestStack({ features: [] });
29
+ await unsafeCreateEntityTable(stack.db, statsEntity, "nff-stats");
30
+ });
31
+
32
+ afterAll(async () => stack?.cleanup());
33
+
34
+ describe("createNumberField() without integer:true — fractional values round-trip", () => {
35
+ test("INSERT accepts a non-integer value and SELECT returns it unchanged", async () => {
36
+ await seedRow(stack.db, statsTable, {
37
+ expectedValue: 77.02269129478736,
38
+ wholeCount: 3,
39
+ tenantId: "00000000-0000-4000-8000-000000000001",
40
+ });
41
+ const rows = await selectMany(stack.db, statsTable);
42
+ expect(rows).toHaveLength(1);
43
+ expect(rows[0]?.["expectedValue"]).toBe(77.02269129478736);
44
+ expect(rows[0]?.["wholeCount"]).toBe(3);
45
+ });
46
+ });
47
+
48
+ describe("createNumberField({ integer: true }) — still rejects fractional values at the DB", () => {
49
+ test("INSERT with a non-integer value into an integer-flagged field throws", async () => {
50
+ await expect(
51
+ seedRow(stack.db, statsTable, {
52
+ expectedValue: 1,
53
+ wholeCount: 3.5 as unknown as number,
54
+ tenantId: "00000000-0000-4000-8000-000000000001",
55
+ }),
56
+ ).rejects.toThrow(/invalid input syntax for type integer/);
57
+ });
58
+ });
@@ -105,7 +105,7 @@ describe("schema migration workflows", () => {
105
105
  // Entity fields
106
106
  expect(columns.get("title")?.dataType).toBe("text");
107
107
  expect(columns.get("body")?.dataType).toBe("text");
108
- expect(columns.get("view_count")?.dataType).toBe("integer");
108
+ expect(columns.get("view_count")?.dataType).toBe("double precision");
109
109
  expect(columns.get("published_at")?.dataType).toContain("timestamp");
110
110
  expect(columns.get("is_draft")?.dataType).toBe("boolean");
111
111
  expect(columns.get("is_draft")?.isNullable).toBe(false); // has default → NOT NULL
package/src/db/dialect.ts CHANGED
@@ -72,6 +72,8 @@ export function pgTypeToSqlType(pgType: PgType): string {
72
72
  return "boolean";
73
73
  case "integer":
74
74
  return "integer";
75
+ case "double precision":
76
+ return "double precision";
75
77
  case "bigint":
76
78
  return "bigint";
77
79
  case "serial":
@@ -234,6 +236,10 @@ export function integer(name: string): ColumnBuilder<number> {
234
236
  return buildColumn(name, "integer") as ColumnBuilder<number>;
235
237
  }
236
238
 
239
+ export function doublePrecision(name: string): ColumnBuilder<number> {
240
+ return buildColumn(name, "double precision") as ColumnBuilder<number>;
241
+ }
242
+
237
243
  export function serial(name: string): ColumnBuilder<number> {
238
244
  return buildColumn(name, "serial") as ColumnBuilder<number>;
239
245
  }
@@ -36,6 +36,7 @@ export type PgType =
36
36
  | "text"
37
37
  | "boolean"
38
38
  | "integer"
39
+ | "double precision"
39
40
  | "bigint"
40
41
  | "serial"
41
42
  | "bigserial"
@@ -204,7 +205,7 @@ function fieldToColumnMeta(
204
205
  return [
205
206
  {
206
207
  name: snake,
207
- pgType: "integer",
208
+ pgType: field.integer === true ? "integer" : "double precision",
208
209
  notNull: field.required === true,
209
210
  ...(def !== undefined && { defaultSql: def }),
210
211
  },
@@ -15,6 +15,7 @@ import {
15
15
  type ColumnBuilder,
16
16
  type ColumnHandle,
17
17
  decimalColumn,
18
+ doublePrecision,
18
19
  type IndexBuilderWithCols,
19
20
  index,
20
21
  instant,
@@ -107,7 +108,7 @@ function fieldToColumns(
107
108
  // multi-select.
108
109
  return { [name]: jsonb(snakeName).default([]).notNull() };
109
110
  case "number": {
110
- const base = integer(snakeName);
111
+ const base = field.integer === true ? integer(snakeName) : doublePrecision(snakeName);
111
112
  const col = field.default !== undefined ? base.default(field.default) : base;
112
113
  return { [name]: field.required ? col.notNull() : col };
113
114
  }
@@ -1208,29 +1208,32 @@ describe("r.config()", () => {
1208
1208
  });
1209
1209
  });
1210
1210
 
1211
- // --- r.configKey() ---
1211
+ // --- r.config() single-key overload ---
1212
1212
 
1213
- describe("r.configKey()", () => {
1214
- test("returns a bare handle with the same qualified name r.config({keys}) would produce", () => {
1215
- let viaConfigKey!: { readonly name: string; readonly type: "boolean" };
1216
- let viaConfig!: { readonly defaultVat: { readonly name: string; readonly type: "number" } };
1213
+ describe("r.config() single-key overload", () => {
1214
+ test("returns a bare handle with the same qualified name the multi-key form would produce", () => {
1215
+ let viaSingleKey!: { readonly name: string; readonly type: "boolean" };
1216
+ let viaMultiKey!: { readonly defaultVat: { readonly name: string; readonly type: "number" } };
1217
1217
  defineFeature("invoicing", (r) => {
1218
- viaConfigKey = r.configKey("enabled", createTenantConfig("boolean", { default: false }));
1218
+ viaSingleKey = r.config("enabled", createTenantConfig("boolean", { default: false }));
1219
1219
  });
1220
1220
  defineFeature("invoicing2", (r) => {
1221
- viaConfig = r.config({ keys: { defaultVat: createTenantConfig("number", { default: 19 }) } });
1221
+ viaMultiKey = r.config({
1222
+ keys: { defaultVat: createTenantConfig("number", { default: 19 }) },
1223
+ });
1222
1224
  });
1223
1225
 
1224
- expect(viaConfigKey.name).toBe("invoicing:config:enabled");
1225
- expect(viaConfigKey.type).toBe("boolean");
1226
- // Structural check: configKey()'s handle is exactly what config({keys:{one}})
1227
- // would have produced for that single key — same shape, same qualification scheme.
1228
- expect(viaConfig.defaultVat.name).toBe("invoicing2:config:default-vat");
1226
+ expect(viaSingleKey.name).toBe("invoicing:config:enabled");
1227
+ expect(viaSingleKey.type).toBe("boolean");
1228
+ // Structural check: the single-key form's handle is exactly what
1229
+ // {keys:{one}} would have produced for that single key — same shape,
1230
+ // same qualification scheme.
1231
+ expect(viaMultiKey.defaultVat.name).toBe("invoicing2:config:default-vat");
1229
1232
  });
1230
1233
 
1231
- test("registers the key on the feature exactly like r.config would", () => {
1234
+ test("registers the key on the feature exactly like the multi-key form would", () => {
1232
1235
  const feature = defineFeature("shop", (r) => {
1233
- r.configKey("maxItems", createTenantConfig("number", { default: 10 }));
1236
+ r.config("maxItems", createTenantConfig("number", { default: 10 }));
1234
1237
  });
1235
1238
  expect(feature.configKeys["maxItems"]).toBeDefined();
1236
1239
  expect(feature.configKeys["maxItems"]?.type).toBe("number");
@@ -1243,7 +1246,7 @@ describe("r.configKey()", () => {
1243
1246
  test("camelCase feature + key are kebab-cased in the handle name", () => {
1244
1247
  let handle!: { readonly name: string };
1245
1248
  defineFeature("billingCore", (r) => {
1246
- handle = r.configKey("monthlyTotalCents", createSystemConfig("number", { default: 0 }));
1249
+ handle = r.config("monthlyTotalCents", createSystemConfig("number", { default: 0 }));
1247
1250
  });
1248
1251
  expect(handle.name).toBe("billing-core:config:monthly-total-cents");
1249
1252
  });
@@ -133,6 +133,12 @@ export function createMultiSelectField<const TOptions extends readonly string[]>
133
133
  };
134
134
  }
135
135
 
136
+ /**
137
+ * Numeric field — `double precision` column by default (fractional values
138
+ * allowed end to end). Pass `integer: true` for a 32-bit `integer` column
139
+ * with `.int()` write-boundary validation. Need exact decimal storage
140
+ * instead (money-adjacent math)? Use `createDecimalField` (`numeric`).
141
+ */
136
142
  export function createNumberField<R extends true | false = false>(
137
143
  overrides?: Partial<Omit<NumberFieldDef, "type" | "required">> & { required?: R },
138
144
  ): NumberFieldDef & { required: R } {
@@ -32,15 +32,36 @@ export function buildConfigEventsJobsMethods<TName extends string>(
32
32
  state: FeatureBuilderState,
33
33
  name: TName,
34
34
  ) {
35
- // Hoisted out of the returned object literal (not just a method) so
36
- // `configKey()` below can call it directly object-literal methods
37
- // aren't in scope for their siblings without a `this`-bind.
35
+ // Overloaded: (keyName, def) for the single-key case, ({keys, seeds}) for
36
+ // the multi-key case both funnel through the same qualify/register loop
37
+ // below, so a single-key call is byte-identical to what
38
+ // `{keys:{name:def}}` would have produced.
39
+ function config<T extends ConfigKeyType>(
40
+ keyName: string,
41
+ def: ConfigKeyDefinition<T>,
42
+ ): ConfigKeyHandle<T>;
38
43
  function config<
39
44
  TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>,
40
45
  >(definition: {
41
46
  readonly keys: TKeys;
42
47
  readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
43
- }): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> } {
48
+ }): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> };
49
+ function config(
50
+ arg1:
51
+ | string
52
+ | {
53
+ readonly keys: Record<string, ConfigKeyDefinition<ConfigKeyType>>;
54
+ readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
55
+ },
56
+ arg2?: ConfigKeyDefinition<ConfigKeyType>,
57
+ ): unknown {
58
+ // arg2 is always defined here — the two public overloads above guarantee
59
+ // it whenever arg1 is a string; the impl signature just has to widen it
60
+ // to optional to satisfy both call shapes.
61
+ const definition = typeof arg1 === "string" && arg2 ? { keys: { [arg1]: arg2 } } : arg1;
62
+ if (typeof definition === "string") {
63
+ throw new Error("config(): single-key form requires a definition as the second argument");
64
+ }
44
65
  // Qualify eagerly (same as defineEvent) so the handle name matches what
45
66
  // the registry stores — lazy qualification would break compile-time
46
67
  // autocomplete and hand-built test registries.
@@ -68,22 +89,12 @@ export function buildConfigEventsJobsMethods<TName extends string>(
68
89
  });
69
90
  }
70
91
  }
71
- return handles as {
72
- readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]>;
73
- }; // @cast-boundary engine-bridge — Mapped-Type-Inference at config()-callsite
92
+ // Single-key call unwraps its own handle; multi-key returns the record.
93
+ return typeof arg1 === "string" ? handles[arg1] : handles; // @cast-boundary engine-bridge — overload impl signature widens to unknown, narrowed by the two public overloads above
74
94
  }
75
95
 
76
96
  return {
77
97
  config,
78
- // Shorthand for a single key — same handle shape `r.config({keys:{name:def}})`
79
- // would produce for that key, just without the wrapping record. No seeds
80
- // param: callers needing seeds use `r.config` directly.
81
- configKey<T extends ConfigKeyType>(
82
- keyName: string,
83
- def: ConfigKeyDefinition<T>,
84
- ): ConfigKeyHandle<T> {
85
- return config({ keys: { [keyName]: def } })[keyName] as ConfigKeyHandle<T>; // @cast-boundary engine-bridge — mapped-type narrows to the single key
86
- },
87
98
  job(
88
99
  jobName: string,
89
100
  options: Omit<JobDefinition, "name" | "handler">,
@@ -499,23 +499,20 @@ export type FeatureRegistrar<TFeature extends string = string> = {
499
499
  // tags-array as searchable. See `SearchPayloadContributorFn`.
500
500
  searchPayloadExtension(entity: NameOrRef, fn: SearchPayloadContributorFn): void;
501
501
 
502
- // Returns a handle map keyed exactly like the input. Pass any handle to
503
- // `ctx.config(handle)` to get the value type narrowed by the key's `type`.
504
- // Optional `seeds` declare boot-time system-rows that are written via the
505
- // event-store executor — idempotent, skipped when the stream already exists.
502
+ // Single-key form: bare handle, no wrapping record, no seeds (callers
503
+ // needing seeds use the multi-key form below).
504
+ config<T extends ConfigKeyType>(keyName: string, def: ConfigKeyDefinition<T>): ConfigKeyHandle<T>;
505
+
506
+ // Multi-key form: returns a handle map keyed exactly like the input. Pass
507
+ // any handle to `ctx.config(handle)` to get the value type narrowed by the
508
+ // key's `type`. Optional `seeds` declare boot-time system-rows that are
509
+ // written via the event-store executor — idempotent, skipped when the
510
+ // stream already exists.
506
511
  config<TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(definition: {
507
512
  readonly keys: TKeys;
508
513
  readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
509
514
  }): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> };
510
515
 
511
- // Shorthand for a single key — same handle shape `config({keys:{name:def}})`
512
- // would produce for that key, no wrapping record. No seeds param: callers
513
- // needing seeds use `config` directly.
514
- configKey<T extends ConfigKeyType>(
515
- keyName: string,
516
- def: ConfigKeyDefinition<T>,
517
- ): ConfigKeyHandle<T>;
518
-
519
516
  job(name: string, options: Omit<JobDefinition, "name" | "handler">, handler: JobHandlerFn): void;
520
517
 
521
518
  notification(
@@ -197,6 +197,14 @@ export type MultiSelectFieldDef<TOptions extends readonly string[] = readonly st
197
197
  readonly access?: FieldAccess;
198
198
  } & PiiAnnotations;
199
199
 
200
+ /**
201
+ * Storage: `integer` flag decides the Postgres column type, not just Zod
202
+ * validation. `integer: true` → `integer` column (32-bit, ~±2.1 billion),
203
+ * write-boundary enforces `.int()`. Omitted/`false` (the default) →
204
+ * `double precision` column — fractional values are accepted end to end.
205
+ * Need exact decimal storage instead of a binary float (money-adjacent
206
+ * math, no representation error)? Use `createDecimalField` (`numeric`).
207
+ */
200
208
  export type NumberFieldDef = {
201
209
  readonly type: "number";
202
210
  readonly required?: boolean;
@@ -205,10 +213,9 @@ export type NumberFieldDef = {
205
213
  readonly sensitive?: boolean;
206
214
  readonly default?: number;
207
215
  readonly access?: FieldAccess;
208
- // Write-boundary constraints (Zod-level, no migration/storage impact — the
209
- // Postgres column stays a plain numeric). Opt-in, so existing entities are
210
- // unaffected.
211
216
  readonly min?: number;
217
+ /** `true` → `integer` column + `.int()` Zod validation. Omitted/`false` →
218
+ * `double precision` column, fractional values allowed. */
212
219
  readonly integer?: boolean;
213
220
  } & PiiAnnotations;
214
221