rastack 0.0.16
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 +43013 -0
- package/dist/.prettierrc +7 -0
- package/dist/compile/analyze.d.ts +12 -0
- package/dist/compile/analyze.js +233 -0
- package/dist/compile/index.d.ts +19 -0
- package/dist/compile/index.js +107 -0
- package/dist/compile/manifest.d.ts +4 -0
- package/dist/compile/manifest.js +12 -0
- package/dist/compile/model.d.ts +54 -0
- package/dist/compile/model.js +7 -0
- package/dist/compile/openapi.d.ts +11 -0
- package/dist/compile/openapi.js +233 -0
- package/dist/compile/program.d.ts +10 -0
- package/dist/compile/program.js +61 -0
- package/dist/csv-schema.d.ts +28 -0
- package/dist/csv-schema.js +162 -0
- package/dist/define/index.d.ts +117 -0
- package/dist/define/index.js +74 -0
- package/dist/entity-generation/delete-method.d.ts +1 -0
- package/dist/entity-generation/delete-method.js +31 -0
- package/dist/entity-generation/form.d.ts +13 -0
- package/dist/entity-generation/form.js +136 -0
- package/dist/entity-generation/get-method.d.ts +1 -0
- package/dist/entity-generation/get-method.js +67 -0
- package/dist/entity-generation/imports.d.ts +5 -0
- package/dist/entity-generation/imports.js +91 -0
- package/dist/entity-generation/sync-method.d.ts +30 -0
- package/dist/entity-generation/sync-method.js +158 -0
- package/dist/entity-generation/update-method.d.ts +1 -0
- package/dist/entity-generation/update-method.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +53 -0
- package/dist/rad-compile.d.ts +11 -0
- package/dist/rad-compile.js +82 -0
- package/dist/rad-wasm-build.d.ts +14 -0
- package/dist/rad-wasm-build.js +74 -0
- package/dist/rad.d.ts +17 -0
- package/dist/rad.js +88 -0
- package/dist/scan.d.ts +12 -0
- package/dist/scan.js +79 -0
- package/dist/schema/camel-to-pastel.d.ts +1 -0
- package/dist/schema/camel-to-pastel.js +7 -0
- package/dist/schema/capitalise-first-letter.d.ts +1 -0
- package/dist/schema/capitalise-first-letter.js +7 -0
- package/dist/schema/extract-response.d.ts +2 -0
- package/dist/schema/extract-response.js +30 -0
- package/dist/schema/fetch-schema.d.ts +7 -0
- package/dist/schema/fetch-schema.js +23 -0
- package/dist/schema/remove-non-model-paths.d.ts +2 -0
- package/dist/schema/remove-non-model-paths.js +15 -0
- package/dist/schema/to-camel-case.d.ts +1 -0
- package/dist/schema/to-camel-case.js +11 -0
- package/dist/schema/to-pastel-case.d.ts +1 -0
- package/dist/schema/to-pastel-case.js +10 -0
- package/dist/schema-convert.d.ts +1 -0
- package/dist/schema-convert.js +63 -0
- package/dist/schema-entities.d.ts +14 -0
- package/dist/schema-entities.js +293 -0
- package/dist/schema-fetch.d.ts +1 -0
- package/dist/schema-fetch.js +81 -0
- package/dist/schema-full.d.ts +1 -0
- package/dist/schema-full.js +45 -0
- package/dist/schema-index.d.ts +1 -0
- package/dist/schema-index.js +34 -0
- package/dist/schema-params.d.ts +1 -0
- package/dist/schema-params.js +100 -0
- package/dist/seed.d.ts +17 -0
- package/dist/seed.js +252 -0
- package/hooks/form/form.ts +207 -0
- package/hooks/form/index.ts +8 -0
- package/hooks/form/interfaces.ts +217 -0
- package/hooks/form/structure.ts +39 -0
- package/hooks/form/validate-schema.ts +49 -0
- package/hooks/index.ts +3 -0
- package/hooks/query/api.ts +42 -0
- package/hooks/query/delete.ts +45 -0
- package/hooks/query/fetch.ts +48 -0
- package/hooks/query/index.ts +21 -0
- package/hooks/query/interfaces.ts +111 -0
- package/hooks/query/list.ts +286 -0
- package/hooks/query/update.ts +88 -0
- package/hooks/query/url.ts +31 -0
- package/hooks/real-time/index.ts +1 -0
- package/hooks/real-time/pusher.ts +43 -0
- package/jest.config.cjs +6 -0
- package/package.json +57 -0
- package/provider/index.ts +13 -0
- package/provider/provider.tsx +187 -0
- package/provider/types.ts +114 -0
- package/provider/warehouse.ts +167 -0
- package/provider/wasm.ts +124 -0
- package/runtime.ts +3 -0
- package/src/.prettierrc +7 -0
- package/src/compile/analyze.ts +224 -0
- package/src/compile/index.ts +86 -0
- package/src/compile/manifest.ts +10 -0
- package/src/compile/model.ts +69 -0
- package/src/compile/openapi.ts +266 -0
- package/src/compile/program.ts +40 -0
- package/src/csv-schema.ts +187 -0
- package/src/define/index.ts +162 -0
- package/src/entity-generation/delete-method.ts +37 -0
- package/src/entity-generation/form.ts +169 -0
- package/src/entity-generation/get-method.ts +80 -0
- package/src/entity-generation/imports.ts +110 -0
- package/src/entity-generation/sync-method.ts +222 -0
- package/src/entity-generation/update-method.ts +38 -0
- package/src/index.ts +77 -0
- package/src/rad-compile.ts +88 -0
- package/src/rad-wasm-build.ts +102 -0
- package/src/rad.ts +101 -0
- package/src/scan.ts +89 -0
- package/src/schema/camel-to-pastel.ts +3 -0
- package/src/schema/capitalise-first-letter.ts +3 -0
- package/src/schema/extract-response.ts +38 -0
- package/src/schema/fetch-schema.ts +12 -0
- package/src/schema/remove-non-model-paths.ts +13 -0
- package/src/schema/to-camel-case.ts +7 -0
- package/src/schema/to-pastel-case.ts +6 -0
- package/src/schema-convert.ts +61 -0
- package/src/schema-entities.ts +401 -0
- package/src/schema-fetch.ts +82 -0
- package/src/schema-full.ts +38 -0
- package/src/schema-index.ts +29 -0
- package/src/schema-params.ts +116 -0
- package/src/seed.ts +297 -0
- package/sync/engine.ts +392 -0
- package/sync/hooks.ts +450 -0
- package/sync/index.ts +8 -0
- package/sync/persistence.ts +237 -0
- package/sync/provider.tsx +91 -0
- package/sync/registry.ts +36 -0
- package/sync/store.ts +126 -0
- package/sync/transactions.ts +300 -0
- package/sync/types.ts +94 -0
- package/test/compile.spec.ts +192 -0
- package/test/csv-schema.spec.ts +143 -0
- package/test/schema-entities.spec.ts +688 -0
- package/tsconfig.json +24 -0
- package/types.ts +19 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { FieldModel, ResourceModel } from "./model";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Emit an OpenAPI 3 document from the analysed resources. The output reproduces
|
|
5
|
+
* the *exact* contract the existing `tools/` codegen and `stack/` runtime rely
|
|
6
|
+
* on — `/api/{app}/v1/{model}/` URL segments and the `x-rastack-sync*` vendor
|
|
7
|
+
* extensions — so those packages are unchanged. Foreign keys additionally carry
|
|
8
|
+
* a new `x-rastack-relation` extension so downstream tooling (and the admin) can
|
|
9
|
+
* follow relations.
|
|
10
|
+
*/
|
|
11
|
+
export function buildOpenApi(resources: ResourceModel[]): any {
|
|
12
|
+
const paths: Record<string, any> = {};
|
|
13
|
+
const schemas: Record<string, any> = {};
|
|
14
|
+
|
|
15
|
+
for (const resource of resources) {
|
|
16
|
+
const P = pascal(resource.model);
|
|
17
|
+
const listUrl = `/api/${resource.app}/v1/${resource.model}/`;
|
|
18
|
+
const detailUrl = `${listUrl}{id}/`;
|
|
19
|
+
const local = resource.sync?.mode === "local";
|
|
20
|
+
|
|
21
|
+
schemas[P] = readSchema(resource);
|
|
22
|
+
schemas[`Paginated${P}List`] = paginatedSchema(P);
|
|
23
|
+
schemas[`${P}Request`] = writeSchema(resource, false);
|
|
24
|
+
schemas[`Patched${P}Request`] = writeSchema(resource, true);
|
|
25
|
+
|
|
26
|
+
paths[listUrl] = {
|
|
27
|
+
get: syncStamp(
|
|
28
|
+
{
|
|
29
|
+
operationId: `list${P}`,
|
|
30
|
+
parameters: [
|
|
31
|
+
queryParam("page", "integer"),
|
|
32
|
+
queryParam("search", "string"),
|
|
33
|
+
],
|
|
34
|
+
responses: jsonResponse("200", `Paginated${P}List`),
|
|
35
|
+
},
|
|
36
|
+
resource,
|
|
37
|
+
"list",
|
|
38
|
+
),
|
|
39
|
+
post: syncStamp(
|
|
40
|
+
{
|
|
41
|
+
operationId: `create${P}`,
|
|
42
|
+
requestBody: jsonBody(`${P}Request`),
|
|
43
|
+
responses: jsonResponse("201", P),
|
|
44
|
+
},
|
|
45
|
+
resource,
|
|
46
|
+
"create",
|
|
47
|
+
),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
paths[detailUrl] = {
|
|
51
|
+
get: syncStamp(
|
|
52
|
+
{
|
|
53
|
+
operationId: `get${P}ById`,
|
|
54
|
+
parameters: [idParam()],
|
|
55
|
+
responses: jsonResponse("200", P),
|
|
56
|
+
},
|
|
57
|
+
resource,
|
|
58
|
+
"retrieve",
|
|
59
|
+
),
|
|
60
|
+
put: syncStamp(
|
|
61
|
+
{
|
|
62
|
+
operationId: `update${P}`,
|
|
63
|
+
parameters: [idParam()],
|
|
64
|
+
requestBody: jsonBody(`${P}Request`),
|
|
65
|
+
responses: jsonResponse("200", P),
|
|
66
|
+
},
|
|
67
|
+
resource,
|
|
68
|
+
"update",
|
|
69
|
+
),
|
|
70
|
+
patch: syncStamp(
|
|
71
|
+
{
|
|
72
|
+
operationId: `partialUpdate${P}`,
|
|
73
|
+
parameters: [idParam()],
|
|
74
|
+
requestBody: jsonBody(`Patched${P}Request`),
|
|
75
|
+
responses: jsonResponse("200", P),
|
|
76
|
+
},
|
|
77
|
+
resource,
|
|
78
|
+
"partial_update",
|
|
79
|
+
),
|
|
80
|
+
delete: syncStamp(
|
|
81
|
+
{
|
|
82
|
+
operationId: `delete${P}`,
|
|
83
|
+
parameters: [idParam()],
|
|
84
|
+
responses: { "204": { description: "No response body" } },
|
|
85
|
+
},
|
|
86
|
+
resource,
|
|
87
|
+
"destroy",
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
openapi: "3.0.3",
|
|
94
|
+
info: { title: "React API Stack", version: "1.0.0" },
|
|
95
|
+
paths,
|
|
96
|
+
components: { schemas },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function openApiToJson(doc: any): string {
|
|
101
|
+
return JSON.stringify(doc, null, 2) + "\n";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// -- schema builders --------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function readSchema(resource: ResourceModel): any {
|
|
107
|
+
const properties: Record<string, any> = {
|
|
108
|
+
id: idProperty(resource),
|
|
109
|
+
};
|
|
110
|
+
const required: string[] = [];
|
|
111
|
+
for (const field of resource.fields) {
|
|
112
|
+
properties[field.name] = fieldProperty(field);
|
|
113
|
+
if (!field.null && field.default === undefined) required.push(field.name);
|
|
114
|
+
}
|
|
115
|
+
return { type: "object", properties, required };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeSchema(resource: ResourceModel, patched: boolean): any {
|
|
119
|
+
const properties: Record<string, any> = {};
|
|
120
|
+
const required: string[] = [];
|
|
121
|
+
for (const field of resource.fields) {
|
|
122
|
+
if (field.primaryKey) continue;
|
|
123
|
+
properties[field.name] = fieldProperty(field);
|
|
124
|
+
if (!patched && !field.null && field.default === undefined) {
|
|
125
|
+
required.push(field.name);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const schema: any = { type: "object", properties };
|
|
129
|
+
if (required.length) schema.required = required;
|
|
130
|
+
return schema;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function paginatedSchema(pascalModel: string): any {
|
|
134
|
+
return {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: {
|
|
137
|
+
count: { type: "integer" },
|
|
138
|
+
next: { type: "string", nullable: true },
|
|
139
|
+
previous: { type: "string", nullable: true },
|
|
140
|
+
results: {
|
|
141
|
+
type: "array",
|
|
142
|
+
items: { $ref: `#/components/schemas/${pascalModel}` },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
required: ["count", "results"],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fieldProperty(field: FieldModel): any {
|
|
150
|
+
if (field.type === "fk") {
|
|
151
|
+
return {
|
|
152
|
+
type: "integer",
|
|
153
|
+
"x-rastack-relation": field.relation,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const base = scalarProperty(field.type);
|
|
157
|
+
if (field.maxLength !== undefined) base.maxLength = field.maxLength;
|
|
158
|
+
if (field.null) base.nullable = true;
|
|
159
|
+
return base;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function idProperty(resource: ResourceModel): any {
|
|
163
|
+
const pk = resource.fields.find((f) => f.primaryKey);
|
|
164
|
+
if (pk) return { ...scalarProperty(pk.type), readOnly: true };
|
|
165
|
+
if (resource.sync?.clientIds) {
|
|
166
|
+
return { type: "string", format: "uuid" };
|
|
167
|
+
}
|
|
168
|
+
return { type: "integer", readOnly: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function scalarProperty(type: string): any {
|
|
172
|
+
switch (type) {
|
|
173
|
+
case "int":
|
|
174
|
+
return { type: "integer" };
|
|
175
|
+
case "float":
|
|
176
|
+
return { type: "number" };
|
|
177
|
+
case "bool":
|
|
178
|
+
return { type: "boolean" };
|
|
179
|
+
case "datetime":
|
|
180
|
+
return { type: "string", format: "date-time" };
|
|
181
|
+
case "uuid":
|
|
182
|
+
return { type: "string", format: "uuid" };
|
|
183
|
+
default:
|
|
184
|
+
return { type: "string" };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// -- operation helpers ------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
function jsonResponse(status: string, schemaName: string): any {
|
|
191
|
+
return {
|
|
192
|
+
[status]: {
|
|
193
|
+
description: "",
|
|
194
|
+
content: {
|
|
195
|
+
"application/json": {
|
|
196
|
+
schema: { $ref: `#/components/schemas/${schemaName}` },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function jsonBody(schemaName: string): any {
|
|
204
|
+
return {
|
|
205
|
+
required: true,
|
|
206
|
+
content: {
|
|
207
|
+
"application/json": {
|
|
208
|
+
schema: { $ref: `#/components/schemas/${schemaName}` },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function idParam(): any {
|
|
215
|
+
return {
|
|
216
|
+
name: "id",
|
|
217
|
+
in: "path",
|
|
218
|
+
required: true,
|
|
219
|
+
schema: { type: "string" },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function queryParam(name: string, type: string): any {
|
|
224
|
+
return { name, in: "query", required: false, schema: { type } };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Stamp the `x-rastack-sync*` vendor extensions on an operation of a local-mode
|
|
229
|
+
* resource. Ported from the old `python/rastack/spectacular.py` postprocess
|
|
230
|
+
* hook: confirmed operations become blocking client hooks, everything else on a
|
|
231
|
+
* local resource replicates.
|
|
232
|
+
*/
|
|
233
|
+
function syncStamp(operation: any, resource: ResourceModel, verb: string): any {
|
|
234
|
+
const sync = resource.sync;
|
|
235
|
+
if (!sync || sync.mode !== "local") return operation;
|
|
236
|
+
|
|
237
|
+
operation["x-rastack-sync"] = isConfirmed(sync.confirmed, verb, operation)
|
|
238
|
+
? "confirmed"
|
|
239
|
+
: "local";
|
|
240
|
+
if (sync.load === "lazy") operation["x-rastack-sync-load"] = "lazy";
|
|
241
|
+
if (sync.clientIds) operation["x-rastack-sync-client-ids"] = true;
|
|
242
|
+
return operation;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isConfirmed(
|
|
246
|
+
confirmed: string[] | undefined,
|
|
247
|
+
verb: string,
|
|
248
|
+
operation: any,
|
|
249
|
+
): boolean {
|
|
250
|
+
if (!confirmed || !confirmed.length) return false;
|
|
251
|
+
for (const name of confirmed) {
|
|
252
|
+
if (name === verb) return true;
|
|
253
|
+
if (name === "update" && verb === "partial_update") return true;
|
|
254
|
+
if (operation.operationId && operation.operationId.includes(name)) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pascal(str: string): string {
|
|
262
|
+
return str
|
|
263
|
+
.split(/[-_]/)
|
|
264
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
265
|
+
.join("");
|
|
266
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a `ts.Program` (and thus a `TypeChecker`) over a set of resource
|
|
6
|
+
* definition files. The DSL package (`rad-tools/define`) is mapped to the
|
|
7
|
+
* in-tree/installed sources via `paths` so both the shipped package name and
|
|
8
|
+
* the local demo/tests resolve without extra linking.
|
|
9
|
+
*/
|
|
10
|
+
export function createProgram(fileNames: string[]): ts.Program {
|
|
11
|
+
const defineDir = path.resolve(__dirname, "../define");
|
|
12
|
+
|
|
13
|
+
const options: ts.CompilerOptions = {
|
|
14
|
+
target: ts.ScriptTarget.ES2019,
|
|
15
|
+
module: ts.ModuleKind.CommonJS,
|
|
16
|
+
moduleResolution: ts.ModuleResolutionKind.Node10,
|
|
17
|
+
strict: true,
|
|
18
|
+
skipLibCheck: true,
|
|
19
|
+
esModuleInterop: true,
|
|
20
|
+
noEmit: true,
|
|
21
|
+
baseUrl: process.cwd(),
|
|
22
|
+
paths: {
|
|
23
|
+
"rad-tools/define": [defineDir],
|
|
24
|
+
"rad-tools/define/*": [defineDir + "/*"],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return ts.createProgram(fileNames, options);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every non-declaration source file the program was rooted at. */
|
|
32
|
+
export function resourceSourceFiles(
|
|
33
|
+
program: ts.Program,
|
|
34
|
+
fileNames: string[],
|
|
35
|
+
): ts.SourceFile[] {
|
|
36
|
+
const wanted = new Set(fileNames.map((f) => path.resolve(f)));
|
|
37
|
+
return program
|
|
38
|
+
.getSourceFiles()
|
|
39
|
+
.filter((sf) => !sf.isDeclarationFile && wanted.has(path.resolve(sf.fileName)));
|
|
40
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { minimatch } from "minimatch";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
|
|
6
|
+
export interface ColumnSchema {
|
|
7
|
+
name: string;
|
|
8
|
+
type: "integer" | "float" | "boolean" | "date" | "datetime" | "string";
|
|
9
|
+
nullable: boolean;
|
|
10
|
+
unique: boolean;
|
|
11
|
+
primaryKey: boolean;
|
|
12
|
+
pii: boolean;
|
|
13
|
+
piiReason?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TableSchema {
|
|
17
|
+
tableName: string;
|
|
18
|
+
sourceFile: string;
|
|
19
|
+
columns: ColumnSchema[];
|
|
20
|
+
rowCount: number;
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PiiConfig {
|
|
25
|
+
patterns: string[];
|
|
26
|
+
luhn_check: boolean;
|
|
27
|
+
require_policy: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_PII_PATTERNS = [
|
|
31
|
+
"*passport*",
|
|
32
|
+
"*_no",
|
|
33
|
+
"*_number",
|
|
34
|
+
"ssn",
|
|
35
|
+
"national_id",
|
|
36
|
+
"date_of_birth",
|
|
37
|
+
"dob",
|
|
38
|
+
"email",
|
|
39
|
+
"*phone*",
|
|
40
|
+
"*credit_card*",
|
|
41
|
+
"*card_number*",
|
|
42
|
+
"ip_address",
|
|
43
|
+
"latitude",
|
|
44
|
+
"longitude",
|
|
45
|
+
"*salary*",
|
|
46
|
+
"*income*",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function loadPiiConfig(repoRoot: string): PiiConfig {
|
|
50
|
+
const configPath = path.join(repoRoot, ".rad", "pii-patterns.yml");
|
|
51
|
+
if (!fs.existsSync(configPath)) {
|
|
52
|
+
return { patterns: DEFAULT_PII_PATTERNS, luhn_check: true, require_policy: true };
|
|
53
|
+
}
|
|
54
|
+
return yaml.load(fs.readFileSync(configPath, "utf-8")) as PiiConfig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isPiiColumn(name: string, config: PiiConfig): string | null {
|
|
58
|
+
const lower = name.toLowerCase();
|
|
59
|
+
for (const pattern of config.patterns) {
|
|
60
|
+
if (minimatch(lower, pattern.toLowerCase())) {
|
|
61
|
+
return `matches PII pattern "${pattern}"`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function luhnCheck(value: string): boolean {
|
|
68
|
+
const digits = value.replace(/\D/g, "");
|
|
69
|
+
if (digits.length < 13) return false;
|
|
70
|
+
let sum = 0;
|
|
71
|
+
let alternate = false;
|
|
72
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
73
|
+
let n = parseInt(digits[i], 10);
|
|
74
|
+
if (alternate) {
|
|
75
|
+
n *= 2;
|
|
76
|
+
if (n > 9) n -= 9;
|
|
77
|
+
}
|
|
78
|
+
sum += n;
|
|
79
|
+
alternate = !alternate;
|
|
80
|
+
}
|
|
81
|
+
return sum % 10 === 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inferType(values: string[]): ColumnSchema["type"] {
|
|
85
|
+
const nonEmpty = values.filter((v) => v.trim() !== "" && v.toLowerCase() !== "null");
|
|
86
|
+
if (nonEmpty.length === 0) return "string";
|
|
87
|
+
|
|
88
|
+
if (nonEmpty.every((v) => /^\d+$/.test(v.trim()))) return "integer";
|
|
89
|
+
if (nonEmpty.every((v) => /^-?\d+(\.\d+)?$/.test(v.trim()))) return "float";
|
|
90
|
+
if (nonEmpty.every((v) => /^(true|false|yes|no|1|0)$/i.test(v.trim()))) return "boolean";
|
|
91
|
+
if (nonEmpty.every((v) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(v.trim()))) return "datetime";
|
|
92
|
+
if (nonEmpty.every((v) => /^\d{4}-\d{2}-\d{2}$/.test(v.trim()))) return "date";
|
|
93
|
+
return "string";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseCsv(content: string): { headers: string[]; rows: string[][] } {
|
|
97
|
+
const lines = content.split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
98
|
+
if (lines.length === 0) return { headers: [], rows: [] };
|
|
99
|
+
|
|
100
|
+
const splitLine = (line: string): string[] => {
|
|
101
|
+
const result: string[] = [];
|
|
102
|
+
let current = "";
|
|
103
|
+
let inQuotes = false;
|
|
104
|
+
for (let i = 0; i < line.length; i++) {
|
|
105
|
+
if (line[i] === '"') {
|
|
106
|
+
inQuotes = !inQuotes;
|
|
107
|
+
} else if (line[i] === "," && !inQuotes) {
|
|
108
|
+
result.push(current.trim());
|
|
109
|
+
current = "";
|
|
110
|
+
} else {
|
|
111
|
+
current += line[i];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
result.push(current.trim());
|
|
115
|
+
return result;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const headers = splitLine(lines[0]);
|
|
119
|
+
const rows = lines.slice(1).map(splitLine);
|
|
120
|
+
return { headers, rows };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fingerprint(schema: Omit<TableSchema, "fingerprint">): string {
|
|
124
|
+
const sig = schema.columns.map((c) => `${c.name}:${c.type}:${c.pii}`).join("|");
|
|
125
|
+
// Simple hash — not crypto, just change-detection
|
|
126
|
+
let h = 0;
|
|
127
|
+
for (let i = 0; i < sig.length; i++) {
|
|
128
|
+
h = (Math.imul(31, h) + sig.charCodeAt(i)) | 0;
|
|
129
|
+
}
|
|
130
|
+
return Math.abs(h).toString(16).padStart(8, "0");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function inferSchema(
|
|
134
|
+
csvPath: string,
|
|
135
|
+
repoRoot: string,
|
|
136
|
+
): { schema: TableSchema; piiViolations: { column: string; reason: string }[] } {
|
|
137
|
+
const content = fs.readFileSync(csvPath, "utf-8");
|
|
138
|
+
const { headers, rows } = parseCsv(content);
|
|
139
|
+
const piiConfig = loadPiiConfig(repoRoot);
|
|
140
|
+
const tableName = path.basename(csvPath, ".csv");
|
|
141
|
+
|
|
142
|
+
const piiViolations: { column: string; reason: string }[] = [];
|
|
143
|
+
|
|
144
|
+
const columns: ColumnSchema[] = headers.map((name, i) => {
|
|
145
|
+
const values = rows.map((r) => r[i] ?? "");
|
|
146
|
+
const type = inferType(values);
|
|
147
|
+
const nullable = values.some((v) => v.trim() === "" || v.toLowerCase() === "null");
|
|
148
|
+
const nonEmpty = values.filter((v) => v.trim() !== "");
|
|
149
|
+
const unique = new Set(nonEmpty).size === nonEmpty.length;
|
|
150
|
+
const isPrimary = name.toLowerCase() === "id";
|
|
151
|
+
|
|
152
|
+
// PII: column name pattern match
|
|
153
|
+
let pii = false;
|
|
154
|
+
let piiReason: string | undefined;
|
|
155
|
+
const nameReason = isPiiColumn(name, piiConfig);
|
|
156
|
+
if (nameReason) {
|
|
157
|
+
pii = true;
|
|
158
|
+
piiReason = nameReason;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// PII: Luhn check on string columns that look numeric
|
|
162
|
+
if (!pii && piiConfig.luhn_check && type === "string") {
|
|
163
|
+
const luhnHits = values.filter((v) => v.trim() !== "" && luhnCheck(v));
|
|
164
|
+
if (luhnHits.length > values.length * 0.5) {
|
|
165
|
+
pii = true;
|
|
166
|
+
piiReason = "majority of values pass Luhn check (possible card numbers)";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (pii && piiReason) {
|
|
171
|
+
piiViolations.push({ column: name, reason: piiReason });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { name, type, nullable, unique, primaryKey: isPrimary, pii, piiReason };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const base = { tableName, sourceFile: csvPath, columns, rowCount: rows.length };
|
|
178
|
+
return { schema: { ...base, fingerprint: fingerprint(base) }, piiViolations };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function discoverCsvFiles(dir: string): string[] {
|
|
182
|
+
if (!fs.existsSync(dir)) return [];
|
|
183
|
+
return fs
|
|
184
|
+
.readdirSync(dir)
|
|
185
|
+
.filter((f) => f.endsWith(".csv"))
|
|
186
|
+
.map((f) => path.join(dir, f));
|
|
187
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rad-tools authoring DSL — the TypeScript-first source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Resources are authored as plain TypeScript. The compiler
|
|
5
|
+
* (`tools/src/compile`) reads these definitions and, crucially, **infers
|
|
6
|
+
* foreign-key relations directly from the field types** via the TypeScript type
|
|
7
|
+
* checker: a field whose type is a {@link Ref} to another {@link ResourceType}
|
|
8
|
+
* is recorded as a foreign key, with the target app/model recovered from the
|
|
9
|
+
* `Ref`'s string-literal type arguments.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { resource, s } from "rad-tools/define";
|
|
13
|
+
*
|
|
14
|
+
* export const Airport = resource("airports", "airport", {
|
|
15
|
+
* code: s.string({ maxLength: 3, unique: true }),
|
|
16
|
+
* name: s.string({ maxLength: 120 }),
|
|
17
|
+
* }, { search: ["code", "name"], admin: { listDisplay: ["code", "name"] } });
|
|
18
|
+
*
|
|
19
|
+
* export const Terminal = resource("airports", "terminal", {
|
|
20
|
+
* airport: s.ref(() => Airport), // FK inferred from the type of this field
|
|
21
|
+
* label: s.string({ maxLength: 20 }),
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A scalar field. The literal `__radScalar` type is what the compiler reads. */
|
|
27
|
+
export interface Scalar<T extends string> {
|
|
28
|
+
readonly __radScalar: T;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A foreign-key field. `App`/`Model` are carried as string-literal type
|
|
33
|
+
* arguments so the compiler can recover the relation target purely from types.
|
|
34
|
+
*/
|
|
35
|
+
export interface Ref<App extends string, Model extends string> {
|
|
36
|
+
readonly __radRef: true;
|
|
37
|
+
readonly __app: App;
|
|
38
|
+
readonly __model: Model;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StringOptions {
|
|
42
|
+
maxLength?: number;
|
|
43
|
+
unique?: boolean;
|
|
44
|
+
primaryKey?: boolean;
|
|
45
|
+
default?: string;
|
|
46
|
+
null?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface NumberOptions {
|
|
50
|
+
unique?: boolean;
|
|
51
|
+
primaryKey?: boolean;
|
|
52
|
+
default?: number;
|
|
53
|
+
null?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BoolOptions {
|
|
57
|
+
default?: boolean;
|
|
58
|
+
null?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DateOptions {
|
|
62
|
+
autoNow?: boolean;
|
|
63
|
+
autoNowAdd?: boolean;
|
|
64
|
+
null?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Field builders. Each returns a phantom value whose *type* encodes the field
|
|
69
|
+
* kind; the runtime object is never used at compile time (the compiler analyses
|
|
70
|
+
* the source, it does not execute it) but is real so the DSL is also usable at
|
|
71
|
+
* runtime if a consumer wants it.
|
|
72
|
+
*/
|
|
73
|
+
export const s = {
|
|
74
|
+
string(_opts?: StringOptions): Scalar<"string"> {
|
|
75
|
+
return { __radScalar: "string" } as Scalar<"string">;
|
|
76
|
+
},
|
|
77
|
+
int(_opts?: NumberOptions): Scalar<"int"> {
|
|
78
|
+
return { __radScalar: "int" } as Scalar<"int">;
|
|
79
|
+
},
|
|
80
|
+
float(_opts?: NumberOptions): Scalar<"float"> {
|
|
81
|
+
return { __radScalar: "float" } as Scalar<"float">;
|
|
82
|
+
},
|
|
83
|
+
bool(_opts?: BoolOptions): Scalar<"bool"> {
|
|
84
|
+
return { __radScalar: "bool" } as Scalar<"bool">;
|
|
85
|
+
},
|
|
86
|
+
datetime(_opts?: DateOptions): Scalar<"datetime"> {
|
|
87
|
+
return { __radScalar: "datetime" } as Scalar<"datetime">;
|
|
88
|
+
},
|
|
89
|
+
uuid(_opts?: StringOptions): Scalar<"uuid"> {
|
|
90
|
+
return { __radScalar: "uuid" } as Scalar<"uuid">;
|
|
91
|
+
},
|
|
92
|
+
/**
|
|
93
|
+
* A foreign key. The target is `() => SomeResource`; the compiler resolves
|
|
94
|
+
* the relation from the *type* `Ref<App, Model>` this returns — it does not
|
|
95
|
+
* need to run the thunk.
|
|
96
|
+
*/
|
|
97
|
+
ref<A extends string, M extends string>(
|
|
98
|
+
_thunk: () => ResourceType<A, M, any>,
|
|
99
|
+
): Ref<A, M> {
|
|
100
|
+
return { __radRef: true } as Ref<A, M>;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type AnyField = Scalar<string> | Ref<string, string>;
|
|
105
|
+
|
|
106
|
+
/** Local-first sync configuration (mirrors the old Python `class Sync:`). */
|
|
107
|
+
export interface SyncOptions {
|
|
108
|
+
mode?: "server" | "local";
|
|
109
|
+
scope?: string;
|
|
110
|
+
confirmed?: string[];
|
|
111
|
+
load?: "instant" | "lazy";
|
|
112
|
+
clientIds?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ResourceOptions {
|
|
116
|
+
/** DRF-style search fields. */
|
|
117
|
+
search?: string[];
|
|
118
|
+
/** Orderable fields. */
|
|
119
|
+
ordering?: string[];
|
|
120
|
+
/** Admin config, or `false` to opt out of the admin. */
|
|
121
|
+
admin?: { listDisplay?: string[] } | false;
|
|
122
|
+
/** Local-first replication config. */
|
|
123
|
+
sync?: SyncOptions;
|
|
124
|
+
/** Access policy (secure by default). */
|
|
125
|
+
permission?: "authenticatedOrReadOnly" | "authenticated" | "public";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A resource definition. `App`/`Model` are preserved as string-literal type
|
|
130
|
+
* arguments so a {@link Ref} to this resource carries a recoverable target.
|
|
131
|
+
*/
|
|
132
|
+
export interface ResourceType<App extends string, Model extends string, F> {
|
|
133
|
+
readonly __rad: "resource";
|
|
134
|
+
readonly __app: App;
|
|
135
|
+
readonly __model: Model;
|
|
136
|
+
readonly app: App;
|
|
137
|
+
readonly model: Model;
|
|
138
|
+
readonly fields: F;
|
|
139
|
+
readonly options: ResourceOptions;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Define a resource. Capture app/model as literal types for FK inference. */
|
|
143
|
+
export function resource<
|
|
144
|
+
App extends string,
|
|
145
|
+
Model extends string,
|
|
146
|
+
F extends Record<string, AnyField>,
|
|
147
|
+
>(
|
|
148
|
+
app: App,
|
|
149
|
+
model: Model,
|
|
150
|
+
fields: F,
|
|
151
|
+
options: ResourceOptions = {},
|
|
152
|
+
): ResourceType<App, Model, F> {
|
|
153
|
+
return {
|
|
154
|
+
__rad: "resource",
|
|
155
|
+
__app: app,
|
|
156
|
+
__model: model,
|
|
157
|
+
app,
|
|
158
|
+
model,
|
|
159
|
+
fields,
|
|
160
|
+
options,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { capitaliseFirstLetter } from "../schema/capitalise-first-letter";
|
|
2
|
+
|
|
3
|
+
export function generateDeleteAPIMethod(
|
|
4
|
+
url: string,
|
|
5
|
+
operationId: string,
|
|
6
|
+
listQueryCacheKey?: string,
|
|
7
|
+
): [string, Set<string>] {
|
|
8
|
+
const operationIdUpper = capitaliseFirstLetter(operationId);
|
|
9
|
+
|
|
10
|
+
let urlStructure = `'${url}'`;
|
|
11
|
+
const pathParams =
|
|
12
|
+
url.match(/{([^}]+)}/g)?.map((param) => param.slice(1, -1)) || [];
|
|
13
|
+
|
|
14
|
+
if (pathParams.length > 0) {
|
|
15
|
+
urlStructure = url.replace(/{([^}]+)}/g, "${$1}");
|
|
16
|
+
const paramList = pathParams.join(", ");
|
|
17
|
+
urlStructure = `(${paramList}) => \`${urlStructure}\``;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const importStatements = new Set<string>();
|
|
21
|
+
importStatements.add(`useDeleteQuery`);
|
|
22
|
+
|
|
23
|
+
const cacheKeyLine = listQueryCacheKey
|
|
24
|
+
? `\n listQueryCacheKey: ${listQueryCacheKey},`
|
|
25
|
+
: "";
|
|
26
|
+
|
|
27
|
+
return [
|
|
28
|
+
`
|
|
29
|
+
export function use${operationIdUpper}() {
|
|
30
|
+
return useDeleteQuery({
|
|
31
|
+
deleteUrl: ${urlStructure},${cacheKeyLine}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
`,
|
|
35
|
+
importStatements,
|
|
36
|
+
];
|
|
37
|
+
}
|