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.
Files changed (49) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/compile/entities.d.ts +0 -49
  3. package/dist/compile/entities.js +110 -22
  4. package/dist/compile/index.d.ts +1 -1
  5. package/dist/compile/index.js +1 -1
  6. package/dist/compile/program.js +1 -1
  7. package/dist/define/db.d.ts +1 -1
  8. package/dist/define/db.js +1 -1
  9. package/dist/import/index.d.ts +18 -0
  10. package/dist/import/index.js +57 -0
  11. package/dist/import/pdf.d.ts +25 -0
  12. package/dist/import/pdf.js +326 -0
  13. package/dist/import/tabular.d.ts +76 -0
  14. package/dist/import/tabular.js +98 -0
  15. package/dist/import/xlsx.d.ts +18 -0
  16. package/dist/import/xlsx.js +160 -0
  17. package/dist/rastack-import.d.ts +22 -0
  18. package/dist/rastack-import.js +165 -0
  19. package/dist/rastack.d.ts +1 -0
  20. package/dist/rastack.js +7 -0
  21. package/dist/validate/adapters.d.ts +42 -0
  22. package/dist/validate/adapters.js +109 -0
  23. package/dist/validate/index.d.ts +2 -0
  24. package/dist/validate/index.js +18 -0
  25. package/dist/validate/machine.d.ts +175 -0
  26. package/dist/validate/machine.js +347 -0
  27. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  28. package/hooks/form/form.ts +16 -5
  29. package/hooks/form/interfaces.ts +15 -0
  30. package/hooks/form/structure.ts +28 -0
  31. package/package.json +1 -1
  32. package/src/compile/entities.ts +161 -59
  33. package/src/compile/index.ts +1 -1
  34. package/src/compile/program.ts +1 -1
  35. package/src/define/db.ts +1 -1
  36. package/src/import/index.ts +41 -0
  37. package/src/import/pdf.ts +304 -0
  38. package/src/import/tabular.ts +159 -0
  39. package/src/import/xlsx.ts +186 -0
  40. package/src/rastack-import.ts +203 -0
  41. package/src/rastack.ts +7 -0
  42. package/src/validate/adapters.ts +118 -0
  43. package/src/validate/index.ts +2 -0
  44. package/src/validate/machine.ts +525 -0
  45. package/test/entities.spec.ts +99 -0
  46. package/test/import.spec.ts +241 -0
  47. package/test/validate.spec.ts +319 -0
  48. package/validate.ts +10 -0
  49. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -0,0 +1,347 @@
