rastack 0.0.46 → 0.0.48
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/dist/compile/entities.d.ts +0 -49
- package/dist/compile/entities.js +110 -22
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +1 -1
- package/dist/compile/program.js +1 -1
- package/dist/define/db.d.ts +1 -1
- package/dist/define/db.js +1 -1
- package/dist/import/index.d.ts +18 -0
- package/dist/import/index.js +57 -0
- package/dist/import/pdf.d.ts +25 -0
- package/dist/import/pdf.js +326 -0
- package/dist/import/tabular.d.ts +76 -0
- package/dist/import/tabular.js +98 -0
- package/dist/import/xlsx.d.ts +18 -0
- package/dist/import/xlsx.js +160 -0
- package/dist/rastack-import.d.ts +22 -0
- package/dist/rastack-import.js +165 -0
- package/dist/rastack.d.ts +1 -0
- package/dist/rastack.js +7 -0
- package/dist/validate/adapters.d.ts +42 -0
- package/dist/validate/adapters.js +109 -0
- package/dist/validate/index.d.ts +2 -0
- package/dist/validate/index.js +18 -0
- package/dist/validate/machine.d.ts +175 -0
- package/dist/validate/machine.js +347 -0
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/hooks/form/form.ts +16 -5
- package/hooks/form/interfaces.ts +15 -0
- package/hooks/form/structure.ts +28 -0
- package/package.json +1 -1
- package/src/compile/entities.ts +161 -59
- package/src/compile/index.ts +1 -1
- package/src/compile/program.ts +1 -1
- package/src/define/db.ts +1 -1
- package/src/import/index.ts +41 -0
- package/src/import/pdf.ts +304 -0
- package/src/import/tabular.ts +159 -0
- package/src/import/xlsx.ts +186 -0
- package/src/rastack-import.ts +203 -0
- package/src/rastack.ts +7 -0
- package/src/validate/adapters.ts +118 -0
- package/src/validate/index.ts +2 -0
- package/src/validate/machine.ts +525 -0
- package/test/entities.spec.ts +99 -0
- package/test/import.spec.ts +241 -0
- package/test/validate.spec.ts +319 -0
- package/validate.ts +10 -0
- package/wasm/rastack_wasm_bg.wasm +0 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The constraint state machine — the validation core of the database generator.
|
|
3
|
+
*
|
|
4
|
+
* A field's constraints are not a bag of ad-hoc checks: they are compiled into
|
|
5
|
+
* a **deterministic finite state machine** whose intermediate states are the
|
|
6
|
+
* constraint gates a value must pass through, in a fixed order, to reach the
|
|
7
|
+
* accepting `valid` state:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* pristine → present → typed → bounded → member → unique → resolved → valid
|
|
11
|
+
* └────────┴───── any gate fails ─────┴────────┘
|
|
12
|
+
* ↓
|
|
13
|
+
* invalid
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Compiling (`compileFieldMachine`) *builds the constraints*: each constraint
|
|
17
|
+
* on the field spec becomes exactly one gate (a guarded transition), and gates
|
|
18
|
+
* whose constraint is absent are simply not emitted — an unconstrained string
|
|
19
|
+
* field's machine is just `pristine → present → typed → valid`. Running
|
|
20
|
+
* (`runFieldMachine`) walks the gates with a raw value, coercing it along the
|
|
21
|
+
* way (`typed` parses `"12"` into `12`), and halts at the first failing gate,
|
|
22
|
+
* reporting the state reached and the constraint that rejected the value.
|
|
23
|
+
*
|
|
24
|
+
* Because the machine is pure data + pure functions (no filesystem, no DOM),
|
|
25
|
+
* the *same* machine drives every surface: the form hooks derive per-field
|
|
26
|
+
* machines from the write JSON Schema (`specFromSchemaProperty`), and the data
|
|
27
|
+
* import pipeline derives them from `schema.rastack.json`
|
|
28
|
+
* (`specFromManifestField`) — one validation brain, two adapters, mirroring
|
|
29
|
+
* how `rastack-api-core` is one request brain behind two transports.
|
|
30
|
+
*
|
|
31
|
+
* The `unique` and `resolved` (foreign-key) gates are dataset-level: they only
|
|
32
|
+
* engage when the {@link ValidationContext} provides the capability (a seen-set
|
|
33
|
+
* or a relation resolver). A context-free run — a form field validating a
|
|
34
|
+
* single keystroke — passes through them untouched, exactly like the server,
|
|
35
|
+
* where uniqueness and FK existence are checked against the table, not the
|
|
36
|
+
* payload.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// The field spec — the normalised constraint set a machine is compiled from
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** The `{ app, model }` a foreign-key field points at. */
|
|
44
|
+
export interface SpecRelation {
|
|
45
|
+
app: string;
|
|
46
|
+
model: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Scalar kinds, matching the manifest's `ScalarType` plus `fk`. */
|
|
50
|
+
export type SpecType =
|
|
51
|
+
| "string"
|
|
52
|
+
| "int"
|
|
53
|
+
| "float"
|
|
54
|
+
| "bool"
|
|
55
|
+
| "datetime"
|
|
56
|
+
| "uuid"
|
|
57
|
+
| "fk";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The normalised constraint set for one field. Both adapters (manifest field,
|
|
61
|
+
* JSON-Schema property) lower to this shape; the machine compiler only ever
|
|
62
|
+
* sees a `FieldSpec`.
|
|
63
|
+
*/
|
|
64
|
+
export interface FieldSpec {
|
|
65
|
+
name: string;
|
|
66
|
+
type: SpecType;
|
|
67
|
+
/** Whether a value must be present (`required` short of a default). */
|
|
68
|
+
required: boolean;
|
|
69
|
+
/** Whether `null` is an accepted value. */
|
|
70
|
+
nullable: boolean;
|
|
71
|
+
/** Max string length (the `bounded` gate). */
|
|
72
|
+
maxLength?: number;
|
|
73
|
+
/** Dataset-level uniqueness (the `unique` gate). */
|
|
74
|
+
unique?: boolean;
|
|
75
|
+
/** String format refinement: `email` | `url` | `date` | `date-time` | `uuid`. */
|
|
76
|
+
format?: string;
|
|
77
|
+
/** Enum membership (the `member` gate). */
|
|
78
|
+
options?: Array<string | number>;
|
|
79
|
+
/** FK target — present iff `type === "fk"` (the `resolved` gate). */
|
|
80
|
+
relation?: SpecRelation;
|
|
81
|
+
/** Default applied when an optional field is empty. */
|
|
82
|
+
default?: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// States, gates, machines
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** Every state a field machine can be in. */
|
|
90
|
+
export type FieldState =
|
|
91
|
+
| "pristine"
|
|
92
|
+
| "present"
|
|
93
|
+
| "typed"
|
|
94
|
+
| "bounded"
|
|
95
|
+
| "member"
|
|
96
|
+
| "unique"
|
|
97
|
+
| "resolved"
|
|
98
|
+
| "valid"
|
|
99
|
+
| "invalid";
|
|
100
|
+
|
|
101
|
+
/** The intermediate (gate) states, in canonical machine order. */
|
|
102
|
+
export const GATE_ORDER: readonly FieldState[] = [
|
|
103
|
+
"present",
|
|
104
|
+
"typed",
|
|
105
|
+
"bounded",
|
|
106
|
+
"member",
|
|
107
|
+
"unique",
|
|
108
|
+
"resolved",
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/** The outcome of one gate check: pass (with the possibly-coerced value) or fail. */
|
|
112
|
+
export type GateResult =
|
|
113
|
+
| { ok: true; value: unknown }
|
|
114
|
+
| { ok: false; message: string };
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One guarded transition of the machine. Passing the guard enters state `to`
|
|
118
|
+
* and threads the (possibly coerced) value into the next gate.
|
|
119
|
+
*/
|
|
120
|
+
export interface Gate {
|
|
121
|
+
to: FieldState;
|
|
122
|
+
/** Human-readable constraint, e.g. `"maxLength ≤ 3"` — drives UI hints and import reports. */
|
|
123
|
+
constraint: string;
|
|
124
|
+
check: (value: unknown, ctx: ValidationContext) => GateResult;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A compiled field machine: the spec, its gates, and the full state chain. */
|
|
128
|
+
export interface FieldMachine {
|
|
129
|
+
spec: FieldSpec;
|
|
130
|
+
gates: Gate[];
|
|
131
|
+
/** `["pristine", ...gate states..., "valid"]` — the happy path. */
|
|
132
|
+
states: FieldState[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Dataset-level capabilities. Absent capabilities leave the corresponding
|
|
137
|
+
* gates inert (they pass), so a machine runs identically in a browser form
|
|
138
|
+
* (no context) and in the import pipeline (full context) minus the
|
|
139
|
+
* dataset-level checks.
|
|
140
|
+
*/
|
|
141
|
+
export interface ValidationContext {
|
|
142
|
+
/** Per-field sets of already-seen values, keyed by field name (the `unique` gate). */
|
|
143
|
+
seen?: Map<string, Set<string>>;
|
|
144
|
+
/** Resolve whether a FK value exists on the target resource (the `resolved` gate). */
|
|
145
|
+
resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The result of running a value through a field machine. */
|
|
149
|
+
export interface FieldRun {
|
|
150
|
+
/** Terminal state: `valid` or `invalid`. */
|
|
151
|
+
state: "valid" | "invalid";
|
|
152
|
+
/** Every state entered, `pristine` first. Ends in `valid` or `invalid`. */
|
|
153
|
+
path: FieldState[];
|
|
154
|
+
/** The coerced value (e.g. `"12"` → `12`; empty optional → default/null). */
|
|
155
|
+
value: unknown;
|
|
156
|
+
/** Present iff `state === "invalid"`: the gate that rejected the value. */
|
|
157
|
+
failed?: { gate: FieldState; constraint: string; message: string };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const EMPTY_CTX: ValidationContext = {};
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Value predicates (shared by gates)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function isEmpty(value: unknown): boolean {
|
|
167
|
+
return (
|
|
168
|
+
value === undefined ||
|
|
169
|
+
value === null ||
|
|
170
|
+
(typeof value === "string" && value.trim() === "")
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const UUID_RE =
|
|
175
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
176
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
177
|
+
const URL_RE = /^https?:\/\/[^\s]+$/i;
|
|
178
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
179
|
+
const DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
|
|
180
|
+
|
|
181
|
+
/** Coerce a raw value to the spec's scalar type, or explain why it can't be. */
|
|
182
|
+
function coerce(spec: FieldSpec, value: unknown): GateResult {
|
|
183
|
+
switch (spec.type) {
|
|
184
|
+
case "int": {
|
|
185
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
186
|
+
return { ok: true, value };
|
|
187
|
+
if (typeof value === "string" && /^-?\d+$/.test(value.trim()))
|
|
188
|
+
return { ok: true, value: parseInt(value.trim(), 10) };
|
|
189
|
+
return { ok: false, message: `"${String(value)}" is not an integer` };
|
|
190
|
+
}
|
|
191
|
+
case "float": {
|
|
192
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
193
|
+
return { ok: true, value };
|
|
194
|
+
if (
|
|
195
|
+
typeof value === "string" &&
|
|
196
|
+
/^-?\d+(\.\d+)?$/.test(value.trim())
|
|
197
|
+
)
|
|
198
|
+
return { ok: true, value: parseFloat(value.trim()) };
|
|
199
|
+
return { ok: false, message: `"${String(value)}" is not a number` };
|
|
200
|
+
}
|
|
201
|
+
case "bool": {
|
|
202
|
+
if (typeof value === "boolean") return { ok: true, value };
|
|
203
|
+
if (typeof value === "string") {
|
|
204
|
+
const v = value.trim().toLowerCase();
|
|
205
|
+
if (["true", "yes", "1"].includes(v)) return { ok: true, value: true };
|
|
206
|
+
if (["false", "no", "0"].includes(v))
|
|
207
|
+
return { ok: true, value: false };
|
|
208
|
+
}
|
|
209
|
+
if (value === 1) return { ok: true, value: true };
|
|
210
|
+
if (value === 0) return { ok: true, value: false };
|
|
211
|
+
return { ok: false, message: `"${String(value)}" is not a boolean` };
|
|
212
|
+
}
|
|
213
|
+
case "datetime": {
|
|
214
|
+
if (value instanceof Date && !isNaN(value.getTime()))
|
|
215
|
+
return { ok: true, value: value.toISOString() };
|
|
216
|
+
if (
|
|
217
|
+
typeof value === "string" &&
|
|
218
|
+
(DATETIME_RE.test(value.trim()) || DATE_RE.test(value.trim())) &&
|
|
219
|
+
!isNaN(Date.parse(value.trim()))
|
|
220
|
+
)
|
|
221
|
+
return { ok: true, value: value.trim() };
|
|
222
|
+
return { ok: false, message: `"${String(value)}" is not a datetime` };
|
|
223
|
+
}
|
|
224
|
+
case "uuid": {
|
|
225
|
+
if (typeof value === "string" && UUID_RE.test(value.trim()))
|
|
226
|
+
return { ok: true, value: value.trim() };
|
|
227
|
+
return { ok: false, message: `"${String(value)}" is not a uuid` };
|
|
228
|
+
}
|
|
229
|
+
case "fk": {
|
|
230
|
+
// FK values are opaque ids — integer or string, but never structured.
|
|
231
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
232
|
+
return { ok: true, value };
|
|
233
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
234
|
+
const v = value.trim();
|
|
235
|
+
return { ok: true, value: /^\d+$/.test(v) ? parseInt(v, 10) : v };
|
|
236
|
+
}
|
|
237
|
+
return { ok: false, message: `"${String(value)}" is not a valid id` };
|
|
238
|
+
}
|
|
239
|
+
default: {
|
|
240
|
+
// string (+ format refinement)
|
|
241
|
+
if (
|
|
242
|
+
typeof value !== "string" &&
|
|
243
|
+
typeof value !== "number" &&
|
|
244
|
+
typeof value !== "boolean"
|
|
245
|
+
)
|
|
246
|
+
return { ok: false, message: "value is not a string" };
|
|
247
|
+
const str = String(value);
|
|
248
|
+
switch (spec.format) {
|
|
249
|
+
case "email":
|
|
250
|
+
if (!EMAIL_RE.test(str))
|
|
251
|
+
return { ok: false, message: `"${str}" is not an email address` };
|
|
252
|
+
break;
|
|
253
|
+
case "uri":
|
|
254
|
+
case "url":
|
|
255
|
+
if (!URL_RE.test(str))
|
|
256
|
+
return { ok: false, message: `"${str}" is not a URL` };
|
|
257
|
+
break;
|
|
258
|
+
case "date":
|
|
259
|
+
if (!DATE_RE.test(str) || isNaN(Date.parse(str)))
|
|
260
|
+
return { ok: false, message: `"${str}" is not a date` };
|
|
261
|
+
break;
|
|
262
|
+
case "date-time":
|
|
263
|
+
if (!DATETIME_RE.test(str) || isNaN(Date.parse(str)))
|
|
264
|
+
return { ok: false, message: `"${str}" is not a datetime` };
|
|
265
|
+
break;
|
|
266
|
+
case "uuid":
|
|
267
|
+
if (!UUID_RE.test(str))
|
|
268
|
+
return { ok: false, message: `"${str}" is not a uuid` };
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
return { ok: true, value: str };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Compilation — the machine *is* the constraints
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
function typeConstraint(spec: FieldSpec): string {
|
|
281
|
+
if (spec.type === "fk")
|
|
282
|
+
return `references ${spec.relation ? `${spec.relation.app}.${spec.relation.model}` : "a related record"}`;
|
|
283
|
+
return spec.format ? `${spec.type} (${spec.format})` : spec.type;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Compile a field spec into its constraint state machine. Every present
|
|
288
|
+
* constraint becomes one gate; absent constraints emit no gate, so the state
|
|
289
|
+
* chain is exactly the field's constraint set.
|
|
290
|
+
*/
|
|
291
|
+
export function compileFieldMachine(spec: FieldSpec): FieldMachine {
|
|
292
|
+
const gates: Gate[] = [];
|
|
293
|
+
|
|
294
|
+
gates.push({
|
|
295
|
+
to: "present",
|
|
296
|
+
constraint: spec.required ? "required" : "optional",
|
|
297
|
+
check: (value) =>
|
|
298
|
+
spec.required && isEmpty(value)
|
|
299
|
+
? { ok: false, message: `${spec.name} is required` }
|
|
300
|
+
: { ok: true, value },
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
gates.push({
|
|
304
|
+
to: "typed",
|
|
305
|
+
constraint: typeConstraint(spec),
|
|
306
|
+
check: (value) => coerce(spec, value),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (spec.maxLength !== undefined) {
|
|
310
|
+
const max = spec.maxLength;
|
|
311
|
+
gates.push({
|
|
312
|
+
to: "bounded",
|
|
313
|
+
constraint: `maxLength ≤ ${max}`,
|
|
314
|
+
check: (value) =>
|
|
315
|
+
typeof value === "string" && value.length > max
|
|
316
|
+
? {
|
|
317
|
+
ok: false,
|
|
318
|
+
message: `"${value}" is longer than ${max} characters`,
|
|
319
|
+
}
|
|
320
|
+
: { ok: true, value },
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (spec.options && spec.options.length > 0) {
|
|
325
|
+
const options = spec.options;
|
|
326
|
+
gates.push({
|
|
327
|
+
to: "member",
|
|
328
|
+
constraint: `one of ${options.map(String).join(", ")}`,
|
|
329
|
+
check: (value) =>
|
|
330
|
+
options.some((o) => o === value || String(o) === String(value))
|
|
331
|
+
? { ok: true, value }
|
|
332
|
+
: {
|
|
333
|
+
ok: false,
|
|
334
|
+
message: `"${String(value)}" is not one of the allowed values`,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (spec.unique) {
|
|
340
|
+
gates.push({
|
|
341
|
+
to: "unique",
|
|
342
|
+
constraint: "unique",
|
|
343
|
+
check: (value, ctx) => {
|
|
344
|
+
const seen = ctx.seen?.get(spec.name);
|
|
345
|
+
if (!seen) return { ok: true, value }; // no dataset context — gate is inert
|
|
346
|
+
const key = String(value);
|
|
347
|
+
if (seen.has(key))
|
|
348
|
+
return { ok: false, message: `duplicate value "${key}"` };
|
|
349
|
+
seen.add(key);
|
|
350
|
+
return { ok: true, value };
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (spec.type === "fk" && spec.relation) {
|
|
356
|
+
const relation = spec.relation;
|
|
357
|
+
gates.push({
|
|
358
|
+
to: "resolved",
|
|
359
|
+
constraint: `exists on ${relation.app}.${relation.model}`,
|
|
360
|
+
check: (value, ctx) => {
|
|
361
|
+
if (!ctx.resolveRelation) return { ok: true, value }; // inert without a resolver
|
|
362
|
+
return ctx.resolveRelation(relation, value)
|
|
363
|
+
? { ok: true, value }
|
|
364
|
+
: {
|
|
365
|
+
ok: false,
|
|
366
|
+
message: `no ${relation.app}.${relation.model} with id "${String(value)}"`,
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
spec,
|
|
374
|
+
gates,
|
|
375
|
+
states: ["pristine", ...gates.map((g) => g.to), "valid"],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The human-readable constraint chain — what the machine enforces, in order. */
|
|
380
|
+
export function describeMachine(machine: FieldMachine): string[] {
|
|
381
|
+
return machine.gates.map((g) => g.constraint);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// Execution
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Run a raw value through a field machine. Walks the gates in order, coercing
|
|
390
|
+
* the value as it goes; the first failing guard sends the machine to
|
|
391
|
+
* `invalid` with the offending constraint attached.
|
|
392
|
+
*
|
|
393
|
+
* An empty value on an optional field short-circuits from `present` straight
|
|
394
|
+
* to `valid` (yielding the default, else `null`) — there is nothing further
|
|
395
|
+
* to constrain, exactly like a nullable column.
|
|
396
|
+
*/
|
|
397
|
+
export function runFieldMachine(
|
|
398
|
+
machine: FieldMachine,
|
|
399
|
+
rawValue: unknown,
|
|
400
|
+
ctx: ValidationContext = EMPTY_CTX,
|
|
401
|
+
): FieldRun {
|
|
402
|
+
const path: FieldState[] = ["pristine"];
|
|
403
|
+
let value: unknown = rawValue;
|
|
404
|
+
|
|
405
|
+
for (const gate of machine.gates) {
|
|
406
|
+
const result = gate.check(value, ctx);
|
|
407
|
+
if (!result.ok) {
|
|
408
|
+
path.push("invalid");
|
|
409
|
+
return {
|
|
410
|
+
state: "invalid",
|
|
411
|
+
path,
|
|
412
|
+
value,
|
|
413
|
+
failed: {
|
|
414
|
+
gate: gate.to,
|
|
415
|
+
constraint: gate.constraint,
|
|
416
|
+
message: result.message,
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
value = result.value;
|
|
421
|
+
path.push(gate.to);
|
|
422
|
+
|
|
423
|
+
// Optional + empty: nothing left to constrain — accept with the default.
|
|
424
|
+
if (gate.to === "present" && isEmpty(value)) {
|
|
425
|
+
path.push("valid");
|
|
426
|
+
return {
|
|
427
|
+
state: "valid",
|
|
428
|
+
path,
|
|
429
|
+
value: machine.spec.default !== undefined ? machine.spec.default : null,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
path.push("valid");
|
|
435
|
+
return { state: "valid", path, value };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Record machines — one machine per field, run over a whole record
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/** A record machine is the composition of its fields' machines. */
|
|
443
|
+
export interface RecordMachine {
|
|
444
|
+
fields: FieldMachine[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** One field's failure inside a record run. */
|
|
448
|
+
export interface RecordError {
|
|
449
|
+
field: string;
|
|
450
|
+
gate: FieldState;
|
|
451
|
+
constraint: string;
|
|
452
|
+
message: string;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The result of running one record through a record machine. */
|
|
456
|
+
export interface RecordRun {
|
|
457
|
+
state: "valid" | "invalid";
|
|
458
|
+
/** The coerced record — only meaningful when `state === "valid"`. */
|
|
459
|
+
values: Record<string, unknown>;
|
|
460
|
+
/** Per-field machine runs, keyed by field name. */
|
|
461
|
+
fields: Record<string, FieldRun>;
|
|
462
|
+
errors: RecordError[];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function compileRecordMachine(specs: FieldSpec[]): RecordMachine {
|
|
466
|
+
return { fields: specs.map(compileFieldMachine) };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function runRecordMachine(
|
|
470
|
+
machine: RecordMachine,
|
|
471
|
+
record: Record<string, unknown>,
|
|
472
|
+
ctx: ValidationContext = EMPTY_CTX,
|
|
473
|
+
): RecordRun {
|
|
474
|
+
const fields: Record<string, FieldRun> = {};
|
|
475
|
+
const values: Record<string, unknown> = {};
|
|
476
|
+
const errors: RecordError[] = [];
|
|
477
|
+
|
|
478
|
+
for (const fieldMachine of machine.fields) {
|
|
479
|
+
const name = fieldMachine.spec.name;
|
|
480
|
+
const run = runFieldMachine(fieldMachine, record[name], ctx);
|
|
481
|
+
fields[name] = run;
|
|
482
|
+
values[name] = run.value;
|
|
483
|
+
if (run.failed) {
|
|
484
|
+
errors.push({ field: name, ...run.failed });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
state: errors.length === 0 ? "valid" : "invalid",
|
|
490
|
+
values,
|
|
491
|
+
fields,
|
|
492
|
+
errors,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Build the dataset-level context for a record machine: a seen-set per
|
|
498
|
+
* `unique` field (so the `unique` gate engages), and — when related ids are
|
|
499
|
+
* supplied — a resolver for the `resolved` gate. `relatedIds` is keyed by
|
|
500
|
+
* `"app.model"`.
|
|
501
|
+
*/
|
|
502
|
+
export function createDatasetContext(
|
|
503
|
+
machine: RecordMachine,
|
|
504
|
+
opts: {
|
|
505
|
+
relatedIds?: Record<string, Set<string>>;
|
|
506
|
+
resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
|
|
507
|
+
} = {},
|
|
508
|
+
): ValidationContext {
|
|
509
|
+
const seen = new Map<string, Set<string>>();
|
|
510
|
+
for (const field of machine.fields) {
|
|
511
|
+
if (field.spec.unique) seen.set(field.spec.name, new Set());
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const resolveRelation =
|
|
515
|
+
opts.resolveRelation ??
|
|
516
|
+
(opts.relatedIds
|
|
517
|
+
? (relation: SpecRelation, value: unknown) => {
|
|
518
|
+
const ids = opts.relatedIds![`${relation.app}.${relation.model}`];
|
|
519
|
+
// Unknown target tables are out of scope for the check — stay inert.
|
|
520
|
+
return ids ? ids.has(String(value)) : true;
|
|
521
|
+
}
|
|
522
|
+
: undefined);
|
|
523
|
+
|
|
524
|
+
return { seen, resolveRelation };
|
|
525
|
+
}
|
package/test/entities.spec.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
*/
|
|
17
17
|
const workRoot = join(__dirname, "entities-fixtures");
|
|
18
18
|
const appDir = join(workRoot, "app");
|
|
19
|
+
const classesDir = join(workRoot, "classes");
|
|
19
20
|
const cycleDir = join(workRoot, "cycles");
|
|
20
21
|
const hardCycleDir = join(workRoot, "hard-cycles");
|
|
21
22
|
const mixedDir = join(workRoot, "mixed");
|
|
@@ -150,6 +151,39 @@ declare function useEntity<T>(name: string): T;
|
|
|
150
151
|
export const a = useEntity<Airport>("airport");
|
|
151
152
|
`;
|
|
152
153
|
|
|
154
|
+
const CLASSES_TS = `
|
|
155
|
+
import { MaxLength, Unique } from "rastack/types";
|
|
156
|
+
|
|
157
|
+
/** @rastackSearch code,name */
|
|
158
|
+
export class Airport {
|
|
159
|
+
code!: string & Unique & MaxLength<3>;
|
|
160
|
+
/** @maxLength 120 */
|
|
161
|
+
name!: string;
|
|
162
|
+
region = "EU"; // initializer → type inferred, value is the default
|
|
163
|
+
active = true;
|
|
164
|
+
/** @int */
|
|
165
|
+
gates = 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class Terminal {
|
|
169
|
+
airport!: Airport; // FK — inferred from the property type
|
|
170
|
+
/** @maxLength 20 */
|
|
171
|
+
label!: string;
|
|
172
|
+
opened?: Date;
|
|
173
|
+
static kind = "terminal"; // not a column
|
|
174
|
+
private secret!: string; // not a column
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Not a plain data class — a method means it must opt in explicitly. */
|
|
178
|
+
export class BoardingService {
|
|
179
|
+
queue: string[] = [];
|
|
180
|
+
board(): void {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Heritage means app code, not a table. */
|
|
184
|
+
export class SplashScreen extends BoardingService {}
|
|
185
|
+
`;
|
|
186
|
+
|
|
153
187
|
function findResource(
|
|
154
188
|
resources: ResourceModel[],
|
|
155
189
|
app: string,
|
|
@@ -166,6 +200,8 @@ beforeAll(() => {
|
|
|
166
200
|
writeFileSync(join(appDir, "flights.ts"), FLIGHTS_TS);
|
|
167
201
|
writeFileSync(join(appDir, "passengers.ts"), PASSENGERS_TS);
|
|
168
202
|
writeFileSync(join(appDir, "screens.tsx"), SCREENS_TSX);
|
|
203
|
+
mkdirSync(classesDir, { recursive: true });
|
|
204
|
+
writeFileSync(join(classesDir, "airports.ts"), CLASSES_TS);
|
|
169
205
|
mkdirSync(cycleDir, { recursive: true });
|
|
170
206
|
writeFileSync(join(cycleDir, "org.ts"), SOFT_CYCLE_TS);
|
|
171
207
|
mkdirSync(hardCycleDir, { recursive: true });
|
|
@@ -307,6 +343,69 @@ describe("interface entities — FK inference", () => {
|
|
|
307
343
|
});
|
|
308
344
|
});
|
|
309
345
|
|
|
346
|
+
describe("class entities — plain data classes", () => {
|
|
347
|
+
it("roots a plain data class by declaration — no hook, tag, or DSL needed", () => {
|
|
348
|
+
const { resources } = analyzeProject(classesDir);
|
|
349
|
+
const keys = resources.map((r) => `${r.app}.${r.model}`);
|
|
350
|
+
expect(keys).toContain("airports.airport");
|
|
351
|
+
expect(keys).toContain("airports.terminal");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("does not root classes with methods or heritage clauses", () => {
|
|
355
|
+
const { resources } = analyzeProject(classesDir);
|
|
356
|
+
const keys = resources.map((r) => `${r.app}.${r.model}`);
|
|
357
|
+
expect(keys).not.toContain("airports.boardingService");
|
|
358
|
+
expect(keys).not.toContain("airports.splashScreen");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("reads DbConfig brands and JSDoc tags off class fields", () => {
|
|
362
|
+
const { resources } = analyzeProject(classesDir);
|
|
363
|
+
const airport = findResource(resources, "airports", "airport");
|
|
364
|
+
expect(airport.search).toEqual(["code", "name"]);
|
|
365
|
+
expect(airport.fields.find((f) => f.name === "code")).toMatchObject({
|
|
366
|
+
type: "string",
|
|
367
|
+
maxLength: 3,
|
|
368
|
+
unique: true,
|
|
369
|
+
});
|
|
370
|
+
expect(airport.fields.find((f) => f.name === "name")).toMatchObject({
|
|
371
|
+
type: "string",
|
|
372
|
+
maxLength: 120,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("infers types and defaults from field initializers", () => {
|
|
377
|
+
const { resources } = analyzeProject(classesDir);
|
|
378
|
+
const airport = findResource(resources, "airports", "airport");
|
|
379
|
+
const field = (name: string) => airport.fields.find((f) => f.name === name);
|
|
380
|
+
expect(field("region")).toMatchObject({ type: "string", default: "EU" });
|
|
381
|
+
expect(field("active")).toMatchObject({ type: "bool", default: true });
|
|
382
|
+
expect(field("gates")).toMatchObject({ type: "int", default: 0 });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("treats a class-typed field as a foreign key", () => {
|
|
386
|
+
const { resources } = analyzeProject(classesDir);
|
|
387
|
+
const terminal = findResource(resources, "airports", "terminal");
|
|
388
|
+
expect(terminal.fields.find((f) => f.name === "airport")).toMatchObject({
|
|
389
|
+
type: "fk",
|
|
390
|
+
relation: { app: "airports", model: "airport" },
|
|
391
|
+
});
|
|
392
|
+
expect(terminal.relations).toEqual([
|
|
393
|
+
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
394
|
+
]);
|
|
395
|
+
expect(terminal.fields.find((f) => f.name === "opened")).toMatchObject({
|
|
396
|
+
type: "datetime",
|
|
397
|
+
null: true,
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("ignores static and private class members", () => {
|
|
402
|
+
const { resources } = analyzeProject(classesDir);
|
|
403
|
+
const terminal = findResource(resources, "airports", "terminal");
|
|
404
|
+
expect(terminal.fields.find((f) => f.name === "kind")).toBeUndefined();
|
|
405
|
+
expect(terminal.fields.find((f) => f.name === "secret")).toBeUndefined();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
310
409
|
describe("interface entities — circular dependencies", () => {
|
|
311
410
|
it("reports a breakable cycle (optional self-FK) as a warning", () => {
|
|
312
411
|
const { diagnostics } = analyzeProject(cycleDir);
|