rastack 0.0.57 → 0.0.59
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 +11 -0
- package/README.md +190 -0
- package/admin/index.tsx +19 -13
- package/admin/ui/BootScreen.tsx +201 -0
- package/dist/admin.js +156 -18
- package/dist/deploy/ci.js +2 -2
- package/dist/deploy/io.js +2 -2
- package/dist/dev/harness.d.ts +1 -1
- package/dist/dev/harness.js +21 -5
- package/dist/external/cache.d.ts +28 -0
- package/dist/external/cache.js +35 -0
- package/dist/external/envelope.d.ts +35 -0
- package/dist/external/envelope.js +48 -0
- package/dist/external/graphql.d.ts +25 -0
- package/dist/external/graphql.js +78 -0
- package/dist/external/handler.d.ts +52 -0
- package/dist/external/handler.js +139 -0
- package/dist/external/index.d.ts +19 -0
- package/dist/external/index.js +38 -0
- package/dist/external/lambda.d.ts +49 -0
- package/dist/external/lambda.js +86 -0
- package/dist/external/rest.d.ts +17 -0
- package/dist/external/rest.js +46 -0
- package/dist/external/route.d.ts +12 -0
- package/dist/external/route.js +33 -0
- package/dist/external/service.d.ts +50 -0
- package/dist/external/service.js +59 -0
- package/dist/external/types.d.ts +107 -0
- package/dist/external/types.js +22 -0
- package/dist/external/util.d.ts +16 -0
- package/dist/external/util.js +58 -0
- package/dist/rastack-dev.js +200 -4
- package/dist/rastack-wasm-build.d.ts +1 -1
- package/dist/rastack-wasm-build.js +6 -6
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/external.ts +10 -0
- package/hooks/query/delete.ts +12 -11
- package/hooks/query/fetch.ts +6 -8
- package/hooks/query/interfaces.ts +1 -4
- package/hooks/query/list.ts +16 -9
- package/hooks/query/update.ts +8 -6
- package/package.json +25 -2
- package/scripts/copy-templates.js +1 -1
- package/scripts/copy-wasm.js +1 -1
- package/scripts/dev-install.mjs +63 -0
- package/src/deploy/ci.ts +2 -2
- package/src/deploy/io.ts +2 -2
- package/src/dev/harness.ts +21 -5
- package/src/external/cache.ts +53 -0
- package/src/external/envelope.ts +78 -0
- package/src/external/graphql.ts +98 -0
- package/src/external/handler.ts +207 -0
- package/src/external/index.ts +25 -0
- package/src/external/lambda.ts +108 -0
- package/src/external/rest.ts +60 -0
- package/src/external/route.ts +33 -0
- package/src/external/service.ts +86 -0
- package/src/external/types.ts +121 -0
- package/src/external/util.ts +57 -0
- package/src/rastack-dev.ts +233 -6
- package/src/rastack-wasm-build.ts +6 -6
- package/jest.config.cjs +0 -12
- package/test/auth.spec.ts +0 -207
- package/test/cache.spec.ts +0 -263
- package/test/cognito.spec.ts +0 -281
- package/test/compile.spec.ts +0 -240
- package/test/components.spec.ts +0 -524
- package/test/csv-schema.spec.ts +0 -143
- package/test/deploy.spec.ts +0 -571
- package/test/dev.spec.ts +0 -414
- package/test/entities.spec.ts +0 -597
- package/test/import.spec.ts +0 -241
- package/test/plugin.spec.ts +0 -315
- package/test/runtime-manifest.spec.ts +0 -309
- package/test/schema-entities.spec.ts +0 -688
- package/test/tokens.spec.ts +0 -302
- package/test/transitions.spec.ts +0 -372
- package/test/typed-hooks.spec.ts +0 -412
- package/test/update.spec.ts +0 -152
- package/test/validate.spec.ts +0 -319
- package/tsconfig.json +0 -27
- package/wasm/package.json +0 -4
- package/wasm/rastack_wasm.d.ts +0 -100
- package/wasm/rastack_wasm.js +0 -745
- package/wasm/rastack_wasm_bg.wasm +0 -0
- package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
package/test/entities.spec.ts
DELETED
|
@@ -1,597 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import {
|
|
4
|
-
analyzeProject,
|
|
5
|
-
buildOpenApi,
|
|
6
|
-
compile,
|
|
7
|
-
detectCycles,
|
|
8
|
-
ResourceModel,
|
|
9
|
-
} from "../src/compile";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Interface-driven inference: the app's entities are plain TypeScript
|
|
13
|
-
* interfaces, discovered from the typed hook calls that consume them. The
|
|
14
|
-
* fixtures are real `.ts`/`.tsx` files so the TypeChecker resolves the
|
|
15
|
-
* interfaces exactly as it would in a consumer project.
|
|
16
|
-
*/
|
|
17
|
-
const workRoot = join(__dirname, "entities-fixtures");
|
|
18
|
-
const appDir = join(workRoot, "app");
|
|
19
|
-
const classesDir = join(workRoot, "classes");
|
|
20
|
-
const authDir = join(workRoot, "auth");
|
|
21
|
-
const cycleDir = join(workRoot, "cycles");
|
|
22
|
-
const hardCycleDir = join(workRoot, "hard-cycles");
|
|
23
|
-
const mixedDir = join(workRoot, "mixed");
|
|
24
|
-
const outDir = join(workRoot, "out");
|
|
25
|
-
|
|
26
|
-
const AIRPORTS_TS = `
|
|
27
|
-
/** @rastackSearch code,name */
|
|
28
|
-
export interface Airport {
|
|
29
|
-
/**
|
|
30
|
-
* @maxLength 3
|
|
31
|
-
* @unique
|
|
32
|
-
*/
|
|
33
|
-
code: string;
|
|
34
|
-
/** @maxLength 120 */
|
|
35
|
-
name: string;
|
|
36
|
-
terminals?: Terminal[]; // reverse side of Terminal.airport — no column
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface Terminal {
|
|
40
|
-
airport: Airport; // FK — inferred from the property type
|
|
41
|
-
/** @maxLength 20 */
|
|
42
|
-
label: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Never consumed by a hook and never referenced — not a resource. */
|
|
46
|
-
export interface Runway {
|
|
47
|
-
heading: number;
|
|
48
|
-
}
|
|
49
|
-
`;
|
|
50
|
-
|
|
51
|
-
const FLIGHTS_TS = `
|
|
52
|
-
import { Terminal } from "./airports";
|
|
53
|
-
|
|
54
|
-
export interface Flight {
|
|
55
|
-
/** @maxLength 8 */
|
|
56
|
-
number: string;
|
|
57
|
-
/** @int */
|
|
58
|
-
seats: number;
|
|
59
|
-
onTime: boolean;
|
|
60
|
-
departure: Date;
|
|
61
|
-
notes?: string;
|
|
62
|
-
/** @default scheduled */
|
|
63
|
-
status: string;
|
|
64
|
-
terminal: Terminal | null; // nullable FK across files
|
|
65
|
-
tags: string[]; // unsupported — skipped with a warning
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** @rastackResource
|
|
69
|
-
* @rastackApp bookings
|
|
70
|
-
* @rastackModel booking
|
|
71
|
-
*/
|
|
72
|
-
export interface IBookingRecord {
|
|
73
|
-
reference: string;
|
|
74
|
-
}
|
|
75
|
-
`;
|
|
76
|
-
|
|
77
|
-
const PASSENGERS_TS = `
|
|
78
|
-
import {
|
|
79
|
-
DbConfig,
|
|
80
|
-
Default,
|
|
81
|
-
Int,
|
|
82
|
-
MaxLength,
|
|
83
|
-
PrimaryKey,
|
|
84
|
-
Unique,
|
|
85
|
-
UUID,
|
|
86
|
-
} from "rastack/db";
|
|
87
|
-
import { Terminal } from "./airports";
|
|
88
|
-
|
|
89
|
-
/** A reusable column config — "extend DbConfig once, use it everywhere". */
|
|
90
|
-
interface SeatCode extends DbConfig<{ maxLength: 4; unique: true }> {}
|
|
91
|
-
|
|
92
|
-
export interface Passenger {
|
|
93
|
-
id: UUID & PrimaryKey;
|
|
94
|
-
seat: string & SeatCode;
|
|
95
|
-
fullName: string & MaxLength<80>;
|
|
96
|
-
age: Int;
|
|
97
|
-
loyaltyPoints: number & Default<0>;
|
|
98
|
-
status: string & Default<"active">;
|
|
99
|
-
terminal?: Terminal;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
declare function useEntityList<T>(name: string): T[];
|
|
103
|
-
export const p = useEntityList<Passenger>("passengers");
|
|
104
|
-
`;
|
|
105
|
-
|
|
106
|
-
const SCREENS_TSX = `
|
|
107
|
-
import { Flight } from "./flights";
|
|
108
|
-
|
|
109
|
-
declare function useEntityList<T>(name: string): T[];
|
|
110
|
-
|
|
111
|
-
export function FlightsScreen() {
|
|
112
|
-
// The hook call is what roots the entity graph.
|
|
113
|
-
return useEntityList<Flight>("flights");
|
|
114
|
-
}
|
|
115
|
-
`;
|
|
116
|
-
|
|
117
|
-
const SOFT_CYCLE_TS = `
|
|
118
|
-
export interface Employee {
|
|
119
|
-
name: string;
|
|
120
|
-
manager?: Employee; // optional self-FK — breakable cycle
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
declare function useEntity<T>(name: string): T;
|
|
124
|
-
export const e = useEntity<Employee>("employee");
|
|
125
|
-
`;
|
|
126
|
-
|
|
127
|
-
const HARD_CYCLE_TS = `
|
|
128
|
-
export interface Chicken {
|
|
129
|
-
egg: Egg;
|
|
130
|
-
}
|
|
131
|
-
export interface Egg {
|
|
132
|
-
chicken: Chicken;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
declare function useEntity<T>(name: string): T;
|
|
136
|
-
export const c = useEntity<Chicken>("chicken");
|
|
137
|
-
`;
|
|
138
|
-
|
|
139
|
-
const MIXED_TS = `
|
|
140
|
-
import { resource, s } from "rastack/define";
|
|
141
|
-
|
|
142
|
-
export const AirportResource = resource("mixed", "airport", {
|
|
143
|
-
code: s.string({ maxLength: 3, unique: true }),
|
|
144
|
-
}, { admin: { listDisplay: ["code"] } });
|
|
145
|
-
|
|
146
|
-
/** @rastackApp mixed */
|
|
147
|
-
export interface Airport {
|
|
148
|
-
code: string;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
declare function useEntity<T>(name: string): T;
|
|
152
|
-
export const a = useEntity<Airport>("airport");
|
|
153
|
-
`;
|
|
154
|
-
|
|
155
|
-
const CLASSES_TS = `
|
|
156
|
-
import { MaxLength, Unique } from "rastack/types";
|
|
157
|
-
|
|
158
|
-
/** @rastackSearch code,name */
|
|
159
|
-
export class Airport {
|
|
160
|
-
code!: string & Unique & MaxLength<3>;
|
|
161
|
-
/** @maxLength 120 */
|
|
162
|
-
name!: string;
|
|
163
|
-
region = "EU"; // initializer → type inferred, value is the default
|
|
164
|
-
active = true;
|
|
165
|
-
/** @int */
|
|
166
|
-
gates = 0;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export class Terminal {
|
|
170
|
-
airport!: Airport; // FK — inferred from the property type
|
|
171
|
-
/** @maxLength 20 */
|
|
172
|
-
label!: string;
|
|
173
|
-
opened?: Date;
|
|
174
|
-
static kind = "terminal"; // not a column
|
|
175
|
-
private secret!: string; // not a column
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Not a plain data class — a method means it must opt in explicitly. */
|
|
179
|
-
export class BoardingService {
|
|
180
|
-
queue: string[] = [];
|
|
181
|
-
board(): void {}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Heritage means app code, not a table. */
|
|
185
|
-
export class SplashScreen extends BoardingService {}
|
|
186
|
-
`;
|
|
187
|
-
|
|
188
|
-
const AUTH_TS = `
|
|
189
|
-
import {
|
|
190
|
-
AdminGroups,
|
|
191
|
-
Group,
|
|
192
|
-
IsAuthenticated,
|
|
193
|
-
IsAuthenticatedOrReadOnly,
|
|
194
|
-
Owner,
|
|
195
|
-
OwnerScoped,
|
|
196
|
-
SharedStorage,
|
|
197
|
-
} from "rastack/auth";
|
|
198
|
-
|
|
199
|
-
// Per-user rows: RLS on owner, physically partitioned per identity. The
|
|
200
|
-
// implements clause is policy, not behaviour — still a plain data class.
|
|
201
|
-
export class Itinerary
|
|
202
|
-
implements IsAuthenticated, AdminGroups<"staff">, OwnerScoped
|
|
203
|
-
{
|
|
204
|
-
owner!: string & Owner;
|
|
205
|
-
notes!: string;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// A group-owned dataset — physically partitioned per group by default.
|
|
209
|
-
export class Report
|
|
210
|
-
implements IsAuthenticatedOrReadOnly, AdminGroups<"staff" | "ops">
|
|
211
|
-
{
|
|
212
|
-
team!: string & Group;
|
|
213
|
-
title!: string;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Group RLS, but explicitly opted out of per-group storage.
|
|
217
|
-
export class Note implements SharedStorage {
|
|
218
|
-
team!: string & Group;
|
|
219
|
-
body!: string;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Interfaces attach the same markers with extends.
|
|
223
|
-
export interface Memo extends IsAuthenticated, AdminGroups<"staff"> {
|
|
224
|
-
body: string;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** JSDoc equivalents, tag-for-marker.
|
|
228
|
-
* @rastackResource
|
|
229
|
-
* @rastackPermission public
|
|
230
|
-
* @rastackAdminGroups staff, ops
|
|
231
|
-
*/
|
|
232
|
-
export interface Bulletin {
|
|
233
|
-
/** @owner */
|
|
234
|
-
author: string;
|
|
235
|
-
text: string;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
declare function useEntityList<T>(name: string): T[];
|
|
239
|
-
export const m = useEntityList<Memo>("memos");
|
|
240
|
-
`;
|
|
241
|
-
|
|
242
|
-
function findResource(
|
|
243
|
-
resources: ResourceModel[],
|
|
244
|
-
app: string,
|
|
245
|
-
model: string,
|
|
246
|
-
): ResourceModel {
|
|
247
|
-
const found = resources.find((r) => r.app === app && r.model === model);
|
|
248
|
-
if (!found) throw new Error(`missing ${app}.${model}`);
|
|
249
|
-
return found;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
beforeAll(() => {
|
|
253
|
-
mkdirSync(appDir, { recursive: true });
|
|
254
|
-
writeFileSync(join(appDir, "airports.ts"), AIRPORTS_TS);
|
|
255
|
-
writeFileSync(join(appDir, "flights.ts"), FLIGHTS_TS);
|
|
256
|
-
writeFileSync(join(appDir, "passengers.ts"), PASSENGERS_TS);
|
|
257
|
-
writeFileSync(join(appDir, "screens.tsx"), SCREENS_TSX);
|
|
258
|
-
mkdirSync(classesDir, { recursive: true });
|
|
259
|
-
writeFileSync(join(classesDir, "airports.ts"), CLASSES_TS);
|
|
260
|
-
mkdirSync(authDir, { recursive: true });
|
|
261
|
-
writeFileSync(join(authDir, "workspace.ts"), AUTH_TS);
|
|
262
|
-
mkdirSync(cycleDir, { recursive: true });
|
|
263
|
-
writeFileSync(join(cycleDir, "org.ts"), SOFT_CYCLE_TS);
|
|
264
|
-
mkdirSync(hardCycleDir, { recursive: true });
|
|
265
|
-
writeFileSync(join(hardCycleDir, "paradox.ts"), HARD_CYCLE_TS);
|
|
266
|
-
mkdirSync(mixedDir, { recursive: true });
|
|
267
|
-
writeFileSync(join(mixedDir, "airport.ts"), MIXED_TS);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
afterAll(() => {
|
|
271
|
-
rmSync(workRoot, { recursive: true, force: true });
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
describe("interface entities — discovery from hooks", () => {
|
|
275
|
-
it("roots the graph at hook type arguments and closes over FK references", () => {
|
|
276
|
-
const { resources } = analyzeProject(appDir);
|
|
277
|
-
const keys = resources.map((r) => `${r.app}.${r.model}`);
|
|
278
|
-
|
|
279
|
-
// Flight is the hook root; Terminal and Airport are pulled in through the
|
|
280
|
-
// FK chain flight → terminal → airport.
|
|
281
|
-
expect(keys).toContain("flights.flight");
|
|
282
|
-
expect(keys).toContain("airports.terminal");
|
|
283
|
-
expect(keys).toContain("airports.airport");
|
|
284
|
-
|
|
285
|
-
// Declared but neither hooked nor referenced — not a resource.
|
|
286
|
-
expect(keys).not.toContain("airports.runway");
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("includes @rastackResource interfaces and honours @rastackApp/@rastackModel + I-prefix stripping", () => {
|
|
290
|
-
const { resources } = analyzeProject(appDir);
|
|
291
|
-
const booking = findResource(resources, "bookings", "booking");
|
|
292
|
-
expect(booking.fields).toEqual([{ name: "reference", type: "string" }]);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it("names entities from the interface and the defining file", () => {
|
|
296
|
-
const { resources } = analyzeProject(appDir);
|
|
297
|
-
// airports.ts → app "airports"; interface Airport → model "airport".
|
|
298
|
-
const airport = findResource(resources, "airports", "airport");
|
|
299
|
-
expect(airport.search).toEqual(["code", "name"]);
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
describe("interface entities — datatypes from the type system", () => {
|
|
304
|
-
it("maps property types to manifest scalars with JSDoc refinements", () => {
|
|
305
|
-
const { resources } = analyzeProject(appDir);
|
|
306
|
-
const flight = findResource(resources, "flights", "flight");
|
|
307
|
-
const field = (name: string) => flight.fields.find((f) => f.name === name);
|
|
308
|
-
|
|
309
|
-
expect(field("number")).toMatchObject({ type: "string", maxLength: 8 });
|
|
310
|
-
expect(field("seats")).toMatchObject({ type: "int" }); // number + @int
|
|
311
|
-
expect(field("onTime")).toMatchObject({ type: "bool" });
|
|
312
|
-
expect(field("departure")).toMatchObject({ type: "datetime" });
|
|
313
|
-
expect(field("notes")).toMatchObject({ type: "string", null: true });
|
|
314
|
-
expect(field("status")).toMatchObject({
|
|
315
|
-
type: "string",
|
|
316
|
-
default: "scheduled",
|
|
317
|
-
});
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it("reads constraint tags (@maxLength/@unique) off interface properties", () => {
|
|
321
|
-
const { resources } = analyzeProject(appDir);
|
|
322
|
-
const airport = findResource(resources, "airports", "airport");
|
|
323
|
-
expect(airport.fields.find((f) => f.name === "code")).toMatchObject({
|
|
324
|
-
type: "string",
|
|
325
|
-
maxLength: 3,
|
|
326
|
-
unique: true,
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it("reads column config from DbConfig brand types", () => {
|
|
331
|
-
const { resources } = analyzeProject(appDir);
|
|
332
|
-
const passenger = findResource(resources, "passengers", "passenger");
|
|
333
|
-
const field = (name: string) =>
|
|
334
|
-
passenger.fields.find((f) => f.name === name);
|
|
335
|
-
|
|
336
|
-
expect(field("id")).toMatchObject({ type: "uuid", primaryKey: true });
|
|
337
|
-
expect(field("fullName")).toMatchObject({ type: "string", maxLength: 80 });
|
|
338
|
-
expect(field("age")).toMatchObject({ type: "int" });
|
|
339
|
-
expect(field("loyaltyPoints")).toMatchObject({ type: "float", default: 0 });
|
|
340
|
-
expect(field("status")).toMatchObject({
|
|
341
|
-
type: "string",
|
|
342
|
-
default: "active",
|
|
343
|
-
});
|
|
344
|
-
expect(field("terminal")).toMatchObject({
|
|
345
|
-
type: "fk",
|
|
346
|
-
null: true,
|
|
347
|
-
relation: { app: "airports", model: "terminal" },
|
|
348
|
-
});
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it("supports reusable configs that extend DbConfig", () => {
|
|
352
|
-
const { resources } = analyzeProject(appDir);
|
|
353
|
-
const passenger = findResource(resources, "passengers", "passenger");
|
|
354
|
-
expect(passenger.fields.find((f) => f.name === "seat")).toMatchObject({
|
|
355
|
-
type: "string",
|
|
356
|
-
maxLength: 4,
|
|
357
|
-
unique: true,
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
// The brand interface itself is configuration, not an entity.
|
|
361
|
-
expect(
|
|
362
|
-
resources.find((r) => r.model.toLowerCase().includes("seatcode")),
|
|
363
|
-
).toBeUndefined();
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
it("skips unsupported property types with a warning", () => {
|
|
367
|
-
const { resources, diagnostics } = analyzeProject(appDir);
|
|
368
|
-
const flight = findResource(resources, "flights", "flight");
|
|
369
|
-
expect(flight.fields.find((f) => f.name === "tags")).toBeUndefined();
|
|
370
|
-
expect(
|
|
371
|
-
diagnostics.some(
|
|
372
|
-
(d) =>
|
|
373
|
-
d.severity === "warning" && d.message.includes("flights.flight.tags"),
|
|
374
|
-
),
|
|
375
|
-
).toBe(true);
|
|
376
|
-
});
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
describe("interface entities — FK inference", () => {
|
|
380
|
-
it("treats an interface-typed property as a foreign key", () => {
|
|
381
|
-
const { resources } = analyzeProject(appDir);
|
|
382
|
-
const terminal = findResource(resources, "airports", "terminal");
|
|
383
|
-
|
|
384
|
-
expect(terminal.fields.find((f) => f.name === "airport")).toMatchObject({
|
|
385
|
-
type: "fk",
|
|
386
|
-
relation: { app: "airports", model: "airport" },
|
|
387
|
-
});
|
|
388
|
-
expect(terminal.relations).toEqual([
|
|
389
|
-
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
390
|
-
]);
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
it("maps `Entity | null` to a nullable foreign key", () => {
|
|
394
|
-
const { resources } = analyzeProject(appDir);
|
|
395
|
-
const flight = findResource(resources, "flights", "flight");
|
|
396
|
-
expect(flight.fields.find((f) => f.name === "terminal")).toMatchObject({
|
|
397
|
-
type: "fk",
|
|
398
|
-
null: true,
|
|
399
|
-
relation: { app: "airports", model: "terminal" },
|
|
400
|
-
});
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it("ignores `Entity[]` arrays — the reverse side of the FK", () => {
|
|
404
|
-
const { resources } = analyzeProject(appDir);
|
|
405
|
-
const airport = findResource(resources, "airports", "airport");
|
|
406
|
-
expect(airport.fields.find((f) => f.name === "terminals")).toBeUndefined();
|
|
407
|
-
expect(airport.relations).toEqual([]);
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
describe("class entities — plain data classes", () => {
|
|
412
|
-
it("roots a plain data class by declaration — no hook, tag, or DSL needed", () => {
|
|
413
|
-
const { resources } = analyzeProject(classesDir);
|
|
414
|
-
const keys = resources.map((r) => `${r.app}.${r.model}`);
|
|
415
|
-
expect(keys).toContain("airports.airport");
|
|
416
|
-
expect(keys).toContain("airports.terminal");
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it("does not root classes with methods or heritage clauses", () => {
|
|
420
|
-
const { resources } = analyzeProject(classesDir);
|
|
421
|
-
const keys = resources.map((r) => `${r.app}.${r.model}`);
|
|
422
|
-
expect(keys).not.toContain("airports.boardingService");
|
|
423
|
-
expect(keys).not.toContain("airports.splashScreen");
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
it("reads DbConfig brands and JSDoc tags off class fields", () => {
|
|
427
|
-
const { resources } = analyzeProject(classesDir);
|
|
428
|
-
const airport = findResource(resources, "airports", "airport");
|
|
429
|
-
expect(airport.search).toEqual(["code", "name"]);
|
|
430
|
-
expect(airport.fields.find((f) => f.name === "code")).toMatchObject({
|
|
431
|
-
type: "string",
|
|
432
|
-
maxLength: 3,
|
|
433
|
-
unique: true,
|
|
434
|
-
});
|
|
435
|
-
expect(airport.fields.find((f) => f.name === "name")).toMatchObject({
|
|
436
|
-
type: "string",
|
|
437
|
-
maxLength: 120,
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
it("infers types and defaults from field initializers", () => {
|
|
442
|
-
const { resources } = analyzeProject(classesDir);
|
|
443
|
-
const airport = findResource(resources, "airports", "airport");
|
|
444
|
-
const field = (name: string) => airport.fields.find((f) => f.name === name);
|
|
445
|
-
expect(field("region")).toMatchObject({ type: "string", default: "EU" });
|
|
446
|
-
expect(field("active")).toMatchObject({ type: "bool", default: true });
|
|
447
|
-
expect(field("gates")).toMatchObject({ type: "int", default: 0 });
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
it("treats a class-typed field as a foreign key", () => {
|
|
451
|
-
const { resources } = analyzeProject(classesDir);
|
|
452
|
-
const terminal = findResource(resources, "airports", "terminal");
|
|
453
|
-
expect(terminal.fields.find((f) => f.name === "airport")).toMatchObject({
|
|
454
|
-
type: "fk",
|
|
455
|
-
relation: { app: "airports", model: "airport" },
|
|
456
|
-
});
|
|
457
|
-
expect(terminal.relations).toEqual([
|
|
458
|
-
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
459
|
-
]);
|
|
460
|
-
expect(terminal.fields.find((f) => f.name === "opened")).toMatchObject({
|
|
461
|
-
type: "datetime",
|
|
462
|
-
null: true,
|
|
463
|
-
});
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
it("ignores static and private class members", () => {
|
|
467
|
-
const { resources } = analyzeProject(classesDir);
|
|
468
|
-
const terminal = findResource(resources, "airports", "terminal");
|
|
469
|
-
expect(terminal.fields.find((f) => f.name === "kind")).toBeUndefined();
|
|
470
|
-
expect(terminal.fields.find((f) => f.name === "secret")).toBeUndefined();
|
|
471
|
-
});
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
describe("entity access policy — auth as types", () => {
|
|
475
|
-
it("reads permission / adminGroups / scope markers off a class's implements clause", () => {
|
|
476
|
-
const { resources } = analyzeProject(authDir);
|
|
477
|
-
const itinerary = findResource(resources, "workspace", "itinerary");
|
|
478
|
-
expect(itinerary.permission).toBe("authenticated");
|
|
479
|
-
expect(itinerary.access).toEqual({
|
|
480
|
-
ownerField: "owner",
|
|
481
|
-
scope: "owner",
|
|
482
|
-
adminGroups: ["staff"],
|
|
483
|
-
});
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
it("a Group field makes a group-owned dataset — partitioned per group by default", () => {
|
|
487
|
-
const { resources } = analyzeProject(authDir);
|
|
488
|
-
const report = findResource(resources, "workspace", "report");
|
|
489
|
-
expect(report.permission).toBe("authenticatedOrReadOnly");
|
|
490
|
-
expect(report.access).toEqual({
|
|
491
|
-
groupField: "team",
|
|
492
|
-
scope: "group",
|
|
493
|
-
adminGroups: ["ops", "staff"],
|
|
494
|
-
});
|
|
495
|
-
// The group column is an ordinary string field in the schema.
|
|
496
|
-
expect(report.fields.find((f) => f.name === "team")?.type).toBe("string");
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it("SharedStorage opts a Group dataset out of physical partitioning", () => {
|
|
500
|
-
const { resources } = analyzeProject(authDir);
|
|
501
|
-
const note = findResource(resources, "workspace", "note");
|
|
502
|
-
expect(note.access).toEqual({ groupField: "team", scope: "shared" });
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
it("interfaces attach markers via extends; markers add no columns", () => {
|
|
506
|
-
const { resources } = analyzeProject(authDir);
|
|
507
|
-
const memo = findResource(resources, "workspace", "memo");
|
|
508
|
-
expect(memo.permission).toBe("authenticated");
|
|
509
|
-
expect(memo.access).toEqual({ adminGroups: ["staff"] });
|
|
510
|
-
expect(memo.fields.map((f) => f.name)).toEqual(["body"]);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
it("JSDoc tags mirror every marker (@rastackPermission/@rastackAdminGroups/@owner)", () => {
|
|
514
|
-
const { resources } = analyzeProject(authDir);
|
|
515
|
-
const bulletin = findResource(resources, "workspace", "bulletin");
|
|
516
|
-
expect(bulletin.permission).toBe("public");
|
|
517
|
-
expect(bulletin.access).toEqual({
|
|
518
|
-
ownerField: "author",
|
|
519
|
-
adminGroups: ["staff", "ops"],
|
|
520
|
-
});
|
|
521
|
-
});
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
describe("interface entities — circular dependencies", () => {
|
|
525
|
-
it("reports a breakable cycle (optional self-FK) as a warning", () => {
|
|
526
|
-
const { diagnostics } = analyzeProject(cycleDir);
|
|
527
|
-
const cycle = diagnostics.find((d) =>
|
|
528
|
-
d.message.includes("Circular dependency"),
|
|
529
|
-
);
|
|
530
|
-
expect(cycle).toBeDefined();
|
|
531
|
-
expect(cycle!.severity).toBe("warning");
|
|
532
|
-
expect(cycle!.message).toContain("org.employee → org.employee");
|
|
533
|
-
expect(cycle!.message).toContain("org.employee.manager");
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
it("reports an unbreakable cycle (all FKs required) as an error", () => {
|
|
537
|
-
const { diagnostics } = analyzeProject(hardCycleDir);
|
|
538
|
-
const cycle = diagnostics.find((d) => d.severity === "error");
|
|
539
|
-
expect(cycle).toBeDefined();
|
|
540
|
-
expect(cycle!.message).toContain("Circular dependency");
|
|
541
|
-
expect(cycle!.message).toMatch(/paradox\.(chicken|egg)/);
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
it("compile() refuses to emit a manifest containing an unbreakable cycle", () => {
|
|
545
|
-
expect(() => compile(hardCycleDir, outDir)).toThrow(/Circular dependency/);
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
it("detectCycles also covers DSL-authored relations", () => {
|
|
549
|
-
const resources: ResourceModel[] = [
|
|
550
|
-
{
|
|
551
|
-
app: "a",
|
|
552
|
-
model: "x",
|
|
553
|
-
fields: [{ name: "y", type: "fk", relation: { app: "a", model: "y" } }],
|
|
554
|
-
relations: [{ field: "y", target: "a.y", kind: "fk" }],
|
|
555
|
-
},
|
|
556
|
-
{
|
|
557
|
-
app: "a",
|
|
558
|
-
model: "y",
|
|
559
|
-
fields: [{ name: "x", type: "fk", relation: { app: "a", model: "x" } }],
|
|
560
|
-
relations: [{ field: "x", target: "a.x", kind: "fk" }],
|
|
561
|
-
},
|
|
562
|
-
];
|
|
563
|
-
const diagnostics = detectCycles(resources);
|
|
564
|
-
expect(diagnostics).toHaveLength(1);
|
|
565
|
-
expect(diagnostics[0].severity).toBe("error");
|
|
566
|
-
});
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
describe("interface entities — merging with the resource() DSL", () => {
|
|
570
|
-
it("prefers the explicit DSL definition when both define the same app.model", () => {
|
|
571
|
-
const { resources } = analyzeProject(mixedDir);
|
|
572
|
-
const airports = resources.filter(
|
|
573
|
-
(r) => r.app === "mixed" && r.model === "airport",
|
|
574
|
-
);
|
|
575
|
-
expect(airports).toHaveLength(1);
|
|
576
|
-
// The DSL definition (with admin config) won over the inferred interface.
|
|
577
|
-
expect(airports[0].admin).toEqual({ listDisplay: ["code"] });
|
|
578
|
-
});
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
describe("interface entities — OpenAPI emission", () => {
|
|
582
|
-
it("inferred resources reproduce the /api/{app}/v1/{model}/ contract", () => {
|
|
583
|
-
const { resources } = analyzeProject(appDir);
|
|
584
|
-
const doc = buildOpenApi(resources);
|
|
585
|
-
|
|
586
|
-
expect(doc.paths["/api/flights/v1/flight/"]).toBeDefined();
|
|
587
|
-
expect(doc.paths["/api/flights/v1/flight/{id}/"]).toBeDefined();
|
|
588
|
-
expect(doc.paths["/api/airports/v1/terminal/"]).toBeDefined();
|
|
589
|
-
|
|
590
|
-
// The inferred FK carries the relation extension the admin/codegen follow.
|
|
591
|
-
const flightSchema = doc.components.schemas.Flight;
|
|
592
|
-
expect(flightSchema.properties.terminal["x-rastack-relation"]).toEqual({
|
|
593
|
-
app: "airports",
|
|
594
|
-
model: "terminal",
|
|
595
|
-
});
|
|
596
|
-
});
|
|
597
|
-
});
|