1
+ "use strict";
2
+ /**
3
+ * The constraint state machine — the validation core of the database generator.
4
+ *
5
+ * A field's constraints are not a bag of ad-hoc checks: they are compiled into
6
+ * a **deterministic finite state machine** whose intermediate states are the
7
+ * constraint gates a value must pass through, in a fixed order, to reach the
8
+ * accepting `valid` state:
9
+ *
10
+ * ```
11
+ * pristine → present → typed → bounded → member → unique → resolved → valid
12
+ * └────────┴───── any gate fails ─────┴────────┘
13
+ * ↓
14
+ * invalid
15
+ * ```
16
+ *
17
+ * Compiling (`compileFieldMachine`) *builds the constraints*: each constraint
18
+ * on the field spec becomes exactly one gate (a guarded transition), and gates
19
+ * whose constraint is absent are simply not emitted — an unconstrained string
20
+ * field's machine is just `pristine → present → typed → valid`. Running
21
+ * (`runFieldMachine`) walks the gates with a raw value, coercing it along the
22
+ * way (`typed` parses `"12"` into `12`), and halts at the first failing gate,
23
+ * reporting the state reached and the constraint that rejected the value.
24
+ *
25
+ * Because the machine is pure data + pure functions (no filesystem, no DOM),
26
+ * the *same* machine drives every surface: the form hooks derive per-field
27
+ * machines from the write JSON Schema (`specFromSchemaProperty`), and the data
28
+ * import pipeline derives them from `schema.rastack.json`
29
+ * (`specFromManifestField`) — one validation brain, two adapters, mirroring
30
+ * how `rastack-api-core` is one request brain behind two transports.
31
+ *
32
+ * The `unique` and `resolved` (foreign-key) gates are dataset-level: they only
33
+ * engage when the {@link ValidationContext} provides the capability (a seen-set
34
+ * or a relation resolver). A context-free run — a form field validating a
35
+ * single keystroke — passes through them untouched, exactly like the server,
36
+ * where uniqueness and FK existence are checked against the table, not the
37
+ * payload.
38
+ */
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.GATE_ORDER = void 0;
41
+ exports.compileFieldMachine = compileFieldMachine;
42
+ exports.describeMachine = describeMachine;
43
+ exports.runFieldMachine = runFieldMachine;
44
+ exports.compileRecordMachine = compileRecordMachine;
45
+ exports.runRecordMachine = runRecordMachine;
46
+ exports.createDatasetContext = createDatasetContext;
47
+ /** The intermediate (gate) states, in canonical machine order. */
48
+ exports.GATE_ORDER = [
49
+ "present",
50
+ "typed",
51
+ "bounded",
52
+ "member",
53
+ "unique",
54
+ "resolved",
55
+ ];
56
+ const EMPTY_CTX = {};
57
+ // ---------------------------------------------------------------------------
58
+ // Value predicates (shared by gates)
59
+ // ---------------------------------------------------------------------------
60
+ function isEmpty(value) {
61
+ return (value === undefined ||
62
+ value === null ||
63
+ (typeof value === "string" && value.trim() === ""));
64
+ }
65
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
66
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
67
+ const URL_RE = /^https?:\/\/[^\s]+$/i;
68
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
69
+ const DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
70
+ /** Coerce a raw value to the spec's scalar type, or explain why it can't be. */
71
+ function coerce(spec, value) {
72
+ switch (spec.type) {
73
+ case "int": {
74
+ if (typeof value === "number" && Number.isInteger(value))
75
+ return { ok: true, value };
76
+ if (typeof value === "string" && /^-?\d+$/.test(value.trim()))
77
+ return { ok: true, value: parseInt(value.trim(), 10) };
78
+ return { ok: false, message: `"${String(value)}" is not an integer` };
79
+ }
80
+ case "float": {
81
+ if (typeof value === "number" && Number.isFinite(value))
82
+ return { ok: true, value };
83
+ if (typeof value === "string" &&
84
+ /^-?\d+(\.\d+)?$/.test(value.trim()))
85
+ return { ok: true, value: parseFloat(value.trim()) };
86
+ return { ok: false, message: `"${String(value)}" is not a number` };
87
+ }
88
+ case "bool": {
89
+ if (typeof value === "boolean")
90
+ return { ok: true, value };
91
+ if (typeof value === "string") {
92
+ const v = value.trim().toLowerCase();
93
+ if (["true", "yes", "1"].includes(v))
94
+ return { ok: true, value: true };
95
+ if (["false", "no", "0"].includes(v))
96
+ return { ok: true, value: false };
97
+ }
98
+ if (value === 1)
99
+ return { ok: true, value: true };
100
+ if (value === 0)
101
+ return { ok: true, value: false };
102
+ return { ok: false, message: `"${String(value)}" is not a boolean` };
103
+ }
104
+ case "datetime": {
105
+ if (value instanceof Date && !isNaN(value.getTime()))
106
+ return { ok: true, value: value.toISOString() };
107
+ if (typeof value === "string" &&
108
+ (DATETIME_RE.test(value.trim()) || DATE_RE.test(value.trim())) &&
109
+ !isNaN(Date.parse(value.trim())))
110
+ return { ok: true, value: value.trim() };
111
+ return { ok: false, message: `"${String(value)}" is not a datetime` };
112
+ }
113
+ case "uuid": {
114
+ if (typeof value === "string" && UUID_RE.test(value.trim()))
115
+ return { ok: true, value: value.trim() };
116
+ return { ok: false, message: `"${String(value)}" is not a uuid` };
117
+ }
118
+ case "fk": {
119
+ // FK values are opaque ids — integer or string, but never structured.
120
+ if (typeof value === "number" && Number.isInteger(value))
121
+ return { ok: true, value };
122
+ if (typeof value === "string" && value.trim() !== "") {
123
+ const v = value.trim();
124
+ return { ok: true, value: /^\d+$/.test(v) ? parseInt(v, 10) : v };
125
+ }
126
+ return { ok: false, message: `"${String(value)}" is not a valid id` };
127
+ }
128
+ default: {
129
+ // string (+ format refinement)
130
+ if (typeof value !== "string" &&
131
+ typeof value !== "number" &&
132
+ typeof value !== "boolean")
133
+ return { ok: false, message: "value is not a string" };
134
+ const str = String(value);
135
+ switch (spec.format) {
136
+ case "email":
137
+ if (!EMAIL_RE.test(str))
138
+ return { ok: false, message: `"${str}" is not an email address` };
139
+ break;
140
+ case "uri":
141
+ case "url":
142
+ if (!URL_RE.test(str))
143
+ return { ok: false, message: `"${str}" is not a URL` };
144
+ break;
145
+ case "date":
146
+ if (!DATE_RE.test(str) || isNaN(Date.parse(str)))
147
+ return { ok: false, message: `"${str}" is not a date` };
148
+ break;
149
+ case "date-time":
150
+ if (!DATETIME_RE.test(str) || isNaN(Date.parse(str)))
151
+ return { ok: false, message: `"${str}" is not a datetime` };
152
+ break;
153
+ case "uuid":
154
+ if (!UUID_RE.test(str))
155
+ return { ok: false, message: `"${str}" is not a uuid` };
156
+ break;
157
+ }
158
+ return { ok: true, value: str };
159
+ }
160
+ }
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Compilation — the machine *is* the constraints
164
+ // ---------------------------------------------------------------------------
165
+ function typeConstraint(spec) {
166
+ if (spec.type === "fk")
167
+ return `references ${spec.relation ? `${spec.relation.app}.${spec.relation.model}` : "a related record"}`;
168
+ return spec.format ? `${spec.type} (${spec.format})` : spec.type;
169
+ }
170
+ /**
171
+ * Compile a field spec into its constraint state machine. Every present
172
+ * constraint becomes one gate; absent constraints emit no gate, so the state
173
+ * chain is exactly the field's constraint set.
174
+ */
175
+ function compileFieldMachine(spec) {
176
+ const gates = [];
177
+ gates.push({
178
+ to: "present",
179
+ constraint: spec.required ? "required" : "optional",
180
+ check: (value) => spec.required && isEmpty(value)
181
+ ? { ok: false, message: `${spec.name} is required` }
182
+ : { ok: true, value },
183
+ });
184
+ gates.push({
185
+ to: "typed",
186
+ constraint: typeConstraint(spec),
187
+ check: (value) => coerce(spec, value),
188
+ });
189
+ if (spec.maxLength !== undefined) {
190
+ const max = spec.maxLength;
191
+ gates.push({
192
+ to: "bounded",
193
+ constraint: `maxLength ≤ ${max}`,
194
+ check: (value) => typeof value === "string" && value.length > max
195
+ ? {
196
+ ok: false,
197
+ message: `"${value}" is longer than ${max} characters`,
198
+ }
199
+ : { ok: true, value },
200
+ });
201
+ }
202
+ if (spec.options && spec.options.length > 0) {
203
+ const options = spec.options;
204
+ gates.push({
205
+ to: "member",
206
+ constraint: `one of ${options.map(String).join(", ")}`,
207
+ check: (value) => options.some((o) => o === value || String(o) === String(value))
208
+ ? { ok: true, value }
209
+ : {
210
+ ok: false,
211
+ message: `"${String(value)}" is not one of the allowed values`,
212
+ },
213
+ });
214
+ }
215
+ if (spec.unique) {
216
+ gates.push({
217
+ to: "unique",
218
+ constraint: "unique",
219
+ check: (value, ctx) => {
220
+ const seen = ctx.seen?.get(spec.name);
221
+ if (!seen)
222
+ return { ok: true, value }; // no dataset context — gate is inert
223
+ const key = String(value);
224
+ if (seen.has(key))
225
+ return { ok: false, message: `duplicate value "${key}"` };
226
+ seen.add(key);
227
+ return { ok: true, value };
228
+ },
229
+ });
230
+ }
231
+ if (spec.type === "fk" && spec.relation) {
232
+ const relation = spec.relation;
233
+ gates.push({
234
+ to: "resolved",
235
+ constraint: `exists on ${relation.app}.${relation.model}`,
236
+ check: (value, ctx) => {
237
+ if (!ctx.resolveRelation)
238
+ return { ok: true, value }; // inert without a resolver
239
+ return ctx.resolveRelation(relation, value)
240
+ ? { ok: true, value }
241
+ : {
242
+ ok: false,
243
+ message: `no ${relation.app}.${relation.model} with id "${String(value)}"`,
244
+ };
245
+ },
246
+ });
247
+ }
248
+ return {
249
+ spec,
250
+ gates,
251
+ states: ["pristine", ...gates.map((g) => g.to), "valid"],
252
+ };
253
+ }
254
+ /** The human-readable constraint chain — what the machine enforces, in order. */
255
+ function describeMachine(machine) {
256
+ return machine.gates.map((g) => g.constraint);
257
+ }
258
+ // ---------------------------------------------------------------------------
259
+ // Execution
260
+ // ---------------------------------------------------------------------------
261
+ /**
262
+ * Run a raw value through a field machine. Walks the gates in order, coercing
263
+ * the value as it goes; the first failing guard sends the machine to
264
+ * `invalid` with the offending constraint attached.
265
+ *
266
+ * An empty value on an optional field short-circuits from `present` straight
267
+ * to `valid` (yielding the default, else `null`) — there is nothing further
268
+ * to constrain, exactly like a nullable column.
269
+ */
270
+ function runFieldMachine(machine, rawValue, ctx = EMPTY_CTX) {
271
+ const path = ["pristine"];
272
+ let value = rawValue;
273
+ for (const gate of machine.gates) {
274
+ const result = gate.check(value, ctx);
275
+ if (!result.ok) {
276
+ path.push("invalid");
277
+ return {
278
+ state: "invalid",
279
+ path,
280
+ value,
281
+ failed: {
282
+ gate: gate.to,
283
+ constraint: gate.constraint,
284
+ message: result.message,
285
+ },
286
+ };
287
+ }
288
+ value = result.value;
289
+ path.push(gate.to);
290
+ // Optional + empty: nothing left to constrain — accept with the default.
291
+ if (gate.to === "present" && isEmpty(value)) {
292
+ path.push("valid");
293
+ return {
294
+ state: "valid",
295
+ path,
296
+ value: machine.spec.default !== undefined ? machine.spec.default : null,
297
+ };
298
+ }
299
+ }
300
+ path.push("valid");
301
+ return { state: "valid", path, value };
302
+ }
303
+ function compileRecordMachine(specs) {
304
+ return { fields: specs.map(compileFieldMachine) };
305
+ }
306
+ function runRecordMachine(machine, record, ctx = EMPTY_CTX) {
307
+ const fields = {};
308
+ const values = {};
309
+ const errors = [];
310
+ for (const fieldMachine of machine.fields) {
311
+ const name = fieldMachine.spec.name;
312
+ const run = runFieldMachine(fieldMachine, record[name], ctx);
313
+ fields[name] = run;
314
+ values[name] = run.value;
315
+ if (run.failed) {
316
+ errors.push({ field: name, ...run.failed });
317
+ }
318
+ }
319
+ return {
320
+ state: errors.length === 0 ? "valid" : "invalid",
321
+ values,
322
+ fields,
323
+ errors,
324
+ };
325
+ }
326
+ /**
327
+ * Build the dataset-level context for a record machine: a seen-set per
328
+ * `unique` field (so the `unique` gate engages), and — when related ids are
329
+ * supplied — a resolver for the `resolved` gate. `relatedIds` is keyed by
330
+ * `"app.model"`.
331
+ */
332
+ function createDatasetContext(machine, opts = {}) {
333
+ const seen = new Map();
334
+ for (const field of machine.fields) {
335
+ if (field.spec.unique)
336
+ seen.set(field.spec.name, new Set());
337
+ }
338
+ const resolveRelation = opts.resolveRelation ??
339
+ (opts.relatedIds
340
+ ? (relation, value) => {
341
+ const ids = opts.relatedIds[`${relation.app}.${relation.model}`];
342
+ // Unknown target tables are out of scope for the check — stay inert.
343
+ return ids ? ids.has(String(value)) : true;
344
+ }
345
+ : undefined);
346
+ return { seen, resolveRelation };
347
+ }
Binary file
@@ -8,7 +8,8 @@ import {
8
8
  } from "./interfaces";
9
9
  import { FlattenedRecord } from "../../types";
10
10
  import { formikJSONSchemaValidation } from "./validate-schema";
11
- import { getFieldStructure } from "./structure";
11
+ import { getFieldMachine, getFieldStructure } from "./structure";
12
+ import { describeMachine, runFieldMachine } from "../../src/validate";
12
13
 
13
14
  export function useForm<
14
15
  TFormValues extends object,
@@ -160,11 +161,18 @@ export function useForm<
160
161
  const { setValue: setFormValue, setTouched } =
161
162
  formik.getFieldHelpers(fieldName);
162
163
 
164
+ const validationSchema = mutations
165
+ ? mutations[updateMethod]?.validation
166
+ : undefined;
163
167
  const { type, isRequired, maxLength, options, relation, nullable, format } =
164
- getFieldStructure(
165
- mutations ? mutations[updateMethod]?.validation : undefined,
166
- fieldName,
167
- );
168
+ getFieldStructure(validationSchema, fieldName);
169
+
170
+ // The constraint state machine for this field — the same gate chain the
171
+ // import pipeline and the server run, so the form can show exactly which
172
+ // constraint the current value stops at.
173
+ const machine = getFieldMachine(validationSchema, fieldName);
174
+ const validation = machine ? runFieldMachine(machine, value) : undefined;
175
+ const constraints = machine ? describeMachine(machine) : undefined;
168
176
 
169
177
  const error = errorMessage ?? "";
170
178
  const isError = errorMessage !== undefined;
@@ -195,6 +203,9 @@ export function useForm<
195
203
  relation,
196
204
  nullable,
197
205
  format,
206
+ machine,
207
+ validation,
208
+ constraints,
198
209
  };
