rastack 0.0.16 → 0.0.20
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 +43876 -43013
- package/dist/.prettierrc +7 -7
- package/dist/compile/analyze.js +19 -11
- package/dist/compile/index.js +26 -17
- package/dist/compile/manifest.js +2 -3
- package/dist/compile/openapi.js +5 -8
- package/dist/compile/program.js +19 -10
- package/dist/csv-schema.js +5 -6
- package/dist/define/index.js +2 -2
- package/dist/entity-generation/delete-method.js +8 -10
- package/dist/entity-generation/form.js +80 -82
- package/dist/entity-generation/get-method.js +35 -36
- package/dist/entity-generation/imports.js +24 -20
- package/dist/entity-generation/sync-method.d.ts +1 -1
- package/dist/entity-generation/sync-method.js +45 -46
- package/dist/entity-generation/update-method.js +9 -10
- package/dist/rad-wasm-build.d.ts +1 -1
- package/dist/rad-wasm-build.js +15 -6
- package/dist/rad.js +9 -2
- package/dist/schema/camel-to-pastel.js +1 -2
- package/dist/schema/capitalise-first-letter.js +1 -2
- package/dist/schema/extract-response.js +3 -4
- package/dist/schema/fetch-schema.js +4 -16
- package/dist/schema/remove-non-model-paths.js +1 -2
- package/dist/schema/to-camel-case.js +1 -2
- package/dist/schema/to-pastel-case.js +1 -2
- package/dist/schema-convert.js +22 -33
- package/dist/schema-entities.js +177 -189
- package/dist/schema-fetch.js +46 -63
- package/dist/schema-full.js +23 -36
- package/dist/schema-index.js +16 -27
- package/dist/schema-params.js +11 -22
- package/dist/seed.js +95 -97
- package/hooks/form/form.ts +207 -207
- package/hooks/form/index.ts +8 -8
- package/hooks/form/interfaces.ts +217 -217
- package/hooks/form/structure.ts +39 -39
- package/hooks/form/validate-schema.ts +49 -49
- package/hooks/index.ts +3 -3
- package/hooks/query/api.ts +42 -42
- package/hooks/query/delete.ts +45 -45
- package/hooks/query/fetch.ts +48 -48
- package/hooks/query/index.ts +21 -21
- package/hooks/query/interfaces.ts +111 -111
- package/hooks/query/list.ts +286 -286
- package/hooks/query/update.ts +88 -88
- package/hooks/query/url.ts +31 -31
- package/hooks/real-time/index.ts +1 -1
- package/hooks/real-time/pusher.ts +43 -43
- package/jest.config.cjs +6 -6
- package/package.json +57 -57
- package/provider/index.ts +13 -13
- package/provider/provider.tsx +187 -187
- package/provider/types.ts +114 -114
- package/provider/warehouse.ts +167 -167
- package/provider/wasm.ts +124 -124
- package/runtime.ts +3 -3
- package/src/.prettierrc +7 -7
- package/src/compile/analyze.ts +224 -224
- package/src/compile/index.ts +86 -86
- package/src/compile/manifest.ts +10 -10
- package/src/compile/model.ts +69 -69
- package/src/compile/openapi.ts +266 -266
- package/src/compile/program.ts +40 -40
- package/src/csv-schema.ts +187 -187
- package/src/define/index.ts +162 -162
- package/src/entity-generation/delete-method.ts +37 -37
- package/src/entity-generation/form.ts +169 -169
- package/src/entity-generation/get-method.ts +80 -80
- package/src/entity-generation/imports.ts +110 -110
- package/src/entity-generation/sync-method.ts +222 -222
- package/src/entity-generation/update-method.ts +38 -38
- package/src/index.ts +77 -77
- package/src/rad-compile.ts +88 -88
- package/src/rad-wasm-build.ts +102 -102
- package/src/rad.ts +101 -101
- package/src/scan.ts +89 -89
- package/src/schema/camel-to-pastel.ts +3 -3
- package/src/schema/capitalise-first-letter.ts +3 -3
- package/src/schema/extract-response.ts +38 -38
- package/src/schema/fetch-schema.ts +12 -12
- package/src/schema/remove-non-model-paths.ts +13 -13
- package/src/schema/to-camel-case.ts +7 -7
- package/src/schema/to-pastel-case.ts +6 -6
- package/src/schema-convert.ts +61 -61
- package/src/schema-entities.ts +401 -401
- package/src/schema-fetch.ts +82 -82
- package/src/schema-full.ts +38 -38
- package/src/schema-index.ts +29 -29
- package/src/schema-params.ts +116 -116
- package/src/seed.ts +297 -297
- package/sync/engine.ts +392 -392
- package/sync/hooks.ts +450 -450
- package/sync/index.ts +8 -8
- package/sync/persistence.ts +237 -237
- package/sync/provider.tsx +91 -91
- package/sync/registry.ts +36 -36
- package/sync/store.ts +126 -126
- package/sync/transactions.ts +300 -300
- package/sync/types.ts +94 -94
- package/test/compile.spec.ts +192 -192
- package/test/csv-schema.spec.ts +143 -143
- package/test/schema-entities.spec.ts +688 -688
- package/tsconfig.json +24 -24
- package/types.ts +18 -18
package/sync/types.ts
CHANGED
|
@@ -1,94 +1,94 @@
|
|
|
1
|
-
export type EntityKey = string; // `${app}.${model}`
|
|
2
|
-
|
|
3
|
-
export interface EntityDescriptor<TRow = any> {
|
|
4
|
-
app: string;
|
|
5
|
-
model: string;
|
|
6
|
-
/** Collection endpoint, e.g. /api/shop/v1/pets */
|
|
7
|
-
listUrl: string;
|
|
8
|
-
/** Detail endpoint builder, e.g. (id) => `/api/shop/v1/pets/${id}` */
|
|
9
|
-
detailUrl?: (id: string) => string;
|
|
10
|
-
/** Primary key field on the row. Defaults to "id". */
|
|
11
|
-
idField?: string;
|
|
12
|
-
/**
|
|
13
|
-
* Lazy entities (Sync.load = "lazy") are excluded from the initial
|
|
14
|
-
* bootstrap and partial-bootstrapped on first use instead.
|
|
15
|
-
*/
|
|
16
|
-
lazy?: boolean;
|
|
17
|
-
/**
|
|
18
|
-
* Sync.client_ids = True: the row's primary key is a client-generated
|
|
19
|
-
* UUID, so creates carry their real id from the start (no temp-id
|
|
20
|
-
* reconciliation) and offline-created rows can be referenced immediately.
|
|
21
|
-
*/
|
|
22
|
-
clientIds?: boolean;
|
|
23
|
-
/** Phantom field so TRow is used; never set at runtime. */
|
|
24
|
-
__row?: TRow;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function entityKey(entity: EntityDescriptor): EntityKey {
|
|
28
|
-
return `${entity.app}.${entity.model}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export type SyncOp = "I" | "U" | "D";
|
|
32
|
-
|
|
33
|
-
export interface SyncActionPacket {
|
|
34
|
-
op: SyncOp;
|
|
35
|
-
model: EntityKey;
|
|
36
|
-
id: string;
|
|
37
|
-
data: Record<string, any> | null;
|
|
38
|
-
sync_id: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface ChangesResponse {
|
|
42
|
-
actions: SyncActionPacket[];
|
|
43
|
-
last_sync_id: number;
|
|
44
|
-
/** Hash of the sync-relevant server schema; a change forces a re-bootstrap. */
|
|
45
|
-
schema_hash?: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface BootstrapResponse {
|
|
49
|
-
models: { [key: EntityKey]: Record<string, any>[] };
|
|
50
|
-
last_sync_id: number;
|
|
51
|
-
schema_hash?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export type TransactionOp = "create" | "update" | "delete";
|
|
55
|
-
|
|
56
|
-
export type TransactionStatus = "queued" | "inflight" | "completed";
|
|
57
|
-
|
|
58
|
-
export interface SyncTransaction {
|
|
59
|
-
txnId: string;
|
|
60
|
-
entity: EntityKey;
|
|
61
|
-
op: TransactionOp;
|
|
62
|
-
/** Row id. Creates use a temporary `tmp_*` id until the server echoes the real one. */
|
|
63
|
-
id: string;
|
|
64
|
-
method: "post" | "put" | "patch" | "delete";
|
|
65
|
-
url: string;
|
|
66
|
-
payload: Record<string, any> | null;
|
|
67
|
-
/**
|
|
68
|
-
* Rollback snapshot: for updates, the previous values of the changed
|
|
69
|
-
* fields; for deletes, the full previous row; null for creates.
|
|
70
|
-
*/
|
|
71
|
-
original: Record<string, any> | null;
|
|
72
|
-
status: TransactionStatus;
|
|
73
|
-
createdAt: number;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export type EngineStatus =
|
|
77
|
-
| "idle"
|
|
78
|
-
| "hydrating"
|
|
79
|
-
| "bootstrapping"
|
|
80
|
-
| "live"
|
|
81
|
-
| "offline"
|
|
82
|
-
| "error";
|
|
83
|
-
|
|
84
|
-
export interface SyncEngineConfig {
|
|
85
|
-
/** Base path of the sync endpoints. */
|
|
86
|
-
syncBasePath?: string; // default /api/sync/v1
|
|
87
|
-
/** Long-poll hold time requested from /changes, seconds. */
|
|
88
|
-
wait?: number; // default 25
|
|
89
|
-
/** Persist to IndexedDB (default true; falls back to memory when unavailable). */
|
|
90
|
-
persist?: boolean;
|
|
91
|
-
/** Database name for IndexedDB. */
|
|
92
|
-
databaseName?: string; // default "rad-sync"
|
|
93
|
-
onError?: (error: unknown) => void;
|
|
94
|
-
}
|
|
1
|
+
export type EntityKey = string; // `${app}.${model}`
|
|
2
|
+
|
|
3
|
+
export interface EntityDescriptor<TRow = any> {
|
|
4
|
+
app: string;
|
|
5
|
+
model: string;
|
|
6
|
+
/** Collection endpoint, e.g. /api/shop/v1/pets */
|
|
7
|
+
listUrl: string;
|
|
8
|
+
/** Detail endpoint builder, e.g. (id) => `/api/shop/v1/pets/${id}` */
|
|
9
|
+
detailUrl?: (id: string) => string;
|
|
10
|
+
/** Primary key field on the row. Defaults to "id". */
|
|
11
|
+
idField?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Lazy entities (Sync.load = "lazy") are excluded from the initial
|
|
14
|
+
* bootstrap and partial-bootstrapped on first use instead.
|
|
15
|
+
*/
|
|
16
|
+
lazy?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Sync.client_ids = True: the row's primary key is a client-generated
|
|
19
|
+
* UUID, so creates carry their real id from the start (no temp-id
|
|
20
|
+
* reconciliation) and offline-created rows can be referenced immediately.
|
|
21
|
+
*/
|
|
22
|
+
clientIds?: boolean;
|
|
23
|
+
/** Phantom field so TRow is used; never set at runtime. */
|
|
24
|
+
__row?: TRow;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function entityKey(entity: EntityDescriptor): EntityKey {
|
|
28
|
+
return `${entity.app}.${entity.model}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SyncOp = "I" | "U" | "D";
|
|
32
|
+
|
|
33
|
+
export interface SyncActionPacket {
|
|
34
|
+
op: SyncOp;
|
|
35
|
+
model: EntityKey;
|
|
36
|
+
id: string;
|
|
37
|
+
data: Record<string, any> | null;
|
|
38
|
+
sync_id: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChangesResponse {
|
|
42
|
+
actions: SyncActionPacket[];
|
|
43
|
+
last_sync_id: number;
|
|
44
|
+
/** Hash of the sync-relevant server schema; a change forces a re-bootstrap. */
|
|
45
|
+
schema_hash?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BootstrapResponse {
|
|
49
|
+
models: { [key: EntityKey]: Record<string, any>[] };
|
|
50
|
+
last_sync_id: number;
|
|
51
|
+
schema_hash?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type TransactionOp = "create" | "update" | "delete";
|
|
55
|
+
|
|
56
|
+
export type TransactionStatus = "queued" | "inflight" | "completed";
|
|
57
|
+
|
|
58
|
+
export interface SyncTransaction {
|
|
59
|
+
txnId: string;
|
|
60
|
+
entity: EntityKey;
|
|
61
|
+
op: TransactionOp;
|
|
62
|
+
/** Row id. Creates use a temporary `tmp_*` id until the server echoes the real one. */
|
|
63
|
+
id: string;
|
|
64
|
+
method: "post" | "put" | "patch" | "delete";
|
|
65
|
+
url: string;
|
|
66
|
+
payload: Record<string, any> | null;
|
|
67
|
+
/**
|
|
68
|
+
* Rollback snapshot: for updates, the previous values of the changed
|
|
69
|
+
* fields; for deletes, the full previous row; null for creates.
|
|
70
|
+
*/
|
|
71
|
+
original: Record<string, any> | null;
|
|
72
|
+
status: TransactionStatus;
|
|
73
|
+
createdAt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type EngineStatus =
|
|
77
|
+
| "idle"
|
|
78
|
+
| "hydrating"
|
|
79
|
+
| "bootstrapping"
|
|
80
|
+
| "live"
|
|
81
|
+
| "offline"
|
|
82
|
+
| "error";
|
|
83
|
+
|
|
84
|
+
export interface SyncEngineConfig {
|
|
85
|
+
/** Base path of the sync endpoints. */
|
|
86
|
+
syncBasePath?: string; // default /api/sync/v1
|
|
87
|
+
/** Long-poll hold time requested from /changes, seconds. */
|
|
88
|
+
wait?: number; // default 25
|
|
89
|
+
/** Persist to IndexedDB (default true; falls back to memory when unavailable). */
|
|
90
|
+
persist?: boolean;
|
|
91
|
+
/** Database name for IndexedDB. */
|
|
92
|
+
databaseName?: string; // default "rad-sync"
|
|
93
|
+
onError?: (error: unknown) => void;
|
|
94
|
+
}
|
package/test/compile.spec.ts
CHANGED
|
@@ -1,192 +1,192 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import {
|
|
4
|
-
analyzeResources,
|
|
5
|
-
buildOpenApi,
|
|
6
|
-
collisions,
|
|
7
|
-
compile,
|
|
8
|
-
ResourceModel,
|
|
9
|
-
} from "../src/compile";
|
|
10
|
-
import { generateFiles, generateFolders } from "../src/schema-entities";
|
|
11
|
-
import { removeNonModelPaths } from "../src/schema/remove-non-model-paths";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* The compiler's fixtures are real `.ts` files that import the DSL by its
|
|
15
|
-
* package name (`rad-tools/define`); `createProgram` maps that to the in-tree
|
|
16
|
-
* source, so the type checker resolves the `Ref`/`Resource` brands exactly as it
|
|
17
|
-
* would in a consumer project.
|
|
18
|
-
*/
|
|
19
|
-
const workRoot = join(__dirname, "compile-fixtures");
|
|
20
|
-
const resourcesDir = join(workRoot, "resources");
|
|
21
|
-
const outDir = join(workRoot, "out");
|
|
22
|
-
|
|
23
|
-
const AIRPORTS_TS = `
|
|
24
|
-
import { resource, s } from "rad-tools/define";
|
|
25
|
-
|
|
26
|
-
export const Airport = resource("airports", "airport", {
|
|
27
|
-
code: s.string({ maxLength: 3, unique: true }),
|
|
28
|
-
name: s.string({ maxLength: 120 }),
|
|
29
|
-
}, { search: ["code", "name"], admin: { listDisplay: ["code", "name"] } });
|
|
30
|
-
|
|
31
|
-
export const Terminal = resource("airports", "terminal", {
|
|
32
|
-
airport: s.ref(() => Airport),
|
|
33
|
-
label: s.string({ maxLength: 20 }),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
export const Flight = resource("airports", "flight", {
|
|
37
|
-
number: s.string({ maxLength: 8 }),
|
|
38
|
-
status: s.string({ maxLength: 20, default: "scheduled" }),
|
|
39
|
-
}, { sync: { mode: "local", scope: "owner_id={user.id}", confirmed: ["update"] } });
|
|
40
|
-
|
|
41
|
-
export const Gate = resource("airports", "gate", {
|
|
42
|
-
number: s.string({ maxLength: 5 }),
|
|
43
|
-
}, { sync: { mode: "local", load: "lazy" } });
|
|
44
|
-
|
|
45
|
-
export const Booking = resource("airports", "booking", {
|
|
46
|
-
reference: s.string({ maxLength: 12 }),
|
|
47
|
-
}, { sync: { mode: "local", clientIds: true } });
|
|
48
|
-
`;
|
|
49
|
-
|
|
50
|
-
function findResource(
|
|
51
|
-
resources: ResourceModel[],
|
|
52
|
-
app: string,
|
|
53
|
-
model: string,
|
|
54
|
-
): ResourceModel {
|
|
55
|
-
const found = resources.find((r) => r.app === app && r.model === model);
|
|
56
|
-
if (!found) throw new Error(`missing ${app}.${model}`);
|
|
57
|
-
return found;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
describe("rad compile — resource analysis & FK inference", () => {
|
|
61
|
-
beforeAll(() => {
|
|
62
|
-
mkdirSync(resourcesDir, { recursive: true });
|
|
63
|
-
writeFileSync(join(resourcesDir, "airports.ts"), AIRPORTS_TS);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
afterAll(() => {
|
|
67
|
-
rmSync(workRoot, { recursive: true, force: true });
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("infers a foreign key from the field type", () => {
|
|
71
|
-
const resources = analyzeResources(resourcesDir);
|
|
72
|
-
const terminal = findResource(resources, "airports", "terminal");
|
|
73
|
-
|
|
74
|
-
const airportField = terminal.fields.find((f) => f.name === "airport");
|
|
75
|
-
expect(airportField?.type).toBe("fk");
|
|
76
|
-
expect(airportField?.relation).toEqual({ app: "airports", model: "airport" });
|
|
77
|
-
|
|
78
|
-
expect(terminal.relations).toEqual([
|
|
79
|
-
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
80
|
-
]);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("maps scalar fields and their constraints", () => {
|
|
84
|
-
const resources = analyzeResources(resourcesDir);
|
|
85
|
-
const airport = findResource(resources, "airports", "airport");
|
|
86
|
-
|
|
87
|
-
const code = airport.fields.find((f) => f.name === "code");
|
|
88
|
-
expect(code).toMatchObject({ type: "string", maxLength: 3, unique: true });
|
|
89
|
-
|
|
90
|
-
const name = airport.fields.find((f) => f.name === "name");
|
|
91
|
-
expect(name).toMatchObject({ type: "string", maxLength: 120 });
|
|
92
|
-
|
|
93
|
-
expect(airport.search).toEqual(["code", "name"]);
|
|
94
|
-
expect(airport.admin).toEqual({ listDisplay: ["code", "name"] });
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("captures sync config (local / lazy / client-ids / confirmed)", () => {
|
|
98
|
-
const resources = analyzeResources(resourcesDir);
|
|
99
|
-
|
|
100
|
-
expect(findResource(resources, "airports", "flight").sync).toMatchObject({
|
|
101
|
-
mode: "local",
|
|
102
|
-
scope: "owner_id={user.id}",
|
|
103
|
-
confirmed: ["update"],
|
|
104
|
-
});
|
|
105
|
-
expect(findResource(resources, "airports", "gate").sync).toMatchObject({
|
|
106
|
-
mode: "local",
|
|
107
|
-
load: "lazy",
|
|
108
|
-
});
|
|
109
|
-
expect(findResource(resources, "airports", "booking").sync).toMatchObject({
|
|
110
|
-
mode: "local",
|
|
111
|
-
clientIds: true,
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("reports no collisions for a valid graph", () => {
|
|
116
|
-
const resources = analyzeResources(resourcesDir);
|
|
117
|
-
expect(collisions(resources)).toEqual([]);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe("rad compile — OpenAPI emission", () => {
|
|
122
|
-
beforeAll(() => {
|
|
123
|
-
mkdirSync(resourcesDir, { recursive: true });
|
|
124
|
-
writeFileSync(join(resourcesDir, "airports.ts"), AIRPORTS_TS);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
afterAll(() => {
|
|
128
|
-
rmSync(workRoot, { recursive: true, force: true });
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("writes schema.rad.json and openapi.json", () => {
|
|
132
|
-
const result = compile(resourcesDir, outDir);
|
|
133
|
-
expect(existsSync(join(outDir, "schema.rad.json"))).toBe(true);
|
|
134
|
-
expect(existsSync(join(outDir, "openapi.json"))).toBe(true);
|
|
135
|
-
expect(result.manifest.resources.length).toBe(5);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("emits the /api/{app}/v1/{model}/ contract with FK + sync extensions", () => {
|
|
139
|
-
const resources = analyzeResources(resourcesDir);
|
|
140
|
-
const doc = buildOpenApi(resources);
|
|
141
|
-
|
|
142
|
-
// URL-segment contract the codegen keys off.
|
|
143
|
-
expect(doc.paths["/api/airports/v1/terminal/"]).toBeDefined();
|
|
144
|
-
expect(doc.paths["/api/airports/v1/terminal/{id}/"]).toBeDefined();
|
|
145
|
-
|
|
146
|
-
// FK field carries the new relation extension.
|
|
147
|
-
const terminalSchema = doc.components.schemas.Terminal;
|
|
148
|
-
expect(terminalSchema.properties.airport["x-rastack-relation"]).toEqual({
|
|
149
|
-
app: "airports",
|
|
150
|
-
model: "airport",
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
// Local-mode sync extensions, confirmed on update.
|
|
154
|
-
const flightList = doc.paths["/api/airports/v1/flight/"];
|
|
155
|
-
expect(flightList.get["x-rastack-sync"]).toBe("local");
|
|
156
|
-
expect(doc.paths["/api/airports/v1/flight/{id}/"].put["x-rastack-sync"]).toBe(
|
|
157
|
-
"confirmed",
|
|
158
|
-
);
|
|
159
|
-
expect(doc.paths["/api/airports/v1/gate/"].get["x-rastack-sync-load"]).toBe(
|
|
160
|
-
"lazy",
|
|
161
|
-
);
|
|
162
|
-
expect(
|
|
163
|
-
doc.paths["/api/airports/v1/booking/"].post["x-rastack-sync-client-ids"],
|
|
164
|
-
).toBe(true);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("round-trips through the existing hooks codegen", async () => {
|
|
168
|
-
const resources = analyzeResources(resourcesDir);
|
|
169
|
-
const doc = buildOpenApi(resources);
|
|
170
|
-
const paths = removeNonModelPaths(doc.paths);
|
|
171
|
-
|
|
172
|
-
const entitiesPath = join(workRoot, "entities");
|
|
173
|
-
mkdirSync(entitiesPath, { recursive: true });
|
|
174
|
-
await generateFolders(entitiesPath, paths);
|
|
175
|
-
await generateFiles(entitiesPath, paths);
|
|
176
|
-
|
|
177
|
-
// Non-sync entity → plain async hook.
|
|
178
|
-
const airportApi = readFileSync(
|
|
179
|
-
join(entitiesPath, "airports", "airport", "api.ts"),
|
|
180
|
-
"utf-8",
|
|
181
|
-
);
|
|
182
|
-
expect(airportApi).toContain("export function useListAirport(");
|
|
183
|
-
|
|
184
|
-
// Sync entity → store-backed hooks + a defineEntity descriptor.
|
|
185
|
-
const flightApi = readFileSync(
|
|
186
|
-
join(entitiesPath, "airports", "flight", "api.ts"),
|
|
187
|
-
"utf-8",
|
|
188
|
-
);
|
|
189
|
-
expect(flightApi).toContain("defineEntity<IFlight>({");
|
|
190
|
-
expect(flightApi).toContain("from 'rastack/sync'");
|
|
191
|
-
});
|
|
192
|
-
});
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import {
|
|
4
|
+
analyzeResources,
|
|
5
|
+
buildOpenApi,
|
|
6
|
+
collisions,
|
|
7
|
+
compile,
|
|
8
|
+
ResourceModel,
|
|
9
|
+
} from "../src/compile";
|
|
10
|
+
import { generateFiles, generateFolders } from "../src/schema-entities";
|
|
11
|
+
import { removeNonModelPaths } from "../src/schema/remove-non-model-paths";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The compiler's fixtures are real `.ts` files that import the DSL by its
|
|
15
|
+
* package name (`rad-tools/define`); `createProgram` maps that to the in-tree
|
|
16
|
+
* source, so the type checker resolves the `Ref`/`Resource` brands exactly as it
|
|
17
|
+
* would in a consumer project.
|
|
18
|
+
*/
|
|
19
|
+
const workRoot = join(__dirname, "compile-fixtures");
|
|
20
|
+
const resourcesDir = join(workRoot, "resources");
|
|
21
|
+
const outDir = join(workRoot, "out");
|
|
22
|
+
|
|
23
|
+
const AIRPORTS_TS = `
|
|
24
|
+
import { resource, s } from "rad-tools/define";
|
|
25
|
+
|
|
26
|
+
export const Airport = resource("airports", "airport", {
|
|
27
|
+
code: s.string({ maxLength: 3, unique: true }),
|
|
28
|
+
name: s.string({ maxLength: 120 }),
|
|
29
|
+
}, { search: ["code", "name"], admin: { listDisplay: ["code", "name"] } });
|
|
30
|
+
|
|
31
|
+
export const Terminal = resource("airports", "terminal", {
|
|
32
|
+
airport: s.ref(() => Airport),
|
|
33
|
+
label: s.string({ maxLength: 20 }),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const Flight = resource("airports", "flight", {
|
|
37
|
+
number: s.string({ maxLength: 8 }),
|
|
38
|
+
status: s.string({ maxLength: 20, default: "scheduled" }),
|
|
39
|
+
}, { sync: { mode: "local", scope: "owner_id={user.id}", confirmed: ["update"] } });
|
|
40
|
+
|
|
41
|
+
export const Gate = resource("airports", "gate", {
|
|
42
|
+
number: s.string({ maxLength: 5 }),
|
|
43
|
+
}, { sync: { mode: "local", load: "lazy" } });
|
|
44
|
+
|
|
45
|
+
export const Booking = resource("airports", "booking", {
|
|
46
|
+
reference: s.string({ maxLength: 12 }),
|
|
47
|
+
}, { sync: { mode: "local", clientIds: true } });
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
function findResource(
|
|
51
|
+
resources: ResourceModel[],
|
|
52
|
+
app: string,
|
|
53
|
+
model: string,
|
|
54
|
+
): ResourceModel {
|
|
55
|
+
const found = resources.find((r) => r.app === app && r.model === model);
|
|
56
|
+
if (!found) throw new Error(`missing ${app}.${model}`);
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("rad compile — resource analysis & FK inference", () => {
|
|
61
|
+
beforeAll(() => {
|
|
62
|
+
mkdirSync(resourcesDir, { recursive: true });
|
|
63
|
+
writeFileSync(join(resourcesDir, "airports.ts"), AIRPORTS_TS);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("infers a foreign key from the field type", () => {
|
|
71
|
+
const resources = analyzeResources(resourcesDir);
|
|
72
|
+
const terminal = findResource(resources, "airports", "terminal");
|
|
73
|
+
|
|
74
|
+
const airportField = terminal.fields.find((f) => f.name === "airport");
|
|
75
|
+
expect(airportField?.type).toBe("fk");
|
|
76
|
+
expect(airportField?.relation).toEqual({ app: "airports", model: "airport" });
|
|
77
|
+
|
|
78
|
+
expect(terminal.relations).toEqual([
|
|
79
|
+
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("maps scalar fields and their constraints", () => {
|
|
84
|
+
const resources = analyzeResources(resourcesDir);
|
|
85
|
+
const airport = findResource(resources, "airports", "airport");
|
|
86
|
+
|
|
87
|
+
const code = airport.fields.find((f) => f.name === "code");
|
|
88
|
+
expect(code).toMatchObject({ type: "string", maxLength: 3, unique: true });
|
|
89
|
+
|
|
90
|
+
const name = airport.fields.find((f) => f.name === "name");
|
|
91
|
+
expect(name).toMatchObject({ type: "string", maxLength: 120 });
|
|
92
|
+
|
|
93
|
+
expect(airport.search).toEqual(["code", "name"]);
|
|
94
|
+
expect(airport.admin).toEqual({ listDisplay: ["code", "name"] });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("captures sync config (local / lazy / client-ids / confirmed)", () => {
|
|
98
|
+
const resources = analyzeResources(resourcesDir);
|
|
99
|
+
|
|
100
|
+
expect(findResource(resources, "airports", "flight").sync).toMatchObject({
|
|
101
|
+
mode: "local",
|
|
102
|
+
scope: "owner_id={user.id}",
|
|
103
|
+
confirmed: ["update"],
|
|
104
|
+
});
|
|
105
|
+
expect(findResource(resources, "airports", "gate").sync).toMatchObject({
|
|
106
|
+
mode: "local",
|
|
107
|
+
load: "lazy",
|
|
108
|
+
});
|
|
109
|
+
expect(findResource(resources, "airports", "booking").sync).toMatchObject({
|
|
110
|
+
mode: "local",
|
|
111
|
+
clientIds: true,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("reports no collisions for a valid graph", () => {
|
|
116
|
+
const resources = analyzeResources(resourcesDir);
|
|
117
|
+
expect(collisions(resources)).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("rad compile — OpenAPI emission", () => {
|
|
122
|
+
beforeAll(() => {
|
|
123
|
+
mkdirSync(resourcesDir, { recursive: true });
|
|
124
|
+
writeFileSync(join(resourcesDir, "airports.ts"), AIRPORTS_TS);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterAll(() => {
|
|
128
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("writes schema.rad.json and openapi.json", () => {
|
|
132
|
+
const result = compile(resourcesDir, outDir);
|
|
133
|
+
expect(existsSync(join(outDir, "schema.rad.json"))).toBe(true);
|
|
134
|
+
expect(existsSync(join(outDir, "openapi.json"))).toBe(true);
|
|
135
|
+
expect(result.manifest.resources.length).toBe(5);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("emits the /api/{app}/v1/{model}/ contract with FK + sync extensions", () => {
|
|
139
|
+
const resources = analyzeResources(resourcesDir);
|
|
140
|
+
const doc = buildOpenApi(resources);
|
|
141
|
+
|
|
142
|
+
// URL-segment contract the codegen keys off.
|
|
143
|
+
expect(doc.paths["/api/airports/v1/terminal/"]).toBeDefined();
|
|
144
|
+
expect(doc.paths["/api/airports/v1/terminal/{id}/"]).toBeDefined();
|
|
145
|
+
|
|
146
|
+
// FK field carries the new relation extension.
|
|
147
|
+
const terminalSchema = doc.components.schemas.Terminal;
|
|
148
|
+
expect(terminalSchema.properties.airport["x-rastack-relation"]).toEqual({
|
|
149
|
+
app: "airports",
|
|
150
|
+
model: "airport",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Local-mode sync extensions, confirmed on update.
|
|
154
|
+
const flightList = doc.paths["/api/airports/v1/flight/"];
|
|
155
|
+
expect(flightList.get["x-rastack-sync"]).toBe("local");
|
|
156
|
+
expect(doc.paths["/api/airports/v1/flight/{id}/"].put["x-rastack-sync"]).toBe(
|
|
157
|
+
"confirmed",
|
|
158
|
+
);
|
|
159
|
+
expect(doc.paths["/api/airports/v1/gate/"].get["x-rastack-sync-load"]).toBe(
|
|
160
|
+
"lazy",
|
|
161
|
+
);
|
|
162
|
+
expect(
|
|
163
|
+
doc.paths["/api/airports/v1/booking/"].post["x-rastack-sync-client-ids"],
|
|
164
|
+
).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("round-trips through the existing hooks codegen", async () => {
|
|
168
|
+
const resources = analyzeResources(resourcesDir);
|
|
169
|
+
const doc = buildOpenApi(resources);
|
|
170
|
+
const paths = removeNonModelPaths(doc.paths);
|
|
171
|
+
|
|
172
|
+
const entitiesPath = join(workRoot, "entities");
|
|
173
|
+
mkdirSync(entitiesPath, { recursive: true });
|
|
174
|
+
await generateFolders(entitiesPath, paths);
|
|
175
|
+
await generateFiles(entitiesPath, paths);
|
|
176
|
+
|
|
177
|
+
// Non-sync entity → plain async hook.
|
|
178
|
+
const airportApi = readFileSync(
|
|
179
|
+
join(entitiesPath, "airports", "airport", "api.ts"),
|
|
180
|
+
"utf-8",
|
|
181
|
+
);
|
|
182
|
+
expect(airportApi).toContain("export function useListAirport(");
|
|
183
|
+
|
|
184
|
+
// Sync entity → store-backed hooks + a defineEntity descriptor.
|
|
185
|
+
const flightApi = readFileSync(
|
|
186
|
+
join(entitiesPath, "airports", "flight", "api.ts"),
|
|
187
|
+
"utf-8",
|
|
188
|
+
);
|
|
189
|
+
expect(flightApi).toContain("defineEntity<IFlight>({");
|
|
190
|
+
expect(flightApi).toContain("from 'rastack/sync'");
|
|
191
|
+
});
|
|
192
|
+
});
|