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
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The manifest as React context — what lets `useData(Airport)` /
3
+ * `useForm(Flight)` resolve a plain TypeScript type to its resource at
4
+ * runtime with no generated code in between.
5
+ *
6
+ * `<RAStackProvider manifest={…}>` supplies it (in every mode — the manifest
7
+ * is required for the WASM engine anyway, and in `remote` mode it is the only
8
+ * schema the client needs). It accepts the compiled `schema.rastack.json`
9
+ * (object or JSON string) **or the `resource()` definitions themselves** — an
10
+ * array, or a whole module namespace — in which case the manifest is built at
11
+ * runtime and no compile step is involved. Hooks read it with
12
+ * {@link useRastackManifest}; tests and one-off callers can bypass the context
13
+ * by passing `manifest` directly in a hook's options.
14
+ */
15
+
16
+ import { createContext, createElement, useContext, useMemo } from "react";
17
+ import type { ReactNode } from "react";
18
+ import type { Manifest } from "../src/compile/model";
19
+ import { toManifest, type ManifestInput } from "../src/define/manifest";
20
+
21
+ export const RastackManifestContext = createContext<Manifest | undefined>(
22
+ undefined,
23
+ );
24
+
25
+ export interface RastackManifestProviderProps {
26
+ /**
27
+ * `schema.rastack.json` (parsed object or JSON string), or `resource()`
28
+ * definitions (an array or a module namespace) to build it from at runtime.
29
+ */
30
+ manifest?: ManifestInput;
31
+ children?: ReactNode;
32
+ }
33
+
34
+ /** Provide the manifest to every `useData`/`useForm` beneath it. */
35
+ export function RastackManifestProvider(props: RastackManifestProviderProps) {
36
+ const { manifest, children } = props;
37
+ const value = useMemo<Manifest | undefined>(
38
+ () => toManifest(manifest) ?? getGlobalManifest(),
39
+ [manifest],
40
+ );
41
+ return createElement(RastackManifestContext.Provider, { value }, children);
42
+ }
43
+
44
+ /**
45
+ * The manifest in effect: the nearest provider's, else the one the dev-server
46
+ * plugin registered globally (see {@link getGlobalManifest}) — which is what
47
+ * lets an app pass **no manifest anywhere**.
48
+ */
49
+ export function useRastackManifest(): Manifest | undefined {
50
+ return useContext(RastackManifestContext) ?? getGlobalManifest();
51
+ }
52
+
53
+ // -- the auto-registered manifest -------------------------------------------
54
+
55
+ /**
56
+ * The global the `rastack/plugin` dev-server plugin assigns before the app
57
+ * boots (its injected register module runs first): the manifest compiled
58
+ * from your TypeScript entities, refreshed on every save. With it in place,
59
+ * `<RAStackProvider mode="local">` needs no `manifest` prop and `useData` /
60
+ * `useForm` resolve entities with zero schema wiring in app code — the
61
+ * types are the only thing you author.
62
+ */
63
+ export const GLOBAL_MANIFEST_KEY = "__RASTACK_MANIFEST__";
64
+
65
+ let cachedRaw: unknown;
66
+ let cachedManifest: Manifest | undefined;
67
+
68
+ /** The plugin-registered manifest, normalised (and cached per assignment). */
69
+ export function getGlobalManifest(): Manifest | undefined {
70
+ const raw = (globalThis as Record<string, unknown>)[GLOBAL_MANIFEST_KEY];
71
+ if (raw === undefined || raw === null) return undefined;
72
+ if (raw !== cachedRaw) {
73
+ cachedRaw = raw;
74
+ cachedManifest = toManifest(raw as ManifestInput);
75
+ }
76
+ return cachedManifest;
77
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The type-level entity registry — what lets a bare string infer its rows:
3
+ *
4
+ * ```tsx
5
+ * const terminals = useData("airports.terminal"); // rows: Terminal[]
6
+ * ```
7
+ *
8
+ * TypeScript can't conjure an interface out of a string on its own, so the
9
+ * registry maps manifest keys (`"app.model"`) to your own types via ordinary
10
+ * declaration merging. Note that classes and `resource()` values never need
11
+ * it — they infer on their own; the registry only serves the string form.
12
+ * Two ways to populate it, **neither generating runtime code**:
13
+ *
14
+ * 1. **Hand-written, no compile step** — point `EntityTypesOf` at your
15
+ * resources module once; it derives every `"app.model"` key and row type
16
+ * from the definitions and tracks them forever:
17
+ *
18
+ * ```ts
19
+ * // rastack-env.d.ts — written once by you, never regenerated
20
+ * import type { EntityTypesOf } from "rastack/define";
21
+ * declare module "rastack/hooks/registry" {
22
+ * interface EntityTypes
23
+ * extends EntityTypesOf<typeof import("./resources/airports")> {}
24
+ * }
25
+ * ```
26
+ *
27
+ * 2. **Emitted by `rastack compile`** as `rastack-env.d.ts` beside the
28
+ * manifest — one entry per class/interface entity in the compiled graph
29
+ * (which have no runtime value for `EntityTypesOf` to read), pointing at
30
+ * *your* declaration:
31
+ *
32
+ * ```ts
33
+ * declare module "rastack/hooks/registry" {
34
+ * interface EntityTypes {
35
+ * "airports.terminal": import("../resources/airports").Terminal;
36
+ * }
37
+ * }
38
+ * ```
39
+ *
40
+ * With either file in the program, `useData` / `useRecord` / `useForm`
41
+ * overloads resolve `"app.model"` arguments through {@link EntityTypes};
42
+ * unregistered strings fall back to `any` (or an explicit
43
+ * `useData<ITerminal>(…)` argument, which always still works).
44
+ */
45
+
46
+ /** `"app.model"` → row type. Populated by declaration merging — see above. */
47
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
48
+ export interface EntityTypes {}
49
+
50
+ /** Every registered manifest key. `never` until the registry is augmented. */
51
+ export type RegisteredEntityKey = Extract<keyof EntityTypes, string>;
52
+
53
+ /** The row type behind a manifest key, else `any` for unregistered strings. */
54
+ export type RowForKey<K extends string> = K extends keyof EntityTypes
55
+ ? EntityTypes[K]
56
+ : any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.49",
3
+ "version": "0.0.51",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",
package/plugin.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `rastack/plugin` — the dev-server schema compiler. Add it to the bundler
3
+ * you already run (Vite/Rollup: `rastackPlugin()`; anything else:
4
+ * `startRastackWatcher()`) and class/interface entities compile to the
5
+ * manifest on every save — no `rastack compile` command in the loop.
6
+ * Node-only: import from build config, never from app code.
7
+ */
8
+ export * from "./src/plugin";
@@ -8,6 +8,7 @@ import React, {
8
8
  useState,
9
9
  } from "react";
10
10
  import { API, configureApi, resetApi } from "../hooks/query/api";
11
+ import { RastackManifestProvider } from "../hooks/manifest";
11
12
  import {
12
13
  RastackClient,
13
14
  RastackEngine,
@@ -32,6 +33,8 @@ import {
32
33
  isAccessRevoked,
33
34
  purgeIdentityCache,
34
35
  } from "../cache";
36
+ import { toManifest } from "../src/define/manifest";
37
+ import { getGlobalManifest } from "../hooks/manifest";
35
38
 
36
39
  const RastackContext = createContext<RastackClient | null>(null);
37
40
 
@@ -56,6 +59,16 @@ export function RAStackProvider({
56
59
  const persistRef = useRef<(() => void) & { flush: () => void }>();
57
60
  const cacheStorageRef = useRef<CacheStorage | null>(null);
58
61
 
62
+ // The `manifest` prop is optional: when the rastack dev-server plugin is
63
+ // in the bundler it registers the compiled schema globally before the app
64
+ // boots, so `<RAStackProvider mode="local">` needs no schema wiring at
65
+ // all. An explicit prop (schema.rastack.json object/string, or resource()
66
+ // definitions — array or module namespace) always wins.
67
+ const manifest = useMemo(
68
+ () => toManifest(config.manifest) ?? getGlobalManifest(),
69
+ [config.manifest],
70
+ );
71
+
59
72
  const setStatusSafe = (next: RadStatus) => {
60
73
  setStatus(next);
61
74
  config.onStatusChange?.(next);
@@ -109,15 +122,17 @@ export function RAStackProvider({
109
122
  }
110
123
 
111
124
  // -- WASM modes (local / s3) --
112
- if (!config.manifest) {
113
- throw new Error("RAStackProvider: `manifest` is required in local/s3 mode.");
125
+ if (!manifest) {
126
+ throw new Error(
127
+ "RAStackProvider: no manifest found for local/s3 mode — add rastackPlugin() to your bundler (rastack/plugin), or pass the `manifest` prop.",
128
+ );
114
129
  }
115
130
 
116
131
  setStatusSafe("loading");
117
132
  const loader = config.loadEngine ?? defaultEngineLoader;
118
133
  const mod = await loader();
119
134
  if (cancelled) return;
120
- const engine = createEngine(mod, config.manifest, config.openapi);
135
+ const engine = createEngine(mod, manifest, config.openapi);
121
136
 
122
137
  // Run the in-browser engine as the signed-in identity so owner stamping
123
138
  // and row-level security match the server exactly.
@@ -139,7 +154,7 @@ export function RAStackProvider({
139
154
  await engine.importWarehouse(existing);
140
155
  } else {
141
156
  const base = config.warehouseBaseUrl ?? "/data/warehouse/";
142
- const resources = manifestResources(config.manifest);
157
+ const resources = manifestResources(manifest);
143
158
  if (config.cache === false) {
144
159
  await seedFromIceberg(engine, base, resources);
145
160
  } else {
@@ -227,7 +242,13 @@ export function RAStackProvider({
227
242
  [config.mode, status, config.identity],
228
243
  );
229
244
 
230
- return <RastackContext.Provider value={client}>{children}</RastackContext.Provider>;
245
+ return (
246
+ <RastackContext.Provider value={client}>
247
+ <RastackManifestProvider manifest={manifest}>
248
+ {children}
249
+ </RastackManifestProvider>
250
+ </RastackContext.Provider>
251
+ );
231
252
  }
232
253
 
233
254
  function pickPersistence(
package/provider/types.ts CHANGED
@@ -96,9 +96,21 @@ export interface RAStackProviderConfig {
96
96
  */
97
97
  maxOfflineMs?: number;
98
98
 
99
- // -- local / s3 mode --
100
- /** `schema.rastack.json` — object or JSON string. Required for WASM modes. */
101
- manifest?: object | string;
99
+ // -- all modes --
100
+ /**
101
+ * The schema — **usually omitted**: with the `rastack/plugin` dev-server
102
+ * plugin in the bundler, the manifest compiled from your entities is
103
+ * registered globally before the app boots and the provider picks it up
104
+ * automatically. Pass it explicitly to override: the compiled
105
+ * `schema.rastack.json` (object or JSON string), the
106
+ * `virtual:rastack-manifest` module, or `resource()` definitions (an array
107
+ * or a module namespace), from which the provider builds the identical
108
+ * manifest at runtime. One of the two (plugin or prop) is required for
109
+ * WASM modes (the manifest drives the in-browser engine); it is also what
110
+ * lets `useData(Entity)` / `useForm(Entity)` resolve a plain TypeScript
111
+ * type to its resource at runtime.
112
+ */
113
+ manifest?: object | readonly object[] | string;
102
114
  /** `openapi.json` — object or JSON string. Optional (`/api/schema/`). */
103
115
  openapi?: object | string;
104
116
  /**
@@ -1,14 +1,25 @@
1
+ import * as path from "path";
1
2
  import * as ts from "typescript";
2
3
  import {
3
- AccessModel,
4
- AdminModel,
5
4
  FieldModel,
6
5
  RelationModel,
7
6
  ResourceModel,
8
7
  ScalarType,
9
- SyncModel,
10
8
  } from "./model";
11
9
  import { resourceSourceFiles } from "./program";
10
+ import {
11
+ applyTransitions,
12
+ normaliseAccess,
13
+ normaliseAdmin,
14
+ normaliseSync,
15
+ } from "../define/manifest";
16
+ import type { EntityTypeExport } from "./entities";
17
+
18
+ // The option normalisers live beside the runtime manifest builder
19
+ // (`define/manifest.ts`) and are shared with it, so a manifest built from
20
+ // runtime `resource()` values and one compiled from the same source can
21
+ // never disagree. Re-exported here for existing importers.
22
+ export { applyTransitions } from "../define/manifest";
12
23
 
13
24
  /**
14
25
  * Statically analyse resource definitions and produce the canonical model.
@@ -23,14 +34,34 @@ export function analyze(
23
34
  program: ts.Program,
24
35
  fileNames: string[],
25
36
  ): ResourceModel[] {
37
+ return analyzeDsl(program, fileNames).resources;
38
+ }
39
+
40
+ /**
41
+ * Analyse the DSL surface, also collecting where each `resource()` const
42
+ * lives — `export const Flight = resource(…)` registers in the emitted
43
+ * `rastack-env.d.ts` as `RowOf<typeof Flight>`, so string references
44
+ * (`useData("airports.flight")`) infer rows for DSL resources too.
45
+ */
46
+ export function analyzeDsl(
47
+ program: ts.Program,
48
+ fileNames: string[],
49
+ ): { resources: ResourceModel[]; types: EntityTypeExport[] } {
26
50
  const checker = program.getTypeChecker();
27
51
  const resources: ResourceModel[] = [];
52
+ const types: EntityTypeExport[] = [];
28
53
 
29
54
  for (const sourceFile of resourceSourceFiles(program, fileNames)) {
30
55
  ts.forEachChild(sourceFile, function walk(node) {
31
56
  if (isResourceCall(node)) {
32
57
  const model = analyzeResourceCall(node, checker);
33
- if (model) resources.push(model);
58
+ if (model) {
59
+ resources.push(model);
60
+ const constExport = resourceConstExport(node);
61
+ if (constExport) {
62
+ types.push({ key: `${model.app}.${model.model}`, ...constExport });
63
+ }
64
+ }
34
65
  }
35
66
  ts.forEachChild(node, walk);
36
67
  });
@@ -40,7 +71,31 @@ export function analyze(
40
71
  resources.sort((a, b) =>
41
72
  a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
42
73
  );
43
- return resources;
74
+ types.sort((a, b) => a.key.localeCompare(b.key));
75
+ return { resources, types };
76
+ }
77
+
78
+ /** The `const X = resource(…)` binding a resource call is assigned to. */
79
+ function resourceConstExport(
80
+ call: ts.CallExpression,
81
+ ): Omit<EntityTypeExport, "key"> | undefined {
82
+ const decl = call.parent;
83
+ if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) {
84
+ return undefined;
85
+ }
86
+ const statement = decl.parent?.parent;
87
+ const exported =
88
+ !!statement &&
89
+ ts.isVariableStatement(statement) &&
90
+ !!ts.getModifiers(statement)?.some(
91
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword,
92
+ );
93
+ return {
94
+ typeName: decl.name.text,
95
+ fileName: path.resolve(call.getSourceFile().fileName),
96
+ exported,
97
+ kind: "resourceConst",
98
+ };
44
99
  }
45
100
 
46
101
  function isResourceCall(node: ts.Node): node is ts.CallExpression {
@@ -84,7 +139,7 @@ function analyzeResourceCall(
84
139
  }
85
140
 
86
141
  const options = literalToValue(call.arguments[3]) ?? {};
87
- return {
142
+ const resource: ResourceModel = {
88
143
  app,
89
144
  model,
90
145
  fields,
@@ -96,6 +151,8 @@ function analyzeResourceCall(
96
151
  permission: options.permission,
97
152
  access: normaliseAccess(options.access),
98
153
  };
154
+ applyTransitions(resource, options.transitions);
155
+ return resource;
99
156
  }
100
157
 
101
158
  /**
@@ -171,9 +228,19 @@ function propertyName(name: ts.PropertyName): string | undefined {
171
228
  * Evaluate a *static literal* AST node (string/number/bool/null/array/object)
172
229
  * into a JS value. Anything non-literal (a function, an identifier) yields
173
230
  * `undefined` — the options and constraint blocks are always literals.
231
+ * Exported for the class-entity analyser, which reads `static transitions`
232
+ * blocks with the same evaluator.
174
233
  */
175
- function literalToValue(node: ts.Expression | undefined): any {
234
+ export function literalToValue(node: ts.Expression | undefined): any {
176
235
  if (!node) return undefined;
236
+ // Look through `as const` / `satisfies` / parens — the value is unchanged.
237
+ if (
238
+ ts.isAsExpression(node) ||
239
+ ts.isSatisfiesExpression(node) ||
240
+ ts.isParenthesizedExpression(node)
241
+ ) {
242
+ return literalToValue(node.expression);
243
+ }
177
244
  if (ts.isStringLiteralLike(node)) return node.text;
178
245
  if (ts.isNumericLiteral(node)) return Number(node.text);
179
246
  if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
@@ -201,41 +268,3 @@ function literalToValue(node: ts.Expression | undefined): any {
201
268
  }
202
269
  return undefined;
203
270
  }
204
-
205
- function normaliseSync(sync: any): SyncModel | undefined {
206
- if (!sync || typeof sync !== "object") return undefined;
207
- const mode = sync.mode === "local" ? "local" : "server";
208
- if (mode === "server" && !sync.confirmed) {
209
- // A bare server resource carries no sync semantics.
210
- return undefined;
211
- }
212
- const model: SyncModel = { mode };
213
- if (typeof sync.scope === "string") model.scope = sync.scope;
214
- if (Array.isArray(sync.confirmed)) model.confirmed = sync.confirmed;
215
- if (sync.load === "lazy") model.load = "lazy";
216
- if (sync.clientIds === true) model.clientIds = true;
217
- return model;
218
- }
219
-
220
- function normaliseAccess(access: any): AccessModel | undefined {
221
- if (!access || typeof access !== "object") return undefined;
222
- const model: AccessModel = {};
223
- if (typeof access.ownerField === "string") model.ownerField = access.ownerField;
224
- if (access.scope === "owner" || access.scope === "shared") {
225
- model.scope = access.scope;
226
- }
227
- if (Array.isArray(access.adminGroups)) {
228
- model.adminGroups = access.adminGroups.filter(
229
- (g: unknown) => typeof g === "string",
230
- );
231
- }
232
- return Object.keys(model).length ? model : undefined;
233
- }
234
-
235
- function normaliseAdmin(admin: any): AdminModel | false | undefined {
236
- if (admin === false) return false;
237
- if (!admin || typeof admin !== "object") return undefined;
238
- const model: AdminModel = {};
239
- if (Array.isArray(admin.listDisplay)) model.listDisplay = admin.listDisplay;
240
- return model;
241
- }
@@ -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. */