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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for the in-browser warehouse blob produced by
|
|
3
|
+
* `RadApi.exportWarehouse()`. A single blob (the whole Iceberg warehouse)
|
|
4
|
+
* lives in a one-row IndexedDB store so `local` mode survives reloads; `s3`
|
|
5
|
+
* mode swaps this for a {@link WarehouseSource} over object storage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STORE = "warehouse";
|
|
9
|
+
const KEY = "blob";
|
|
10
|
+
|
|
11
|
+
function openDb(name: string): Promise<IDBDatabase> {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const request = indexedDB.open(name, 1);
|
|
14
|
+
request.onupgradeneeded = () => {
|
|
15
|
+
const db = request.result;
|
|
16
|
+
if (!db.objectStoreNames.contains(STORE)) {
|
|
17
|
+
db.createObjectStore(STORE);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
request.onsuccess = () => resolve(request.result);
|
|
21
|
+
request.onerror = () => reject(request.error);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class WarehousePersistence {
|
|
26
|
+
private db: Promise<IDBDatabase> | null;
|
|
27
|
+
|
|
28
|
+
constructor(private databaseName: string) {
|
|
29
|
+
this.db =
|
|
30
|
+
typeof indexedDB !== "undefined" ? openDb(databaseName) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get enabled(): boolean {
|
|
34
|
+
return this.db !== null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async load(): Promise<Uint8Array | null> {
|
|
38
|
+
if (!this.db) return null;
|
|
39
|
+
const db = await this.db;
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const tx = db.transaction(STORE, "readonly");
|
|
42
|
+
const request = tx.objectStore(STORE).get(KEY);
|
|
43
|
+
request.onsuccess = () => {
|
|
44
|
+
const value = request.result;
|
|
45
|
+
resolve(value ? new Uint8Array(value) : null);
|
|
46
|
+
};
|
|
47
|
+
request.onerror = () => reject(request.error);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async save(blob: Uint8Array): Promise<void> {
|
|
52
|
+
if (!this.db) return;
|
|
53
|
+
const db = await this.db;
|
|
54
|
+
await new Promise<void>((resolve, reject) => {
|
|
55
|
+
const tx = db.transaction(STORE, "readwrite");
|
|
56
|
+
// Store a copy of the bytes; the ArrayBuffer is structured-clonable.
|
|
57
|
+
tx.objectStore(STORE).put(blob.slice().buffer, KEY);
|
|
58
|
+
tx.oncomplete = () => resolve();
|
|
59
|
+
tx.onerror = () => reject(tx.error);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Fetch bytes, or `null` on 404. */
|
|
65
|
+
async function fetchBytes(url: string): Promise<Uint8Array | null> {
|
|
66
|
+
const response = await fetch(url);
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
if (response.status === 404) return null;
|
|
69
|
+
throw new Error(`failed to fetch ${url} (${response.status})`);
|
|
70
|
+
}
|
|
71
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The engine surface the seeder needs. */
|
|
75
|
+
interface Seedable {
|
|
76
|
+
seedFile(key: string, bytes: Uint8Array): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Seed the engine by walking a committed Iceberg warehouse over HTTP — the
|
|
81
|
+
* database is a *list of Iceberg files*, not a bundled blob. For each resource
|
|
82
|
+
* it follows the Iceberg pointers: `version-hint.text` → the current snapshot
|
|
83
|
+
* metadata → that snapshot's `.parquet` data files, handing each raw file to
|
|
84
|
+
* `engine.seedFile`. This is the same walk the server does over object storage;
|
|
85
|
+
* no directory listing is needed because Iceberg metadata is self-describing.
|
|
86
|
+
*
|
|
87
|
+
* Returns the number of tables seeded.
|
|
88
|
+
*/
|
|
89
|
+
export async function seedFromIceberg(
|
|
90
|
+
engine: Seedable,
|
|
91
|
+
baseUrl: string,
|
|
92
|
+
resources: { app: string; model: string }[],
|
|
93
|
+
): Promise<number> {
|
|
94
|
+
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
95
|
+
const decoder = new TextDecoder();
|
|
96
|
+
let tables = 0;
|
|
97
|
+
|
|
98
|
+
for (const { app, model } of resources) {
|
|
99
|
+
const prefix = `${app}.db/${model}`;
|
|
100
|
+
const hintKey = `${prefix}/metadata/version-hint.text`;
|
|
101
|
+
const hint = await fetchBytes(base + hintKey);
|
|
102
|
+
if (!hint) continue; // table has no committed data yet
|
|
103
|
+
|
|
104
|
+
await engine.seedFile(hintKey, hint);
|
|
105
|
+
const version = decoder.decode(hint).trim();
|
|
106
|
+
|
|
107
|
+
const metaKey = `${prefix}/metadata/v${version}.metadata.json`;
|
|
108
|
+
const metaBytes = await fetchBytes(base + metaKey);
|
|
109
|
+
if (!metaBytes) continue;
|
|
110
|
+
await engine.seedFile(metaKey, metaBytes);
|
|
111
|
+
|
|
112
|
+
const meta = JSON.parse(decoder.decode(metaBytes)) as { data_files?: string[] };
|
|
113
|
+
for (const rel of meta.data_files || []) {
|
|
114
|
+
const key = `${prefix}/${rel}`;
|
|
115
|
+
const bytes = await fetchBytes(base + key);
|
|
116
|
+
if (bytes) await engine.seedFile(key, bytes);
|
|
117
|
+
}
|
|
118
|
+
tables++;
|
|
119
|
+
}
|
|
120
|
+
return tables;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Read the `{app, model}` list out of a compiled manifest (object or string). */
|
|
124
|
+
export function manifestResources(
|
|
125
|
+
manifest: object | string,
|
|
126
|
+
): { app: string; model: string }[] {
|
|
127
|
+
const parsed = typeof manifest === "string" ? JSON.parse(manifest) : manifest;
|
|
128
|
+
return (parsed?.resources || []).map((r: any) => ({
|
|
129
|
+
app: r.app,
|
|
130
|
+
model: r.model,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Trigger a browser download of the warehouse blob so it can be committed. */
|
|
135
|
+
export function downloadBlob(blob: Uint8Array, filename: string): void {
|
|
136
|
+
const url = URL.createObjectURL(new Blob([blob.slice().buffer]));
|
|
137
|
+
const a = document.createElement("a");
|
|
138
|
+
a.href = url;
|
|
139
|
+
a.download = filename;
|
|
140
|
+
document.body.appendChild(a);
|
|
141
|
+
a.click();
|
|
142
|
+
a.remove();
|
|
143
|
+
URL.revokeObjectURL(url);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A trailing debounce that always fires a final call. */
|
|
147
|
+
export function debounce<T extends (...args: any[]) => void>(
|
|
148
|
+
fn: T,
|
|
149
|
+
ms: number,
|
|
150
|
+
): T & { flush: () => void } {
|
|
151
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
152
|
+
const wrapped = ((...args: any[]) => {
|
|
153
|
+
if (timer) clearTimeout(timer);
|
|
154
|
+
timer = setTimeout(() => {
|
|
155
|
+
timer = null;
|
|
156
|
+
fn(...args);
|
|
157
|
+
}, ms);
|
|
158
|
+
}) as T & { flush: () => void };
|
|
159
|
+
wrapped.flush = () => {
|
|
160
|
+
if (timer) {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
timer = null;
|
|
163
|
+
fn();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
return wrapped;
|
|
167
|
+
}
|
package/provider/wasm.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { AxiosAdapter, AxiosError, AxiosResponse } from "axios";
|
|
2
|
+
import { RadEngine, RadEngineLoader, RadWasmModule } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default WASM loader: import the bundle `rad wasm` generates
|
|
6
|
+
* (`rastack/wasm/rad_wasm.js`) and run its init. The indirection through
|
|
7
|
+
* `new Function` keeps a genuine dynamic `import()` at runtime even when the
|
|
8
|
+
* package is transpiled to CommonJS, so the consumer's bundler resolves and
|
|
9
|
+
* code-splits the `.wasm`.
|
|
10
|
+
*/
|
|
11
|
+
const runtimeImport: (specifier: string) => Promise<any> =
|
|
12
|
+
// eslint-disable-next-line no-new-func
|
|
13
|
+
new Function("s", "return import(s)") as any;
|
|
14
|
+
|
|
15
|
+
export const defaultEngineLoader: RadEngineLoader = async () => {
|
|
16
|
+
const mod: RadWasmModule = await runtimeImport("rastack/wasm/rad_wasm.js");
|
|
17
|
+
const init = mod.default || mod.init;
|
|
18
|
+
if (typeof init === "function") {
|
|
19
|
+
await init();
|
|
20
|
+
}
|
|
21
|
+
return mod;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Instantiate the engine from a loaded module and the compiled schema. The
|
|
26
|
+
* manifest/openapi are passed as JSON strings — the same `schema.rad.json` /
|
|
27
|
+
* `openapi.json` the Rust server reads.
|
|
28
|
+
*/
|
|
29
|
+
export function createEngine(
|
|
30
|
+
mod: RadWasmModule,
|
|
31
|
+
manifest: object | string,
|
|
32
|
+
openapi?: object | string,
|
|
33
|
+
): RadEngine {
|
|
34
|
+
const manifestJson =
|
|
35
|
+
typeof manifest === "string" ? manifest : JSON.stringify(manifest);
|
|
36
|
+
const openapiJson =
|
|
37
|
+
openapi === undefined
|
|
38
|
+
? undefined
|
|
39
|
+
: typeof openapi === "string"
|
|
40
|
+
? openapi
|
|
41
|
+
: JSON.stringify(openapi);
|
|
42
|
+
return new mod.RadApi(manifestJson, openapiJson);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* An axios adapter that dispatches requests into the in-browser WASM engine
|
|
49
|
+
* instead of over the network. Translates an axios request into the engine's
|
|
50
|
+
* `handle(method, path, query, body)` and maps the `{ status, body }` result
|
|
51
|
+
* back to an `AxiosResponse` — rejecting on non-2xx exactly as HTTP axios does,
|
|
52
|
+
* so `react-query` error handling is unchanged.
|
|
53
|
+
*
|
|
54
|
+
* `onWrite` fires after each successful mutation so the provider can debounce a
|
|
55
|
+
* persist of the warehouse.
|
|
56
|
+
*/
|
|
57
|
+
export function createWasmAdapter(
|
|
58
|
+
engine: RadEngine,
|
|
59
|
+
onWrite?: () => void,
|
|
60
|
+
): AxiosAdapter {
|
|
61
|
+
return async (config: any): Promise<AxiosResponse> => {
|
|
62
|
+
const method = String(config.method || "get").toUpperCase();
|
|
63
|
+
|
|
64
|
+
const rawUrl = config.url || "";
|
|
65
|
+
const [rawPath, rawQuery] = rawUrl.split("?");
|
|
66
|
+
// Strip any origin so only the `/api/...` path reaches the engine.
|
|
67
|
+
const path = rawPath.replace(/^https?:\/\/[^/]+/i, "") || "/";
|
|
68
|
+
|
|
69
|
+
const query: Record<string, string> = {};
|
|
70
|
+
if (rawQuery) {
|
|
71
|
+
new URLSearchParams(rawQuery).forEach((value, key) => {
|
|
72
|
+
query[key] = value;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (config.params && typeof config.params === "object") {
|
|
76
|
+
for (const [key, value] of Object.entries(config.params)) {
|
|
77
|
+
if (value !== undefined && value !== null) query[key] = String(value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// axios has already run transformRequest, so JSON bodies arrive as strings.
|
|
82
|
+
let body: unknown;
|
|
83
|
+
if (typeof config.data === "string" && config.data.length > 0) {
|
|
84
|
+
try {
|
|
85
|
+
body = JSON.parse(config.data);
|
|
86
|
+
} catch {
|
|
87
|
+
body = config.data;
|
|
88
|
+
}
|
|
89
|
+
} else if (config.data && typeof config.data === "object") {
|
|
90
|
+
body = config.data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = await engine.handle(
|
|
94
|
+
method,
|
|
95
|
+
path,
|
|
96
|
+
Object.keys(query).length ? query : undefined,
|
|
97
|
+
body,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const response: AxiosResponse = {
|
|
101
|
+
data: result.body,
|
|
102
|
+
status: result.status,
|
|
103
|
+
statusText: String(result.status),
|
|
104
|
+
headers: {},
|
|
105
|
+
config,
|
|
106
|
+
request: null,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const validate =
|
|
110
|
+
config.validateStatus || ((s: number) => s >= 200 && s < 300);
|
|
111
|
+
if (!validate(result.status)) {
|
|
112
|
+
throw new AxiosError(
|
|
113
|
+
`Request failed with status code ${result.status}`,
|
|
114
|
+
result.status >= 500 ? "ERR_BAD_RESPONSE" : "ERR_BAD_REQUEST",
|
|
115
|
+
config,
|
|
116
|
+
null,
|
|
117
|
+
response,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (MUTATING.has(method) && onWrite) onWrite();
|
|
122
|
+
return response;
|
|
123
|
+
};
|
|
124
|
+
}
|
package/runtime.ts
ADDED
package/src/.prettierrc
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import {
|
|
3
|
+
AdminModel,
|
|
4
|
+
FieldModel,
|
|
5
|
+
RelationModel,
|
|
6
|
+
ResourceModel,
|
|
7
|
+
ScalarType,
|
|
8
|
+
SyncModel,
|
|
9
|
+
} from "./model";
|
|
10
|
+
import { resourceSourceFiles } from "./program";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Statically analyse resource definitions and produce the canonical model.
|
|
14
|
+
*
|
|
15
|
+
* The headline behaviour is **foreign-key inference from types**: for every
|
|
16
|
+
* field we ask the `TypeChecker` for the field's type and, if that type is a
|
|
17
|
+
* `Ref<App, Model>`, we recover the relation target from the `__app`/`__model`
|
|
18
|
+
* string-literal type arguments. No runtime execution, no string heuristics on
|
|
19
|
+
* the value — the relation is read straight out of the type system.
|
|
20
|
+
*/
|
|
21
|
+
export function analyze(
|
|
22
|
+
program: ts.Program,
|
|
23
|
+
fileNames: string[],
|
|
24
|
+
): ResourceModel[] {
|
|
25
|
+
const checker = program.getTypeChecker();
|
|
26
|
+
const resources: ResourceModel[] = [];
|
|
27
|
+
|
|
28
|
+
for (const sourceFile of resourceSourceFiles(program, fileNames)) {
|
|
29
|
+
ts.forEachChild(sourceFile, function walk(node) {
|
|
30
|
+
if (isResourceCall(node)) {
|
|
31
|
+
const model = analyzeResourceCall(node, checker);
|
|
32
|
+
if (model) resources.push(model);
|
|
33
|
+
}
|
|
34
|
+
ts.forEachChild(node, walk);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Deterministic order: app, then model.
|
|
39
|
+
resources.sort((a, b) =>
|
|
40
|
+
a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
|
|
41
|
+
);
|
|
42
|
+
return resources;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isResourceCall(node: ts.Node): node is ts.CallExpression {
|
|
46
|
+
return (
|
|
47
|
+
ts.isCallExpression(node) &&
|
|
48
|
+
ts.isIdentifier(node.expression) &&
|
|
49
|
+
node.expression.text === "resource" &&
|
|
50
|
+
node.arguments.length >= 3
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function analyzeResourceCall(
|
|
55
|
+
call: ts.CallExpression,
|
|
56
|
+
checker: ts.TypeChecker,
|
|
57
|
+
): ResourceModel | undefined {
|
|
58
|
+
const app = stringLiteralArg(call.arguments[0]);
|
|
59
|
+
const model = stringLiteralArg(call.arguments[1]);
|
|
60
|
+
const fieldsArg = call.arguments[2];
|
|
61
|
+
if (!app || !model || !fieldsArg || !ts.isObjectLiteralExpression(fieldsArg)) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fields: FieldModel[] = [];
|
|
66
|
+
const relations: RelationModel[] = [];
|
|
67
|
+
|
|
68
|
+
for (const prop of fieldsArg.properties) {
|
|
69
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
70
|
+
const name = propertyName(prop.name);
|
|
71
|
+
if (!name) continue;
|
|
72
|
+
|
|
73
|
+
const field = analyzeField(name, prop.initializer, checker);
|
|
74
|
+
if (!field) continue;
|
|
75
|
+
fields.push(field);
|
|
76
|
+
if (field.type === "fk" && field.relation) {
|
|
77
|
+
relations.push({
|
|
78
|
+
field: name,
|
|
79
|
+
target: `${field.relation.app}.${field.relation.model}`,
|
|
80
|
+
kind: "fk",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const options = literalToValue(call.arguments[3]) ?? {};
|
|
86
|
+
return {
|
|
87
|
+
app,
|
|
88
|
+
model,
|
|
89
|
+
fields,
|
|
90
|
+
relations,
|
|
91
|
+
sync: normaliseSync(options.sync),
|
|
92
|
+
admin: normaliseAdmin(options.admin),
|
|
93
|
+
search: options.search,
|
|
94
|
+
ordering: options.ordering,
|
|
95
|
+
permission: options.permission,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a single field from its initializer. FK detection is type-driven:
|
|
101
|
+
* the initializer's *type* is inspected, and a `Ref` type yields a foreign key.
|
|
102
|
+
*/
|
|
103
|
+
function analyzeField(
|
|
104
|
+
name: string,
|
|
105
|
+
initializer: ts.Expression,
|
|
106
|
+
checker: ts.TypeChecker,
|
|
107
|
+
): FieldModel | undefined {
|
|
108
|
+
const fieldType = checker.getTypeAtLocation(initializer);
|
|
109
|
+
|
|
110
|
+
// Foreign key: the field type is Ref<App, Model>. Recover the target from
|
|
111
|
+
// the string-literal type arguments — this is the "infer FK from types" step.
|
|
112
|
+
if (fieldType.getProperty("__radRef")) {
|
|
113
|
+
const app = literalTypeProp(fieldType, "__app", checker, initializer);
|
|
114
|
+
const model = literalTypeProp(fieldType, "__model", checker, initializer);
|
|
115
|
+
if (app && model) {
|
|
116
|
+
return { name, type: "fk", relation: { app, model } };
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Scalar: the field type carries a `__radScalar` string-literal discriminant.
|
|
122
|
+
const scalar = literalTypeProp(fieldType, "__radScalar", checker, initializer);
|
|
123
|
+
if (!scalar) return undefined;
|
|
124
|
+
|
|
125
|
+
const field: FieldModel = { name, type: scalar as ScalarType };
|
|
126
|
+
applyConstraints(field, initializer);
|
|
127
|
+
return field;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read scalar constraints (maxLength, unique, …) from the builder call args. */
|
|
131
|
+
function applyConstraints(field: FieldModel, initializer: ts.Expression): void {
|
|
132
|
+
if (!ts.isCallExpression(initializer)) return;
|
|
133
|
+
const opts = literalToValue(initializer.arguments[0]);
|
|
134
|
+
if (!opts || typeof opts !== "object") return;
|
|
135
|
+
if (typeof opts.maxLength === "number") field.maxLength = opts.maxLength;
|
|
136
|
+
if (opts.unique === true) field.unique = true;
|
|
137
|
+
if (opts.primaryKey === true) field.primaryKey = true;
|
|
138
|
+
if (opts.null === true) field.null = true;
|
|
139
|
+
if (opts.default !== undefined) field.default = opts.default;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// -- type/AST helpers -------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** Read a string-literal-typed property (e.g. `__app: "airports"`) off a type. */
|
|
145
|
+
function literalTypeProp(
|
|
146
|
+
type: ts.Type,
|
|
147
|
+
name: string,
|
|
148
|
+
checker: ts.TypeChecker,
|
|
149
|
+
location: ts.Node,
|
|
150
|
+
): string | undefined {
|
|
151
|
+
const symbol = type.getProperty(name);
|
|
152
|
+
if (!symbol) return undefined;
|
|
153
|
+
const propType = checker.getTypeOfSymbolAtLocation(symbol, location);
|
|
154
|
+
if (propType.isStringLiteral()) return propType.value;
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stringLiteralArg(node: ts.Expression | undefined): string | undefined {
|
|
159
|
+
if (node && ts.isStringLiteralLike(node)) return node.text;
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function propertyName(name: ts.PropertyName): string | undefined {
|
|
164
|
+
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text;
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Evaluate a *static literal* AST node (string/number/bool/null/array/object)
|
|
170
|
+
* into a JS value. Anything non-literal (a function, an identifier) yields
|
|
171
|
+
* `undefined` — the options and constraint blocks are always literals.
|
|
172
|
+
*/
|
|
173
|
+
function literalToValue(node: ts.Expression | undefined): any {
|
|
174
|
+
if (!node) return undefined;
|
|
175
|
+
if (ts.isStringLiteralLike(node)) return node.text;
|
|
176
|
+
if (ts.isNumericLiteral(node)) return Number(node.text);
|
|
177
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
178
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
179
|
+
if (node.kind === ts.SyntaxKind.NullKeyword) return null;
|
|
180
|
+
if (
|
|
181
|
+
ts.isPrefixUnaryExpression(node) &&
|
|
182
|
+
node.operator === ts.SyntaxKind.MinusToken
|
|
183
|
+
) {
|
|
184
|
+
const inner = literalToValue(node.operand);
|
|
185
|
+
return typeof inner === "number" ? -inner : undefined;
|
|
186
|
+
}
|
|
187
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
188
|
+
return node.elements.map((el) => literalToValue(el));
|
|
189
|
+
}
|
|
190
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
191
|
+
const obj: any = {};
|
|
192
|
+
for (const p of node.properties) {
|
|
193
|
+
if (ts.isPropertyAssignment(p)) {
|
|
194
|
+
const key = propertyName(p.name);
|
|
195
|
+
if (key) obj[key] = literalToValue(p.initializer);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return obj;
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normaliseSync(sync: any): SyncModel | undefined {
|
|
204
|
+
if (!sync || typeof sync !== "object") return undefined;
|
|
205
|
+
const mode = sync.mode === "local" ? "local" : "server";
|
|
206
|
+
if (mode === "server" && !sync.confirmed) {
|
|
207
|
+
// A bare server resource carries no sync semantics.
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
const model: SyncModel = { mode };
|
|
211
|
+
if (typeof sync.scope === "string") model.scope = sync.scope;
|
|
212
|
+
if (Array.isArray(sync.confirmed)) model.confirmed = sync.confirmed;
|
|
213
|
+
if (sync.load === "lazy") model.load = "lazy";
|
|
214
|
+
if (sync.clientIds === true) model.clientIds = true;
|
|
215
|
+
return model;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normaliseAdmin(admin: any): AdminModel | false | undefined {
|
|
219
|
+
if (admin === false) return false;
|
|
220
|
+
if (!admin || typeof admin !== "object") return undefined;
|
|
221
|
+
const model: AdminModel = {};
|
|
222
|
+
if (Array.isArray(admin.listDisplay)) model.listDisplay = admin.listDisplay;
|
|
223
|
+
return model;
|
|
224
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { analyze } from "./analyze";
|
|
4
|
+
import { buildManifest, manifestToJson } from "./manifest";
|
|
5
|
+
import { Manifest, ResourceModel } from "./model";
|
|
6
|
+
import { buildOpenApi, openApiToJson } from "./openapi";
|
|
7
|
+
import { createProgram } from "./program";
|
|
8
|
+
|
|
9
|
+
export { analyze } from "./analyze";
|
|
10
|
+
export { buildManifest } from "./manifest";
|
|
11
|
+
export { buildOpenApi } from "./openapi";
|
|
12
|
+
export * from "./model";
|
|
13
|
+
|
|
14
|
+
export interface CompileResult {
|
|
15
|
+
manifest: Manifest;
|
|
16
|
+
openapi: any;
|
|
17
|
+
files: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** List every `.ts` resource file under a directory (or accept explicit files). */
|
|
21
|
+
export function resolveResourceFiles(input: string): string[] {
|
|
22
|
+
const stat = fs.existsSync(input) ? fs.statSync(input) : undefined;
|
|
23
|
+
if (stat?.isFile()) return [path.resolve(input)];
|
|
24
|
+
if (stat?.isDirectory()) {
|
|
25
|
+
return fs
|
|
26
|
+
.readdirSync(input)
|
|
27
|
+
.filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"))
|
|
28
|
+
.map((f) => path.resolve(input, f))
|
|
29
|
+
.sort();
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`No such resources path: ${input}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Analyse resource definitions into the canonical model (no file output). */
|
|
35
|
+
export function analyzeResources(input: string): ResourceModel[] {
|
|
36
|
+
const files = resolveResourceFiles(input);
|
|
37
|
+
const program = createProgram(files);
|
|
38
|
+
return analyze(program, files);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Compile resource definitions to a manifest + OpenAPI, writing both to disk. */
|
|
42
|
+
export function compile(input: string, outDir: string): CompileResult {
|
|
43
|
+
const resources = analyzeResources(input);
|
|
44
|
+
const manifest = buildManifest(resources);
|
|
45
|
+
const openapi = buildOpenApi(resources);
|
|
46
|
+
|
|
47
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
48
|
+
const manifestPath = path.join(outDir, "schema.rad.json");
|
|
49
|
+
const openapiPath = path.join(outDir, "openapi.json");
|
|
50
|
+
fs.writeFileSync(manifestPath, manifestToJson(manifest), { flag: "w" });
|
|
51
|
+
fs.writeFileSync(openapiPath, openApiToJson(openapi), { flag: "w" });
|
|
52
|
+
|
|
53
|
+
return { manifest, openapi, files: [manifestPath, openapiPath] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// -- introspection (replaces `manage.py rastack check|list|urls`) -----------
|
|
57
|
+
|
|
58
|
+
/** Detect duplicate `app.model` definitions and dangling FK targets. */
|
|
59
|
+
export function collisions(resources: ResourceModel[]): string[] {
|
|
60
|
+
const errors: string[] = [];
|
|
61
|
+
const seen = new Map<string, number>();
|
|
62
|
+
const keys = new Set(resources.map((r) => `${r.app}.${r.model}`));
|
|
63
|
+
|
|
64
|
+
for (const r of resources) {
|
|
65
|
+
const key = `${r.app}.${r.model}`;
|
|
66
|
+
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
67
|
+
for (const rel of r.relations) {
|
|
68
|
+
if (!keys.has(rel.target)) {
|
|
69
|
+
errors.push(
|
|
70
|
+
`${key}.${rel.field} references unknown resource ${rel.target}.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const [key, count] of seen) {
|
|
76
|
+
if (count > 1) errors.push(`Duplicate resource ${key} defined ${count} times.`);
|
|
77
|
+
}
|
|
78
|
+
return errors;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function urlsFor(resources: ResourceModel[]): string[] {
|
|
82
|
+
return resources.flatMap((r) => [
|
|
83
|
+
`/api/${r.app}/v1/${r.model}/`,
|
|
84
|
+
`/api/${r.app}/v1/${r.model}/{id}/`,
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Manifest, ResourceModel } from "./model";
|
|
2
|
+
|
|
3
|
+
/** Assemble the canonical manifest from analysed resources. */
|
|
4
|
+
export function buildManifest(resources: ResourceModel[]): Manifest {
|
|
5
|
+
return { version: 1, resources };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function manifestToJson(manifest: Manifest): string {
|
|
9
|
+
return JSON.stringify(manifest, null, 2) + "\n";
|
|
10
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical, backend-agnostic data model produced by the compiler. This is
|
|
3
|
+
* what `schema.rad.json` serializes and what both the OpenAPI emitter and the
|
|
4
|
+
* Rust API consume.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type ScalarType =
|
|
8
|
+
| "string"
|
|
9
|
+
| "int"
|
|
10
|
+
| "float"
|
|
11
|
+
| "bool"
|
|
12
|
+
| "datetime"
|
|
13
|
+
| "uuid";
|
|
14
|
+
|
|
15
|
+
export type FieldType = ScalarType | "fk";
|
|
16
|
+
|
|
17
|
+
export interface RelationTarget {
|
|
18
|
+
app: string;
|
|
19
|
+
model: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FieldModel {
|
|
23
|
+
name: string;
|
|
24
|
+
type: FieldType;
|
|
25
|
+
maxLength?: number;
|
|
26
|
+
unique?: boolean;
|
|
27
|
+
primaryKey?: boolean;
|
|
28
|
+
null?: boolean;
|
|
29
|
+
default?: unknown;
|
|
30
|
+
/** Present iff `type === "fk"` — the resource this field points at. */
|
|
31
|
+
relation?: RelationTarget;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RelationModel {
|
|
35
|
+
/** The field on *this* resource that holds the foreign key. */
|
|
36
|
+
field: string;
|
|
37
|
+
/** `"app.model"` of the referenced resource. */
|
|
38
|
+
target: string;
|
|
39
|
+
kind: "fk";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SyncModel {
|
|
43
|
+
mode: "server" | "local";
|
|
44
|
+
scope?: string;
|
|
45
|
+
confirmed?: string[];
|
|
46
|
+
load?: "instant" | "lazy";
|
|
47
|
+
clientIds?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AdminModel {
|
|
51
|
+
listDisplay?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ResourceModel {
|
|
55
|
+
app: string;
|
|
56
|
+
model: string;
|
|
57
|
+
fields: FieldModel[];
|
|
58
|
+
relations: RelationModel[];
|
|
59
|
+
sync?: SyncModel;
|
|
60
|
+
admin?: AdminModel | false;
|
|
61
|
+
search?: string[];
|
|
62
|
+
ordering?: string[];
|
|
63
|
+
permission?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Manifest {
|
|
67
|
+
version: 1;
|
|
68
|
+
resources: ResourceModel[];
|
|
69
|
+
}
|