rastack 0.0.49 → 0.0.50

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/components/auto-form/AutoForm.tsx +13 -0
  3. package/components/auto-form/use-auto-form.ts +28 -0
  4. package/components/types.ts +8 -0
  5. package/dist/admin.js +13 -13
  6. package/dist/compile/analyze.d.ts +26 -0
  7. package/dist/compile/analyze.js +70 -3
  8. package/dist/compile/entities.d.ts +19 -0
  9. package/dist/compile/entities.js +87 -13
  10. package/dist/compile/index.d.ts +23 -4
  11. package/dist/compile/index.js +86 -7
  12. package/dist/compile/model.d.ts +38 -0
  13. package/dist/compile/openapi.d.ts +9 -0
  14. package/dist/compile/openapi.js +14 -0
  15. package/dist/define/index.d.ts +107 -13
  16. package/dist/define/index.js +1 -1
  17. package/dist/import/tabular.d.ts +8 -2
  18. package/dist/import/tabular.js +1 -1
  19. package/dist/rastack-import.js +4 -1
  20. package/dist/validate/adapters.js +2 -0
  21. package/dist/validate/index.d.ts +1 -0
  22. package/dist/validate/index.js +1 -0
  23. package/dist/validate/machine.d.ts +23 -2
  24. package/dist/validate/machine.js +35 -2
  25. package/dist/validate/transitions.d.ts +86 -0
  26. package/dist/validate/transitions.js +199 -0
  27. package/dist/wasm/rastack_wasm.js +1 -1
  28. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  29. package/hooks/data.ts +209 -0
  30. package/hooks/entity.ts +222 -0
  31. package/hooks/form/entity-form.ts +354 -0
  32. package/hooks/form/form.ts +8 -1
  33. package/hooks/form/index.ts +7 -1
  34. package/hooks/index.ts +4 -0
  35. package/hooks/manifest.ts +41 -0
  36. package/hooks/registry.ts +39 -0
  37. package/package.json +1 -1
  38. package/provider/provider.tsx +8 -1
  39. package/provider/types.ts +7 -2
  40. package/src/compile/analyze.ts +83 -4
  41. package/src/compile/entities.ts +111 -11
  42. package/src/compile/index.ts +108 -11
  43. package/src/compile/model.ts +40 -0
  44. package/src/compile/openapi.ts +13 -1
  45. package/src/define/index.ts +148 -20
  46. package/src/import/tabular.ts +9 -2
  47. package/src/rastack-import.ts +4 -1
  48. package/src/validate/adapters.ts +1 -0
  49. package/src/validate/index.ts +1 -0
  50. package/src/validate/machine.ts +55 -3
  51. package/src/validate/transitions.ts +232 -0
  52. package/test/components.spec.ts +22 -0
  53. package/test/transitions.spec.ts +372 -0
  54. package/test/typed-hooks.spec.ts +407 -0
  55. package/wasm/rastack_wasm.js +1 -1
  56. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -2,6 +2,7 @@ import * as path from "path";
2
2
  import * as ts from "typescript";
3
3
  import { FieldModel, RelationModel, ResourceModel, ScalarType } from "./model";
4
4
  import { resourceSourceFiles } from "./program";
5
+ import { applyTransitions, literalToValue } from "./analyze";
5
6
 
6
7
  /**
7
8
  * Type-driven resource inference — "the entities in the app are a list of
@@ -70,9 +71,29 @@ export interface EntityDiagnostic {
70
71
  message: string;
71
72
  }
72
73
 
74
+ /**
75
+ * Where one entity's TypeScript type lives — what `rastack compile` needs to
76
+ * emit the `rastack-env.d.ts` registry (`"app.model"` → `import("…").Name`)
77
+ * that lets `useData("app.model")` infer its rows with no type argument.
78
+ */
79
+ export interface EntityTypeExport {
80
+ /** `"app.model"`. */
81
+ key: string;
82
+ /** The declared class/interface name (or DSL const name). */
83
+ typeName: string;
84
+ /** Absolute path of the declaring source file. */
85
+ fileName: string;
86
+ /** Whether the declaration is exported (unexported types can't register). */
87
+ exported: boolean;
88
+ /** DSL consts register as `RowOf<typeof X>` rather than the type itself. */
89
+ kind: "type" | "resourceConst";
90
+ }
91
+
73
92
  export interface EntityAnalysis {
74
93
  resources: ResourceModel[];
75
94
  diagnostics: EntityDiagnostic[];
95
+ /** Registry entries for the class/interface entities. */
96
+ types: EntityTypeExport[];
76
97
  }