199
210
  };
200
211
 
@@ -2,6 +2,7 @@ import { FlattenedRecord } from "../../types";
2
2
  import { SchemaObject } from "ajv";
3
3
  import { IUpdateQueryResult } from "../../hooks";
4
4
  import { FormikValues } from "formik";
5
+ import type { FieldMachine, FieldRun } from "../../src/validate";
5
6
 
6
7
  /** A choice for an `enum`-backed (`select`) field. */
7
8
  export interface FieldOption {
@@ -35,6 +36,20 @@ export interface FormFieldStructure<TValue> {
35
36
  nullable?: boolean;
36
37
  /** The schema `format` (`date-time`, `email`, `uuid`, …), for finer control choices. */
37
38
  format?: string;
39
+ /**
40
+ * The field's compiled constraint state machine (built from the write JSON
41
+ * Schema). Its gate chain *is* the field's constraint set, in the order the
42
+ * import pipeline and the server enforce it.
43
+ */
44
+ machine?: FieldMachine;
45
+ /**
46
+ * The machine's run against the current value: terminal state
47
+ * (`valid`/`invalid`), the states traversed, and — on failure — the exact
48
+ * gate/constraint that rejected the value.
49
+ */
50
+ validation?: FieldRun;
51
+ /** Human-readable constraint chain (one entry per gate), for UI hints. */
52
+ constraints?: string[];
38
53
  }
39
54
 
40
55
  interface FormMutation<
@@ -1,5 +1,10 @@
1
1
  import type { SchemaObject } from "ajv";
2
2
  import type { FieldOption, FormType, RelationTarget } from "./interfaces";
3
+ import {
4
+ compileFieldMachine,
5
+ specFromSchemaProperty,
6
+ type FieldMachine,
7
+ } from "../../src/validate";
3
8
 
4
9
  export interface GetFieldStructureResponse {
5
10
  isRequired: boolean;
@@ -23,6 +28,29 @@ export interface GetFieldStructureResponse {
23
28
  * `getFormField`) can render the correct input per datatype instead of a text
24
29
  * box for everything.
25
30
  */
31
+ /**
32
+ * Compile the constraint state machine for one field of a write JSON Schema.
33
+ *
34
+ * The database generator's constraints *are* a state machine — `pristine →
35
+ * present → typed → bounded → member → unique → resolved → valid` — and this
36
+ * builds the form-side instance of it from the same schema
37
+ * {@link getFieldStructure} reads, so a form field steps through exactly the
38
+ * gates the import pipeline and the server enforce. Returns `undefined` when
39
+ * the schema doesn't describe the field.
40
+ */
41
+ export function getFieldMachine<TFieldName>(
42
+ validationSchema: SchemaObject | undefined,
43
+ fieldName: TFieldName,
44
+ ): FieldMachine | undefined {
45
+ const spec = specFromSchemaProperty(
46
+ validationSchema as
47
+ | { properties?: Record<string, any>; required?: unknown }
48
+ | undefined,
49
+ fieldName as string,
50
+ );
51
+ return spec ? compileFieldMachine(spec) : undefined;
52
+ }
53
+
26
54
  export function getFieldStructure<TFieldName>(
27
55
  validationSchema: SchemaObject | undefined,
28
56
  fieldName: TFieldName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",