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.
Files changed (105) hide show
  1. package/CHANGELOG.md +43876 -43013
  2. package/dist/.prettierrc +7 -7
  3. package/dist/compile/analyze.js +19 -11
  4. package/dist/compile/index.js +26 -17
  5. package/dist/compile/manifest.js +2 -3
  6. package/dist/compile/openapi.js +5 -8
  7. package/dist/compile/program.js +19 -10
  8. package/dist/csv-schema.js +5 -6
  9. package/dist/define/index.js +2 -2
  10. package/dist/entity-generation/delete-method.js +8 -10
  11. package/dist/entity-generation/form.js +80 -82
  12. package/dist/entity-generation/get-method.js +35 -36
  13. package/dist/entity-generation/imports.js +24 -20
  14. package/dist/entity-generation/sync-method.d.ts +1 -1
  15. package/dist/entity-generation/sync-method.js +45 -46
  16. package/dist/entity-generation/update-method.js +9 -10
  17. package/dist/rad-wasm-build.d.ts +1 -1
  18. package/dist/rad-wasm-build.js +15 -6
  19. package/dist/rad.js +9 -2
  20. package/dist/schema/camel-to-pastel.js +1 -2
  21. package/dist/schema/capitalise-first-letter.js +1 -2
  22. package/dist/schema/extract-response.js +3 -4
  23. package/dist/schema/fetch-schema.js +4 -16
  24. package/dist/schema/remove-non-model-paths.js +1 -2
  25. package/dist/schema/to-camel-case.js +1 -2
  26. package/dist/schema/to-pastel-case.js +1 -2
  27. package/dist/schema-convert.js +22 -33
  28. package/dist/schema-entities.js +177 -189
  29. package/dist/schema-fetch.js +46 -63
  30. package/dist/schema-full.js +23 -36
  31. package/dist/schema-index.js +16 -27
  32. package/dist/schema-params.js +11 -22
  33. package/dist/seed.js +95 -97
  34. package/hooks/form/form.ts +207 -207
  35. package/hooks/form/index.ts +8 -8
  36. package/hooks/form/interfaces.ts +217 -217
  37. package/hooks/form/structure.ts +39 -39
  38. package/hooks/form/validate-schema.ts +49 -49
  39. package/hooks/index.ts +3 -3
  40. package/hooks/query/api.ts +42 -42
  41. package/hooks/query/delete.ts +45 -45
  42. package/hooks/query/fetch.ts +48 -48
  43. package/hooks/query/index.ts +21 -21
  44. package/hooks/query/interfaces.ts +111 -111
  45. package/hooks/query/list.ts +286 -286
  46. package/hooks/query/update.ts +88 -88
  47. package/hooks/query/url.ts +31 -31
  48. package/hooks/real-time/index.ts +1 -1
  49. package/hooks/real-time/pusher.ts +43 -43
  50. package/jest.config.cjs +6 -6
  51. package/package.json +57 -57
  52. package/provider/index.ts +13 -13
  53. package/provider/provider.tsx +187 -187
  54. package/provider/types.ts +114 -114
  55. package/provider/warehouse.ts +167 -167
  56. package/provider/wasm.ts +124 -124
  57. package/runtime.ts +3 -3
  58. package/src/.prettierrc +7 -7
  59. package/src/compile/analyze.ts +224 -224
  60. package/src/compile/index.ts +86 -86
  61. package/src/compile/manifest.ts +10 -10
  62. package/src/compile/model.ts +69 -69
  63. package/src/compile/openapi.ts +266 -266
  64. package/src/compile/program.ts +40 -40
  65. package/src/csv-schema.ts +187 -187
  66. package/src/define/index.ts +162 -162
  67. package/src/entity-generation/delete-method.ts +37 -37
  68. package/src/entity-generation/form.ts +169 -169
  69. package/src/entity-generation/get-method.ts +80 -80
  70. package/src/entity-generation/imports.ts +110 -110
  71. package/src/entity-generation/sync-method.ts +222 -222
  72. package/src/entity-generation/update-method.ts +38 -38
  73. package/src/index.ts +77 -77
  74. package/src/rad-compile.ts +88 -88
  75. package/src/rad-wasm-build.ts +102 -102
  76. package/src/rad.ts +101 -101
  77. package/src/scan.ts +89 -89
  78. package/src/schema/camel-to-pastel.ts +3 -3
  79. package/src/schema/capitalise-first-letter.ts +3 -3
  80. package/src/schema/extract-response.ts +38 -38
  81. package/src/schema/fetch-schema.ts +12 -12
  82. package/src/schema/remove-non-model-paths.ts +13 -13
  83. package/src/schema/to-camel-case.ts +7 -7
  84. package/src/schema/to-pastel-case.ts +6 -6
  85. package/src/schema-convert.ts +61 -61
  86. package/src/schema-entities.ts +401 -401
  87. package/src/schema-fetch.ts +82 -82
  88. package/src/schema-full.ts +38 -38
  89. package/src/schema-index.ts +29 -29
  90. package/src/schema-params.ts +116 -116
  91. package/src/seed.ts +297 -297
  92. package/sync/engine.ts +392 -392
  93. package/sync/hooks.ts +450 -450
  94. package/sync/index.ts +8 -8
  95. package/sync/persistence.ts +237 -237
  96. package/sync/provider.tsx +91 -91
  97. package/sync/registry.ts +36 -36
  98. package/sync/store.ts +126 -126
  99. package/sync/transactions.ts +300 -300
  100. package/sync/types.ts +94 -94
  101. package/test/compile.spec.ts +192 -192
  102. package/test/csv-schema.spec.ts +143 -143
  103. package/test/schema-entities.spec.ts +688 -688
  104. package/tsconfig.json +24 -24
  105. 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
+ }
@@ -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
+ });