77
98
 
78
99
  export function analyzeEntities(
@@ -105,12 +126,39 @@ export function analyzeEntities(
105
126
  roots.push(decl);
106
127
  }
107
128
  };
129
+ // Names of every candidate, for string-argument rooting.
130
+ const candidateNaming = new Map<EntityDecl, EntityNaming>();
131
+ const namingOf = (decl: EntityDecl): EntityNaming => {
132
+ let naming = candidateNaming.get(decl);
133
+ if (!naming) {
134
+ naming = entityNaming(decl);
135
+ candidateNaming.set(decl, naming);
136
+ }
137
+ return naming;
138
+ };
139
+
108
140
  for (const sf of sourceFiles) {
109
141
  ts.forEachChild(sf, function walk(node) {
110
- if (ts.isCallExpression(node) && isHookCall(node)) {
111
- for (const typeArg of node.typeArguments ?? []) {
112
- const decl = entityBehindTypeNode(typeArg, checker, scanned);
113
- if (decl && candidates.has(decl)) addRoot(decl);
142
+ if (ts.isCallExpression(node)) {
143
+ if (isHookCall(node)) {
144
+ for (const typeArg of node.typeArguments ?? []) {
145
+ const decl = entityBehindTypeNode(typeArg, checker, scanned);
146
+ if (decl && candidates.has(decl)) addRoot(decl);
147
+ }
148
+ }
149
+ // `useData("airports.terminal")` — the manifest-key string names the
150
+ // entity, so it roots the declaration exactly like a type argument
151
+ // would: the compiler resolves the dotted key against the same naming
152
+ // it compiles. (Only the dotted form roots — a bare string argument to
153
+ // some `use*` hook is far too often not an entity key.)
154
+ const key = hookEntityKeyArg(node);
155
+ if (key) {
156
+ for (const decl of candidates) {
157
+ const naming = namingOf(decl);
158
+ if (naming.app === key.app && naming.model === key.model) {
159
+ addRoot(decl);
160
+ }
161
+ }
114
162
  }
115
163
  }
116
164
  ts.forEachChild(node, walk);
@@ -153,9 +201,17 @@ export function analyzeEntities(
153
201
  included.map((decl) => [decl, entityNaming(decl)] as const),
154
202
  );
155
203
  const resources: ResourceModel[] = [];
204
+ const types: EntityTypeExport[] = [];
156
205
  for (const decl of included) {
157
206
  const naming = namingByDecl.get(decl)!;
158
207
  const key = `${naming.app}.${naming.model}`;
208
+ types.push({
209
+ key,
210
+ typeName: decl.name!.text,
211
+ fileName: path.resolve(decl.getSourceFile().fileName),
212
+ exported: !!(ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Export),
213
+ kind: "type",
214
+ });
159
215
  const fields: FieldModel[] = [];
160
216
  const relations: RelationModel[] = [];
161
217
 
@@ -185,34 +241,78 @@ export function analyzeEntities(
185
241
  }
186
242
  }
187
243
 
188
- resources.push({
244
+ const resource: ResourceModel = {
189
245
  app: naming.app,
190
246
  model: naming.model,
191
247
  fields,
192
248
  relations,
193
249
  search: naming.search,
194
250
  permission: naming.permission,
195
- });
251
+ };
252
+ // A class may declare its state machine inline: `static transitions =
253
+ // {...}`. Statics are never columns, so the block rides alongside the
254
+ // data properties — the class-authored twin of the DSL option.
255
+ applyTransitions(resource, staticTransitions(decl));
256
+ resources.push(resource);
196
257
  }
197
258
 
198
259
  resources.sort((a, b) =>
199
260
  a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
200
261
  );
201
- return { resources, diagnostics };
262
+ return { resources, diagnostics, types };
263
+ }
264
+
265
+ /**
266
+ * The literal value of a class's `static transitions = {...}` block, when
267
+ * declared. Only static object literals qualify — the block is data, exactly
268
+ * like the DSL option, and is evaluated with the same literal evaluator.
269
+ */
270
+ function staticTransitions(decl: EntityDecl): unknown {
271
+ if (!ts.isClassDeclaration(decl)) return undefined;
272
+ for (const member of decl.members) {
273
+ if (
274
+ ts.isPropertyDeclaration(member) &&
275
+ ts.getCombinedModifierFlags(member) & ts.ModifierFlags.Static &&
276
+ (ts.isIdentifier(member.name) || ts.isStringLiteralLike(member.name)) &&
277
+ member.name.text === "transitions" &&
278
+ member.initializer
279
+ ) {
280
+ return literalToValue(member.initializer);
281
+ }
282
+ }
283
+ return undefined;
202
284
  }
203
285
 
204
286
  // -- hooks -------------------------------------------------------------------
205
287
 
206
- /** A React-style hook call: `useX(...)` or `obj.useX(...)` with type args. */
207
- function isHookCall(call: ts.CallExpression): boolean {
208
- if (!call.typeArguments?.length) return false;
288
+ /** The `useX` name of a call's callee, if it is hook-shaped. */
289
+ function hookCalleeName(call: ts.CallExpression): string | undefined {
209
290
  const callee = call.expression;
210
291
  const name = ts.isIdentifier(callee)
211
292
  ? callee.text
212
293
  : ts.isPropertyAccessExpression(callee)
213
294
  ? callee.name.text
214
295
  : undefined;
215
- return !!name && /^use[A-Z0-9_]/.test(name);
296
+ return name && /^use[A-Z0-9_]/.test(name) ? name : undefined;
297
+ }
298
+
299
+ /** A React-style hook call: `useX(...)` or `obj.useX(...)` with type args. */
300
+ function isHookCall(call: ts.CallExpression): boolean {
301
+ if (!call.typeArguments?.length) return false;
302
+ return hookCalleeName(call) !== undefined;
303
+ }
304
+
305
+ const ENTITY_KEY_RE = /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/;
306
+
307
+ /** The `{ app, model }` of a hook call's first-argument entity-key string. */
308
+ function hookEntityKeyArg(
309
+ call: ts.CallExpression,
310
+ ): { app: string; model: string } | undefined {
311
+ if (!hookCalleeName(call)) return undefined;
312
+ const first = call.arguments[0];
313
+ if (!first || !ts.isStringLiteralLike(first)) return undefined;
314
+ const match = ENTITY_KEY_RE.exec(first.text);
315
+ return match ? { app: match[1], model: match[2] } : undefined;
216
316
  }
217
317
 
218
318
  /** Resolve a type node to a class/interface declared in a scanned file. */
@@ -1,17 +1,27 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { analyze } from "./analyze";
4
- import { analyzeEntities, detectCycles, EntityDiagnostic } from "./entities";
3
+ import { analyze, analyzeDsl } from "./analyze";
4
+ import {
5
+ analyzeEntities,
6
+ detectCycles,
7
+ EntityDiagnostic,
8
+ EntityTypeExport,
9
+ } from "./entities";
5
10
  import { buildManifest, manifestToJson } from "./manifest";
6
11
  import { Manifest, ResourceModel } from "./model";
7
12
  import { buildOpenApi, openApiToJson } from "./openapi";
8
13
  import { createProgram } from "./program";
14
+ import { transitionProblems } from "../validate/transitions";
9
15
 
10
- export { analyze } from "./analyze";
16
+ export { analyze, analyzeDsl } from "./analyze";
11
17
  export { analyzeEntities, detectCycles } from "./entities";
12
- export type { EntityAnalysis, EntityDiagnostic } from "./entities";
18
+ export type {
19
+ EntityAnalysis,
20
+ EntityDiagnostic,
21
+ EntityTypeExport,
22
+ } from "./entities";
13
23
  export { buildManifest } from "./manifest";
14
- export { buildOpenApi } from "./openapi";
24
+ export { buildOpenApi, writeSchema } from "./openapi";
15
25
  export * from "./model";
16
26
 
17
27
  export interface CompileResult {
@@ -64,6 +74,8 @@ export function resolveResourceFiles(input: string): string[] {
64
74
  export interface ProjectAnalysis {
65
75
  resources: ResourceModel[];
66
76
  diagnostics: EntityDiagnostic[];
77
+ /** Where each entity's TS type lives — the `rastack-env.d.ts` registry input. */
78
+ types: EntityTypeExport[];
67
79
  }
68
80
 
69
81
  /**
@@ -78,24 +90,58 @@ export function analyzeProject(input: string): ProjectAnalysis {
78
90
  const files = resolveResourceFiles(input);
79
91
  const program = createProgram(files);
80
92
 
81
- const fromDsl = analyze(program, files);
93
+ const fromDsl = analyzeDsl(program, files);
82
94
  const fromInterfaces = analyzeEntities(program, files);
83
95
 
84
- const defined = new Set(fromDsl.map((r) => `${r.app}.${r.model}`));
96
+ const defined = new Set(fromDsl.resources.map((r) => `${r.app}.${r.model}`));
85
97
  const resources = [
86
- ...fromDsl,
98
+ ...fromDsl.resources,
87
99
  ...fromInterfaces.resources.filter((r) => !defined.has(`${r.app}.${r.model}`)),
88
100
  ];
89
101
  resources.sort((a, b) =>
90
102
  a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
91
103
  );
92
104
 
105
+ // Registry entries follow the same merge rule as the resources: the DSL
106
+ // definition wins an `app.model` collision.
107
+ const types = [
108
+ ...fromDsl.types,
109
+ ...fromInterfaces.types.filter((t) => !defined.has(t.key)),
110
+ ].sort((a, b) => a.key.localeCompare(b.key));
111
+
93
112
  return {
94
113
  resources,
95
- diagnostics: [...fromInterfaces.diagnostics, ...detectCycles(resources)],
114
+ types,
115
+ diagnostics: [
116
+ ...fromInterfaces.diagnostics,
117
+ ...detectCycles(resources),
118
+ ...detectTransitionProblems(resources),
119
+ ],
96
120
  };
97
121
  }
98
122
 
123
+ /**
124
+ * Validate every resource's `transitions` block against its own fields and
125
+ * state set — an edge to an undeclared state, an unknown state field, or a
126
+ * `set` patch targeting a non-field are authoring errors, caught at compile
127
+ * time like a circular FK.
128
+ */
129
+ export function detectTransitionProblems(
130
+ resources: ResourceModel[],
131
+ ): EntityDiagnostic[] {
132
+ const diagnostics: EntityDiagnostic[] = [];
133
+ for (const r of resources) {
134
+ if (!r.transitions) continue;
135
+ for (const problem of transitionProblems(r.transitions, r.fields)) {
136
+ diagnostics.push({
137
+ severity: "error",
138
+ message: `${r.app}.${r.model}: ${problem}`,
139
+ });
140
+ }
141
+ }
142
+ return diagnostics;
143
+ }
144
+
99
145
  /** Analyse resource definitions into the canonical model (no file output). */
100
146
  export function analyzeResources(input: string): ResourceModel[] {
101
147
  return analyzeProject(input).resources;
@@ -103,7 +149,7 @@ export function analyzeResources(input: string): ResourceModel[] {
103
149
 
104
150
  /** Compile resource definitions to a manifest + OpenAPI, writing both to disk. */
105
151
  export function compile(input: string, outDir: string): CompileResult {
106
- const { resources, diagnostics } = analyzeProject(input);
152
+ const { resources, diagnostics, types } = analyzeProject(input);
107
153
  const errors = diagnostics.filter((d) => d.severity === "error");
108
154
  if (errors.length) {
109
155
  throw new Error(errors.map((e) => e.message).join("\n"));
@@ -116,8 +162,59 @@ export function compile(input: string, outDir: string): CompileResult {
116
162
  const openapiPath = path.join(outDir, "openapi.json");
117
163
  fs.writeFileSync(manifestPath, manifestToJson(manifest), { flag: "w" });
118
164
  fs.writeFileSync(openapiPath, openApiToJson(openapi), { flag: "w" });
165
+ const files = [manifestPath, openapiPath];
119
166
 
120
- return { manifest, openapi, files: [manifestPath, openapiPath], diagnostics };
167
+ const registryDts = buildEntityRegistryDts(types, outDir);
168
+ if (registryDts) {
169
+ const registryPath = path.join(outDir, "rastack-env.d.ts");
170
+ fs.writeFileSync(registryPath, registryDts, { flag: "w" });
171
+ files.push(registryPath);
172
+ }
173
+
174
+ return { manifest, openapi, files, diagnostics };
175
+ }
176
+
177
+ /**
178
+ * The `rastack-env.d.ts` the compiler emits beside the manifest: a types-only
179
+ * declaration-merge of `rastack/hooks/registry`'s `EntityTypes`, mapping every
180
+ * exported entity's `"app.model"` key to *your own* type — classes and
181
+ * interfaces directly, `resource()` consts through `RowOf<typeof X>`. With
182
+ * this file in the program, `useData("airports.terminal")` infers its rows
183
+ * with no type argument. Unexported declarations can't be imported and are
184
+ * skipped. Returns `undefined` when there is nothing to register.
185
+ */
186
+ export function buildEntityRegistryDts(
187
+ types: EntityTypeExport[],
188
+ outDir: string,
189
+ ): string | undefined {
190
+ const entries = types.filter((t) => t.exported);
191
+ if (!entries.length) return undefined;
192
+
193
+ const lines = entries.map((t) => {
194
+ let rel = path
195
+ .relative(path.resolve(outDir), t.fileName)
196
+ .replace(/\\/g, "/")
197
+ .replace(/\.(tsx|ts)$/, "");
198
+ if (!rel.startsWith(".")) rel = `./${rel}`;
199
+ const type =
200
+ t.kind === "resourceConst"
201
+ ? `import("rastack/define").RowOf<typeof import("${rel}").${t.typeName}>`
202
+ : `import("${rel}").${t.typeName}`;
203
+ return ` "${t.key}": ${type};`;
204
+ });
205
+
206
+ return (
207
+ "// Generated by `rastack compile` — do not edit.\n" +
208
+ '// Maps manifest keys to your own entity types so `useData("app.model")` /\n' +
209
+ '// `useForm("app.model")` infer their rows with no type argument.\n' +
210
+ "// Types only: nothing to import, no runtime code.\n" +
211
+ 'declare module "rastack/hooks/registry" {\n' +
212
+ " interface EntityTypes {\n" +
213
+ `${lines.join("\n")}\n` +
214
+ " }\n" +
215
+ "}\n" +
216
+ "export {};\n"
217
+ );
121
218
  }
122
219
 
123
220
  // -- introspection (replaces `manage.py rastack check|list|urls`) -----------
@@ -27,6 +27,12 @@ export interface FieldModel {
27
27
  primaryKey?: boolean;
28
28
  null?: boolean;
29
29
  default?: unknown;
30
+ /**
31
+ * The closed set of allowed values (the `member` gate / a `select` control).
32
+ * Stamped by the compiler — today from a `transitions` block, whose `states`
33
+ * become the state field's value set.
34
+ */
35
+ options?: Array<string | number>;
30
36
  /** Present iff `type === "fk"` — the resource this field points at. */
31
37
  relation?: RelationTarget;
32
38
  }
@@ -63,6 +69,39 @@ export interface AccessModel {
63
69
  adminGroups?: string[];
64
70
  }
65
71
 
72
+ /**
73
+ * One edge of a resource's state machine: a named transition from one (or
74
+ * several) states to exactly one target state. `set` is the transition's
75
+ * *effect* — field patches the engine applies when the edge fires — which is
76
+ * what makes a transition a declarative backend function: declared once in
77
+ * TypeScript, compiled here, executed by `rastack-api-core` on every surface.
78
+ */
79
+ export interface TransitionEdgeModel {
80
+ /** Source states this transition may fire from (normalised to a list). */
81
+ from: string[];
82
+ /** The state entered when the transition fires. */
83
+ to: string;
84
+ /** Field patches applied by the engine alongside the state change. */
85
+ set?: Record<string, unknown>;
86
+ }
87
+
88
+ /**
89
+ * A resource's declarative state machine, keyed off one state field. Compiled
90
+ * into the manifest and enforced on every write surface: the TS record machine
91
+ * (forms, `rastack import`) and `rastack-api-core` (server, Lambda, WASM) all
92
+ * reject a write that jumps between states without a declared edge.
93
+ */
94
+ export interface TransitionsModel {
95
+ /** The field that carries the machine state. */
96
+ field: string;
97
+ /** Every state the field may hold (becomes the field's `options`). */
98
+ states: string[];
99
+ /** The state new records start in (becomes the field's default). */
100
+ initial?: string;
101
+ /** Named transitions — `on.board = { from: ["scheduled"], to: "boarding" }`. */
102
+ on: Record<string, TransitionEdgeModel>;
103
+ }
104
+
66
105
  export interface ResourceModel {
67
106
  app: string;
68
107
  model: string;
@@ -74,6 +113,7 @@ export interface ResourceModel {
74
113
  ordering?: string[];
75
114
  permission?: string;
76
115
  access?: AccessModel;
116
+ transitions?: TransitionsModel;
77
117
  }
78
118
 
79
119
  export interface Manifest {
@@ -116,7 +116,15 @@ function readSchema(resource: ResourceModel): any {
116
116
  return { type: "object", properties, required };
117
117
  }
118
118
 
119
- function writeSchema(resource: ResourceModel, patched: boolean): any {
119
+ /**
120
+ * The write JSON Schema for a resource — what the API accepts on
121
+ * create/update. Exported because the generic `useForm(Entity)` hook derives
122
+ * the *same* schema at runtime straight from the manifest, so a form
123
+ * validates against exactly the contract the emitted OpenAPI documents.
124
+ * Resources with a state machine carry it as `x-rastack-transitions`, the
125
+ * write-side twin of `x-rastack-relation`.
126
+ */
127
+ export function writeSchema(resource: ResourceModel, patched: boolean): any {
120
128
  const properties: Record<string, any> = {};
121
129
  const required: string[] = [];
122
130
  for (const field of resource.fields) {
@@ -128,6 +136,9 @@ function writeSchema(resource: ResourceModel, patched: boolean): any {
128
136
  }
129
137
  const schema: any = { type: "object", properties };
130
138
  if (required.length) schema.required = required;
139
+ if (resource.transitions) {
140
+ schema["x-rastack-transitions"] = resource.transitions;
141
+ }
131
142
  return schema;
132
143
  }
133
144
 
@@ -156,6 +167,7 @@ function fieldProperty(field: FieldModel): any {
156
167
  }
157
168
  const base = scalarProperty(field.type);
158
169
  if (field.maxLength !== undefined) base.maxLength = field.maxLength;
170
+ if (field.options && field.options.length > 0) base.enum = [...field.options];
159
171
  if (field.null) base.nullable = true;
160
172
  return base;
161
173
  }
@@ -23,11 +23,19 @@
23
23
  * ```
24
24
  */
25
25
 
26
- /** A scalar field. The literal `__rastackScalar` type is what the compiler reads. */
27
- export interface Scalar<T extends string> {
26
+ /**
27
+ * A scalar field. The literal `__rastackScalar` type is what the compiler
28
+ * reads; `Nullable` phantom-carries the `null: true` option so {@link RowOf}
29
+ * can add `| null` to the inferred row type.
30
+ */
31
+ export interface Scalar<T extends string, Nullable extends boolean = boolean> {
28
32
  readonly __rastackScalar: T;
33
+ readonly __rastackNullable?: Nullable;
29
34
  }
30
35
 
36
+ /** Whether an options literal declares `null: true` (as a type). */
37
+ type NullOf<O> = O extends { null: true } ? true : false;
38
+
31
39
  /**
32
40
  * A foreign-key field. `App`/`Model` are carried as string-literal type
33
41
  * arguments so the compiler can recover the relation target purely from types.
@@ -71,23 +79,35 @@ export interface DateOptions {
71
79
  * runtime if a consumer wants it.
72
80
  */
73
81
  export const s = {
74
- string(_opts?: StringOptions): Scalar<"string"> {
75
- return { __rastackScalar: "string" } as Scalar<"string">;
82
+ string<const O extends StringOptions = StringOptions>(
83
+ _opts?: O,
84
+ ): Scalar<"string", NullOf<O>> {
85
+ return { __rastackScalar: "string" } as Scalar<"string", NullOf<O>>;
76
86
  },
77
- int(_opts?: NumberOptions): Scalar<"int"> {
78
- return { __rastackScalar: "int" } as Scalar<"int">;
87
+ int<const O extends NumberOptions = NumberOptions>(
88
+ _opts?: O,
89
+ ): Scalar<"int", NullOf<O>> {
90
+ return { __rastackScalar: "int" } as Scalar<"int", NullOf<O>>;
79
91
  },
80
- float(_opts?: NumberOptions): Scalar<"float"> {
81
- return { __rastackScalar: "float" } as Scalar<"float">;
92
+ float<const O extends NumberOptions = NumberOptions>(
93
+ _opts?: O,
94
+ ): Scalar<"float", NullOf<O>> {
95
+ return { __rastackScalar: "float" } as Scalar<"float", NullOf<O>>;
82
96
  },
83
- bool(_opts?: BoolOptions): Scalar<"bool"> {
84
- return { __rastackScalar: "bool" } as Scalar<"bool">;
97
+ bool<const O extends BoolOptions = BoolOptions>(
98
+ _opts?: O,
99
+ ): Scalar<"bool", NullOf<O>> {
100
+ return { __rastackScalar: "bool" } as Scalar<"bool", NullOf<O>>;
85
101
  },
86
- datetime(_opts?: DateOptions): Scalar<"datetime"> {
87
- return { __rastackScalar: "datetime" } as Scalar<"datetime">;
102
+ datetime<const O extends DateOptions = DateOptions>(
103
+ _opts?: O,
104
+ ): Scalar<"datetime", NullOf<O>> {
105
+ return { __rastackScalar: "datetime" } as Scalar<"datetime", NullOf<O>>;
88
106
  },
89
- uuid(_opts?: StringOptions): Scalar<"uuid"> {
90
- return { __rastackScalar: "uuid" } as Scalar<"uuid">;
107
+ uuid<const O extends StringOptions = StringOptions>(
108
+ _opts?: O,
109
+ ): Scalar<"uuid", NullOf<O>> {
110
+ return { __rastackScalar: "uuid" } as Scalar<"uuid", NullOf<O>>;
91
111
  },
92
112
  /**
93
113
  * A foreign key. The target is `() => SomeResource`; the compiler resolves
@@ -132,6 +152,54 @@ export interface AccessOptions {
132
152
  adminGroups?: string[];
133
153
  }
134
154
 
155
+ /**
156
+ * One edge of a resource's state machine. `from` is the state (or states) the
157
+ * transition may fire from, `to` the state it enters, and `set` the effect —
158
+ * field patches the engine applies alongside the state change. The block is
159
+ * static data: it compiles into the manifest and is enforced by
160
+ * `rastack-api-core` on every surface (server, Lambda, in-browser WASM), so a
161
+ * transition behaves as a declarative, typesafe backend function with no
162
+ * per-resource server code.
163
+ */
164
+ export interface TransitionEdge {
165
+ from: string | readonly string[];
166
+ to: string;
167
+ set?: Record<string, string | number | boolean | null>;
168
+ }
169
+
170
+ /**
171
+ * A resource's declarative state machine, keyed off one state field:
172
+ *
173
+ * ```ts
174
+ * transitions: {
175
+ * field: "status",
176
+ * states: ["scheduled", "boarding", "departed", "cancelled"],
177
+ * initial: "scheduled",
178
+ * on: {
179
+ * board: { from: "scheduled", to: "boarding" },
180
+ * depart: { from: "boarding", to: "departed" },
181
+ * cancel: { from: ["scheduled", "boarding"], to: "cancelled" },
182
+ * },
183
+ * }
184
+ * ```
185
+ *
186
+ * `states` becomes the field's allowed-value set (`enum` in OpenAPI, the
187
+ * `member` gate in the validation machine) and `initial` its default. A write
188
+ * that moves the field between states without a declared edge is rejected on
189
+ * every surface, and `useForm(Flight).transition("board")` exposes the edges
190
+ * as typed form actions.
191
+ */
192
+ export interface TransitionsOptions {
193
+ /** The field carrying the machine state. */
194
+ field: string;
195
+ /** Every state the field may hold. */
196
+ states: readonly string[];
197
+ /** The state new records start in. */
198
+ initial?: string;
199
+ /** Named transitions. */
200
+ on: Record<string, TransitionEdge>;
201
+ }
202
+
135
203
  export interface ResourceOptions {
136
204
  /** DRF-style search fields. */
137
205
  search?: string[];
@@ -145,33 +213,44 @@ export interface ResourceOptions {
145
213
  permission?: "authenticatedOrReadOnly" | "authenticated" | "public";
146
214
  /** Row-level security + physical tenant partitioning. */
147
215
  access?: AccessOptions;
216
+ /** Declarative state machine over one field, enforced on every write surface. */
217
+ transitions?: TransitionsOptions;
148
218
  }
149
219
 
150
220
  /**
151
221
  * A resource definition. `App`/`Model` are preserved as string-literal type
152
- * arguments so a {@link Ref} to this resource carries a recoverable target.
222
+ * arguments so a {@link Ref} to this resource carries a recoverable target,
223
+ * and the options object's literal type is preserved so downstream types can
224
+ * read the declared transition names (`useForm(Flight).transition("board")`
225
+ * only accepts names the resource actually declares).
153
226
  */
154
- export interface ResourceType<App extends string, Model extends string, F> {
227
+ export interface ResourceType<
228
+ App extends string,
229
+ Model extends string,
230
+ F,
231
+ O extends ResourceOptions = ResourceOptions,
232
+ > {
155
233
  readonly __rastack: "resource";
156
234
  readonly __app: App;
157
235
  readonly __model: Model;
158
236
  readonly app: App;
159
237
  readonly model: Model;
160
238
  readonly fields: F;
161
- readonly options: ResourceOptions;
239
+ readonly options: O;
162
240
  }
163
241
 
164
- /** Define a resource. Capture app/model as literal types for FK inference. */
242
+ /** Define a resource. Capture app/model (and options) as literal types. */
165
243
  export function resource<
166
244
  App extends string,
167
245
  Model extends string,
168
246
  F extends Record<string, AnyField>,
247
+ const O extends ResourceOptions = ResourceOptions,
169
248
  >(
170
249
  app: App,
171
250
  model: Model,
172
251
  fields: F,
173
- options: ResourceOptions = {},
174
- ): ResourceType<App, Model, F> {
252
+ options: O = {} as O,
253
+ ): ResourceType<App, Model, F, O> {
175
254
  return {
176
255
  __rastack: "resource",
177
256
  __app: app,
@@ -182,3 +261,52 @@ export function resource<
182
261
  options,
183
262
  };
184
263
  }
264
+
265
+ /**
266
+ * The transition names an entity declares — from a `resource()` value's
267
+ * options, or from a class's `static transitions` block. Entities with no
268
+ * transitions resolve to `never`, so `form.transition(...)` is uncallable on
269
+ * them at the type level.
270
+ */
271
+ export type TransitionNamesOf<E> = E extends {
272
+ options: { transitions: { on: infer On } };
273
+ }
274
+ ? Extract<keyof On, string>
275
+ : E extends { transitions: { on: infer On } }
276
+ ? Extract<keyof On, string>
277
+ : E extends string
278
+ ? string // an `"app.model"` ref carries no type-level transition info
279
+ : never;
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Row inference — `useData(Flight)` knows its rows from the definition itself
283
+ // ---------------------------------------------------------------------------
284
+
285
+ type ScalarTsType<T extends string> = T extends "int" | "float"
286
+ ? number
287
+ : T extends "bool"
288
+ ? boolean
289
+ : string; // string / datetime / uuid all travel as strings
290
+
291
+ /** The TypeScript value type one field of a `resource()` definition holds. */
292
+ export type FieldValueOf<F> =
293
+ F extends Scalar<infer T, infer N>
294
+ ? N extends true
295
+ ? ScalarTsType<T> | null
296
+ : ScalarTsType<T>
297
+ : F extends Ref<string, string>
298
+ ? number | string // FK values are opaque ids
299
+ : unknown;
300
+
301
+ /**
302
+ * The row type a `resource()` definition implies — what `useData(Flight)`
303
+ * infers with **no type argument**: each field's scalar kind maps to its TS
304
+ * type (`null: true` adds `| null`), FKs are ids, plus the server-assigned
305
+ * `id`. Classes carry their own row type, and interfaces resolve through the
306
+ * `EntityTypes` registry (`rastack/hooks/registry`), so every reference form
307
+ * infers.
308
+ */
309
+ export type RowOf<R> =
310
+ R extends ResourceType<string, string, infer F, ResourceOptions>
311
+ ? { id: number | string } & { [K in keyof F]: FieldValueOf<F[K]> }
312
+ : never;