@cosmicdrift/kumiko-framework 0.151.1 → 0.153.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.153.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.153.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
  });
@@ -80,6 +80,86 @@ describe("r.nav() — registration", () => {
80
80
  });
81
81
  });
82
82
 
83
+ describe("r.screen({ nav }) — inline nav sugar", () => {
84
+ test("synthesizes a nav entry from the screen's id", () => {
85
+ const feature = defineFeature("shop", (r) => {
86
+ r.entity("product", productEntity());
87
+ r.screen({
88
+ id: "products",
89
+ type: "entityList",
90
+ entity: "product",
91
+ columns: ["name"],
92
+ nav: { label: "shop:nav.products", icon: "box", order: 5 },
93
+ });
94
+ });
95
+ expect(feature.navs["products"]).toMatchObject({
96
+ id: "products",
97
+ label: "shop:nav.products",
98
+ icon: "box",
99
+ order: 5,
100
+ screen: "shop:screen:products",
101
+ });
102
+ });
103
+
104
+ test("supports parent like a standalone r.nav()", () => {
105
+ const feature = defineFeature("shop", (r) => {
106
+ r.nav({ id: "catalog", label: "x" });
107
+ r.entity("product", productEntity());
108
+ r.screen({
109
+ id: "products",
110
+ type: "entityList",
111
+ entity: "product",
112
+ columns: ["name"],
113
+ nav: { label: "y", parent: "shop:nav:catalog" },
114
+ });
115
+ });
116
+ expect(feature.navs["products"]?.parent).toBe("shop:nav:catalog");
117
+ });
118
+
119
+ test("passes the same boot-validation as a standalone r.nav()", () => {
120
+ const feature = defineFeature("shop", (r) => {
121
+ r.entity("product", productEntity());
122
+ r.screen({
123
+ id: "products",
124
+ type: "entityList",
125
+ entity: "product",
126
+ columns: ["name"],
127
+ nav: { label: "y" },
128
+ });
129
+ });
130
+ expect(() => validateBoot([feature])).not.toThrow();
131
+ });
132
+
133
+ test("screen without nav registers no nav entry", () => {
134
+ const feature = defineFeature("shop", (r) => {
135
+ r.entity("product", productEntity());
136
+ r.screen({
137
+ id: "products",
138
+ type: "entityList",
139
+ entity: "product",
140
+ columns: ["name"],
141
+ });
142
+ });
143
+ expect(feature.navs["products"]).toBeUndefined();
144
+ });
145
+
146
+ test("rejects when a standalone r.nav() already used the screen's id", () => {
147
+ expect(() =>
148
+ defineFeature("shop", (r) => {
149
+ r.nav({ id: "products", label: "standalone" });
150
+ r.entity("product", productEntity());
151
+ r.screen({
152
+ id: "products",
153
+ type: "entityList",
154
+ entity: "product",
155
+ columns: ["name"],
156
+ nav: { label: "inline" },
157
+ });
158
+ }),
159
+ ).toThrow(/already registered/);
160
+ });
161
+ });
162
+
83
163
  describe("createRegistry — nav indexing", () => {
84
164
  test("indexes nav entries by qualified name", () => {
85
165
  const feature = defineFeature("shop", (r) => {
@@ -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 } {
@@ -358,3 +358,45 @@ describe("renderPattern — single-pattern shape", () => {
358
358
  expect(out).toBe('r.metric({ name: "requests", type: "counter" });');
359
359
  });
360
360
  });
361
+
362
+ // Regression guard for the class of bug the r.exposesApi/r.usesApi fold
363
+ // hit: a registrar-shape change (there, removing a method; here, adding a
364
+ // nested optional field) must survive the Designer's parse→render→parse
365
+ // cycle, not just a TS compile of the framework itself. r.screen()'s `nav`
366
+ // sugar is a generic nested object on an already-generic pattern (no
367
+ // per-field extractor/renderer), so this is the cheap case — but proving
368
+ // it beats assuming it.
369
+ const SCREEN_WITH_NAV_FEATURE = `
370
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
371
+
372
+ defineFeature("shop", (r) => {
373
+ r.entity("product", { fields: { name: { type: "text" } } });
374
+ r.screen({
375
+ id: "products",
376
+ type: "entityList",
377
+ entity: "product",
378
+ columns: ["name"],
379
+ nav: { label: "shop:nav.products", icon: "box", order: 5 },
380
+ });
381
+ });
382
+ `;
383
+
384
+ describe("render → parse roundtrip — r.screen({ nav }) inline sugar", () => {
385
+ test("nested nav object survives parse → render → parse unchanged", () => {
386
+ const initial = parse(SCREEN_WITH_NAV_FEATURE);
387
+ const rendered = renderFeatureFile({
388
+ featureName: initial.featureName ?? "",
389
+ patterns: initial.patterns,
390
+ });
391
+ const reparsed = parse(rendered);
392
+ expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
393
+
394
+ const screenPattern = reparsed.patterns.find((p) => p.kind === "screen");
395
+ expect(screenPattern?.kind).toBe("screen");
396
+ if (screenPattern?.kind === "screen") {
397
+ expect(screenPattern.definition).toMatchObject({
398
+ nav: { label: "shop:nav.products", icon: "box", order: 5 },
399
+ });
400
+ }
401
+ });
402
+ });
@@ -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">,
@@ -260,6 +260,27 @@ export function buildUiExtensionsMethods<TName extends string>(
260
260
  );
261
261
  }
262
262
  state.screens[definition.id] = definition;
263
+ if (definition.nav) {
264
+ // Sugar for the common "one nav entry pointing at this screen"
265
+ // case — synthesizes id/screen from the screen's own id. Beyond
266
+ // label/icon/parent/order, declare a standalone r.nav() instead.
267
+ if (state.navs[definition.id]) {
268
+ throw new Error(
269
+ `[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
270
+ `Nav ids must be unique per feature — remove the standalone ` +
271
+ `r.nav("${definition.id}", ...) call or the screen's inline nav.`,
272
+ );
273
+ }
274
+ const navDefinition: NavDefinition = {
275
+ id: definition.id,
276
+ label: definition.nav.label,
277
+ icon: definition.nav.icon,
278
+ parent: definition.nav.parent,
279
+ order: definition.nav.order,
280
+ screen: `${name}:screen:${definition.id}`,
281
+ };
282
+ state.navs[definition.id] = navDefinition;
283
+ }
263
284
  },
264
285
  nav(definition: NavDefinition): void {
265
286
  // Reject kebab-drift at registration-time so the stack trace points at
@@ -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(
@@ -687,7 +684,9 @@ export type FeatureRegistrar<TFeature extends string = string> = {
687
684
  // the registry qualifies to "<feature>:screen:<id>". Boot-validation checks
688
685
  // that entity-bound screens reference a registered entity and that the
689
686
  // columns / form-field refs name real fields — cross-feature component-QN
690
- // validation (r.uiComponent) comes in M4/M5.
687
+ // validation (r.uiComponent) comes in M4/M5. Optional `nav` field is
688
+ // sugar for a single nav entry pointing at this screen — equivalent to
689
+ // a standalone r.nav({ id: <same id>, screen: "<feature>:screen:<id>", ... }).
691
690
  screen(definition: ScreenDefinition): void;
692
691
 
693
692
  // Register a nav entry. The id is the feature-local short name (kebab-case);
@@ -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
 
@@ -727,7 +727,19 @@ export type ScreenSlots = {
727
727
 
728
728
  // --- discriminated union ---
729
729
 
730
- export type ScreenDefinition =
730
+ // Inline nav-entry sugar for `r.screen({ ..., nav: {...} })` — covers the
731
+ // common case of "one nav entry pointing at this screen". The nav entry's
732
+ // `id`/`screen` are synthesized from the screen's own id; for anything
733
+ // beyond label/icon/parent/order (access-gating, workspaces, actions),
734
+ // declare a standalone `r.nav()` entry instead.
735
+ export type ScreenNavSugar = {
736
+ readonly label: string;
737
+ readonly icon?: string;
738
+ readonly parent?: string;
739
+ readonly order?: number;
740
+ };
741
+
742
+ export type ScreenDefinition = (
731
743
  | EntityListScreenDefinition
732
744
  | ProjectionListScreenDefinition
733
745
  | ProjectionDetailScreenDefinition
@@ -735,7 +747,8 @@ export type ScreenDefinition =
735
747
  | EntityEditScreenDefinition
736
748
  | ActionFormScreenDefinition
737
749
  | ConfigEditScreenDefinition
738
- | CustomScreenDefinition;
750
+ | CustomScreenDefinition
751
+ ) & { readonly nav?: ScreenNavSugar };
739
752
 
740
753
  // Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
741
754
  // authors who branch on the three FieldRenderer variants without manual