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.
- package/CHANGELOG.md +4 -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 +20 -0
- package/dist/compile/analyze.js +60 -49
- 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 +162 -21
- package/dist/define/index.js +28 -22
- package/dist/define/manifest.d.ts +64 -0
- package/dist/define/manifest.js +250 -0
- package/dist/import/tabular.d.ts +8 -2
- package/dist/import/tabular.js +1 -1
- package/dist/plugin/core.d.ts +108 -0
- package/dist/plugin/core.js +198 -0
- package/dist/plugin/index.d.ts +112 -0
- package/dist/plugin/index.js +203 -0
- 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 +221 -0
- package/hooks/entity.ts +228 -0
- package/hooks/form/entity-form.ts +358 -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 +77 -0
- package/hooks/registry.ts +56 -0
- package/package.json +1 -1
- package/plugin.ts +8 -0
- package/provider/provider.tsx +26 -5
- package/provider/types.ts +15 -3
- package/src/compile/analyze.ts +74 -45
- 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 +233 -29
- package/src/define/manifest.ts +278 -0
- package/src/import/tabular.ts +9 -2
- package/src/plugin/core.ts +236 -0
- package/src/plugin/index.ts +243 -0
- 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/plugin.spec.ts +315 -0
- package/test/runtime-manifest.spec.ts +309 -0
- package/test/transitions.spec.ts +372 -0
- package/test/typed-hooks.spec.ts +412 -0
- package/wasm/rastack_wasm.js +1 -1
- 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
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";
|
package/provider/provider.tsx
CHANGED
|
@@ -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 (!
|
|
113
|
-
throw new Error(
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
// --
|
|
100
|
-
/**
|
|
101
|
-
|
|
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
|
/**
|
package/src/compile/analyze.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
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. */
|