@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 +2 -2
- package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
- package/src/db/dialect.ts +6 -0
- package/src/db/entity-table-meta.ts +2 -1
- package/src/db/table-builder.ts +2 -1
- package/src/engine/__tests__/engine.test.ts +18 -15
- package/src/engine/__tests__/nav.test.ts +80 -0
- package/src/engine/factories.ts +6 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +42 -0
- package/src/engine/feature-config-events-jobs.ts +27 -16
- package/src/engine/feature-ui-extensions.ts +21 -0
- package/src/engine/types/feature.ts +12 -13
- package/src/engine/types/fields.ts +10 -3
- package/src/engine/types/screen.ts +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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("
|
|
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
|
},
|
package/src/db/table-builder.ts
CHANGED
|
@@ -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.
|
|
1211
|
+
// --- r.config() single-key overload ---
|
|
1212
1212
|
|
|
1213
|
-
describe("r.
|
|
1214
|
-
test("returns a bare handle with the same qualified name
|
|
1215
|
-
let
|
|
1216
|
-
let
|
|
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
|
-
|
|
1218
|
+
viaSingleKey = r.config("enabled", createTenantConfig("boolean", { default: false }));
|
|
1219
1219
|
});
|
|
1220
1220
|
defineFeature("invoicing2", (r) => {
|
|
1221
|
-
|
|
1221
|
+
viaMultiKey = r.config({
|
|
1222
|
+
keys: { defaultVat: createTenantConfig("number", { default: 19 }) },
|
|
1223
|
+
});
|
|
1222
1224
|
});
|
|
1223
1225
|
|
|
1224
|
-
expect(
|
|
1225
|
-
expect(
|
|
1226
|
-
// Structural check:
|
|
1227
|
-
// would have produced for that single key — same shape,
|
|
1228
|
-
|
|
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
|
|
1234
|
+
test("registers the key on the feature exactly like the multi-key form would", () => {
|
|
1232
1235
|
const feature = defineFeature("shop", (r) => {
|
|
1233
|
-
r.
|
|
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.
|
|
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) => {
|
package/src/engine/factories.ts
CHANGED
|
@@ -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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|