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,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime manifest builder — `resource()` definitions are their own
|
|
3
|
+
* manifest, **no `rastack compile` step required**.
|
|
4
|
+
*
|
|
5
|
+
* The DSL values carry everything the canonical manifest needs at runtime
|
|
6
|
+
* (field kinds, constraint options, FK target thunks, resource options), so
|
|
7
|
+
* this module assembles the exact `schema.rastack.json` shape the compiler
|
|
8
|
+
* emits — same fields, same relations, same normalisation — as a pure
|
|
9
|
+
* function:
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* import { Airport, Terminal, Flight } from "./resources/airports";
|
|
13
|
+
*
|
|
14
|
+
* <RAStackProvider mode="local" manifest={[Airport, Terminal, Flight]}>
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The compiler (`tools/src/compile/analyze.ts`) imports the option
|
|
18
|
+
* normalisers below, so the two paths cannot drift: `rastack compile` remains
|
|
19
|
+
* the way to get class/interface entities, `openapi.json`, and the artifacts
|
|
20
|
+
* the Rust backend and admin read — but the typed hooks and the in-browser
|
|
21
|
+
* engine need only this.
|
|
22
|
+
*
|
|
23
|
+
* Everything here is browser-safe: no `typescript`, no filesystem.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
AccessModel,
|
|
28
|
+
AdminModel,
|
|
29
|
+
FieldModel,
|
|
30
|
+
Manifest,
|
|
31
|
+
RelationModel,
|
|
32
|
+
ResourceModel,
|
|
33
|
+
ScalarType,
|
|
34
|
+
SyncModel,
|
|
35
|
+
} from "../compile/model";
|
|
36
|
+
import { normalizeTransitions } from "../validate/transitions";
|
|
37
|
+
import type { ResourceOptions, ResourceType } from "./index";
|
|
38
|
+
|
|
39
|
+
/** Any `resource()` value, generics erased. */
|
|
40
|
+
export type AnyResourceValue = ResourceType<string, string, any, any>;
|
|
41
|
+
|
|
42
|
+
/** Everything `toManifest()` understands — see {@link toManifest}. */
|
|
43
|
+
export type ManifestInput =
|
|
44
|
+
| Manifest
|
|
45
|
+
| object
|
|
46
|
+
| string
|
|
47
|
+
| AnyResourceValue
|
|
48
|
+
| readonly AnyResourceValue[];
|
|
49
|
+
|
|
50
|
+
/** Whether a value is a `resource()` definition. */
|
|
51
|
+
export function isResourceValue(value: unknown): value is AnyResourceValue {
|
|
52
|
+
return (
|
|
53
|
+
!!value &&
|
|
54
|
+
typeof value === "object" &&
|
|
55
|
+
(value as { __rastack?: unknown }).__rastack === "resource"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One `resource()` value → its canonical {@link ResourceModel} — the same
|
|
61
|
+
* shape `rastack compile` derives from the source, produced from the runtime
|
|
62
|
+
* value instead.
|
|
63
|
+
*/
|
|
64
|
+
export function resourceModel(res: AnyResourceValue): ResourceModel {
|
|
65
|
+
const fields: FieldModel[] = [];
|
|
66
|
+
const relations: RelationModel[] = [];
|
|
67
|
+
|
|
68
|
+
for (const [name, value] of Object.entries(
|
|
69
|
+
(res.fields ?? {}) as Record<string, unknown>,
|
|
70
|
+
)) {
|
|
71
|
+
const field = runtimeField(res, name, value);
|
|
72
|
+
if (!field) continue;
|
|
73
|
+
fields.push(field);
|
|
74
|
+
if (field.type === "fk" && field.relation) {
|
|
75
|
+
relations.push({
|
|
76
|
+
field: name,
|
|
77
|
+
target: `${field.relation.app}.${field.relation.model}`,
|
|
78
|
+
kind: "fk",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const options = (res.options ?? {}) as ResourceOptions;
|
|
84
|
+
const model: ResourceModel = {
|
|
85
|
+
app: res.app,
|
|
86
|
+
model: res.model,
|
|
87
|
+
fields,
|
|
88
|
+
relations,
|
|
89
|
+
sync: normaliseSync(options.sync),
|
|
90
|
+
admin: normaliseAdmin(options.admin),
|
|
91
|
+
search: options.search,
|
|
92
|
+
ordering: options.ordering,
|
|
93
|
+
permission: options.permission,
|
|
94
|
+
access: normaliseAccess(options.access),
|
|
95
|
+
};
|
|
96
|
+
applyTransitions(model, options.transitions);
|
|
97
|
+
return model;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve one runtime field value to its {@link FieldModel}. */
|
|
101
|
+
function runtimeField(
|
|
102
|
+
res: AnyResourceValue,
|
|
103
|
+
name: string,
|
|
104
|
+
value: unknown,
|
|
105
|
+
): FieldModel | undefined {
|
|
106
|
+
if (!value || typeof value !== "object") return undefined;
|
|
107
|
+
const field = value as {
|
|
108
|
+
__rastackRef?: unknown;
|
|
109
|
+
__rastackScalar?: unknown;
|
|
110
|
+
target?: () => AnyResourceValue;
|
|
111
|
+
options?: Record<string, unknown>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Foreign key: the runtime value keeps the `() => Resource` thunk, so the
|
|
115
|
+
// relation target is recovered by calling it — the runtime twin of the
|
|
116
|
+
// compiler's "read the Ref<App, Model> type arguments" step.
|
|
117
|
+
if (field.__rastackRef === true) {
|
|
118
|
+
const target = field.target?.();
|
|
119
|
+
if (!isResourceValue(target)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`manifestFromResources: ${res.app}.${res.model}.${name} is an s.ref(...) whose target thunk did not return a resource() value`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
name,
|
|
126
|
+
type: "fk",
|
|
127
|
+
relation: { app: target.app, model: target.model },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof field.__rastackScalar !== "string") return undefined;
|
|
132
|
+
const model: FieldModel = {
|
|
133
|
+
name,
|
|
134
|
+
type: field.__rastackScalar as ScalarType,
|
|
135
|
+
};
|
|
136
|
+
// Constraint mapping mirrors the compiler's `applyConstraints` exactly.
|
|
137
|
+
const opts = field.options;
|
|
138
|
+
if (opts && typeof opts === "object") {
|
|
139
|
+
if (typeof opts.maxLength === "number") model.maxLength = opts.maxLength;
|
|
140
|
+
if (opts.unique === true) model.unique = true;
|
|
141
|
+
if (opts.primaryKey === true) model.primaryKey = true;
|
|
142
|
+
if (opts.null === true) model.null = true;
|
|
143
|
+
if (opts.default !== undefined) model.default = opts.default;
|
|
144
|
+
}
|
|
145
|
+
return model;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Assemble the canonical manifest from `resource()` values at runtime —
|
|
150
|
+
* accepts the values themselves, arrays of them, or whole module namespaces
|
|
151
|
+
* (`import * as resources from "./resources"`), in any mix. Output order
|
|
152
|
+
* matches the compiler's (app, then model), so the result is byte-for-byte
|
|
153
|
+
* the manifest `rastack compile` would emit for the same definitions.
|
|
154
|
+
*/
|
|
155
|
+
export function manifestFromResources(
|
|
156
|
+
...inputs: Array<
|
|
157
|
+
AnyResourceValue | readonly AnyResourceValue[] | Record<string, unknown>
|
|
158
|
+
>
|
|
159
|
+
): Manifest {
|
|
160
|
+
const resources: ResourceModel[] = collectResources(inputs).map(resourceModel);
|
|
161
|
+
resources.sort((a, b) =>
|
|
162
|
+
a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
|
|
163
|
+
);
|
|
164
|
+
return { version: 1, resources };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Flatten values / arrays / module namespaces into the resource list. */
|
|
168
|
+
function collectResources(inputs: unknown[]): AnyResourceValue[] {
|
|
169
|
+
const out: AnyResourceValue[] = [];
|
|
170
|
+
const seen = new Set<AnyResourceValue>();
|
|
171
|
+
const push = (res: AnyResourceValue) => {
|
|
172
|
+
if (!seen.has(res)) {
|
|
173
|
+
seen.add(res);
|
|
174
|
+
out.push(res);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
for (const input of inputs) {
|
|
178
|
+
if (isResourceValue(input)) {
|
|
179
|
+
push(input);
|
|
180
|
+
} else if (Array.isArray(input)) {
|
|
181
|
+
for (const item of input) if (isResourceValue(item)) push(item);
|
|
182
|
+
} else if (input && typeof input === "object") {
|
|
183
|
+
for (const item of Object.values(input)) {
|
|
184
|
+
if (isResourceValue(item)) push(item);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Normalise anything a provider may be handed as "the manifest": a compiled
|
|
193
|
+
* manifest (object or JSON string), a `resource()` value, an array of them,
|
|
194
|
+
* or a module namespace containing them. This is what lets
|
|
195
|
+
* `<RAStackProvider manifest={[Airport, Terminal]}>` work with no compiled
|
|
196
|
+
* `schema.rastack.json` anywhere.
|
|
197
|
+
*/
|
|
198
|
+
export function toManifest(
|
|
199
|
+
input: ManifestInput | null | undefined,
|
|
200
|
+
): Manifest | undefined {
|
|
201
|
+
if (input === null || input === undefined) return undefined;
|
|
202
|
+
if (typeof input === "string") return JSON.parse(input) as Manifest;
|
|
203
|
+
if (Array.isArray(input) || isResourceValue(input)) {
|
|
204
|
+
return manifestFromResources(
|
|
205
|
+
input as AnyResourceValue | readonly AnyResourceValue[],
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const record = input as Record<string, unknown>;
|
|
209
|
+
// A compiled manifest passes through untouched.
|
|
210
|
+
if (Array.isArray(record.resources)) return input as Manifest;
|
|
211
|
+
// A module namespace of resource() exports builds one.
|
|
212
|
+
const values = Object.values(record).filter(isResourceValue);
|
|
213
|
+
if (values.length) return manifestFromResources(values);
|
|
214
|
+
return input as Manifest;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Option normalisers — shared with the compiler (`compile/analyze.ts`), so
|
|
219
|
+
// the runtime and compiled manifests can never disagree about a resource.
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Attach a normalised `transitions` block to a resource and derive what the
|
|
224
|
+
* machine implies about its state field: the `states` become the field's
|
|
225
|
+
* allowed-value set (`enum` in OpenAPI, the `member` gate in validation), and
|
|
226
|
+
* `initial` becomes its default when the field doesn't declare one.
|
|
227
|
+
*/
|
|
228
|
+
export function applyTransitions(resource: ResourceModel, raw: unknown): void {
|
|
229
|
+
const transitions = normalizeTransitions(raw);
|
|
230
|
+
if (!transitions) return;
|
|
231
|
+
resource.transitions = transitions;
|
|
232
|
+
|
|
233
|
+
const stateField = resource.fields.find((f) => f.name === transitions.field);
|
|
234
|
+
if (stateField && stateField.type !== "fk") {
|
|
235
|
+
stateField.options = [...transitions.states];
|
|
236
|
+
if (stateField.default === undefined && transitions.initial !== undefined) {
|
|
237
|
+
stateField.default = transitions.initial;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function normaliseSync(sync: any): SyncModel | undefined {
|
|
243
|
+
if (!sync || typeof sync !== "object") return undefined;
|
|
244
|
+
const mode = sync.mode === "local" ? "local" : "server";
|
|
245
|
+
if (mode === "server" && !sync.confirmed) {
|
|
246
|
+
// A bare server resource carries no sync semantics.
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
const model: SyncModel = { mode };
|
|
250
|
+
if (typeof sync.scope === "string") model.scope = sync.scope;
|
|
251
|
+
if (Array.isArray(sync.confirmed)) model.confirmed = sync.confirmed;
|
|
252
|
+
if (sync.load === "lazy") model.load = "lazy";
|
|
253
|
+
if (sync.clientIds === true) model.clientIds = true;
|
|
254
|
+
return model;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function normaliseAccess(access: any): AccessModel | undefined {
|
|
258
|
+
if (!access || typeof access !== "object") return undefined;
|
|
259
|
+
const model: AccessModel = {};
|
|
260
|
+
if (typeof access.ownerField === "string") model.ownerField = access.ownerField;
|
|
261
|
+
if (access.scope === "owner" || access.scope === "shared") {
|
|
262
|
+
model.scope = access.scope;
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(access.adminGroups)) {
|
|
265
|
+
model.adminGroups = access.adminGroups.filter(
|
|
266
|
+
(g: unknown) => typeof g === "string",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return Object.keys(model).length ? model : undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function normaliseAdmin(admin: any): AdminModel | false | undefined {
|
|
273
|
+
if (admin === false) return false;
|
|
274
|
+
if (!admin || typeof admin !== "object") return undefined;
|
|
275
|
+
const model: AdminModel = {};
|
|
276
|
+
if (Array.isArray(admin.listDisplay)) model.listDisplay = admin.listDisplay;
|
|
277
|
+
return model;
|
|
278
|
+
}
|
package/src/import/tabular.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type FieldState,
|
|
19
19
|
type RecordRun,
|
|
20
20
|
type SpecRelation,
|
|
21
|
+
type TransitionsModel,
|
|
21
22
|
} from "../validate";
|
|
22
23
|
|
|
23
24
|
/** A parsed tabular source: one header row + string cell rows. */
|
|
@@ -36,7 +37,7 @@ export interface ColumnMapping {
|
|
|
36
37
|
export interface ImportIssue {
|
|
37
38
|
row: number;
|
|
38
39
|
field: string;
|
|
39
|
-
gate: FieldState;
|
|
40
|
+
gate: FieldState | "transition";
|
|
40
41
|
constraint: string;
|
|
41
42
|
message: string;
|
|
42
43
|
}
|
|
@@ -94,6 +95,12 @@ export interface ValidateDatasetOptions {
|
|
|
94
95
|
relatedIds?: Record<string, Set<string>>;
|
|
95
96
|
/** Custom FK resolver (wins over `relatedIds`). */
|
|
96
97
|
resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
|
|
98
|
+
/**
|
|
99
|
+
* The resource's state machine (`schema.rastack.json` `transitions`).
|
|
100
|
+
* Imported rows are creates, so the record-level `transition` gate rejects
|
|
101
|
+
* rows born mid-machine (a state other than the initial one).
|
|
102
|
+
*/
|
|
103
|
+
transitions?: TransitionsModel;
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
/**
|
|
@@ -108,7 +115,7 @@ export function validateDataset(
|
|
|
108
115
|
options: ValidateDatasetOptions = {},
|
|
109
116
|
): ImportReport {
|
|
110
117
|
const { columns, missingFields } = mapHeaders(data.headers, specs);
|
|
111
|
-
const machine = compileRecordMachine(specs);
|
|
118
|
+
const machine = compileRecordMachine(specs, options.transitions);
|
|
112
119
|
const ctx = createDatasetContext(machine, options);
|
|
113
120
|
|
|
114
121
|
const rows: RecordRun[] = [];
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev-server schema compiler — the way around "classes need a compile
|
|
3
|
+
* step".
|
|
4
|
+
*
|
|
5
|
+
* Class/interface entities have no runtime shape (TypeScript erases their
|
|
6
|
+
* types), so *something* must run the `ts.Program` + `TypeChecker` to read
|
|
7
|
+
* fields, `DbConfig` brands and FK types off them. The insight is that it
|
|
8
|
+
* doesn't have to be a command the developer runs: this module packages the
|
|
9
|
+
* same `analyzeProject`/`compile` pass the CLI uses so it can live **inside
|
|
10
|
+
* the dev server that is already running** — a bundler plugin
|
|
11
|
+
* (`rastackPlugin`, Vite/Rollup-compatible) or a bare filesystem watcher
|
|
12
|
+
* (`startRastackWatcher`, for anything else). The schema recompiles on every
|
|
13
|
+
* save, the app imports it as the `virtual:rastack-manifest` module, and
|
|
14
|
+
* `rastack-env.d.ts` is rewritten so string-key row types stay fresh — the
|
|
15
|
+
* developer never runs `rastack compile`.
|
|
16
|
+
*
|
|
17
|
+
* Design mirrors the rest of `tools/src`: the decisions live here as pure
|
|
18
|
+
* (or thin-IO) functions, tested in `tools/test/plugin.spec.ts`; the plugin
|
|
19
|
+
* object in `./index.ts` is a shell over {@link createSchemaState}.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from "fs";
|
|
23
|
+
import * as path from "path";
|
|
24
|
+
import { analyzeProject, buildManifest, compile } from "../compile";
|
|
25
|
+
import type { EntityDiagnostic, Manifest } from "../compile";
|
|
26
|
+
|
|
27
|
+
/** The module id apps import the live manifest from. */
|
|
28
|
+
export const VIRTUAL_MANIFEST_ID = "virtual:rastack-manifest";
|
|
29
|
+
|
|
30
|
+
/** The resolved (bundler-internal) id — Rollup convention: `\0` prefix. */
|
|
31
|
+
export const RESOLVED_MANIFEST_ID = "\0" + VIRTUAL_MANIFEST_ID;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The auto-registration module the plugin injects into `index.html`: it sets
|
|
35
|
+
* `globalThis.__RASTACK_MANIFEST__` before the app boots, which is what lets
|
|
36
|
+
* `<RAStackProvider mode="local">` (and bare `useData(Airport)`) run with
|
|
37
|
+
* **no manifest wiring in app code at all**. Kept un-prefixed (no `\0`) so
|
|
38
|
+
* its dev-server URL is a plain `/@id/…`.
|
|
39
|
+
*/
|
|
40
|
+
export const VIRTUAL_REGISTER_ID = "virtual:rastack-manifest/register";
|
|
41
|
+
|
|
42
|
+
/** The key the register module assigns (mirrored in `rastack/hooks`). */
|
|
43
|
+
export const GLOBAL_MANIFEST_KEY = "__RASTACK_MANIFEST__";
|
|
44
|
+
|
|
45
|
+
export interface RastackPluginOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Directory (or single file) scanned for entities — classes, interfaces
|
|
48
|
+
* and `resource()` calls alike, recursively (co-located definitions work).
|
|
49
|
+
* Default `"resources"`; pass `"src"` (or `"."`) for co-located entities.
|
|
50
|
+
*/
|
|
51
|
+
resourcesDir?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Where the artifacts land: `schema.rastack.json`, `openapi.json`,
|
|
54
|
+
* `rastack-env.d.ts` (the string-key type registry) and
|
|
55
|
+
* `rastack-plugin.d.ts` (types for the virtual module). Default
|
|
56
|
+
* `".rastack"` — include it in `tsconfig.json` once.
|
|
57
|
+
*/
|
|
58
|
+
outDir?: string;
|
|
59
|
+
/** Skip writing the artifacts to disk (virtual module only). Default false. */
|
|
60
|
+
emitFiles?: boolean;
|
|
61
|
+
/** Where compile problems are reported. Default `console.error`. */
|
|
62
|
+
onError?: (error: unknown) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ResolvedPluginOptions {
|
|
66
|
+
resourcesDir: string;
|
|
67
|
+
outDir: string;
|
|
68
|
+
emitFiles: boolean;
|
|
69
|
+
onError: (error: unknown) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveOptions(
|
|
73
|
+
options: RastackPluginOptions = {},
|
|
74
|
+
root: string = process.cwd(),
|
|
75
|
+
): ResolvedPluginOptions {
|
|
76
|
+
return {
|
|
77
|
+
resourcesDir: path.resolve(root, options.resourcesDir ?? "resources"),
|
|
78
|
+
outDir: path.resolve(root, options.outDir ?? ".rastack"),
|
|
79
|
+
emitFiles: options.emitFiles !== false,
|
|
80
|
+
onError:
|
|
81
|
+
options.onError ??
|
|
82
|
+
((error) =>
|
|
83
|
+
console.error(
|
|
84
|
+
`[rastack] schema compile failed — serving the last good manifest\n` +
|
|
85
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
86
|
+
)),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether a changed file should trigger a schema recompile: a `.ts`/`.tsx`
|
|
92
|
+
* source under the scanned directory that is not itself an emitted artifact.
|
|
93
|
+
*/
|
|
94
|
+
export function isEntitySource(
|
|
95
|
+
file: string,
|
|
96
|
+
options: Pick<ResolvedPluginOptions, "resourcesDir" | "outDir">,
|
|
97
|
+
): boolean {
|
|
98
|
+
const resolved = path.resolve(file);
|
|
99
|
+
if (!/\.tsx?$/.test(resolved) || resolved.endsWith(".d.ts")) return false;
|
|
100
|
+
if (within(resolved, options.outDir)) return false;
|
|
101
|
+
return (
|
|
102
|
+
within(resolved, options.resourcesDir) || resolved === options.resourcesDir
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function within(file: string, dir: string): boolean {
|
|
107
|
+
const rel = path.relative(dir, file);
|
|
108
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The virtual module's source — the manifest as a plain ES module. */
|
|
112
|
+
export function manifestModuleCode(manifest: Manifest): string {
|
|
113
|
+
const json = JSON.stringify(manifest);
|
|
114
|
+
return (
|
|
115
|
+
`// virtual:rastack-manifest — compiled by the rastack dev-server plugin.\n` +
|
|
116
|
+
`const manifest = ${json};\n` +
|
|
117
|
+
`export default manifest;\n` +
|
|
118
|
+
`export { manifest };\n`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The register module's source: import the live manifest, publish it on the
|
|
124
|
+
* global the runtime falls back to. A separate module (rather than a side
|
|
125
|
+
* effect of the manifest module) so explicit `virtual:rastack-manifest`
|
|
126
|
+
* importers stay side-effect-free.
|
|
127
|
+
*/
|
|
128
|
+
export function registerModuleCode(): string {
|
|
129
|
+
return (
|
|
130
|
+
`// ${VIRTUAL_REGISTER_ID} — auto-injected by the rastack plugin.\n` +
|
|
131
|
+
`import manifest from ${JSON.stringify(VIRTUAL_MANIFEST_ID)};\n` +
|
|
132
|
+
`globalThis.${GLOBAL_MANIFEST_KEY} = manifest;\n` +
|
|
133
|
+
`export default manifest;\n`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Types for the virtual module, written once beside `rastack-env.d.ts`. */
|
|
138
|
+
export function virtualModuleDts(): string {
|
|
139
|
+
return (
|
|
140
|
+
"// Generated by the rastack plugin — do not edit.\n" +
|
|
141
|
+
`declare module "${VIRTUAL_MANIFEST_ID}" {\n` +
|
|
142
|
+
' const manifest: import("rastack/define").Manifest;\n' +
|
|
143
|
+
" export default manifest;\n" +
|
|
144
|
+
" export { manifest };\n" +
|
|
145
|
+
"}\n"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface SchemaState {
|
|
150
|
+
/**
|
|
151
|
+
* The virtual module's current source. Compiles (lazily) when stale; on a
|
|
152
|
+
* compile error reports via `onError` and keeps serving the last good
|
|
153
|
+
* manifest — a half-typed entity must not blank the running app.
|
|
154
|
+
*/
|
|
155
|
+
code(): string;
|
|
156
|
+
/** The current manifest (same laziness/fallback as {@link code}). */
|
|
157
|
+
manifest(): Manifest;
|
|
158
|
+
/** Mark the schema stale — the next `code()`/`manifest()` recompiles. */
|
|
159
|
+
invalidate(): void;
|
|
160
|
+
/** The error from the most recent failed compile, if it is still failing. */
|
|
161
|
+
error(): unknown;
|
|
162
|
+
/** Non-error diagnostics from the last successful compile. */
|
|
163
|
+
diagnostics(): EntityDiagnostic[];
|
|
164
|
+
options: ResolvedPluginOptions;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The plugin's whole brain: lazy compile, invalidation, keep-last-good.
|
|
169
|
+
* Both the bundler plugin and the bare watcher drive this one object.
|
|
170
|
+
*/
|
|
171
|
+
export function createSchemaState(
|
|
172
|
+
options: RastackPluginOptions = {},
|
|
173
|
+
root?: string,
|
|
174
|
+
): SchemaState {
|
|
175
|
+
const resolved = resolveOptions(options, root);
|
|
176
|
+
let current: { manifest: Manifest; diagnostics: EntityDiagnostic[] } | null =
|
|
177
|
+
null;
|
|
178
|
+
let stale = true;
|
|
179
|
+
let lastError: unknown = undefined;
|
|
180
|
+
|
|
181
|
+
const recompile = (): void => {
|
|
182
|
+
try {
|
|
183
|
+
const result = runCompile(resolved);
|
|
184
|
+
current = { manifest: result.manifest, diagnostics: result.diagnostics };
|
|
185
|
+
lastError = undefined;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
lastError = error;
|
|
188
|
+
resolved.onError(error);
|
|
189
|
+
if (!current) throw error; // no last-good manifest to fall back to
|
|
190
|
+
} finally {
|
|
191
|
+
stale = false;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const ensure = (): { manifest: Manifest; diagnostics: EntityDiagnostic[] } => {
|
|
196
|
+
if (stale || !current) recompile();
|
|
197
|
+
return current!;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
code: () => manifestModuleCode(ensure().manifest),
|
|
202
|
+
manifest: () => ensure().manifest,
|
|
203
|
+
invalidate: () => {
|
|
204
|
+
stale = true;
|
|
205
|
+
},
|
|
206
|
+
error: () => lastError,
|
|
207
|
+
diagnostics: () => ensure().diagnostics,
|
|
208
|
+
options: resolved,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One compile pass: the same artifacts `rastack compile` writes (manifest,
|
|
214
|
+
* OpenAPI, `rastack-env.d.ts`), plus the virtual module's type declaration.
|
|
215
|
+
* With `emitFiles: false` it only analyses (nothing touches disk).
|
|
216
|
+
*/
|
|
217
|
+
export function runCompile(options: ResolvedPluginOptions): {
|
|
218
|
+
manifest: Manifest;
|
|
219
|
+
diagnostics: EntityDiagnostic[];
|
|
220
|
+
} {
|
|
221
|
+
if (!options.emitFiles) {
|
|
222
|
+
// compile() always writes to disk; the analyse-only path applies the
|
|
223
|
+
// same error gate without touching the filesystem.
|
|
224
|
+
const { resources, diagnostics } = analyzeProject(options.resourcesDir);
|
|
225
|
+
const errors = diagnostics.filter((d) => d.severity === "error");
|
|
226
|
+
if (errors.length) throw new Error(errors.map((e) => e.message).join("\n"));
|
|
227
|
+
return { manifest: buildManifest(resources), diagnostics };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const result = compile(options.resourcesDir, options.outDir);
|
|
231
|
+
const dtsPath = path.join(options.outDir, "rastack-plugin.d.ts");
|
|
232
|
+
if (!fs.existsSync(dtsPath)) {
|
|
233
|
+
fs.writeFileSync(dtsPath, virtualModuleDts());
|
|
234
|
+
}
|
|
235
|
+
return { manifest: result.manifest, diagnostics: result.diagnostics };
|
|
236
|
+
}
|