rastack 0.0.49 → 0.0.51

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 (68) hide show
  1. package/CHANGELOG.md +4 -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 +20 -0
  7. package/dist/compile/analyze.js +60 -49
  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 +162 -21
  16. package/dist/define/index.js +28 -22
  17. package/dist/define/manifest.d.ts +64 -0
  18. package/dist/define/manifest.js +250 -0
  19. package/dist/import/tabular.d.ts +8 -2
  20. package/dist/import/tabular.js +1 -1
  21. package/dist/plugin/core.d.ts +108 -0
  22. package/dist/plugin/core.js +198 -0
  23. package/dist/plugin/index.d.ts +112 -0
  24. package/dist/plugin/index.js +203 -0
  25. package/dist/rastack-import.js +4 -1
  26. package/dist/validate/adapters.js +2 -0
  27. package/dist/validate/index.d.ts +1 -0
  28. package/dist/validate/index.js +1 -0
  29. package/dist/validate/machine.d.ts +23 -2
  30. package/dist/validate/machine.js +35 -2
  31. package/dist/validate/transitions.d.ts +86 -0
  32. package/dist/validate/transitions.js +199 -0
  33. package/dist/wasm/rastack_wasm.js +1 -1
  34. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  35. package/hooks/data.ts +221 -0
  36. package/hooks/entity.ts +228 -0
  37. package/hooks/form/entity-form.ts +358 -0
  38. package/hooks/form/form.ts +8 -1
  39. package/hooks/form/index.ts +7 -1
  40. package/hooks/index.ts +4 -0
  41. package/hooks/manifest.ts +77 -0
  42. package/hooks/registry.ts +56 -0
  43. package/package.json +1 -1
  44. package/plugin.ts +8 -0
  45. package/provider/provider.tsx +26 -5
  46. package/provider/types.ts +15 -3
  47. package/src/compile/analyze.ts +74 -45
  48. package/src/compile/entities.ts +111 -11
  49. package/src/compile/index.ts +108 -11
  50. package/src/compile/model.ts +40 -0
  51. package/src/compile/openapi.ts +13 -1
  52. package/src/define/index.ts +233 -29
  53. package/src/define/manifest.ts +278 -0
  54. package/src/import/tabular.ts +9 -2
  55. package/src/plugin/core.ts +236 -0
  56. package/src/plugin/index.ts +243 -0
  57. package/src/rastack-import.ts +4 -1
  58. package/src/validate/adapters.ts +1 -0
  59. package/src/validate/index.ts +1 -0
  60. package/src/validate/machine.ts +55 -3
  61. package/src/validate/transitions.ts +232 -0
  62. package/test/components.spec.ts +22 -0
  63. package/test/plugin.spec.ts +315 -0
  64. package/test/runtime-manifest.spec.ts +309 -0
  65. package/test/transitions.spec.ts +372 -0
  66. package/test/typed-hooks.spec.ts +412 -0
  67. package/wasm/rastack_wasm.js +1 -1
  68. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -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,19 +23,49 @@
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
+ * Every constraint a scalar builder may be handed, merged — the shape the
28
+ * runtime value carries so a manifest can be assembled without the compiler.
29
+ */
30
+ export interface ScalarFieldOptions {
31
+ maxLength?: number;
32
+ unique?: boolean;
33
+ primaryKey?: boolean;
34
+ default?: unknown;
35
+ null?: boolean;
36
+ autoNow?: boolean;
37
+ autoNowAdd?: boolean;
38
+ }
39
+
40
+ /**
41
+ * A scalar field. The literal `__rastackScalar` type is what the compiler
42
+ * reads; `Nullable` phantom-carries the `null: true` option so {@link RowOf}
43
+ * can add `| null` to the inferred row type. The authored options ride along
44
+ * on the runtime value too, so `manifestFromResources()` can build the same
45
+ * manifest `rastack compile` emits — with no compile step at all.
46
+ */
47
+ export interface Scalar<T extends string, Nullable extends boolean = boolean> {
28
48
  readonly __rastackScalar: T;
49
+ readonly __rastackNullable?: Nullable;
50
+ /** The constraint options as authored — carried at runtime. */
51
+ readonly options?: ScalarFieldOptions;
29
52
  }
30
53
 
