rastack 0.0.45 → 0.0.47
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/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/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/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,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The external-data import pipeline: format parsers (CSV via `parseCsv`, the
|
|
3
|
+
* dependency-free XLSX and PDF readers) and the pure validation core that
|
|
4
|
+
* runs a whole tabular source through a resource's constraint state machine.
|
|
5
|
+
*
|
|
6
|
+
* The XLSX fixture is built with the repo's own `zipStore` (deploy/zip.ts) —
|
|
7
|
+
* the reader handles STORE entries, which is exactly what the writer emits.
|
|
8
|
+
* The PDF fixtures are hand-written minimal documents (one raw content
|
|
9
|
+
* stream, one FlateDecode) exercising the text operators the extractor
|
|
10
|
+
* replays.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { deflateSync } from "zlib";
|
|
14
|
+
import { zipStore } from "../src/deploy/zip";
|
|
15
|
+
import {
|
|
16
|
+
formatOf,
|
|
17
|
+
idsFromTabular,
|
|
18
|
+
mapHeaders,
|
|
19
|
+
parsePdfTable,
|
|
20
|
+
parseTabular,
|
|
21
|
+
parseXlsx,
|
|
22
|
+
validateDataset,
|
|
23
|
+
} from "../src/import";
|
|
24
|
+
import { specsFromResource } from "../src/validate";
|
|
25
|
+
import type { FieldModel } from "../src/compile/model";
|
|
26
|
+
|
|
27
|
+
// The airline fixtures, as `rastack compile` writes them to the manifest.
|
|
28
|
+
const AIRPORT_FIELDS: FieldModel[] = [
|
|
29
|
+
{ name: "code", type: "string", maxLength: 3, unique: true },
|
|
30
|
+
{ name: "name", type: "string", maxLength: 120 },
|
|
31
|
+
];
|
|
32
|
+
const TERMINAL_FIELDS: FieldModel[] = [
|
|
33
|
+
{
|
|
34
|
+
name: "airport",
|
|
35
|
+
type: "fk",
|
|
36
|
+
relation: { app: "airports", model: "airport" },
|
|
37
|
+
},
|
|
38
|
+
{ name: "label", type: "string", maxLength: 20 },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
describe("header mapping", () => {
|
|
42
|
+
it("matches case-, space- and separator-insensitively", () => {
|
|
43
|
+
const specs = specsFromResource({
|
|
44
|
+
fields: [
|
|
45
|
+
{ name: "owner_id", type: "int" },
|
|
46
|
+
{ name: "code", type: "string" },
|
|
47
|
+
] as FieldModel[],
|
|
48
|
+
});
|
|
49
|
+
const { columns, missingFields } = mapHeaders(
|
|
50
|
+
["Owner ID", "CODE", "junk"],
|
|
51
|
+
specs,
|
|
52
|
+
);
|
|
53
|
+
expect(columns).toEqual([
|
|
54
|
+
{ header: "Owner ID", field: "owner_id" },
|
|
55
|
+
{ header: "CODE", field: "code" },
|
|
56
|
+
{ header: "junk", field: null },
|
|
57
|
+
]);
|
|
58
|
+
expect(missingFields).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("reports fields no column covers", () => {
|
|
62
|
+
const specs = specsFromResource({ fields: AIRPORT_FIELDS });
|
|
63
|
+
const { missingFields } = mapHeaders(["code"], specs);
|
|
64
|
+
expect(missingFields).toEqual(["name"]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("dataset validation through the state machine", () => {
|
|
69
|
+
const airportSpecs = specsFromResource({ fields: AIRPORT_FIELDS });
|
|
70
|
+
|
|
71
|
+
it("validates a CSV row-by-row, locating each failed gate", () => {
|
|
72
|
+
const csv = Buffer.from(
|
|
73
|
+
"code,name\n" +
|
|
74
|
+
"LHR,Heathrow\n" +
|
|
75
|
+
"LHRX,Too Long A Code\n" +
|
|
76
|
+
"LHR,Duplicate Of Row One\n" +
|
|
77
|
+
",Missing Code\n",
|
|
78
|
+
);
|
|
79
|
+
const report = validateDataset(airportSpecs, parseTabular("csv", csv));
|
|
80
|
+
|
|
81
|
+
expect(report.total).toBe(4);
|
|
82
|
+
expect(report.valid).toBe(1);
|
|
83
|
+
expect(report.invalid).toBe(3);
|
|
84
|
+
expect(report.records).toEqual([{ code: "LHR", name: "Heathrow" }]);
|
|
85
|
+
expect(report.issues).toMatchObject([
|
|
86
|
+
{ row: 1, field: "code", gate: "bounded" },
|
|
87
|
+
{ row: 2, field: "code", gate: "unique" },
|
|
88
|
+
{ row: 3, field: "code", gate: "present" },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("resolves foreign keys against a related file's ids", () => {
|
|
93
|
+
const airports = parseTabular(
|
|
94
|
+
"csv",
|
|
95
|
+
Buffer.from("id,code,name\n1,LHR,Heathrow\n2,JFK,Kennedy\n"),
|
|
96
|
+
);
|
|
97
|
+
const terminals = parseTabular(
|
|
98
|
+
"csv",
|
|
99
|
+
Buffer.from("airport,label\n1,Terminal 5\n9,Ghost Terminal\n"),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const report = validateDataset(
|
|
103
|
+
specsFromResource({ fields: TERMINAL_FIELDS }),
|
|
104
|
+
terminals,
|
|
105
|
+
{ relatedIds: { "airports.airport": idsFromTabular(airports) } },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(report.valid).toBe(1);
|
|
109
|
+
// FK ids coerce to numbers on the way through the `typed` gate.
|
|
110
|
+
expect(report.records).toEqual([{ airport: 1, label: "Terminal 5" }]);
|
|
111
|
+
expect(report.issues).toMatchObject([
|
|
112
|
+
{ row: 1, field: "airport", gate: "resolved" },
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("infers the source format from the file name", () => {
|
|
117
|
+
expect(formatOf("airports.csv")).toBe("csv");
|
|
118
|
+
expect(formatOf("Airports.XLSX")).toBe("xlsx");
|
|
119
|
+
expect(formatOf("report.pdf")).toBe("pdf");
|
|
120
|
+
expect(formatOf("notes.txt")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("XLSX reader", () => {
|
|
125
|
+
const SHARED_STRINGS =
|
|
126
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
|
127
|
+
`<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="4" uniqueCount="4">` +
|
|
128
|
+
`<si><t>code</t></si><si><t>name</t></si><si><t>LHR</t></si>` +
|
|
129
|
+
`<si><r><t>Heath</t></r><r><t>row & Co</t></r></si>` +
|
|
130
|
+
`</sst>`;
|
|
131
|
+
|
|
132
|
+
const SHEET =
|
|
133
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
|
134
|
+
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>` +
|
|
135
|
+
`<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>` +
|
|
136
|
+
`<row r="2"><c r="A2" t="s"><v>2</v></c><c r="B2" t="s"><v>3</v></c></row>` +
|
|
137
|
+
`<row r="3"><c r="A3" t="inlineStr"><is><t>JFK</t></is></c><c r="B3"><v>42</v></c></row>` +
|
|
138
|
+
// A row with only column B populated — column A must come back as "".
|
|
139
|
+
`<row r="4"><c r="B4" t="inlineStr"><is><t>Orphan</t></is></c></row>` +
|
|
140
|
+
`</sheetData></worksheet>`;
|
|
141
|
+
|
|
142
|
+
const workbook = () =>
|
|
143
|
+
zipStore([
|
|
144
|
+
{ name: "xl/sharedStrings.xml", data: Buffer.from(SHARED_STRINGS) },
|
|
145
|
+
{ name: "xl/worksheets/sheet1.xml", data: Buffer.from(SHEET) },
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
it("reads shared strings (incl. rich-text runs), inline strings and numbers", () => {
|
|
149
|
+
const { headers, rows } = parseXlsx(workbook());
|
|
150
|
+
expect(headers).toEqual(["code", "name"]);
|
|
151
|
+
expect(rows).toEqual([
|
|
152
|
+
["LHR", "Heathrow & Co"],
|
|
153
|
+
["JFK", "42"],
|
|
154
|
+
["", "Orphan"],
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("feeds straight into the state machine like any other source", () => {
|
|
159
|
+
const report = validateDataset(
|
|
160
|
+
specsFromResource({ fields: AIRPORT_FIELDS }),
|
|
161
|
+
parseXlsx(workbook()),
|
|
162
|
+
);
|
|
163
|
+
expect(report.valid).toBe(2);
|
|
164
|
+
// Row 3 has no `code` — required, so it halts at `present`.
|
|
165
|
+
expect(report.issues).toMatchObject([
|
|
166
|
+
{ row: 2, field: "code", gate: "present" },
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("PDF table extractor", () => {
|
|
172
|
+
const CONTENT =
|
|
173
|
+
"BT\n" +
|
|
174
|
+
"/F1 12 Tf\n" +
|
|
175
|
+
"72 720 Td\n" +
|
|
176
|
+
"(code) Tj\n" +
|
|
177
|
+
"150 0 Td\n" +
|
|
178
|
+
"(name) Tj\n" +
|
|
179
|
+
"ET\n" +
|
|
180
|
+
"BT\n" +
|
|
181
|
+
"72 700 Td\n" +
|
|
182
|
+
"(LHR) Tj\n" +
|
|
183
|
+
"150 0 Td\n" +
|
|
184
|
+
"(Heathrow \\(west\\)) Tj\n" +
|
|
185
|
+
"ET\n" +
|
|
186
|
+
"BT\n" +
|
|
187
|
+
"72 680 Td\n" +
|
|
188
|
+
"[(J) 20 (FK)] TJ\n" +
|
|
189
|
+
"150 0 Td\n" +
|
|
190
|
+
"(Kennedy) Tj\n" +
|
|
191
|
+
"ET\n";
|
|
192
|
+
|
|
193
|
+
function rawPdf(content: string): Buffer {
|
|
194
|
+
return Buffer.from(
|
|
195
|
+
`%PDF-1.4\n1 0 obj\n<< /Length ${content.length} >>\nstream\n` +
|
|
196
|
+
content +
|
|
197
|
+
`\nendstream\nendobj\n%%EOF\n`,
|
|
198
|
+
"latin1",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function flatePdf(content: string): Buffer {
|
|
203
|
+
const compressed = deflateSync(Buffer.from(content, "latin1"));
|
|
204
|
+
return Buffer.concat([
|
|
205
|
+
Buffer.from(
|
|
206
|
+
`%PDF-1.4\n1 0 obj\n<< /Length ${compressed.length} /Filter /FlateDecode >>\nstream\n`,
|
|
207
|
+
"latin1",
|
|
208
|
+
),
|
|
209
|
+
compressed,
|
|
210
|
+
Buffer.from(`\nendstream\nendobj\n%%EOF\n`, "latin1"),
|
|
211
|
+
]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const expected = {
|
|
215
|
+
headers: ["code", "name"],
|
|
216
|
+
rows: [
|
|
217
|
+
["LHR", "Heathrow (west)"],
|
|
218
|
+
["JFK", "Kennedy"],
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
it("clusters positioned text into rows (raw stream)", () => {
|
|
223
|
+
expect(parsePdfTable(rawPdf(CONTENT))).toEqual(expected);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("decodes FlateDecode content streams", () => {
|
|
227
|
+
expect(parsePdfTable(flatePdf(CONTENT))).toEqual(expected);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("feeds straight into the state machine like any other source", () => {
|
|
231
|
+
const report = validateDataset(
|
|
232
|
+
specsFromResource({ fields: AIRPORT_FIELDS }),
|
|
233
|
+
parsePdfTable(rawPdf(CONTENT)),
|
|
234
|
+
);
|
|
235
|
+
expect(report.valid).toBe(2);
|
|
236
|
+
expect(report.records).toEqual([
|
|
237
|
+
{ code: "LHR", name: "Heathrow (west)" },
|
|
238
|
+
{ code: "JFK", name: "Kennedy" },
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The constraint state machine (`rastack/validate`): compilation from both
|
|
3
|
+
* adapters (manifest field, write JSON Schema property), gate-by-gate
|
|
4
|
+
* execution, dataset-level `unique`/`resolved` gates, record machines, and
|
|
5
|
+
* the form-hook integration (`getFieldMachine`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
compileFieldMachine,
|
|
10
|
+
compileRecordMachine,
|
|
11
|
+
createDatasetContext,
|
|
12
|
+
describeMachine,
|
|
13
|
+
runFieldMachine,
|
|
14
|
+
runRecordMachine,
|
|
15
|
+
specFromManifestField,
|
|
16
|
+
specFromSchemaProperty,
|
|
17
|
+
specsFromResource,
|
|
18
|
+
type FieldSpec,
|
|
19
|
+
} from "../src/validate";
|
|
20
|
+
import { getFieldMachine } from "../hooks/form/structure";
|
|
21
|
+
import type { FieldModel } from "../src/compile/model";
|
|
22
|
+
|
|
23
|
+
// The Airport.code field, as `rastack compile` writes it to the manifest.
|
|
24
|
+
const CODE_FIELD: FieldModel = {
|
|
25
|
+
name: "code",
|
|
26
|
+
type: "string",
|
|
27
|
+
maxLength: 3,
|
|
28
|
+
unique: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("constraint state machine — compilation", () => {
|
|
32
|
+
it("builds one gate per constraint, in canonical order", () => {
|
|
33
|
+
const machine = compileFieldMachine(specFromManifestField(CODE_FIELD));
|
|
34
|
+
expect(machine.states).toEqual([
|
|
35
|
+
"pristine",
|
|
36
|
+
"present",
|
|
37
|
+
"typed",
|
|
38
|
+
"bounded",
|
|
39
|
+
"unique",
|
|
40
|
+
"valid",
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("emits no gate for an absent constraint", () => {
|
|
45
|
+
const machine = compileFieldMachine(
|
|
46
|
+
specFromManifestField({ name: "note", type: "string", null: true }),
|
|
47
|
+
);
|
|
48
|
+
expect(machine.states).toEqual(["pristine", "present", "typed", "valid"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("describes the constraint chain for UI hints", () => {
|
|
52
|
+
const machine = compileFieldMachine(specFromManifestField(CODE_FIELD));
|
|
53
|
+
expect(describeMachine(machine)).toEqual([
|
|
54
|
+
"required",
|
|
55
|
+
"string",
|
|
56
|
+
"maxLength ≤ 3",
|
|
57
|
+
"unique",
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("constraint state machine — execution", () => {
|
|
63
|
+
const codeMachine = compileFieldMachine(specFromManifestField(CODE_FIELD));
|
|
64
|
+
|
|
65
|
+
it("accepts a conforming value, recording the full path", () => {
|
|
66
|
+
const run = runFieldMachine(codeMachine, "LHR");
|
|
67
|
+
expect(run.state).toBe("valid");
|
|
68
|
+
expect(run.value).toBe("LHR");
|
|
69
|
+
expect(run.path).toEqual([
|
|
70
|
+
"pristine",
|
|
71
|
+
"present",
|
|
72
|
+
"typed",
|
|
73
|
+
"bounded",
|
|
74
|
+
"unique",
|
|
75
|
+
"valid",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("halts at `present` for a missing required value", () => {
|
|
80
|
+
const run = runFieldMachine(codeMachine, "");
|
|
81
|
+
expect(run.state).toBe("invalid");
|
|
82
|
+
expect(run.failed?.gate).toBe("present");
|
|
83
|
+
expect(run.path).toEqual(["pristine", "invalid"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("halts at `bounded` when maxLength is exceeded", () => {
|
|
87
|
+
const run = runFieldMachine(codeMachine, "LHRX");
|
|
88
|
+
expect(run.state).toBe("invalid");
|
|
89
|
+
expect(run.failed).toMatchObject({
|
|
90
|
+
gate: "bounded",
|
|
91
|
+
constraint: "maxLength ≤ 3",
|
|
92
|
+
});
|
|
93
|
+
// The path shows exactly how far the value got.
|
|
94
|
+
expect(run.path).toEqual(["pristine", "present", "typed", "invalid"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("short-circuits an empty optional field to valid with its default", () => {
|
|
98
|
+
const machine = compileFieldMachine(
|
|
99
|
+
specFromManifestField({
|
|
100
|
+
name: "status",
|
|
101
|
+
type: "string",
|
|
102
|
+
maxLength: 20,
|
|
103
|
+
default: "scheduled",
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const run = runFieldMachine(machine, "");
|
|
107
|
+
expect(run.state).toBe("valid");
|
|
108
|
+
expect(run.value).toBe("scheduled");
|
|
109
|
+
expect(run.path).toEqual(["pristine", "present", "valid"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("coerces at the `typed` gate (int strings become ints)", () => {
|
|
113
|
+
const machine = compileFieldMachine(
|
|
114
|
+
specFromManifestField({ name: "owner_id", type: "int" }),
|
|
115
|
+
);
|
|
116
|
+
expect(runFieldMachine(machine, "12").value).toBe(12);
|
|
117
|
+
expect(runFieldMachine(machine, 12).value).toBe(12);
|
|
118
|
+
|
|
119
|
+
const bad = runFieldMachine(machine, "12.5");
|
|
120
|
+
expect(bad.state).toBe("invalid");
|
|
121
|
+
expect(bad.failed?.gate).toBe("typed");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("coerces booleans and datetimes", () => {
|
|
125
|
+
const bool = compileFieldMachine(
|
|
126
|
+
specFromManifestField({ name: "active", type: "bool" }),
|
|
127
|
+
);
|
|
128
|
+
expect(runFieldMachine(bool, "yes").value).toBe(true);
|
|
129
|
+
expect(runFieldMachine(bool, "0").value).toBe(false);
|
|
130
|
+
expect(runFieldMachine(bool, "maybe").state).toBe("invalid");
|
|
131
|
+
|
|
132
|
+
const dt = compileFieldMachine(
|
|
133
|
+
specFromManifestField({ name: "departs_at", type: "datetime" }),
|
|
134
|
+
);
|
|
135
|
+
expect(runFieldMachine(dt, "2026-07-13T10:30:00Z").state).toBe("valid");
|
|
136
|
+
expect(runFieldMachine(dt, "not a date").state).toBe("invalid");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("enforces enum membership at the `member` gate", () => {
|
|
140
|
+
const spec: FieldSpec = {
|
|
141
|
+
name: "status",
|
|
142
|
+
type: "string",
|
|
143
|
+
required: true,
|
|
144
|
+
nullable: false,
|
|
145
|
+
options: ["scheduled", "boarding", "departed"],
|
|
146
|
+
};
|
|
147
|
+
const machine = compileFieldMachine(spec);
|
|
148
|
+
expect(runFieldMachine(machine, "boarding").state).toBe("valid");
|
|
149
|
+
const bad = runFieldMachine(machine, "cancelled");
|
|
150
|
+
expect(bad.failed?.gate).toBe("member");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("leaves the `unique` gate inert without a dataset context", () => {
|
|
154
|
+
expect(runFieldMachine(codeMachine, "LHR").state).toBe("valid");
|
|
155
|
+
expect(runFieldMachine(codeMachine, "LHR").state).toBe("valid");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("rejects duplicates through a dataset context", () => {
|
|
159
|
+
const record = compileRecordMachine([specFromManifestField(CODE_FIELD)]);
|
|
160
|
+
const ctx = createDatasetContext(record);
|
|
161
|
+
expect(runRecordMachine(record, { code: "LHR" }, ctx).state).toBe("valid");
|
|
162
|
+
const dup = runRecordMachine(record, { code: "LHR" }, ctx);
|
|
163
|
+
expect(dup.state).toBe("invalid");
|
|
164
|
+
expect(dup.errors[0]).toMatchObject({ field: "code", gate: "unique" });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("resolves foreign keys through the context, inert without one", () => {
|
|
168
|
+
const fk: FieldModel = {
|
|
169
|
+
name: "airport",
|
|
170
|
+
type: "fk",
|
|
171
|
+
relation: { app: "airports", model: "airport" },
|
|
172
|
+
};
|
|
173
|
+
const machine = compileFieldMachine(specFromManifestField(fk));
|
|
174
|
+
expect(machine.states).toContain("resolved");
|
|
175
|
+
|
|
176
|
+
// No resolver — the gate passes (FK existence is a dataset/server check).
|
|
177
|
+
expect(runFieldMachine(machine, "7").state).toBe("valid");
|
|
178
|
+
|
|
179
|
+
const record = compileRecordMachine([specFromManifestField(fk)]);
|
|
180
|
+
const ctx = createDatasetContext(record, {
|
|
181
|
+
relatedIds: { "airports.airport": new Set(["1", "2"]) },
|
|
182
|
+
});
|
|
183
|
+
expect(runRecordMachine(record, { airport: "2" }, ctx).state).toBe("valid");
|
|
184
|
+
const dangling = runRecordMachine(record, { airport: "7" }, ctx);
|
|
185
|
+
expect(dangling.errors[0]).toMatchObject({
|
|
186
|
+
field: "airport",
|
|
187
|
+
gate: "resolved",
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("spec adapters", () => {
|
|
193
|
+
it("manifest: required iff not nullable, no default, not a primary key", () => {
|
|
194
|
+
expect(
|
|
195
|
+
specFromManifestField({ name: "code", type: "string" }).required,
|
|
196
|
+
).toBe(true);
|
|
197
|
+
expect(
|
|
198
|
+
specFromManifestField({ name: "code", type: "string", null: true })
|
|
199
|
+
.required,
|
|
200
|
+
).toBe(false);
|
|
201
|
+
expect(
|
|
202
|
+
specFromManifestField({ name: "code", type: "string", default: "x" })
|
|
203
|
+
.required,
|
|
204
|
+
).toBe(false);
|
|
205
|
+
expect(
|
|
206
|
+
specFromManifestField({ name: "id", type: "int", primaryKey: true })
|
|
207
|
+
.required,
|
|
208
|
+
).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("manifest: primary keys are excluded from a resource's input specs", () => {
|
|
212
|
+
const specs = specsFromResource({
|
|
213
|
+
fields: [
|
|
214
|
+
{ name: "id", type: "int", primaryKey: true },
|
|
215
|
+
{ name: "code", type: "string", maxLength: 3 },
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
expect(specs.map((s) => s.name)).toEqual(["code"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("JSON Schema: recovers type, nullability, maxLength, enum and the FK relation", () => {
|
|
222
|
+
const schema = {
|
|
223
|
+
type: "object",
|
|
224
|
+
required: ["code", "airport"],
|
|
225
|
+
properties: {
|
|
226
|
+
code: { type: "string", maxLength: 3 },
|
|
227
|
+
seats: { type: ["integer", "null"] },
|
|
228
|
+
status: { type: "string", enum: ["scheduled", "departed"] },
|
|
229
|
+
contact: { type: "string", format: "email" },
|
|
230
|
+
airport: {
|
|
231
|
+
type: "integer",
|
|
232
|
+
"x-rastack-relation": { app: "airports", model: "airport" },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
expect(specFromSchemaProperty(schema, "code")).toMatchObject({
|
|
238
|
+
type: "string",
|
|
239
|
+
required: true,
|
|
240
|
+
maxLength: 3,
|
|
241
|
+
});
|
|
242
|
+
expect(specFromSchemaProperty(schema, "seats")).toMatchObject({
|
|
243
|
+
type: "int",
|
|
244
|
+
required: false,
|
|
245
|
+
nullable: true,
|
|
246
|
+
});
|
|
247
|
+
expect(specFromSchemaProperty(schema, "status")?.options).toEqual([
|
|
248
|
+
"scheduled",
|
|
249
|
+
"departed",
|
|
250
|
+
]);
|
|
251
|
+
expect(specFromSchemaProperty(schema, "contact")).toMatchObject({
|
|
252
|
+
type: "string",
|
|
253
|
+
format: "email",
|
|
254
|
+
});
|
|
255
|
+
expect(specFromSchemaProperty(schema, "airport")).toMatchObject({
|
|
256
|
+
type: "fk",
|
|
257
|
+
relation: { app: "airports", model: "airport" },
|
|
258
|
+
});
|
|
259
|
+
expect(specFromSchemaProperty(schema, "missing")).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("both adapters compile machines that agree on a value", () => {
|
|
263
|
+
const fromManifest = compileFieldMachine(specFromManifestField(CODE_FIELD));
|
|
264
|
+
const fromSchema = compileFieldMachine(
|
|
265
|
+
specFromSchemaProperty(
|
|
266
|
+
{
|
|
267
|
+
type: "object",
|
|
268
|
+
required: ["code"],
|
|
269
|
+
properties: { code: { type: "string", maxLength: 3 } },
|
|
270
|
+
},
|
|
271
|
+
"code",
|
|
272
|
+
)!,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
for (const value of ["LHR", "LHRX", "", 42]) {
|
|
276
|
+
const a = runFieldMachine(fromManifest, value);
|
|
277
|
+
const b = runFieldMachine(fromSchema, value);
|
|
278
|
+
expect(b.state).toBe(a.state);
|
|
279
|
+
expect(b.failed?.gate).toBe(a.failed?.gate);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("form hook integration — getFieldMachine", () => {
|
|
285
|
+
const writeSchema = {
|
|
286
|
+
type: "object",
|
|
287
|
+
required: ["code"],
|
|
288
|
+
properties: {
|
|
289
|
+
code: { type: "string", maxLength: 3 },
|
|
290
|
+
contact: { type: "string", format: "email", nullable: true },
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
it("compiles a machine straight from the write schema the form validates against", () => {
|
|
295
|
+
const machine = getFieldMachine(writeSchema, "code")!;
|
|
296
|
+
expect(machine.states).toEqual([
|
|
297
|
+
"pristine",
|
|
298
|
+
"present",
|
|
299
|
+
"typed",
|
|
300
|
+
"bounded",
|
|
301
|
+
"valid",
|
|
302
|
+
]);
|
|
303
|
+
expect(runFieldMachine(machine, "LHRX").failed?.gate).toBe("bounded");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("routes string formats through the `typed` gate", () => {
|
|
307
|
+
const machine = getFieldMachine(writeSchema, "contact")!;
|
|
308
|
+
expect(runFieldMachine(machine, "ops@example.com").state).toBe("valid");
|
|
309
|
+
const bad = runFieldMachine(machine, "not-an-email");
|
|
310
|
+
expect(bad.failed?.gate).toBe("typed");
|
|
311
|
+
// Optional: empty is fine.
|
|
312
|
+
expect(runFieldMachine(machine, "").state).toBe("valid");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("returns undefined for a field the schema does not describe", () => {
|
|
316
|
+
expect(getFieldMachine(writeSchema, "nope")).toBeUndefined();
|
|
317
|
+
expect(getFieldMachine(undefined, "code")).toBeUndefined();
|
|
318
|
+
});
|
|
319
|
+
});
|
package/validate.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rastack/validate` — the constraint state machine.
|
|
3
|
+
*
|
|
4
|
+
* The database generator's constraints compile into a deterministic finite
|
|
5
|
+
* state machine (`pristine → present → typed → bounded → member → unique →
|
|
6
|
+
* resolved → valid`); the same machine validates form fields in the browser
|
|
7
|
+
* (via the write JSON Schema) and imported datasets on the CLI (via
|
|
8
|
+
* `schema.rastack.json`). Filesystem-free, so it runs everywhere the hooks do.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./src/validate";
|
|
Binary file
|