rastack 0.0.50 → 0.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/auth/context.ts +73 -0
- package/auth/core.ts +293 -0
- package/auth/index.ts +23 -0
- package/components/auto-form/AutoForm.tsx +32 -12
- package/components/auto-form/use-auto-form.ts +75 -6
- package/components/data-table/DataTable.tsx +72 -45
- package/components/data-table/use-data-table.ts +57 -3
- package/components/index.ts +2 -0
- package/db.ts +9 -0
- package/dist/admin.js +13 -13
- package/dist/compile/analyze.d.ts +1 -7
- package/dist/compile/analyze.js +19 -70
- package/dist/compile/entities.js +163 -16
- package/dist/compile/model.d.ts +7 -3
- package/dist/compile/openapi.js +13 -3
- package/dist/compile/program.d.ts +2 -1
- package/dist/compile/program.js +7 -2
- package/dist/define/auth.d.ts +27 -0
- package/dist/define/auth.js +42 -0
- package/dist/define/db.d.ts +24 -1
- package/dist/define/db.js +1 -1
- package/dist/define/index.d.ts +82 -18
- package/dist/define/index.js +27 -21
- package/dist/define/manifest.d.ts +64 -0
- package/dist/define/manifest.js +259 -0
- package/dist/define/markers.d.ts +80 -0
- package/dist/define/markers.js +37 -0
- package/dist/define/types.d.ts +10 -0
- package/dist/define/types.js +26 -0
- package/dist/plugin/core.d.ts +108 -0
- package/dist/plugin/core.js +198 -0
- package/dist/plugin/index.d.ts +112 -0
- package/dist/plugin/index.js +203 -0
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/hooks/data.ts +18 -6
- package/hooks/entity.ts +11 -5
- package/hooks/form/entity-form.ts +12 -8
- package/hooks/manifest.ts +50 -14
- package/hooks/registry.ts +25 -8
- package/package.json +1 -1
- package/plugin.ts +8 -0
- package/provider/index.ts +1 -5
- package/provider/provider.tsx +61 -16
- package/provider/types.ts +26 -7
- package/provider/warehouse.ts +4 -3
- package/provider/wasm.ts +3 -1
- package/runtime.ts +1 -0
- package/src/compile/analyze.ts +27 -65
- package/src/compile/entities.ts +193 -19
- package/src/compile/model.ts +8 -9
- package/src/compile/openapi.ts +11 -3
- package/src/compile/program.ts +10 -3
- package/src/define/auth.ts +28 -0
- package/src/define/db.ts +26 -4
- package/src/define/index.ts +118 -24
- package/src/define/manifest.ts +287 -0
- package/src/define/markers.ts +83 -0
- package/src/define/types.ts +11 -0
- package/src/plugin/core.ts +236 -0
- package/src/plugin/index.ts +243 -0
- package/test/auth.spec.ts +207 -0
- package/test/compile.spec.ts +18 -7
- package/test/components.spec.ts +116 -0
- package/test/entities.spec.ts +123 -6
- package/test/plugin.spec.ts +315 -0
- package/test/runtime-manifest.spec.ts +309 -0
- package/test/typed-hooks.spec.ts +7 -2
- package/types.ts +7 -2
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/test/compile.spec.ts
CHANGED
|
@@ -81,7 +81,10 @@ describe("rastack compile — resource analysis & FK inference", () => {
|
|
|
81
81
|
|
|
82
82
|
const airportField = terminal.fields.find((f) => f.name === "airport");
|
|
83
83
|
expect(airportField?.type).toBe("fk");
|
|
84
|
-
expect(airportField?.relation).toEqual({
|
|
84
|
+
expect(airportField?.relation).toEqual({
|
|
85
|
+
app: "airports",
|
|
86
|
+
model: "airport",
|
|
87
|
+
});
|
|
85
88
|
|
|
86
89
|
expect(terminal.relations).toEqual([
|
|
87
90
|
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
@@ -132,7 +135,9 @@ describe("rastack compile — resource analysis & FK inference", () => {
|
|
|
132
135
|
});
|
|
133
136
|
|
|
134
137
|
// Resources without an access block stay unstamped.
|
|
135
|
-
expect(
|
|
138
|
+
expect(
|
|
139
|
+
findResource(resources, "airports", "airport").access,
|
|
140
|
+
).toBeUndefined();
|
|
136
141
|
});
|
|
137
142
|
|
|
138
143
|
it("reports no collisions for a valid graph", () => {
|
|
@@ -176,9 +181,9 @@ describe("rastack compile — OpenAPI emission", () => {
|
|
|
176
181
|
// Local-mode sync extensions, confirmed on update.
|
|
177
182
|
const flightList = doc.paths["/api/airports/v1/flight/"];
|
|
178
183
|
expect(flightList.get["x-rastack-sync"]).toBe("local");
|
|
179
|
-
expect(
|
|
180
|
-
"
|
|
181
|
-
);
|
|
184
|
+
expect(
|
|
185
|
+
doc.paths["/api/airports/v1/flight/{id}/"].put["x-rastack-sync"],
|
|
186
|
+
).toBe("confirmed");
|
|
182
187
|
expect(doc.paths["/api/airports/v1/gate/"].get["x-rastack-sync-load"]).toBe(
|
|
183
188
|
"lazy",
|
|
184
189
|
);
|
|
@@ -186,19 +191,25 @@ describe("rastack compile — OpenAPI emission", () => {
|
|
|
186
191
|
doc.paths["/api/airports/v1/booking/"].post["x-rastack-sync-client-ids"],
|
|
187
192
|
).toBe(true);
|
|
188
193
|
|
|
189
|
-
// Access-controlled resource carries the
|
|
194
|
+
// Access-controlled resource carries the full policy on every op — the
|
|
195
|
+
// permission gate plus the access block `rastack/auth` mirrors in the UI.
|
|
190
196
|
const itineraryList = doc.paths["/api/airports/v1/itinerary/"];
|
|
197
|
+
expect(itineraryList.get["x-rastack-permission"]).toBe("authenticated");
|
|
191
198
|
expect(itineraryList.get["x-rastack-access"]).toEqual({
|
|
192
199
|
ownerField: "owner",
|
|
193
200
|
scope: "owner",
|
|
201
|
+
adminGroups: ["staff"],
|
|
194
202
|
});
|
|
195
203
|
expect(
|
|
196
204
|
doc.paths["/api/airports/v1/itinerary/{id}/"].delete["x-rastack-access"],
|
|
197
|
-
).toEqual({ ownerField: "owner", scope: "owner" });
|
|
205
|
+
).toEqual({ ownerField: "owner", scope: "owner", adminGroups: ["staff"] });
|
|
198
206
|
// Unscoped resources stay unstamped.
|
|
199
207
|
expect(
|
|
200
208
|
doc.paths["/api/airports/v1/airport/"].get["x-rastack-access"],
|
|
201
209
|
).toBeUndefined();
|
|
210
|
+
expect(
|
|
211
|
+
doc.paths["/api/airports/v1/airport/"].get["x-rastack-permission"],
|
|
212
|
+
).toBeUndefined();
|
|
202
213
|
});
|
|
203
214
|
|
|
204
215
|
it("round-trips through the existing hooks codegen", async () => {
|
package/test/components.spec.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
resolveFieldSpecs,
|
|
17
17
|
} from "../components/auto-form/fields";
|
|
18
18
|
import { buildAutoFormModel } from "../components/auto-form/use-auto-form";
|
|
19
|
+
import { anonymousAuth, userAuth, type ResourcePolicy } from "../auth/core";
|
|
19
20
|
import type {
|
|
20
21
|
TableQuery,
|
|
21
22
|
AutoFormLike,
|
|
@@ -406,3 +407,118 @@ describe("buildAutoFormModel — wiring a form hook into field descriptors", ()
|
|
|
406
407
|
expect(transition).toHaveBeenCalledWith("board");
|
|
407
408
|
});
|
|
408
409
|
});
|
|
410
|
+
|
|
411
|
+
// Auth handling built into the components: the models mirror the server's
|
|
412
|
+
// `permission` gate + row-level security (rastack/auth) so the UI never
|
|
413
|
+
// offers a write the API would reject.
|
|
414
|
+
describe("component models — authorization gating", () => {
|
|
415
|
+
const rls: ResourcePolicy = {
|
|
416
|
+
permission: "authenticated",
|
|
417
|
+
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
418
|
+
};
|
|
419
|
+
const readOnly: ResourcePolicy = { permission: "authenticatedOrReadOnly" };
|
|
420
|
+
|
|
421
|
+
const makeField = (): AutoFormField =>
|
|
422
|
+
({
|
|
423
|
+
value: "",
|
|
424
|
+
error: "",
|
|
425
|
+
type: "text",
|
|
426
|
+
isChecked: false,
|
|
427
|
+
isError: false,
|
|
428
|
+
isDisabled: false,
|
|
429
|
+
isRequired: false,
|
|
430
|
+
setValue: jest.fn(),
|
|
431
|
+
setTouched: jest.fn(),
|
|
432
|
+
}) as unknown as AutoFormField;
|
|
433
|
+
|
|
434
|
+
const form: AutoFormLike = {
|
|
435
|
+
formValues: { owner: "u1", notes: "hi" },
|
|
436
|
+
getFormField: () => makeField(),
|
|
437
|
+
onSubmit: jest.fn(),
|
|
438
|
+
resetForm: jest.fn(),
|
|
439
|
+
isSubmitDisabled: false,
|
|
440
|
+
isSubmitting: false,
|
|
441
|
+
errors: {},
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
it("auto-form: anonymous writes are blocked by the permission gate", () => {
|
|
445
|
+
const model = buildAutoFormModel(form, {
|
|
446
|
+
auth: anonymousAuth(),
|
|
447
|
+
policy: readOnly,
|
|
448
|
+
action: "update",
|
|
449
|
+
});
|
|
450
|
+
expect(model.canWrite).toBe(false);
|
|
451
|
+
expect(model.canSubmit).toBe(false);
|
|
452
|
+
|
|
453
|
+
const asUser = buildAutoFormModel(form, {
|
|
454
|
+
auth: userAuth("u1"),
|
|
455
|
+
policy: readOnly,
|
|
456
|
+
});
|
|
457
|
+
expect(asUser.canWrite).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("auto-form: no identity wired up ⇒ ungated (server stays the enforcer)", () => {
|
|
461
|
+
const model = buildAutoFormModel(form, { policy: rls });
|
|
462
|
+
expect(model.canWrite).toBe(true);
|
|
463
|
+
expect(model.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("auto-form: hides the server-stamped owner field, except for RLS bypassers", () => {
|
|
467
|
+
const model = buildAutoFormModel(form, {
|
|
468
|
+
auth: userAuth("u1"),
|
|
469
|
+
policy: rls,
|
|
470
|
+
});
|
|
471
|
+
expect(model.fields.map((f) => f.name)).toEqual(["notes"]);
|
|
472
|
+
|
|
473
|
+
const staff = buildAutoFormModel(form, {
|
|
474
|
+
auth: userAuth("u1", ["staff"]),
|
|
475
|
+
policy: rls,
|
|
476
|
+
});
|
|
477
|
+
expect(staff.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
|
|
478
|
+
|
|
479
|
+
// An explicit field list is the author's call — never overridden.
|
|
480
|
+
const explicit = buildAutoFormModel(form, {
|
|
481
|
+
auth: userAuth("u1"),
|
|
482
|
+
policy: rls,
|
|
483
|
+
fields: ["owner", "notes"],
|
|
484
|
+
});
|
|
485
|
+
expect(explicit.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("data-table: canRead / can / canActOnRow mirror the server's rules", () => {
|
|
489
|
+
const query = {
|
|
490
|
+
data: [
|
|
491
|
+
{ id: 1, owner: "u1" },
|
|
492
|
+
{ id: 2, owner: "u2" },
|
|
493
|
+
],
|
|
494
|
+
isLoading: false,
|
|
495
|
+
} as TableQuery<{ id: number; owner: string }>;
|
|
496
|
+
|
|
497
|
+
const anon = buildDataTableModel(query, {
|
|
498
|
+
auth: anonymousAuth(),
|
|
499
|
+
policy: rls,
|
|
500
|
+
});
|
|
501
|
+
expect(anon.canRead).toBe(false);
|
|
502
|
+
|
|
503
|
+
const mine = buildDataTableModel(query, {
|
|
504
|
+
auth: userAuth("u1"),
|
|
505
|
+
policy: rls,
|
|
506
|
+
});
|
|
507
|
+
expect(mine.canRead).toBe(true);
|
|
508
|
+
expect(mine.can("delete")).toBe(true);
|
|
509
|
+
expect(mine.canActOnRow("delete", { id: 1, owner: "u1" })).toBe(true);
|
|
510
|
+
expect(mine.canActOnRow("delete", { id: 2, owner: "u2" })).toBe(false);
|
|
511
|
+
|
|
512
|
+
const anonPublicRead = buildDataTableModel(query, {
|
|
513
|
+
auth: anonymousAuth(),
|
|
514
|
+
policy: readOnly,
|
|
515
|
+
});
|
|
516
|
+
expect(anonPublicRead.canRead).toBe(true);
|
|
517
|
+
expect(anonPublicRead.can("create")).toBe(false);
|
|
518
|
+
|
|
519
|
+
// No identity wired up ⇒ everything stays enabled.
|
|
520
|
+
const ungated = buildDataTableModel(query, { policy: rls });
|
|
521
|
+
expect(ungated.canRead).toBe(true);
|
|
522
|
+
expect(ungated.can("delete")).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
});
|
package/test/entities.spec.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
const workRoot = join(__dirname, "entities-fixtures");
|
|
18
18
|
const appDir = join(workRoot, "app");
|
|
19
19
|
const classesDir = join(workRoot, "classes");
|
|
20
|
+
const authDir = join(workRoot, "auth");
|
|
20
21
|
const cycleDir = join(workRoot, "cycles");
|
|
21
22
|
const hardCycleDir = join(workRoot, "hard-cycles");
|
|
22
23
|
const mixedDir = join(workRoot, "mixed");
|
|
@@ -82,7 +83,7 @@ import {
|
|
|
82
83
|
PrimaryKey,
|
|
83
84
|
Unique,
|
|
84
85
|
UUID,
|
|
85
|
-
} from "rastack/
|
|
86
|
+
} from "rastack/db";
|
|
86
87
|
import { Terminal } from "./airports";
|
|
87
88
|
|
|
88
89
|
/** A reusable column config — "extend DbConfig once, use it everywhere". */
|
|
@@ -184,6 +185,60 @@ export class BoardingService {
|
|
|
184
185
|
export class SplashScreen extends BoardingService {}
|
|
185
186
|
`;
|
|
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
|
+
|
|
187
242
|
function findResource(
|
|
188
243
|
resources: ResourceModel[],
|
|
189
244
|
app: string,
|
|
@@ -202,6 +257,8 @@ beforeAll(() => {
|
|
|
202
257
|
writeFileSync(join(appDir, "screens.tsx"), SCREENS_TSX);
|
|
203
258
|
mkdirSync(classesDir, { recursive: true });
|
|
204
259
|
writeFileSync(join(classesDir, "airports.ts"), CLASSES_TS);
|
|
260
|
+
mkdirSync(authDir, { recursive: true });
|
|
261
|
+
writeFileSync(join(authDir, "workspace.ts"), AUTH_TS);
|
|
205
262
|
mkdirSync(cycleDir, { recursive: true });
|
|
206
263
|
writeFileSync(join(cycleDir, "org.ts"), SOFT_CYCLE_TS);
|
|
207
264
|
mkdirSync(hardCycleDir, { recursive: true });
|
|
@@ -254,7 +311,10 @@ describe("interface entities — datatypes from the type system", () => {
|
|
|
254
311
|
expect(field("onTime")).toMatchObject({ type: "bool" });
|
|
255
312
|
expect(field("departure")).toMatchObject({ type: "datetime" });
|
|
256
313
|
expect(field("notes")).toMatchObject({ type: "string", null: true });
|
|
257
|
-
expect(field("status")).toMatchObject({
|
|
314
|
+
expect(field("status")).toMatchObject({
|
|
315
|
+
type: "string",
|
|
316
|
+
default: "scheduled",
|
|
317
|
+
});
|
|
258
318
|
});
|
|
259
319
|
|
|
260
320
|
it("reads constraint tags (@maxLength/@unique) off interface properties", () => {
|
|
@@ -270,13 +330,17 @@ describe("interface entities — datatypes from the type system", () => {
|
|
|
270
330
|
it("reads column config from DbConfig brand types", () => {
|
|
271
331
|
const { resources } = analyzeProject(appDir);
|
|
272
332
|
const passenger = findResource(resources, "passengers", "passenger");
|
|
273
|
-
const field = (name: string) =>
|
|
333
|
+
const field = (name: string) =>
|
|
334
|
+
passenger.fields.find((f) => f.name === name);
|
|
274
335
|
|
|
275
336
|
expect(field("id")).toMatchObject({ type: "uuid", primaryKey: true });
|
|
276
337
|
expect(field("fullName")).toMatchObject({ type: "string", maxLength: 80 });
|
|
277
338
|
expect(field("age")).toMatchObject({ type: "int" });
|
|
278
339
|
expect(field("loyaltyPoints")).toMatchObject({ type: "float", default: 0 });
|
|
279
|
-
expect(field("status")).toMatchObject({
|
|
340
|
+
expect(field("status")).toMatchObject({
|
|
341
|
+
type: "string",
|
|
342
|
+
default: "active",
|
|
343
|
+
});
|
|
280
344
|
expect(field("terminal")).toMatchObject({
|
|
281
345
|
type: "fk",
|
|
282
346
|
null: true,
|
|
@@ -305,7 +369,8 @@ describe("interface entities — datatypes from the type system", () => {
|
|
|
305
369
|
expect(flight.fields.find((f) => f.name === "tags")).toBeUndefined();
|
|
306
370
|
expect(
|
|
307
371
|
diagnostics.some(
|
|
308
|
-
(d) =>
|
|
372
|
+
(d) =>
|
|
373
|
+
d.severity === "warning" && d.message.includes("flights.flight.tags"),
|
|
309
374
|
),
|
|
310
375
|
).toBe(true);
|
|
311
376
|
});
|
|
@@ -406,10 +471,62 @@ describe("class entities — plain data classes", () => {
|
|
|
406
471
|
});
|
|
407
472
|
});
|
|
408
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
|
+
|
|
409
524
|
describe("interface entities — circular dependencies", () => {
|
|
410
525
|
it("reports a breakable cycle (optional self-FK) as a warning", () => {
|
|
411
526
|
const { diagnostics } = analyzeProject(cycleDir);
|
|
412
|
-
const cycle = diagnostics.find((d) =>
|
|
527
|
+
const cycle = diagnostics.find((d) =>
|
|
528
|
+
d.message.includes("Circular dependency"),
|
|
529
|
+
);
|
|
413
530
|
expect(cycle).toBeDefined();
|
|
414
531
|
expect(cycle!.severity).toBe("warning");
|
|
415
532
|
expect(cycle!.message).toContain("org.employee → org.employee");
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev-server schema compiler (`rastack/plugin`) — the way around
|
|
3
|
+
* "classes need a compile step". The plugin runs the same
|
|
4
|
+
* `analyzeProject`/`compile` pass as the CLI *inside* the bundler: entities
|
|
5
|
+
* authored as plain classes/interfaces become the `virtual:rastack-manifest`
|
|
6
|
+
* module, recompiled on save, with `rastack-env.d.ts` kept fresh and the
|
|
7
|
+
* last good manifest served through broken in-progress edits.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import {
|
|
13
|
+
GLOBAL_MANIFEST_KEY,
|
|
14
|
+
isEntitySource,
|
|
15
|
+
manifestModuleCode,
|
|
16
|
+
rastackPlugin,
|
|
17
|
+
registerModuleCode,
|
|
18
|
+
resolveOptions,
|
|
19
|
+
RESOLVED_MANIFEST_ID,
|
|
20
|
+
startRastackWatcher,
|
|
21
|
+
VIRTUAL_MANIFEST_ID,
|
|
22
|
+
VIRTUAL_REGISTER_ID,
|
|
23
|
+
virtualModuleDts,
|
|
24
|
+
} from "../src/plugin";
|
|
25
|
+
import { getGlobalManifest } from "../hooks/manifest";
|
|
26
|
+
import { resolveEntityRef } from "../hooks/entity";
|
|
27
|
+
|
|
28
|
+
const workRoot = join(__dirname, "plugin-fixtures");
|
|
29
|
+
const resourcesDir = join(workRoot, "entities");
|
|
30
|
+
const outDir = join(workRoot, ".rastack");
|
|
31
|
+
|
|
32
|
+
// Plain classes + a hook-rooted interface — no resource() DSL anywhere.
|
|
33
|
+
const GOOD_SOURCE = `
|
|
34
|
+
export class Airport {
|
|
35
|
+
code!: string;
|
|
36
|
+
name!: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class Terminal {
|
|
40
|
+
airport!: Airport; // FK — inferred from the type
|
|
41
|
+
label!: string;
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
// An all-required FK cycle — a compile *error* (the mid-edit breakage case).
|
|
46
|
+
const BROKEN_SOURCE = `
|
|
47
|
+
export class Airport {
|
|
48
|
+
code!: string;
|
|
49
|
+
home!: Terminal;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class Terminal {
|
|
53
|
+
airport!: Airport;
|
|
54
|
+
label!: string;
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
function writeEntities(source: string): void {
|
|
59
|
+
writeFileSync(join(resourcesDir, "airports.ts"), source);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
64
|
+
mkdirSync(resourcesDir, { recursive: true });
|
|
65
|
+
writeEntities(GOOD_SOURCE);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(() => {
|
|
69
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function makePlugin(overrides: Record<string, unknown> = {}) {
|
|
73
|
+
return rastackPlugin({
|
|
74
|
+
resourcesDir,
|
|
75
|
+
outDir,
|
|
76
|
+
onError: () => {},
|
|
77
|
+
...overrides,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("rastackPlugin — the bundler hooks", () => {
|
|
82
|
+
it("serves class entities as the virtual manifest module", () => {
|
|
83
|
+
const plugin = makePlugin();
|
|
84
|
+
plugin.buildStart();
|
|
85
|
+
|
|
86
|
+
expect(plugin.resolveId(VIRTUAL_MANIFEST_ID)).toBe(RESOLVED_MANIFEST_ID);
|
|
87
|
+
expect(plugin.resolveId("some/other/module")).toBeUndefined();
|
|
88
|
+
|
|
89
|
+
const code = plugin.load(RESOLVED_MANIFEST_ID)!;
|
|
90
|
+
expect(code).toContain("export default manifest");
|
|
91
|
+
const manifest = plugin.api.manifest();
|
|
92
|
+
expect(code).toContain(JSON.stringify(manifest));
|
|
93
|
+
|
|
94
|
+
// The classes were compiled: fields read off the type system, the
|
|
95
|
+
// Airport-typed property inferred as a foreign key.
|
|
96
|
+
// The app name derives from the defining file (airports.ts → "airports").
|
|
97
|
+
const terminal = manifest.resources.find((r) => r.model === "terminal")!;
|
|
98
|
+
expect(terminal.relations).toEqual([
|
|
99
|
+
{ field: "airport", target: "airports.airport", kind: "fk" },
|
|
100
|
+
]);
|
|
101
|
+
expect(manifestModuleCode(manifest)).toBe(code);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("writes the artifacts — schema, openapi, env.d.ts, virtual-module types", () => {
|
|
105
|
+
makePlugin().buildStart();
|
|
106
|
+
expect(existsSync(join(outDir, "schema.rastack.json"))).toBe(true);
|
|
107
|
+
expect(existsSync(join(outDir, "openapi.json"))).toBe(true);
|
|
108
|
+
|
|
109
|
+
const env = readFileSync(join(outDir, "rastack-env.d.ts"), "utf8");
|
|
110
|
+
expect(env).toContain('"airports.airport": import("../entities/airports").Airport;');
|
|
111
|
+
|
|
112
|
+
const dts = readFileSync(join(outDir, "rastack-plugin.d.ts"), "utf8");
|
|
113
|
+
expect(dts).toBe(virtualModuleDts());
|
|
114
|
+
expect(dts).toContain(`declare module "${VIRTUAL_MANIFEST_ID}"`);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("recompiles when a watched entity changes", () => {
|
|
118
|
+
const plugin = makePlugin();
|
|
119
|
+
plugin.buildStart();
|
|
120
|
+
expect(
|
|
121
|
+
plugin.api.manifest().resources.map((r) => r.model),
|
|
122
|
+
).toEqual(["airport", "terminal"]);
|
|
123
|
+
|
|
124
|
+
writeEntities(GOOD_SOURCE + "\nexport class Gate { number!: string }\n");
|
|
125
|
+
plugin.watchChange(join(resourcesDir, "airports.ts"));
|
|
126
|
+
|
|
127
|
+
expect(plugin.api.manifest().resources.map((r) => r.model)).toEqual([
|
|
128
|
+
"airport",
|
|
129
|
+
"gate",
|
|
130
|
+
"terminal",
|
|
131
|
+
]);
|
|
132
|
+
expect(plugin.load(RESOLVED_MANIFEST_ID)).toContain('"gate"');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("keeps serving the last good manifest through a broken edit", () => {
|
|
136
|
+
const errors: unknown[] = [];
|
|
137
|
+
const plugin = makePlugin({ onError: (e: unknown) => errors.push(e) });
|
|
138
|
+
plugin.buildStart();
|
|
139
|
+
const goodCode = plugin.load(RESOLVED_MANIFEST_ID);
|
|
140
|
+
|
|
141
|
+
writeEntities(BROKEN_SOURCE);
|
|
142
|
+
plugin.watchChange(join(resourcesDir, "airports.ts"));
|
|
143
|
+
|
|
144
|
+
// The compile failed (reported), but the app keeps its manifest.
|
|
145
|
+
expect(plugin.load(RESOLVED_MANIFEST_ID)).toBe(goodCode);
|
|
146
|
+
expect(errors).toHaveLength(1);
|
|
147
|
+
expect(String((errors[0] as Error).message)).toMatch(/circular/i);
|
|
148
|
+
expect(plugin.api.error()).toBe(errors[0]);
|
|
149
|
+
|
|
150
|
+
// Fixing the file recovers on the next change (load() recompiles).
|
|
151
|
+
writeEntities(GOOD_SOURCE);
|
|
152
|
+
plugin.watchChange(join(resourcesDir, "airports.ts"));
|
|
153
|
+
expect(plugin.load(RESOLVED_MANIFEST_ID)).toBe(goodCode);
|
|
154
|
+
expect(plugin.api.error()).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("throws at startup when there is no good manifest to fall back to", () => {
|
|
158
|
+
writeEntities(BROKEN_SOURCE);
|
|
159
|
+
const plugin = makePlugin();
|
|
160
|
+
expect(() => plugin.buildStart()).toThrow(/circular/i);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("hot-invalidates the virtual module in a Vite-shaped server", () => {
|
|
164
|
+
const plugin = makePlugin();
|
|
165
|
+
plugin.buildStart();
|
|
166
|
+
|
|
167
|
+
const virtualModule = { id: RESOLVED_MANIFEST_ID };
|
|
168
|
+
const invalidated: unknown[] = [];
|
|
169
|
+
const server = {
|
|
170
|
+
moduleGraph: {
|
|
171
|
+
getModuleById: (id: string) =>
|
|
172
|
+
id === RESOLVED_MANIFEST_ID ? virtualModule : undefined,
|
|
173
|
+
invalidateModule: (mod: unknown) => invalidated.push(mod),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const changed = { id: "changed" };
|
|
178
|
+
const result = plugin.handleHotUpdate({
|
|
179
|
+
file: join(resourcesDir, "airports.ts"),
|
|
180
|
+
server,
|
|
181
|
+
modules: [changed],
|
|
182
|
+
});
|
|
183
|
+
expect(invalidated).toEqual([virtualModule]);
|
|
184
|
+
expect(result).toEqual([changed, virtualModule]);
|
|
185
|
+
|
|
186
|
+
// Unrelated files don't touch the schema.
|
|
187
|
+
expect(
|
|
188
|
+
plugin.handleHotUpdate({
|
|
189
|
+
file: join(workRoot, "App.tsx"),
|
|
190
|
+
server,
|
|
191
|
+
modules: [changed],
|
|
192
|
+
}),
|
|
193
|
+
).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("auto-registration — no manifest prop anywhere", () => {
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
delete (globalThis as Record<string, unknown>)[GLOBAL_MANIFEST_KEY];
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("serves a register module that publishes the manifest globally", () => {
|
|
203
|
+
const plugin = makePlugin();
|
|
204
|
+
plugin.buildStart();
|
|
205
|
+
|
|
206
|
+
expect(plugin.resolveId(VIRTUAL_REGISTER_ID)).toBe(VIRTUAL_REGISTER_ID);
|
|
207
|
+
const code = plugin.load(VIRTUAL_REGISTER_ID)!;
|
|
208
|
+
expect(code).toBe(registerModuleCode());
|
|
209
|
+
expect(code).toContain(`import manifest from "${VIRTUAL_MANIFEST_ID}"`);
|
|
210
|
+
expect(code).toContain(`globalThis.${GLOBAL_MANIFEST_KEY} = manifest`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("injects the register script into index.html (dev src, build inline)", () => {
|
|
214
|
+
const plugin = makePlugin();
|
|
215
|
+
expect(plugin.transformIndexHtml.order).toBe("pre");
|
|
216
|
+
|
|
217
|
+
const dev = plugin.transformIndexHtml.handler("<html/>", { server: {} });
|
|
218
|
+
expect(dev).toEqual([
|
|
219
|
+
{
|
|
220
|
+
tag: "script",
|
|
221
|
+
attrs: { type: "module", src: `/@id/${VIRTUAL_REGISTER_ID}` },
|
|
222
|
+
injectTo: "head-prepend",
|
|
223
|
+
},
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
const build = plugin.transformIndexHtml.handler("<html/>", {});
|
|
227
|
+
expect(build).toEqual([
|
|
228
|
+
{
|
|
229
|
+
tag: "script",
|
|
230
|
+
attrs: { type: "module" },
|
|
231
|
+
children: `import "${VIRTUAL_REGISTER_ID}";`,
|
|
232
|
+
injectTo: "head-prepend",
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("the runtime falls back to the registered global manifest", () => {
|
|
238
|
+
class Airport {
|
|
239
|
+
code!: string;
|
|
240
|
+
}
|
|
241
|
+
// Nothing registered → the class cannot resolve without a manifest.
|
|
242
|
+
expect(getGlobalManifest()).toBeUndefined();
|
|
243
|
+
expect(() => resolveEntityRef(Airport, getGlobalManifest())).toThrow(
|
|
244
|
+
/manifest/,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// What the injected register module does at boot:
|
|
248
|
+
(globalThis as Record<string, unknown>)[GLOBAL_MANIFEST_KEY] = {
|
|
249
|
+
version: 1,
|
|
250
|
+
resources: [
|
|
251
|
+
{ app: "airports", model: "airport", fields: [], relations: [] },
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const manifest = getGlobalManifest()!;
|
|
256
|
+
expect(manifest.resources).toHaveLength(1);
|
|
257
|
+
expect(getGlobalManifest()).toBe(manifest); // cached per assignment
|
|
258
|
+
// useRastackManifest (which useData/useForm read outside any provider)
|
|
259
|
+
// falls back to this global, so a bare useData(Airport) resolves with
|
|
260
|
+
// zero schema wiring in app code.
|
|
261
|
+
expect(resolveEntityRef(Airport, getGlobalManifest()).listUrl).toBe(
|
|
262
|
+
"/api/airports/v1/airport/",
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("isEntitySource — what triggers a recompile", () => {
|
|
268
|
+
const options = resolveOptions({ resourcesDir, outDir }, workRoot);
|
|
269
|
+
|
|
270
|
+
it("accepts .ts/.tsx under the resources dir and nothing else", () => {
|
|
271
|
+
expect(isEntitySource(join(resourcesDir, "a.ts"), options)).toBe(true);
|
|
272
|
+
expect(isEntitySource(join(resourcesDir, "deep", "b.tsx"), options)).toBe(true);
|
|
273
|
+
expect(isEntitySource(join(resourcesDir, "a.d.ts"), options)).toBe(false);
|
|
274
|
+
expect(isEntitySource(join(resourcesDir, "notes.md"), options)).toBe(false);
|
|
275
|
+
expect(isEntitySource(join(workRoot, "elsewhere.ts"), options)).toBe(false);
|
|
276
|
+
// The emitted artifacts must never re-trigger the compiler.
|
|
277
|
+
expect(isEntitySource(join(outDir, "rastack-env.d.ts"), options)).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("startRastackWatcher — the non-Rollup dev servers", () => {
|
|
282
|
+
it("compiles at start and rewrites artifacts on save", async () => {
|
|
283
|
+
const watcher = startRastackWatcher({
|
|
284
|
+
resourcesDir,
|
|
285
|
+
outDir,
|
|
286
|
+
onError: () => {},
|
|
287
|
+
});
|
|
288
|
+
try {
|
|
289
|
+
expect(existsSync(join(outDir, "schema.rastack.json"))).toBe(true);
|
|
290
|
+
expect(
|
|
291
|
+
watcher.state.manifest().resources.map((r) => r.model),
|
|
292
|
+
).toEqual(["airport", "terminal"]);
|
|
293
|
+
|
|
294
|
+
writeEntities(GOOD_SOURCE + "\nexport class Gate { number!: string }\n");
|
|
295
|
+
await waitFor(() =>
|
|
296
|
+
readFileSync(join(outDir, "schema.rastack.json"), "utf8").includes(
|
|
297
|
+
'"gate"',
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
} finally {
|
|
301
|
+
watcher.close();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
async function waitFor(check: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
307
|
+
const start = Date.now();
|
|
308
|
+
for (;;) {
|
|
309
|
+
if (check()) return;
|
|
310
|
+
if (Date.now() - start > timeoutMs) {
|
|
311
|
+
throw new Error("waitFor: condition not met in time");
|
|
312
|
+
}
|
|
313
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
314
|
+
}
|
|
315
|
+
}
|