54
+ /** Whether an options literal declares `null: true` (as a type). */
55
+ type NullOf<O> = O extends { null: true } ? true : false;
56
+
31
57
  /**
32
58
  * A foreign-key field. `App`/`Model` are carried as string-literal type
33
- * arguments so the compiler can recover the relation target purely from types.
59
+ * arguments so the compiler can recover the relation target purely from
60
+ * types; the target thunk is *also* kept on the runtime value so
61
+ * `manifestFromResources()` can recover the relation with no compiler.
34
62
  */
35
63
  export interface Ref<App extends string, Model extends string> {
36
64
  readonly __rastackRef: true;
37
65
  readonly __app: App;
38
66
  readonly __model: Model;
67
+ /** The authored `() => Resource` thunk — carried at runtime. */
68
+ readonly target?: () => ResourceType<App, Model, any>;
39
69
  }
40
70
 
41
71
  export interface StringOptions {
@@ -65,39 +95,66 @@ export interface DateOptions {
65
95
  }
66
96
 
67
97
  /**
68
- * Field builders. Each returns a phantom value whose *type* encodes the field
69
- * kind; the runtime object is never used at compile time (the compiler analyses
70
- * the source, it does not execute it) but is real so the DSL is also usable at
71
- * runtime if a consumer wants it.
98
+ * Field builders. Each returns a value whose *type* encodes the field kind
99
+ * (what the compiler reads — it analyses the source, it does not execute it)
100
+ * and whose runtime shape keeps the authored options/target, so the same
101
+ * definition doubles as its own manifest via `manifestFromResources()`.
72
102
  */
73
103
  export const s = {
74
- string(_opts?: StringOptions): Scalar<"string"> {
75
- return { __rastackScalar: "string" } as Scalar<"string">;
104
+ string<const O extends StringOptions = StringOptions>(
105
+ opts?: O,
106
+ ): Scalar<"string", NullOf<O>> {
107
+ return { __rastackScalar: "string", options: opts } as Scalar<
108
+ "string",
109
+ NullOf<O>
110
+ >;
76
111
  },
77
- int(_opts?: NumberOptions): Scalar<"int"> {
78
- return { __rastackScalar: "int" } as Scalar<"int">;
112
+ int<const O extends NumberOptions = NumberOptions>(
113
+ opts?: O,
114
+ ): Scalar<"int", NullOf<O>> {
115
+ return { __rastackScalar: "int", options: opts } as Scalar<"int", NullOf<O>>;
79
116
  },
80
- float(_opts?: NumberOptions): Scalar<"float"> {
81
- return { __rastackScalar: "float" } as Scalar<"float">;
117
+ float<const O extends NumberOptions = NumberOptions>(
118
+ opts?: O,
119
+ ): Scalar<"float", NullOf<O>> {
120
+ return { __rastackScalar: "float", options: opts } as Scalar<
121
+ "float",
122
+ NullOf<O>
123
+ >;
82
124
  },
83
- bool(_opts?: BoolOptions): Scalar<"bool"> {
84
- return { __rastackScalar: "bool" } as Scalar<"bool">;
125
+ bool<const O extends BoolOptions = BoolOptions>(
126
+ opts?: O,
127
+ ): Scalar<"bool", NullOf<O>> {
128
+ return { __rastackScalar: "bool", options: opts } as Scalar<
129
+ "bool",
130
+ NullOf<O>
131
+ >;
85
132
  },
86
- datetime(_opts?: DateOptions): Scalar<"datetime"> {
87
- return { __rastackScalar: "datetime" } as Scalar<"datetime">;
133
+ datetime<const O extends DateOptions = DateOptions>(
134
+ opts?: O,
135
+ ): Scalar<"datetime", NullOf<O>> {
136
+ return { __rastackScalar: "datetime", options: opts } as Scalar<
137
+ "datetime",
138
+ NullOf<O>
139
+ >;
88
140
  },
89
- uuid(_opts?: StringOptions): Scalar<"uuid"> {
90
- return { __rastackScalar: "uuid" } as Scalar<"uuid">;
141
+ uuid<const O extends StringOptions = StringOptions>(
142
+ opts?: O,
143
+ ): Scalar<"uuid", NullOf<O>> {
144
+ return { __rastackScalar: "uuid", options: opts } as Scalar<
145
+ "uuid",
146
+ NullOf<O>
147
+ >;
91
148
  },
92
149
  /**
93
150
  * A foreign key. The target is `() => SomeResource`; the compiler resolves
94
- * the relation from the *type* `Ref<App, Model>` this returns — it does not
95
- * need to run the thunk.
151
+ * the relation from the *type* `Ref<App, Model>` this returns, and the
152
+ * runtime keeps the thunk so `manifestFromResources()` can resolve it too.
96
153
  */
97
154
  ref<A extends string, M extends string>(
98
- _thunk: () => ResourceType<A, M, any>,
155
+ thunk: () => ResourceType<A, M, any>,
99
156
  ): Ref<A, M> {
100
- return { __rastackRef: true } as Ref<A, M>;
157
+ return { __rastackRef: true, target: thunk } as Ref<A, M>;
101
158
  },
102
159
  };
103
160
 
@@ -132,6 +189,54 @@ export interface AccessOptions {
132
189
  adminGroups?: string[];
133
190
  }
134
191
 
192
+ /**
193
+ * One edge of a resource's state machine. `from` is the state (or states) the
194
+ * transition may fire from, `to` the state it enters, and `set` the effect —
195
+ * field patches the engine applies alongside the state change. The block is
196
+ * static data: it compiles into the manifest and is enforced by
197
+ * `rastack-api-core` on every surface (server, Lambda, in-browser WASM), so a
198
+ * transition behaves as a declarative, typesafe backend function with no
199
+ * per-resource server code.
200
+ */
201
+ export interface TransitionEdge {
202
+ from: string | readonly string[];
203
+ to: string;
204
+ set?: Record<string, string | number | boolean | null>;
205
+ }
206
+
207
+ /**
208
+ * A resource's declarative state machine, keyed off one state field:
209
+ *
210
+ * ```ts
211
+ * transitions: {
212
+ * field: "status",
213
+ * states: ["scheduled", "boarding", "departed", "cancelled"],
214
+ * initial: "scheduled",
215
+ * on: {
216
+ * board: { from: "scheduled", to: "boarding" },
217
+ * depart: { from: "boarding", to: "departed" },
218
+ * cancel: { from: ["scheduled", "boarding"], to: "cancelled" },
219
+ * },
220
+ * }
221
+ * ```
222
+ *
223
+ * `states` becomes the field's allowed-value set (`enum` in OpenAPI, the
224
+ * `member` gate in the validation machine) and `initial` its default. A write
225
+ * that moves the field between states without a declared edge is rejected on
226
+ * every surface, and `useForm(Flight).transition("board")` exposes the edges
227
+ * as typed form actions.
228
+ */
229
+ export interface TransitionsOptions {
230
+ /** The field carrying the machine state. */
231
+ field: string;
232
+ /** Every state the field may hold. */
233
+ states: readonly string[];
234
+ /** The state new records start in. */
235
+ initial?: string;
236
+ /** Named transitions. */
237
+ on: Record<string, TransitionEdge>;
238
+ }
239
+
135
240
  export interface ResourceOptions {
136
241
  /** DRF-style search fields. */
137
242
  search?: string[];
@@ -145,33 +250,44 @@ export interface ResourceOptions {
145
250
  permission?: "authenticatedOrReadOnly" | "authenticated" | "public";
146
251
  /** Row-level security + physical tenant partitioning. */
147
252
  access?: AccessOptions;
253
+ /** Declarative state machine over one field, enforced on every write surface. */
254
+ transitions?: TransitionsOptions;
148
255
  }
149
256
 
150
257
  /**
151
258
  * A resource definition. `App`/`Model` are preserved as string-literal type
152
- * arguments so a {@link Ref} to this resource carries a recoverable target.
259
+ * arguments so a {@link Ref} to this resource carries a recoverable target,
260
+ * and the options object's literal type is preserved so downstream types can
261
+ * read the declared transition names (`useForm(Flight).transition("board")`
262
+ * only accepts names the resource actually declares).
153
263
  */
154
- export interface ResourceType<App extends string, Model extends string, F> {
264
+ export interface ResourceType<
265
+ App extends string,
266
+ Model extends string,
267
+ F,
268
+ O extends ResourceOptions = ResourceOptions,
269
+ > {
155
270
  readonly __rastack: "resource";
156
271
  readonly __app: App;
157
272
  readonly __model: Model;
158
273
  readonly app: App;
159
274
  readonly model: Model;
160
275
  readonly fields: F;
161
- readonly options: ResourceOptions;
276
+ readonly options: O;
162
277
  }
163
278
 
164
- /** Define a resource. Capture app/model as literal types for FK inference. */
279
+ /** Define a resource. Capture app/model (and options) as literal types. */
165
280
  export function resource<
166
281
  App extends string,
167
282
  Model extends string,
168
283
  F extends Record<string, AnyField>,
284
+ const O extends ResourceOptions = ResourceOptions,
169
285
  >(
170
286
  app: App,
171
287
  model: Model,
172
288
  fields: F,
173
- options: ResourceOptions = {},
174
- ): ResourceType<App, Model, F> {
289
+ options: O = {} as O,
290
+ ): ResourceType<App, Model, F, O> {
175
291
  return {
176
292
  __rastack: "resource",
177
293
  __app: app,
@@ -182,3 +298,91 @@ export function resource<
182
298
  options,
183
299
  };
184
300
  }
301
+
302
+ /**
303
+ * The transition names an entity declares — from a `resource()` value's
304
+ * options, or from a class's `static transitions` block. Entities with no
305
+ * transitions resolve to `never`, so `form.transition(...)` is uncallable on
306
+ * them at the type level.
307
+ */
308
+ export type TransitionNamesOf<E> = E extends {
309
+ options: { transitions: { on: infer On } };
310
+ }
311
+ ? Extract<keyof On, string>
312
+ : E extends { transitions: { on: infer On } }
313
+ ? Extract<keyof On, string>
314
+ : E extends string
315
+ ? string // an `"app.model"` ref carries no type-level transition info
316
+ : never;
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Row inference — `useData(Flight)` knows its rows from the definition itself
320
+ // ---------------------------------------------------------------------------
321
+
322
+ type ScalarTsType<T extends string> = T extends "int" | "float"
323
+ ? number
324
+ : T extends "bool"
325
+ ? boolean
326
+ : string; // string / datetime / uuid all travel as strings
327
+
328
+ /** The TypeScript value type one field of a `resource()` definition holds. */
329
+ export type FieldValueOf<F> =
330
+ F extends Scalar<infer T, infer N>
331
+ ? N extends true
332
+ ? ScalarTsType<T> | null
333
+ : ScalarTsType<T>
334
+ : F extends Ref<string, string>
335
+ ? number | string // FK values are opaque ids
336
+ : unknown;
337
+
338
+ /**
339
+ * The row type a `resource()` definition implies — what `useData(Flight)`
340
+ * infers with **no type argument**: each field's scalar kind maps to its TS
341
+ * type (`null: true` adds `| null`), FKs are ids, plus the server-assigned
342
+ * `id`. Classes carry their own row type, and interfaces resolve through the
343
+ * `EntityTypes` registry (`rastack/hooks/registry`), so every reference form
344
+ * infers.
345
+ */
346
+ export type RowOf<R> =
347
+ R extends ResourceType<string, string, infer F, ResourceOptions>
348
+ ? { id: number | string } & { [K in keyof F]: FieldValueOf<F[K]> }
349
+ : never;
350
+
351
+ /** The `"app.model"` registry key a `resource()` definition implies. */
352
+ export type EntityKeyOf<R> =
353
+ R extends ResourceType<infer A, infer M, any, ResourceOptions>
354
+ ? `${A}.${M}`
355
+ : never;
356
+
357
+ /**
358
+ * The `EntityTypes` registry entries a module of `resource()` definitions
359
+ * implies — the *hand-written* alternative to the `rastack-env.d.ts` that
360
+ * `rastack compile` emits. Point it at your resources module once and the
361
+ * registry tracks it forever, with no compile step in the loop:
362
+ *
363
+ * ```ts
364
+ * // rastack-env.d.ts — written once by you, never regenerated
365
+ * import type { EntityTypesOf } from "rastack/define";
366
+ * declare module "rastack/hooks/registry" {
367
+ * interface EntityTypes
368
+ * extends EntityTypesOf<typeof import("./resources/airports")> {}
369
+ * }
370
+ * ```
371
+ *
372
+ * Non-resource exports are dropped, so `typeof import("…")` over a whole
373
+ * module is safe. (Class entities carry their own row type — they never need
374
+ * the registry; interfaces still register via the compile-emitted file.)
375
+ */
376
+ export type EntityTypesOf<M> = {
377
+ [K in keyof M as EntityKeyOf<M[K]>]: RowOf<M[K]>;
378
+ };
379
+
380
+ // The runtime manifest builder — a resource() definition is its own manifest.
381
+ export {
382
+ isResourceValue,
383
+ manifestFromResources,
384
+ resourceModel,
385
+ toManifest,
386
+ } from "./manifest";
387
+ export type { AnyResourceValue, ManifestInput } from "./manifest";
388
+ export type { Manifest, ResourceModel } from "../compile/model";