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.
- package/CHANGELOG.md +2 -0
- package/components/auto-form/AutoForm.tsx +13 -0
- package/components/auto-form/use-auto-form.ts +28 -0
- package/components/types.ts +8 -0
- package/dist/admin.js +13 -13
- package/dist/compile/analyze.d.ts +26 -0
- package/dist/compile/analyze.js +70 -3
- package/dist/compile/entities.d.ts +19 -0
- package/dist/compile/entities.js +87 -13
- package/dist/compile/index.d.ts +23 -4
- package/dist/compile/index.js +86 -7
- package/dist/compile/model.d.ts +38 -0
- package/dist/compile/openapi.d.ts +9 -0
- package/dist/compile/openapi.js +14 -0
- package/dist/define/index.d.ts +107 -13
- package/dist/define/index.js +1 -1
- package/dist/import/tabular.d.ts +8 -2
- package/dist/import/tabular.js +1 -1
- package/dist/rastack-import.js +4 -1
- package/dist/validate/adapters.js +2 -0
- package/dist/validate/index.d.ts +1 -0
- package/dist/validate/index.js +1 -0
- package/dist/validate/machine.d.ts +23 -2
- package/dist/validate/machine.js +35 -2
- package/dist/validate/transitions.d.ts +86 -0
- package/dist/validate/transitions.js +199 -0
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/hooks/data.ts +209 -0
- package/hooks/entity.ts +222 -0
- package/hooks/form/entity-form.ts +354 -0
- package/hooks/form/form.ts +8 -1
- package/hooks/form/index.ts +7 -1
- package/hooks/index.ts +4 -0
- package/hooks/manifest.ts +41 -0
- package/hooks/registry.ts +39 -0
- package/package.json +1 -1
- package/provider/provider.tsx +8 -1
- package/provider/types.ts +7 -2
- package/src/compile/analyze.ts +83 -4
- package/src/compile/entities.ts +111 -11
- package/src/compile/index.ts +108 -11
- package/src/compile/model.ts +40 -0
- package/src/compile/openapi.ts +13 -1
- package/src/define/index.ts +148 -20
- package/src/import/tabular.ts +9 -2
- package/src/rastack-import.ts +4 -1
- package/src/validate/adapters.ts +1 -0
- package/src/validate/index.ts +1 -0
- package/src/validate/machine.ts +55 -3
- package/src/validate/transitions.ts +232 -0
- package/test/components.spec.ts +22 -0
- package/test/transitions.spec.ts +372 -0
- package/test/typed-hooks.spec.ts +407 -0
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/src/compile/entities.ts
CHANGED
|
@@ -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)
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
207
|
-
function
|
|
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
|
|
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. */
|
package/src/compile/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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`) -----------
|
package/src/compile/model.ts
CHANGED
|
@@ -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 {
|
package/src/compile/openapi.ts
CHANGED
|
@@ -116,7 +116,15 @@ function readSchema(resource: ResourceModel): any {
|
|
|
116
116
|
return { type: "object", properties, required };
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
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
|
}
|
package/src/define/index.ts
CHANGED
|
@@ -23,11 +23,19 @@
|
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
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
|
|
75
|
-
|
|
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
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
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
|
|
84
|
-
|
|
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
|
|
87
|
-
|
|
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
|
|
90
|
-
|
|
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<
|
|
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:
|
|
239
|
+
readonly options: O;
|
|
162
240
|
}
|
|
163
241
|
|
|
164
|
-
/** Define a resource. Capture app/model as literal types
|
|
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:
|
|
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;
|