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,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `rastack import` — load external data (CSV, Excel, PDF) into the shape of a
|
|
4
|
+
* compiled resource and run every row through the resource's constraint state
|
|
5
|
+
* machine.
|
|
6
|
+
*
|
|
7
|
+
* rastack import <file> --resource app.model
|
|
8
|
+
* [--schema .rastack/schema.rastack.json] the compiled manifest
|
|
9
|
+
* [--related app.model=<file>] ids to resolve FKs against (repeatable)
|
|
10
|
+
* [--out cleaned.json] write the coerced valid records
|
|
11
|
+
* [--report report.json] write the full validation report
|
|
12
|
+
* [--max-errors 20] issues to print before summarising
|
|
13
|
+
* [--allow-invalid] exit 0 even when rows fail
|
|
14
|
+
*
|
|
15
|
+
* The validation is the same machine the form hooks run per keystroke and the
|
|
16
|
+
* API enforces on writes: `present → typed → bounded → member → unique →
|
|
17
|
+
* resolved`. Here it runs with the full dataset context, so in-file duplicate
|
|
18
|
+
* `unique` values and dangling foreign keys are rejected with row-located
|
|
19
|
+
* errors. Exit status is non-zero when any row fails (like the seed PII gate)
|
|
20
|
+
* unless `--allow-invalid` is passed.
|
|
21
|
+
*/
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* `rastack import` — load external data (CSV, Excel, PDF) into the shape of a
|
|
5
|
+
* compiled resource and run every row through the resource's constraint state
|
|
6
|
+
* machine.
|
|
7
|
+
*
|
|
8
|
+
* rastack import <file> --resource app.model
|
|
9
|
+
* [--schema .rastack/schema.rastack.json] the compiled manifest
|
|
10
|
+
* [--related app.model=<file>] ids to resolve FKs against (repeatable)
|
|
11
|
+
* [--out cleaned.json] write the coerced valid records
|
|
12
|
+
* [--report report.json] write the full validation report
|
|
13
|
+
* [--max-errors 20] issues to print before summarising
|
|
14
|
+
* [--allow-invalid] exit 0 even when rows fail
|
|
15
|
+
*
|
|
16
|
+
* The validation is the same machine the form hooks run per keystroke and the
|
|
17
|
+
* API enforces on writes: `present → typed → bounded → member → unique →
|
|
18
|
+
* resolved`. Here it runs with the full dataset context, so in-file duplicate
|
|
19
|
+
* `unique` values and dangling foreign keys are rejected with row-located
|
|
20
|
+
* errors. Exit status is non-zero when any row fails (like the seed PII gate)
|
|
21
|
+
* unless `--allow-invalid` is passed.
|
|
22
|
+
*/
|
|
23
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
24
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
const fs_1 = __importDefault(require("fs"));
|
|
28
|
+
const path_1 = __importDefault(require("path"));
|
|
29
|
+
const validate_1 = require("./validate");
|
|
30
|
+
const import_1 = require("./import");
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Args
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
function getArg(flag) {
|
|
36
|
+
const i = args.indexOf(flag);
|
|
37
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : null;
|
|
38
|
+
}
|
|
39
|
+
function getArgs(flag) {
|
|
40
|
+
const values = [];
|
|
41
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
42
|
+
if (args[i] === flag)
|
|
43
|
+
values.push(args[i + 1]);
|
|
44
|
+
}
|
|
45
|
+
return values;
|
|
46
|
+
}
|
|
47
|
+
const USAGE = "Usage: rastack import <file.csv|.xlsx|.pdf> --resource app.model\n" +
|
|
48
|
+
" [--schema .rastack/schema.rastack.json] [--related app.model=<file>]\n" +
|
|
49
|
+
" [--out cleaned.json] [--report report.json] [--max-errors n] [--allow-invalid]";
|
|
50
|
+
function fail(message) {
|
|
51
|
+
console.error(`✗ ${message}\n\n${USAGE}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Load
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
function loadFile(filePath) {
|
|
58
|
+
const format = (0, import_1.formatOf)(filePath);
|
|
59
|
+
if (!format)
|
|
60
|
+
fail(`unsupported file type: ${filePath} (use .csv, .xlsx or .pdf)`);
|
|
61
|
+
if (!fs_1.default.existsSync(filePath))
|
|
62
|
+
fail(`file not found: ${filePath}`);
|
|
63
|
+
return (0, import_1.parseTabular)(format, fs_1.default.readFileSync(filePath));
|
|
64
|
+
}
|
|
65
|
+
function loadManifest(schemaPath) {
|
|
66
|
+
if (!fs_1.default.existsSync(schemaPath)) {
|
|
67
|
+
fail(`manifest not found at ${schemaPath} — run \`rastack compile\` first, ` +
|
|
68
|
+
`or point --schema at a schema.rastack.json`);
|
|
69
|
+
}
|
|
70
|
+
return JSON.parse(fs_1.default.readFileSync(schemaPath, "utf-8"));
|
|
71
|
+
}
|
|
72
|
+
function findResource(manifest, name) {
|
|
73
|
+
const resource = manifest.resources.find((r) => `${r.app}.${r.model}` === name);
|
|
74
|
+
if (!resource) {
|
|
75
|
+
fail(`unknown resource "${name}" — available: ` +
|
|
76
|
+
manifest.resources.map((r) => `${r.app}.${r.model}`).join(", "));
|
|
77
|
+
}
|
|
78
|
+
return resource;
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Main
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
const VALUE_FLAGS = new Set([
|
|
84
|
+
"--resource",
|
|
85
|
+
"--schema",
|
|
86
|
+
"--related",
|
|
87
|
+
"--out",
|
|
88
|
+
"--report",
|
|
89
|
+
"--max-errors",
|
|
90
|
+
]);
|
|
91
|
+
/** The first token that is neither a flag nor a flag's value. */
|
|
92
|
+
function positionalArg() {
|
|
93
|
+
for (let i = 0; i < args.length; i++) {
|
|
94
|
+
if (VALUE_FLAGS.has(args[i])) {
|
|
95
|
+
i++; // skip the flag's value
|
|
96
|
+
}
|
|
97
|
+
else if (!args[i].startsWith("--")) {
|
|
98
|
+
return args[i];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function main() {
|
|
104
|
+
const file = positionalArg();
|
|
105
|
+
const resourceName = getArg("--resource");
|
|
106
|
+
if (!file || !resourceName)
|
|
107
|
+
fail("a source file and --resource are required");
|
|
108
|
+
const schemaPath = getArg("--schema") ??
|
|
109
|
+
path_1.default.join(process.cwd(), ".rastack", "schema.rastack.json");
|
|
110
|
+
const manifest = loadManifest(schemaPath);
|
|
111
|
+
const resource = findResource(manifest, resourceName);
|
|
112
|
+
const specs = (0, validate_1.specsFromResource)(resource);
|
|
113
|
+
// Related id sets for FK resolution: --related airports.airport=airports.csv
|
|
114
|
+
const relatedIds = {};
|
|
115
|
+
for (const spec of getArgs("--related")) {
|
|
116
|
+
const eq = spec.indexOf("=");
|
|
117
|
+
if (eq === -1)
|
|
118
|
+
fail(`--related expects app.model=<file>, got "${spec}"`);
|
|
119
|
+
relatedIds[spec.slice(0, eq)] = (0, import_1.idsFromTabular)(loadFile(spec.slice(eq + 1)));
|
|
120
|
+
}
|
|
121
|
+
const data = loadFile(file);
|
|
122
|
+
console.log(`\nrastack import ${path_1.default.basename(file)} → ${resourceName} ` +
|
|
123
|
+
`[${data.headers.length} column(s), ${data.rows.length} row(s)]\n`);
|
|
124
|
+
const report = (0, import_1.validateDataset)(specs, data, { relatedIds });
|
|
125
|
+
printReport(report);
|
|
126
|
+
const outPath = getArg("--out");
|
|
127
|
+
if (outPath) {
|
|
128
|
+
fs_1.default.writeFileSync(outPath, JSON.stringify(report.records, null, 2) + "\n");
|
|
129
|
+
console.log(`✓ ${report.records.length} valid record(s) → ${outPath}`);
|
|
130
|
+
}
|
|
131
|
+
const reportPath = getArg("--report");
|
|
132
|
+
if (reportPath) {
|
|
133
|
+
// Keep the on-disk report data-shaped: drop the per-row machine runs.
|
|
134
|
+
const { rows: _rows, ...summary } = report;
|
|
135
|
+
fs_1.default.writeFileSync(reportPath, JSON.stringify(summary, null, 2) + "\n");
|
|
136
|
+
console.log(`✓ validation report → ${reportPath}`);
|
|
137
|
+
}
|
|
138
|
+
if (report.invalid > 0 && !args.includes("--allow-invalid")) {
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function printReport(report) {
|
|
143
|
+
for (const col of report.columns) {
|
|
144
|
+
console.log(col.field
|
|
145
|
+
? ` ${col.header} → ${col.field}`
|
|
146
|
+
: ` ${col.header} → (ignored — no matching field)`);
|
|
147
|
+
}
|
|
148
|
+
for (const missing of report.missingFields) {
|
|
149
|
+
console.log(` (no column) → ${missing}`);
|
|
150
|
+
}
|
|
151
|
+
console.log("");
|
|
152
|
+
const maxErrors = Number(getArg("--max-errors") ?? 20);
|
|
153
|
+
for (const issue of report.issues.slice(0, maxErrors)) {
|
|
154
|
+
console.log(` ✗ row ${issue.row + 1} ${issue.field}: ${issue.message}` +
|
|
155
|
+
` [failed at "${issue.gate}" — ${issue.constraint}]`);
|
|
156
|
+
}
|
|
157
|
+
if (report.issues.length > maxErrors) {
|
|
158
|
+
console.log(` … and ${report.issues.length - maxErrors} more issue(s)`);
|
|
159
|
+
}
|
|
160
|
+
if (report.issues.length)
|
|
161
|
+
console.log("");
|
|
162
|
+
console.log(`${report.invalid === 0 ? "✓" : "✗"} ${report.valid}/${report.total} row(s) valid` +
|
|
163
|
+
(report.invalid ? `, ${report.invalid} rejected` : ""));
|
|
164
|
+
}
|
|
165
|
+
main();
|
package/dist/rastack.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* rastack design [input] [--port n] Live design-system studio (showcase + edit)
|
|
13
13
|
* rastack dev [--port n] [--warehouse d] Local dev: run the app + admin in-browser over WASM
|
|
14
14
|
* rastack admin [--warehouse dir] [--port n] Full admin console — React UI (rastack/components) over the native server
|
|
15
|
+
* rastack import <file> --resource app.model Load CSV/XLSX/PDF data and validate it (state machine)
|
|
15
16
|
* rastack scan [files...] [--fail-on-pii] Scan CSVs for PII and schema info
|
|
16
17
|
* rastack seed [--env dev] [--source gs://] Load CSVs into Iceberg namespace
|
|
17
18
|
* rastack bootstrap [--owner o] [--repo r] Stand the tier-0 AWS account stack up (once per account+region)
|
package/dist/rastack.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* rastack design [input] [--port n] Live design-system studio (showcase + edit)
|
|
14
14
|
* rastack dev [--port n] [--warehouse d] Local dev: run the app + admin in-browser over WASM
|
|
15
15
|
* rastack admin [--warehouse dir] [--port n] Full admin console — React UI (rastack/components) over the native server
|
|
16
|
+
* rastack import <file> --resource app.model Load CSV/XLSX/PDF data and validate it (state machine)
|
|
16
17
|
* rastack scan [files...] [--fail-on-pii] Scan CSVs for PII and schema info
|
|
17
18
|
* rastack seed [--env dev] [--source gs://] Load CSVs into Iceberg namespace
|
|
18
19
|
* rastack bootstrap [--owner o] [--repo r] Stand the tier-0 AWS account stack up (once per account+region)
|
|
@@ -89,6 +90,11 @@ switch (command) {
|
|
|
89
90
|
// Live design-system studio (showcase + inspect + edit).
|
|
90
91
|
run("rastack-design.js", rest);
|
|
91
92
|
break;
|
|
93
|
+
case "import":
|
|
94
|
+
// Load external data (CSV / Excel / PDF) and validate it row-by-row
|
|
95
|
+
// through the resource's constraint state machine.
|
|
96
|
+
run("rastack-import.js", rest);
|
|
97
|
+
break;
|
|
92
98
|
case "scan":
|
|
93
99
|
run("scan.js");
|
|
94
100
|
break;
|
|
@@ -133,6 +139,7 @@ switch (command) {
|
|
|
133
139
|
` rastack design [input] [--port n] Live design-system studio (showcase + edit)\n` +
|
|
134
140
|
` rastack dev [--port n] [--warehouse dir] [--no-open] Run the app + admin in-browser over WASM (local dev)\n` +
|
|
135
141
|
` rastack admin [--warehouse dir] [--port n] [--no-open] Full admin console (React UI over the native server; needs a built rastack-server)\n` +
|
|
142
|
+
` rastack import <file.csv|.xlsx|.pdf> --resource app.model [--related app.model=<file>] [--out cleaned.json]\n` +
|
|
136
143
|
` rastack scan [files...] [--fail-on-pii]\n` +
|
|
137
144
|
` rastack seed [--env dev|staging] [--source gs://bucket/ | ./myapp-dev/]\n` +
|
|
138
145
|
` rastack bootstrap [--owner o] [--repo r] [--region r] [--prefix p] [--reuse-oidc] [--print] Stand the AWS account stack up\n` +
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two adapters that lower a field definition into a {@link FieldSpec} —
|
|
3
|
+
* the normalised constraint set the state machine is compiled from.
|
|
4
|
+
*
|
|
5
|
+
* - {@link specFromManifestField} reads `schema.rastack.json` (the compiler's
|
|
6
|
+
* canonical manifest) — what the data-import pipeline uses. It sees the
|
|
7
|
+
* *full* constraint set, including `unique` and the FK relation.
|
|
8
|
+
* - {@link specFromSchemaProperty} reads a write JSON Schema property (the
|
|
9
|
+
* dereferenced `Deref{Name}Request` the form hooks validate against) — what
|
|
10
|
+
* `getFormField` uses in the browser. It sees whatever the OpenAPI emitter
|
|
11
|
+
* carries: type/format/maxLength/enum/nullable/`x-rastack-relation`.
|
|
12
|
+
*
|
|
13
|
+
* Both lower to the same `FieldSpec`, so the *same* compiled machine (same
|
|
14
|
+
* states, same gate order, same messages) validates a keystroke in a form and
|
|
15
|
+
* a row in an imported spreadsheet.
|
|
16
|
+
*/
|
|
17
|
+
import type { FieldModel } from "../compile/model";
|
|
18
|
+
import type { FieldSpec } from "./machine";
|
|
19
|
+
/**
|
|
20
|
+
* Lower a manifest field (`schema.rastack.json`) to a field spec. Mirrors the
|
|
21
|
+
* OpenAPI emitter's semantics: a field is required iff it is not nullable and
|
|
22
|
+
* carries no default; primary keys are server-assigned and never validated on
|
|
23
|
+
* input.
|
|
24
|
+
*/
|
|
25
|
+
export declare function specFromManifestField(field: FieldModel): FieldSpec;
|
|
26
|
+
/**
|
|
27
|
+
* Lower every input-validated field of a manifest resource — primary keys are
|
|
28
|
+
* skipped, matching the write schema the OpenAPI emitter produces.
|
|
29
|
+
*/
|
|
30
|
+
export declare function specsFromResource(resource: {
|
|
31
|
+
fields: FieldModel[];
|
|
32
|
+
}): FieldSpec[];
|
|
33
|
+
/**
|
|
34
|
+
* Lower one property of a write JSON Schema to a field spec. `schema` is the
|
|
35
|
+
* object schema (so requiredness can be read from its `required` list);
|
|
36
|
+
* returns `undefined` when the property does not exist.
|
|
37
|
+
*/
|
|
38
|
+
export declare function specFromSchemaProperty(schema: {
|
|
39
|
+
properties?: Record<string, any>;
|
|
40
|
+
required?: unknown;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
} | undefined, fieldName: string): FieldSpec | undefined;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The two adapters that lower a field definition into a {@link FieldSpec} —
|
|
4
|
+
* the normalised constraint set the state machine is compiled from.
|
|
5
|
+
*
|
|
6
|
+
* - {@link specFromManifestField} reads `schema.rastack.json` (the compiler's
|
|
7
|
+
* canonical manifest) — what the data-import pipeline uses. It sees the
|
|
8
|
+
* *full* constraint set, including `unique` and the FK relation.
|
|
9
|
+
* - {@link specFromSchemaProperty} reads a write JSON Schema property (the
|
|
10
|
+
* dereferenced `Deref{Name}Request` the form hooks validate against) — what
|
|
11
|
+
* `getFormField` uses in the browser. It sees whatever the OpenAPI emitter
|
|
12
|
+
* carries: type/format/maxLength/enum/nullable/`x-rastack-relation`.
|
|
13
|
+
*
|
|
14
|
+
* Both lower to the same `FieldSpec`, so the *same* compiled machine (same
|
|
15
|
+
* states, same gate order, same messages) validates a keystroke in a form and
|
|
16
|
+
* a row in an imported spreadsheet.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.specFromManifestField = specFromManifestField;
|
|
20
|
+
exports.specsFromResource = specsFromResource;
|
|
21
|
+
exports.specFromSchemaProperty = specFromSchemaProperty;
|
|
22
|
+
/**
|
|
23
|
+
* Lower a manifest field (`schema.rastack.json`) to a field spec. Mirrors the
|
|
24
|
+
* OpenAPI emitter's semantics: a field is required iff it is not nullable and
|
|
25
|
+
* carries no default; primary keys are server-assigned and never validated on
|
|
26
|
+
* input.
|
|
27
|
+
*/
|
|
28
|
+
function specFromManifestField(field) {
|
|
29
|
+
const spec = {
|
|
30
|
+
name: field.name,
|
|
31
|
+
type: field.type ?? "string",
|
|
32
|
+
required: !field.null && field.default === undefined && !field.primaryKey,
|
|
33
|
+
nullable: field.null === true,
|
|
34
|
+
};
|
|
35
|
+
if (field.maxLength !== undefined)
|
|
36
|
+
spec.maxLength = field.maxLength;
|
|
37
|
+
if (field.unique)
|
|
38
|
+
spec.unique = true;
|
|
39
|
+
if (field.default !== undefined)
|
|
40
|
+
spec.default = field.default;
|
|
41
|
+
if (field.type === "fk" && field.relation)
|
|
42
|
+
spec.relation = field.relation;
|
|
43
|
+
return spec;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Lower every input-validated field of a manifest resource — primary keys are
|
|
47
|
+
* skipped, matching the write schema the OpenAPI emitter produces.
|
|
48
|
+
*/
|
|
49
|
+
function specsFromResource(resource) {
|
|
50
|
+
return resource.fields
|
|
51
|
+
.filter((f) => !f.primaryKey)
|
|
52
|
+
.map(specFromManifestField);
|
|
53
|
+
}
|
|
54
|
+
/** Map a JSON-Schema scalar (`type` + `format`) back to a manifest scalar kind. */
|
|
55
|
+
function schemaScalar(typeList, format) {
|
|
56
|
+
if (typeList.includes("integer"))
|
|
57
|
+
return "int";
|
|
58
|
+
if (typeList.includes("number"))
|
|
59
|
+
return "float";
|
|
60
|
+
if (typeList.includes("boolean"))
|
|
61
|
+
return "bool";
|
|
62
|
+
if (format === "date-time")
|
|
63
|
+
return "datetime";
|
|
64
|
+
if (format === "uuid")
|
|
65
|
+
return "uuid";
|
|
66
|
+
return "string";
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lower one property of a write JSON Schema to a field spec. `schema` is the
|
|
70
|
+
* object schema (so requiredness can be read from its `required` list);
|
|
71
|
+
* returns `undefined` when the property does not exist.
|
|
72
|
+
*/
|
|
73
|
+
function specFromSchemaProperty(schema, fieldName) {
|
|
74
|
+
const property = schema?.properties?.[fieldName];
|
|
75
|
+
if (!property)
|
|
76
|
+
return undefined;
|
|
77
|
+
const rawType = property.type;
|
|
78
|
+
const typeList = typeof rawType === "string"
|
|
79
|
+
? [rawType]
|
|
80
|
+
: Array.isArray(rawType)
|
|
81
|
+
? rawType
|
|
82
|
+
: [];
|
|
83
|
+
const nullable = typeList.includes("null") || property.nullable === true;
|
|
84
|
+
const required = Array.isArray(schema?.required) && schema.required.includes(fieldName);
|
|
85
|
+
const relation = property["x-rastack-relation"];
|
|
86
|
+
const format = typeof property.format === "string" ? property.format : undefined;
|
|
87
|
+
const spec = {
|
|
88
|
+
name: fieldName,
|
|
89
|
+
type: relation && typeof relation === "object"
|
|
90
|
+
? "fk"
|
|
91
|
+
: schemaScalar(typeList, format),
|
|
92
|
+
required,
|
|
93
|
+
nullable,
|
|
94
|
+
};
|
|
95
|
+
if (relation && typeof relation === "object")
|
|
96
|
+
spec.relation = relation;
|
|
97
|
+
// `format` refines strings (`email`/`url`/`date`/`date-time`/`uuid`); the
|
|
98
|
+
// scalar kinds already encode datetime/uuid, so only keep it for strings.
|
|
99
|
+
if (spec.type === "string" && format)
|
|
100
|
+
spec.format = format;
|
|
101
|
+
if (typeof property.maxLength === "number")
|
|
102
|
+
spec.maxLength = property.maxLength;
|
|
103
|
+
if (Array.isArray(property.enum) && property.enum.length > 0) {
|
|
104
|
+
spec.options = property.enum.filter((v) => v !== null && v !== undefined);
|
|
105
|
+
}
|
|
106
|
+
if (property.default !== undefined)
|
|
107
|
+
spec.default = property.default;
|
|
108
|
+
return spec;
|
|
109
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./machine"), exports);
|
|
18
|
+
__exportStar(require("./adapters"), exports);
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
/** The `{ app, model }` a foreign-key field points at. */
|
|
39
|
+
export interface SpecRelation {
|
|
40
|
+
app: string;
|
|
41
|
+
model: string;
|
|
42
|
+
}
|
|
43
|
+
/** Scalar kinds, matching the manifest's `ScalarType` plus `fk`. */
|
|
44
|
+
export type SpecType = "string" | "int" | "float" | "bool" | "datetime" | "uuid" | "fk";
|
|
45
|
+
/**
|
|
46
|
+
* The normalised constraint set for one field. Both adapters (manifest field,
|
|
47
|
+
* JSON-Schema property) lower to this shape; the machine compiler only ever
|
|
48
|
+
* sees a `FieldSpec`.
|
|
49
|
+
*/
|
|
50
|
+
export interface FieldSpec {
|
|
51
|
+
name: string;
|
|
52
|
+
type: SpecType;
|
|
53
|
+
/** Whether a value must be present (`required` short of a default). */
|
|
54
|
+
required: boolean;
|
|
55
|
+
/** Whether `null` is an accepted value. */
|
|
56
|
+
nullable: boolean;
|
|
57
|
+
/** Max string length (the `bounded` gate). */
|
|
58
|
+
maxLength?: number;
|
|
59
|
+
/** Dataset-level uniqueness (the `unique` gate). */
|
|
60
|
+
unique?: boolean;
|
|
61
|
+
/** String format refinement: `email` | `url` | `date` | `date-time` | `uuid`. */
|
|
62
|
+
format?: string;
|
|
63
|
+
/** Enum membership (the `member` gate). */
|
|
64
|
+
options?: Array<string | number>;
|
|
65
|
+
/** FK target — present iff `type === "fk"` (the `resolved` gate). */
|
|
66
|
+
relation?: SpecRelation;
|
|
67
|
+
/** Default applied when an optional field is empty. */
|
|
68
|
+
default?: unknown;
|
|
69
|
+
}
|
|
70
|
+
/** Every state a field machine can be in. */
|
|
71
|
+
export type FieldState = "pristine" | "present" | "typed" | "bounded" | "member" | "unique" | "resolved" | "valid" | "invalid";
|
|
72
|
+
/** The intermediate (gate) states, in canonical machine order. */
|
|
73
|
+
export declare const GATE_ORDER: readonly FieldState[];
|
|
74
|
+
/** The outcome of one gate check: pass (with the possibly-coerced value) or fail. */
|
|
75
|
+
export type GateResult = {
|
|
76
|
+
ok: true;
|
|
77
|
+
value: unknown;
|
|
78
|
+
} | {
|
|
79
|
+
ok: false;
|
|
80
|
+
message: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* One guarded transition of the machine. Passing the guard enters state `to`
|
|
84
|
+
* and threads the (possibly coerced) value into the next gate.
|
|
85
|
+
*/
|
|
86
|
+
export interface Gate {
|
|
87
|
+
to: FieldState;
|
|
88
|
+
/** Human-readable constraint, e.g. `"maxLength ≤ 3"` — drives UI hints and import reports. */
|
|
89
|
+
constraint: string;
|
|
90
|
+
check: (value: unknown, ctx: ValidationContext) => GateResult;
|
|
91
|
+
}
|
|
92
|
+
/** A compiled field machine: the spec, its gates, and the full state chain. */
|
|
93
|
+
export interface FieldMachine {
|
|
94
|
+
spec: FieldSpec;
|
|
95
|
+
gates: Gate[];
|
|
96
|
+
/** `["pristine", ...gate states..., "valid"]` — the happy path. */
|
|
97
|
+
states: FieldState[];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Dataset-level capabilities. Absent capabilities leave the corresponding
|
|
101
|
+
* gates inert (they pass), so a machine runs identically in a browser form
|
|
102
|
+
* (no context) and in the import pipeline (full context) minus the
|
|
103
|
+
* dataset-level checks.
|
|
104
|
+
*/
|
|
105
|
+
export interface ValidationContext {
|
|
106
|
+
/** Per-field sets of already-seen values, keyed by field name (the `unique` gate). */
|
|
107
|
+
seen?: Map<string, Set<string>>;
|
|
108
|
+
/** Resolve whether a FK value exists on the target resource (the `resolved` gate). */
|
|
109
|
+
resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
|
|
110
|
+
}
|
|
111
|
+
/** The result of running a value through a field machine. */
|
|
112
|
+
export interface FieldRun {
|
|
113
|
+
/** Terminal state: `valid` or `invalid`. */
|
|
114
|
+
state: "valid" | "invalid";
|
|
115
|
+
/** Every state entered, `pristine` first. Ends in `valid` or `invalid`. */
|
|
116
|
+
path: FieldState[];
|
|
117
|
+
/** The coerced value (e.g. `"12"` → `12`; empty optional → default/null). */
|
|
118
|
+
value: unknown;
|
|
119
|
+
/** Present iff `state === "invalid"`: the gate that rejected the value. */
|
|
120
|
+
failed?: {
|
|
121
|
+
gate: FieldState;
|
|
122
|
+
constraint: string;
|
|
123
|
+
message: string;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Compile a field spec into its constraint state machine. Every present
|
|
128
|
+
* constraint becomes one gate; absent constraints emit no gate, so the state
|
|
129
|
+
* chain is exactly the field's constraint set.
|
|
130
|
+
*/
|
|
131
|
+
export declare function compileFieldMachine(spec: FieldSpec): FieldMachine;
|
|
132
|
+
/** The human-readable constraint chain — what the machine enforces, in order. */
|
|
133
|
+
export declare function describeMachine(machine: FieldMachine): string[];
|
|
134
|
+
/**
|
|
135
|
+
* Run a raw value through a field machine. Walks the gates in order, coercing
|
|
136
|
+
* the value as it goes; the first failing guard sends the machine to
|
|
137
|
+
* `invalid` with the offending constraint attached.
|
|
138
|
+
*
|
|
139
|
+
* An empty value on an optional field short-circuits from `present` straight
|
|
140
|
+
* to `valid` (yielding the default, else `null`) — there is nothing further
|
|
141
|
+
* to constrain, exactly like a nullable column.
|
|
142
|
+
*/
|
|
143
|
+
export declare function runFieldMachine(machine: FieldMachine, rawValue: unknown, ctx?: ValidationContext): FieldRun;
|
|
144
|
+
/** A record machine is the composition of its fields' machines. */
|
|
145
|
+
export interface RecordMachine {
|
|
146
|
+
fields: FieldMachine[];
|
|
147
|
+
}
|
|
148
|
+
/** One field's failure inside a record run. */
|
|
149
|
+
export interface RecordError {
|
|
150
|
+
field: string;
|
|
151
|
+
gate: FieldState;
|
|
152
|
+
constraint: string;
|
|
153
|
+
message: string;
|
|
154
|
+
}
|
|
155
|
+
/** The result of running one record through a record machine. */
|
|
156
|
+
export interface RecordRun {
|
|
157
|
+
state: "valid" | "invalid";
|
|
158
|
+
/** The coerced record — only meaningful when `state === "valid"`. */
|
|
159
|
+
values: Record<string, unknown>;
|
|
160
|
+
/** Per-field machine runs, keyed by field name. */
|
|
161
|
+
fields: Record<string, FieldRun>;
|
|
162
|
+
errors: RecordError[];
|
|
163
|
+
}
|
|
164
|
+
export declare function compileRecordMachine(specs: FieldSpec[]): RecordMachine;
|
|
165
|
+
export declare function runRecordMachine(machine: RecordMachine, record: Record<string, unknown>, ctx?: ValidationContext): RecordRun;
|
|
166
|
+
/**
|
|
167
|
+
* Build the dataset-level context for a record machine: a seen-set per
|
|
168
|
+
* `unique` field (so the `unique` gate engages), and — when related ids are
|
|
169
|
+
* supplied — a resolver for the `resolved` gate. `relatedIds` is keyed by
|
|
170
|
+
* `"app.model"`.
|
|
171
|
+
*/
|
|
172
|
+
export declare function createDatasetContext(machine: RecordMachine, opts?: {
|
|
173
|
+
relatedIds?: Record<string, Set<string>>;
|
|
174
|
+
resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
|
|
175
|
+
}): ValidationContext;
|