@rangka/core 0.1.0 → 0.1.2

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 (197) hide show
  1. package/package.json +6 -2
  2. package/.claude/skills/extend-core/SKILL.md +0 -133
  3. package/.turbo/turbo-build.log +0 -4
  4. package/CHANGELOG.md +0 -18
  5. package/CLAUDE.md +0 -180
  6. package/src/__tests__/coerce.test.ts +0 -154
  7. package/src/__tests__/context.test.ts +0 -111
  8. package/src/__tests__/helpers.ts +0 -21
  9. package/src/__tests__/index.test.ts +0 -7
  10. package/src/__tests__/widgets.test.ts +0 -197
  11. package/src/api/__tests__/handlers.test.ts +0 -389
  12. package/src/api/__tests__/include-resolver.test.ts +0 -393
  13. package/src/api/__tests__/middleware.test.ts +0 -100
  14. package/src/api/__tests__/openapi-schema.test.ts +0 -210
  15. package/src/api/__tests__/query-parser.test.ts +0 -291
  16. package/src/api/__tests__/route-generator.test.ts +0 -137
  17. package/src/api/__tests__/server.test.ts +0 -73
  18. package/src/api/__tests__/swagger.test.ts +0 -166
  19. package/src/api/handlers.ts +0 -274
  20. package/src/api/include-resolver.ts +0 -27
  21. package/src/api/index.ts +0 -4
  22. package/src/api/meta-handler.ts +0 -254
  23. package/src/api/openapi-schema.ts +0 -99
  24. package/src/api/query-parser.ts +0 -315
  25. package/src/api/route-generator.ts +0 -448
  26. package/src/api/server.ts +0 -147
  27. package/src/api/types.ts +0 -16
  28. package/src/audit/__tests__/audit.test.ts +0 -144
  29. package/src/audit/index.ts +0 -3
  30. package/src/audit/record.ts +0 -69
  31. package/src/audit/tables.ts +0 -48
  32. package/src/audit/types.ts +0 -26
  33. package/src/auth/__tests__/core-module.test.ts +0 -54
  34. package/src/auth/__tests__/debug.test.ts +0 -47
  35. package/src/auth/__tests__/field-permissions.test.ts +0 -245
  36. package/src/auth/__tests__/integration.test.ts +0 -208
  37. package/src/auth/__tests__/meta-boot.test.ts +0 -538
  38. package/src/auth/__tests__/model-permissions.test.ts +0 -205
  39. package/src/auth/__tests__/password.test.ts +0 -29
  40. package/src/auth/__tests__/permission-registry.test.ts +0 -313
  41. package/src/auth/__tests__/scope-hook.test.ts +0 -509
  42. package/src/auth/__tests__/scope-registry.test.ts +0 -297
  43. package/src/auth/__tests__/scopes.test.ts +0 -66
  44. package/src/auth/__tests__/session.test.ts +0 -214
  45. package/src/auth/core-models.ts +0 -52
  46. package/src/auth/core-module.ts +0 -59
  47. package/src/auth/debug.ts +0 -157
  48. package/src/auth/field-permissions.ts +0 -116
  49. package/src/auth/index.ts +0 -37
  50. package/src/auth/model-permissions.ts +0 -59
  51. package/src/auth/password.ts +0 -22
  52. package/src/auth/permission-registry.ts +0 -171
  53. package/src/auth/scope-filters.ts +0 -11
  54. package/src/auth/scope-registry.ts +0 -121
  55. package/src/auth/scopes.ts +0 -146
  56. package/src/auth/seed.ts +0 -44
  57. package/src/auth/session.ts +0 -178
  58. package/src/auth/types.ts +0 -50
  59. package/src/boot/__tests__/page-scanning.test.ts +0 -170
  60. package/src/boot/__tests__/page-utils.test.ts +0 -225
  61. package/src/boot/__tests__/project-scanner.test.ts +0 -88
  62. package/src/boot/dependency-sort.ts +0 -82
  63. package/src/boot/discovery.ts +0 -85
  64. package/src/boot/index.ts +0 -457
  65. package/src/boot/page-utils.ts +0 -110
  66. package/src/boot/project-scanner.ts +0 -397
  67. package/src/boot/schema-loader.ts +0 -26
  68. package/src/boot/schema-merger.ts +0 -125
  69. package/src/boot/traits.ts +0 -25
  70. package/src/boot/types.ts +0 -73
  71. package/src/context.ts +0 -105
  72. package/src/db/__tests__/cascade-delete.test.ts +0 -182
  73. package/src/db/__tests__/desired-state.test.ts +0 -136
  74. package/src/db/__tests__/diff-engine.test.ts +0 -635
  75. package/src/db/__tests__/field-mapper.test.ts +0 -355
  76. package/src/db/__tests__/introspect.test.ts +0 -70
  77. package/src/db/__tests__/search-filter.test.ts +0 -45
  78. package/src/db/__tests__/sequence.test.ts +0 -221
  79. package/src/db/auto-sync.ts +0 -133
  80. package/src/db/client.ts +0 -147
  81. package/src/db/desired-state.ts +0 -98
  82. package/src/db/diff-engine.ts +0 -305
  83. package/src/db/field-mapper.ts +0 -504
  84. package/src/db/filter-applier.ts +0 -89
  85. package/src/db/include-resolver.ts +0 -40
  86. package/src/db/index.ts +0 -23
  87. package/src/db/introspect.ts +0 -265
  88. package/src/db/model-include-resolver.ts +0 -327
  89. package/src/db/model-ops.ts +0 -281
  90. package/src/db/scope-enforcer.ts +0 -37
  91. package/src/db/types.ts +0 -98
  92. package/src/errors.ts +0 -41
  93. package/src/events/__tests__/bus.test.ts +0 -105
  94. package/src/events/bus.ts +0 -89
  95. package/src/events/index.ts +0 -2
  96. package/src/events/types.ts +0 -9
  97. package/src/external-model/__tests__/computed-fields.test.ts +0 -106
  98. package/src/external-model/__tests__/field-mapper.test.ts +0 -160
  99. package/src/external-model/__tests__/in-memory-ops.test.ts +0 -247
  100. package/src/external-model/__tests__/mutation-executor.test.ts +0 -160
  101. package/src/external-model/__tests__/query-executor.test.ts +0 -284
  102. package/src/external-model/__tests__/schema-converter.test.ts +0 -174
  103. package/src/external-model/computed-fields.ts +0 -15
  104. package/src/external-model/define.ts +0 -5
  105. package/src/external-model/external-model-ops.ts +0 -108
  106. package/src/external-model/field-mapper.ts +0 -66
  107. package/src/external-model/in-memory-ops.ts +0 -107
  108. package/src/external-model/index.ts +0 -7
  109. package/src/external-model/mutation-executor.ts +0 -71
  110. package/src/external-model/query-executor.ts +0 -100
  111. package/src/external-model/schema-converter.ts +0 -53
  112. package/src/external-model/types.ts +0 -32
  113. package/src/fixtures/__tests__/fixtures.test.ts +0 -203
  114. package/src/fixtures/index.ts +0 -10
  115. package/src/fixtures/loader.ts +0 -196
  116. package/src/fixtures/registry.ts +0 -125
  117. package/src/fixtures/types.ts +0 -33
  118. package/src/helpers/assert-ownership.ts +0 -19
  119. package/src/helpers/coerce.ts +0 -28
  120. package/src/helpers/stamping.ts +0 -28
  121. package/src/helpers/validation.ts +0 -14
  122. package/src/hooks/__tests__/context.test.ts +0 -73
  123. package/src/hooks/__tests__/executor.test.ts +0 -433
  124. package/src/hooks/__tests__/middleware.test.ts +0 -224
  125. package/src/hooks/__tests__/registry.test.ts +0 -50
  126. package/src/hooks/context.ts +0 -89
  127. package/src/hooks/errors.ts +0 -11
  128. package/src/hooks/executor.ts +0 -115
  129. package/src/hooks/index.ts +0 -10
  130. package/src/hooks/middleware.ts +0 -220
  131. package/src/hooks/registry.ts +0 -20
  132. package/src/hooks/types.ts +0 -32
  133. package/src/index.ts +0 -172
  134. package/src/jobs/__tests__/enqueue.test.ts +0 -77
  135. package/src/jobs/__tests__/integration.test.ts +0 -71
  136. package/src/jobs/__tests__/registry.test.ts +0 -103
  137. package/src/jobs/__tests__/scheduler.test.ts +0 -92
  138. package/src/jobs/__tests__/worker-execution.test.ts +0 -202
  139. package/src/jobs/__tests__/worker.test.ts +0 -119
  140. package/src/jobs/enqueue.ts +0 -93
  141. package/src/jobs/index.ts +0 -14
  142. package/src/jobs/registry.ts +0 -92
  143. package/src/jobs/scheduler.ts +0 -205
  144. package/src/jobs/tables.ts +0 -132
  145. package/src/jobs/types.ts +0 -62
  146. package/src/jobs/worker.ts +0 -272
  147. package/src/model-api/__tests__/cross-boundary-includes.test.ts +0 -366
  148. package/src/model-api/__tests__/extended-api.test.ts +0 -244
  149. package/src/model-api/__tests__/filter-applier.test.ts +0 -177
  150. package/src/model-api/__tests__/filter-translator.test.ts +0 -186
  151. package/src/model-api/__tests__/include-resolver.test.ts +0 -226
  152. package/src/model-api/__tests__/model-access.test.ts +0 -284
  153. package/src/model-api/__tests__/query-builder.test.ts +0 -224
  154. package/src/model-api/__tests__/scope-enforcer.test.ts +0 -268
  155. package/src/model-api/field-access.ts +0 -28
  156. package/src/model-api/filter-applier.ts +0 -1
  157. package/src/model-api/filter-translator.ts +0 -67
  158. package/src/model-api/include-resolver.ts +0 -2
  159. package/src/model-api/index.ts +0 -86
  160. package/src/model-api/query-builder.ts +0 -155
  161. package/src/model-api/scope-enforcer.ts +0 -3
  162. package/src/model-api/types.ts +0 -139
  163. package/src/plugins/__tests__/adapter-registry.test.ts +0 -92
  164. package/src/plugins/__tests__/lifecycle.test.ts +0 -96
  165. package/src/plugins/__tests__/loader.test.ts +0 -273
  166. package/src/plugins/__tests__/validator.test.ts +0 -275
  167. package/src/plugins/adapter-registry.ts +0 -42
  168. package/src/plugins/define.ts +0 -5
  169. package/src/plugins/index.ts +0 -28
  170. package/src/plugins/lifecycle.ts +0 -27
  171. package/src/plugins/loader.ts +0 -126
  172. package/src/plugins/types.ts +0 -76
  173. package/src/plugins/validator.ts +0 -141
  174. package/src/schema/__tests__/registry-models-by-module.test.ts +0 -58
  175. package/src/schema/registry.ts +0 -93
  176. package/src/schema/relationships.ts +0 -93
  177. package/src/schema/types.ts +0 -43
  178. package/src/services/__tests__/integration.test.ts +0 -63
  179. package/src/services/__tests__/registry.test.ts +0 -175
  180. package/src/services/index.ts +0 -13
  181. package/src/services/registry.ts +0 -156
  182. package/src/services/types.ts +0 -27
  183. package/src/validation/__tests__/field-validator.test.ts +0 -195
  184. package/src/validation/field-validator.ts +0 -113
  185. package/src/validation/index.ts +0 -1
  186. package/src/widgets/index.ts +0 -3
  187. package/src/widgets/slot-validator.ts +0 -87
  188. package/src/widgets/widget-registry.ts +0 -32
  189. package/tests/boot.test.ts +0 -323
  190. package/tests/dependency-sort.test.ts +0 -99
  191. package/tests/discovery.test.ts +0 -126
  192. package/tests/registry.test.ts +0 -216
  193. package/tests/schema-loader.test.ts +0 -52
  194. package/tests/schema-merger.test.ts +0 -180
  195. package/tsconfig.json +0 -9
  196. package/tsconfig.tsbuildinfo +0 -1
  197. package/vitest.config.ts +0 -14
@@ -1,274 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import type { FastifyRequest, FastifyReply } from 'fastify';
3
- import type { Kysely } from 'kysely';
4
- import type { ResolvedModel } from '../schema/types.js';
5
- import type { SchemaRegistry } from '../schema/registry.js';
6
- import type { ModelAccessOptions } from '../model-api/types.js';
7
- import { createModelAccess } from '../model-api/index.js';
8
- import { QueryParser, QueryValidationError } from './query-parser.js';
9
- import { resolveIncludes } from './include-resolver.js';
10
- import { getAuthContext } from '../auth/session.js';
11
- import { isOwnerOnly, modelHasCreatedBy } from '../auth/model-permissions.js';
12
- import { validateFields } from '../validation/field-validator.js';
13
- import { toBool } from '../helpers/coerce.js';
14
- import { assertOwnership } from '../helpers/assert-ownership.js';
15
- import { stampCreate, stampUpdate } from '../helpers/stamping.js';
16
- import { findMissingRequiredFields } from '../helpers/validation.js';
17
- import { BadRequestError, ForbiddenError, NotFoundError } from '../errors.js';
18
-
19
- // ---------- Types ----------
20
-
21
- /** Shared dependencies passed to each handler factory. */
22
- export interface HandlerContext {
23
- model: ResolvedModel;
24
- registry: SchemaRegistry;
25
- db: Kysely<any>;
26
- modelAccessOpts: Omit<ModelAccessOptions, 'auth'>;
27
- }
28
-
29
- // ---------- Internal helpers ----------
30
-
31
- /** Creates a QueryParser configured with the model's fields and relationship names. */
32
- function createParserForModel(ctx: HandlerContext): QueryParser {
33
- const relationFieldNames = ctx.registry
34
- .getRelationshipsForModel(ctx.model.qualifiedName)
35
- .map((r) => r.field);
36
- return new QueryParser(ctx.model.fields, relationFieldNames);
37
- }
38
-
39
- /** Validates that the request body is a non-null object. Throws BadRequestError if invalid. */
40
- function parseRequestBody(request: FastifyRequest): Record<string, unknown> {
41
- const body = request.body as Record<string, unknown> | undefined;
42
- if (!body || typeof body !== 'object') {
43
- throw new BadRequestError('VALIDATION_ERROR', 'Request body is required');
44
- }
45
- return body;
46
- }
47
-
48
- // ---------- Route handler factories ----------
49
-
50
- /** GET /model — Returns a paginated list of records with filtering, sorting, and field selection. */
51
- export function listHandler(ctx: HandlerContext) {
52
- return async (request: FastifyRequest, reply: FastifyReply) => {
53
- try {
54
- const parser = createParserForModel(ctx);
55
- const parsed = parser.parse(request.query as Record<string, unknown>);
56
- const includeArchived = toBool((request.query as any).includeArchived);
57
-
58
- const authContext = getAuthContext(request);
59
- const models = createModelAccess({ ...ctx.modelAccessOpts, auth: authContext });
60
-
61
- const ownerRead = isOwnerOnly(authContext.permissions, ctx.model.qualifiedName, 'read');
62
- if (ownerRead && !modelHasCreatedBy(ctx.model)) {
63
- throw new ForbiddenError(
64
- 'FORBIDDEN',
65
- `Owner-based read requires created_by field on ${ctx.model.qualifiedName}`,
66
- );
67
- }
68
-
69
- let queryBuilder = models.query(ctx.model.qualifiedName);
70
-
71
- if (includeArchived) {
72
- queryBuilder = queryBuilder.includeArchived();
73
- }
74
-
75
- // Apply parsed filters directly (already translated by QueryParser)
76
- queryBuilder = queryBuilder.filterRaw(parsed.filters);
77
-
78
- // Apply search across searchable fields
79
- if (parsed.search) {
80
- const searchableFields = ctx.model.fields
81
- .filter((f) => 'searchable' in f.config && f.config.searchable)
82
- .map((f) => f.name);
83
- if (searchableFields.length > 0) {
84
- queryBuilder = queryBuilder.search(parsed.search, searchableFields);
85
- }
86
- }
87
-
88
- // Apply owner-only filter
89
- if (ownerRead) {
90
- queryBuilder = queryBuilder.filter({ created_by: authContext.user!.id });
91
- }
92
-
93
- // Apply field selection
94
- if (parsed.fields.length > 0) {
95
- queryBuilder = queryBuilder.fields(parsed.fields);
96
- }
97
-
98
- // Apply sorting
99
- for (const s of parsed.sort) {
100
- queryBuilder = queryBuilder.sort(s.field, s.direction);
101
- }
102
-
103
- // Apply pagination
104
- queryBuilder = queryBuilder.limit(parsed.pagination.limit).page(parsed.pagination.page);
105
-
106
- const result = await queryBuilder.execWithMeta();
107
-
108
- if (parsed.includes.length > 0) {
109
- await resolveIncludes(
110
- result.data,
111
- parsed.includes,
112
- ctx.registry,
113
- ctx.db,
114
- ctx.model.qualifiedName,
115
- request,
116
- );
117
- }
118
-
119
- return reply.send({ data: result.data, meta: result.meta });
120
- } catch (err: any) {
121
- if (err instanceof QueryValidationError) {
122
- throw new BadRequestError(err.code, err.message);
123
- }
124
- throw err;
125
- }
126
- };
127
- }
128
-
129
- /** GET /model/:id — Returns a single record by ID with optional field selection. */
130
- export function getHandler(ctx: HandlerContext) {
131
- return async (request: FastifyRequest, reply: FastifyReply) => {
132
- try {
133
- const { id } = request.params as { id: string };
134
-
135
- const parser = createParserForModel(ctx);
136
- const parsed = parser.parse(request.query as Record<string, unknown>);
137
-
138
- const authContext = getAuthContext(request);
139
- const models = createModelAccess({ ...ctx.modelAccessOpts, auth: authContext });
140
-
141
- let queryBuilder = models.query(ctx.model.qualifiedName).filter({ id });
142
-
143
- if (parsed.fields.length > 0) {
144
- queryBuilder = queryBuilder.fields(parsed.fields);
145
- }
146
-
147
- const record = await queryBuilder.first();
148
-
149
- if (!record) {
150
- throw new NotFoundError(`Record not found: ${id}`);
151
- }
152
-
153
- if (isOwnerOnly(authContext.permissions, ctx.model.qualifiedName, 'read')) {
154
- if (!modelHasCreatedBy(ctx.model) || record.created_by !== authContext.user?.id) {
155
- throw new NotFoundError(`Record not found: ${id}`);
156
- }
157
- }
158
-
159
- if (parsed.includes.length > 0) {
160
- await resolveIncludes(
161
- [record],
162
- parsed.includes,
163
- ctx.registry,
164
- ctx.db,
165
- ctx.model.qualifiedName,
166
- request,
167
- );
168
- }
169
-
170
- return reply.send({ data: record });
171
- } catch (err: any) {
172
- if (err instanceof QueryValidationError) {
173
- throw new BadRequestError(err.code, err.message);
174
- }
175
- throw err;
176
- }
177
- };
178
- }
179
-
180
- /** POST /model — Creates a new record after validating required fields. */
181
- export function createHandler(ctx: HandlerContext) {
182
- return async (request: FastifyRequest, reply: FastifyReply) => {
183
- const body = parseRequestBody(request);
184
-
185
- // Validate required fields
186
- const missing = findMissingRequiredFields(ctx.model, body);
187
- if (missing.length > 0) {
188
- throw new BadRequestError(
189
- 'VALIDATION_ERROR',
190
- `Missing required fields: ${missing.join(', ')}`,
191
- missing,
192
- );
193
- }
194
-
195
- // Validate field rules
196
- const violations = validateFields(ctx.model, body, 'create');
197
- if (violations.length > 0) {
198
- throw new BadRequestError(
199
- 'VALIDATION_ERROR',
200
- violations.map((v) => v.message).join('; '),
201
- violations,
202
- );
203
- }
204
-
205
- const authContext = getAuthContext(request);
206
- stampCreate(body, ctx.model, authContext);
207
-
208
- const models = createModelAccess({ ...ctx.modelAccessOpts, auth: authContext });
209
- const record = await models.create(ctx.model.qualifiedName, body);
210
-
211
- return reply.status(201).send({ data: record });
212
- };
213
- }
214
-
215
- /** PUT /model/:id — Updates an existing record after verifying it exists within auth scopes. */
216
- export function updateHandler(ctx: HandlerContext) {
217
- return async (request: FastifyRequest, reply: FastifyReply) => {
218
- const { id } = request.params as { id: string };
219
-
220
- const body = parseRequestBody(request);
221
-
222
- // Validate field rules
223
- const violations = validateFields(ctx.model, body, 'update');
224
- if (violations.length > 0) {
225
- throw new BadRequestError(
226
- 'VALIDATION_ERROR',
227
- violations.map((v) => v.message).join('; '),
228
- violations,
229
- );
230
- }
231
-
232
- const authContext = getAuthContext(request);
233
- const models = createModelAccess({ ...ctx.modelAccessOpts, auth: authContext });
234
-
235
- // Verify record exists within auth scopes
236
- const existing = await models.query(ctx.model.qualifiedName).filter({ id }).first();
237
-
238
- if (!existing) {
239
- throw new NotFoundError(`Record not found: ${id}`);
240
- }
241
-
242
- // Check owner-only permission
243
- assertOwnership(authContext.permissions, ctx.model, existing, authContext.user?.id, 'write');
244
-
245
- stampUpdate(body, ctx.model, authContext);
246
- const record = await models.update(ctx.model.qualifiedName, id, body);
247
-
248
- return reply.send({ data: record });
249
- };
250
- }
251
-
252
- /** DELETE /model/:id — Deletes a record after verifying it exists within auth scopes. */
253
- export function deleteHandler(ctx: HandlerContext) {
254
- return async (request: FastifyRequest, reply: FastifyReply) => {
255
- const { id } = request.params as { id: string };
256
-
257
- const authContext = getAuthContext(request);
258
- const models = createModelAccess({ ...ctx.modelAccessOpts, auth: authContext });
259
-
260
- // Verify record exists within auth scopes
261
- const existing = await models.query(ctx.model.qualifiedName).filter({ id }).first();
262
-
263
- if (!existing) {
264
- throw new NotFoundError(`Record not found: ${id}`);
265
- }
266
-
267
- // Check owner-only permission
268
- assertOwnership(authContext.permissions, ctx.model, existing, authContext.user?.id, 'delete');
269
-
270
- await models.delete(ctx.model.qualifiedName, id);
271
-
272
- return reply.status(204).send();
273
- };
274
- }
@@ -1,27 +0,0 @@
1
- import type { Kysely } from 'kysely';
2
- import type { SchemaRegistry } from '../schema/registry.js';
3
- import type { ParsedInclude } from './query-parser.js';
4
- import {
5
- resolveModelIncludes,
6
- type IncludeSpec,
7
- type IncludeResolverOptions,
8
- } from '../db/model-include-resolver.js';
9
-
10
- function toIncludeSpec(parsed: ParsedInclude): IncludeSpec {
11
- if (!parsed.nested || parsed.nested.length === 0) return parsed.relation;
12
- return { relation: parsed.relation, nested: parsed.nested.map(toIncludeSpec) };
13
- }
14
-
15
- export async function resolveIncludes(
16
- records: Record<string, unknown>[],
17
- includes: ParsedInclude[],
18
- registry: SchemaRegistry,
19
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
- db: Kysely<any>,
21
- sourceModel: string,
22
- _request: unknown,
23
- options?: IncludeResolverOptions,
24
- ): Promise<void> {
25
- const specs = includes.map(toIncludeSpec);
26
- await resolveModelIncludes(records, specs, registry, db, sourceModel, options);
27
- }
package/src/api/index.ts DELETED
@@ -1,4 +0,0 @@
1
- export { createServer } from './server.js';
2
- export { QueryParser } from './query-parser.js';
3
- export { generateRoutes } from './route-generator.js';
4
- export type { ServerConfig, ApiDefinition } from './types.js';
@@ -1,254 +0,0 @@
1
- import type { FastifyRequest, FastifyReply } from 'fastify';
2
- import type {
3
- ModuleConfig,
4
- PageDefinition,
5
- BootResponse,
6
- ModelMeta,
7
- FieldMeta,
8
- NavigationTree,
9
- NavigationTreeSection,
10
- WidgetDefinitionMeta,
11
- WidgetNode,
12
- } from '@rangka/shared';
13
- import type { SchemaRegistry } from '../schema/registry.js';
14
- import type { ResolvedPermissions } from '../auth/types.js';
15
- import type { ResolvedField } from '../schema/types.js';
16
- import { getAuthContext } from '../auth/session.js';
17
- import { UnauthorizedError } from '../errors.js';
18
-
19
- export interface MetaBootContext {
20
- schemaRegistry: SchemaRegistry;
21
- pages: Array<{ module: string; page: PageDefinition }>;
22
- modules: ModuleConfig[];
23
- widgets?: WidgetDefinitionMeta[];
24
- }
25
-
26
- /**
27
- * Creates the GET /meta/boot handler that returns everything
28
- * the client needs to render the shell: user info, permissions,
29
- * navigation tree, page definitions, and model metadata.
30
- */
31
- export function createMetaBootHandler(ctx: MetaBootContext) {
32
- return async (request: FastifyRequest, reply: FastifyReply) => {
33
- const authCtx = getAuthContext(request);
34
- if (!authCtx.user || !authCtx.permissions) {
35
- throw new UnauthorizedError('Not authenticated');
36
- }
37
-
38
- const { user, permissions } = authCtx;
39
- const accessiblePages = resolveAccessiblePages(ctx.pages, permissions);
40
- const navigation = buildNavigationTree(ctx.modules, accessiblePages);
41
- const models = buildModelMeta(accessiblePages, ctx.schemaRegistry);
42
-
43
- const response: BootResponse = {
44
- user: {
45
- id: user.id,
46
- name: user.full_name,
47
- email: user.email,
48
- roles: authCtx.roles ?? [],
49
- },
50
- permissions: {
51
- models: permissions.models,
52
- pages: permissions.pages,
53
- },
54
- navigation,
55
- pages: accessiblePages.map((p) => p.page),
56
- models,
57
- widgets: ctx.widgets,
58
- };
59
-
60
- return reply.send(response);
61
- };
62
- }
63
-
64
- // --- Page access resolution ---
65
-
66
- /**
67
- * Filters pages to only those the user can access.
68
- * A page is accessible if explicitly listed in page permissions,
69
- * or if the user has read access to any model the page references.
70
- */
71
- function resolveAccessiblePages(
72
- pages: Array<{ module: string; page: PageDefinition }>,
73
- permissions: ResolvedPermissions,
74
- ): Array<{ module: string; page: PageDefinition }> {
75
- return pages.filter(({ page }) => {
76
- if (permissions.pages.includes(page.key)) {
77
- return true;
78
- }
79
-
80
- const referencedModels = collectModelRefs(page);
81
- if (referencedModels.length === 0) {
82
- return true;
83
- }
84
-
85
- return referencedModels.some((model) => permissions.models[model]?.read === true);
86
- });
87
- }
88
-
89
- /**
90
- * Collects all model names referenced by a page's body widget tree.
91
- */
92
- function collectModelRefs(page: PageDefinition): string[] {
93
- const models = new Set<string>();
94
-
95
- for (const node of page.body) {
96
- collectModelRefsFromNode(node, models);
97
- }
98
-
99
- return Array.from(models);
100
- }
101
-
102
- /** Recursively collects model references from a widget node tree. */
103
- function collectModelRefsFromNode(node: WidgetNode, models: Set<string>): void {
104
- if (node.source?.model) {
105
- models.add(node.source.model);
106
- }
107
- if (node.bind?.model?.name) {
108
- models.add(node.bind.model.name);
109
- }
110
- if (node.children) {
111
- for (const child of node.children) {
112
- collectModelRefsFromNode(child, models);
113
- }
114
- }
115
- }
116
-
117
- // --- Navigation tree ---
118
-
119
- /**
120
- * Builds the navigation tree by filtering each module's nav sections
121
- * down to only pages the user can access, then sorting by module order.
122
- */
123
- function buildNavigationTree(
124
- modules: ModuleConfig[],
125
- accessiblePages: Array<{ module: string; page: PageDefinition }>,
126
- ): NavigationTree[] {
127
- const accessiblePageKeys = new Set(accessiblePages.map((p) => p.page.key));
128
- const tree: NavigationTree[] = [];
129
-
130
- for (const mod of modules) {
131
- if (!mod.navigation) continue;
132
-
133
- const sections = buildAccessibleSections(mod.navigation, accessiblePageKeys);
134
- if (sections.length === 0) continue;
135
-
136
- tree.push({
137
- module: mod.name,
138
- label: mod.label,
139
- description: mod.description,
140
- icon: mod.icon,
141
- color: mod.color,
142
- order: mod.order,
143
- type: mod.type,
144
- sections,
145
- });
146
- }
147
-
148
- tree.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
149
- return tree;
150
- }
151
-
152
- /** Filters a module's navigation sections to only include accessible pages. */
153
- function buildAccessibleSections(
154
- navigation: NonNullable<ModuleConfig['navigation']>,
155
- accessiblePageKeys: Set<string>,
156
- ): NavigationTreeSection[] {
157
- const sections: NavigationTreeSection[] = [];
158
-
159
- for (const section of navigation) {
160
- const visibleItems = section.items.filter((item) => accessiblePageKeys.has(item.page));
161
- if (visibleItems.length === 0) continue;
162
-
163
- sections.push({
164
- section: section.section,
165
- items: visibleItems.map((item) => ({
166
- page: item.page,
167
- label: item.label,
168
- icon: item.icon,
169
- })),
170
- });
171
- }
172
-
173
- return sections;
174
- }
175
-
176
- // --- Model metadata ---
177
-
178
- /**
179
- * Builds a map of model metadata for all models referenced by accessible pages.
180
- */
181
- function buildModelMeta(
182
- accessiblePages: Array<{ module: string; page: PageDefinition }>,
183
- schemaRegistry: SchemaRegistry,
184
- ): Record<string, ModelMeta> {
185
- const referencedModelNames = new Set<string>();
186
-
187
- for (const { page } of accessiblePages) {
188
- for (const ref of collectModelRefs(page)) {
189
- referencedModelNames.add(ref);
190
- }
191
- }
192
-
193
- const result: Record<string, ModelMeta> = {};
194
-
195
- for (const name of referencedModelNames) {
196
- const model = schemaRegistry.getModel(name);
197
- if (!model) continue;
198
-
199
- result[name] = {
200
- qualifiedName: model.qualifiedName,
201
- label: model.label,
202
- fields: model.fields.map((field) => resolvedFieldToMeta(field)),
203
- };
204
- }
205
-
206
- return result;
207
- }
208
-
209
- /** Converts a resolved field definition into the client-facing FieldMeta shape. */
210
- function resolvedFieldToMeta(field: ResolvedField): FieldMeta {
211
- const meta: FieldMeta = {
212
- name: field.name,
213
- type: field.config.type,
214
- };
215
-
216
- if ('label' in field.config && field.config.label) {
217
- meta.label = field.config.label;
218
- }
219
-
220
- if ('required' in field.config && field.config.required) {
221
- meta.required = true;
222
- }
223
-
224
- if ('searchable' in field.config && field.config.searchable) {
225
- meta.searchable = true;
226
- }
227
-
228
- if (field.config.type === 'enum') {
229
- meta.options = field.config.options;
230
- }
231
-
232
- // Attach relationship metadata based on field type
233
- meta.relationship = extractRelationshipMeta(field.config);
234
-
235
- return meta;
236
- }
237
-
238
- /** Extracts relationship metadata from a field config, or returns undefined for non-relational fields. */
239
- function extractRelationshipMeta(config: ResolvedField['config']): FieldMeta['relationship'] {
240
- switch (config.type) {
241
- case 'link':
242
- return { type: 'link', model: config.model };
243
- case 'hasMany':
244
- return { type: 'hasMany', model: config.model, foreignKey: config.foreignKey };
245
- case 'children':
246
- return { type: 'children', model: config.model, foreignKey: config.foreignKey };
247
- case 'manyToMany':
248
- return { type: 'manyToMany', model: config.model, through: config.through };
249
- case 'dynamicLink':
250
- return { type: 'dynamicLink', modelField: config.modelField };
251
- default:
252
- return undefined;
253
- }
254
- }
@@ -1,99 +0,0 @@
1
- import type { FieldConfig } from '@rangka/shared';
2
- import type { ResolvedModel } from '../schema/types.js';
3
-
4
- export interface JsonSchemaProperty {
5
- type: string;
6
- format?: string;
7
- description?: string;
8
- enum?: readonly string[];
9
- items?: { type: string };
10
- }
11
-
12
- export interface JsonSchemaObject {
13
- type: 'object';
14
- properties: Record<string, JsonSchemaProperty>;
15
- required?: string[];
16
- }
17
-
18
- /** Map a Rangka field type to its JSON Schema equivalent. Returns null for relation/computed types. */
19
- export function fieldToJsonSchema(config: FieldConfig): JsonSchemaProperty | null {
20
- switch (config.type) {
21
- case 'string':
22
- case 'text':
23
- case 'code':
24
- case 'sequence':
25
- case 'dynamicLink':
26
- return { type: 'string' };
27
- case 'int':
28
- return { type: 'integer' };
29
- case 'decimal':
30
- case 'money':
31
- return { type: 'number' };
32
- case 'boolean':
33
- return { type: 'boolean' };
34
- case 'date':
35
- return { type: 'string', format: 'date' };
36
- case 'datetime':
37
- return { type: 'string', format: 'date-time' };
38
- case 'enum':
39
- return { type: 'string', enum: config.options };
40
- case 'json':
41
- return { type: 'object' };
42
- case 'link':
43
- return { type: 'string', description: `Reference to ${config.model}` };
44
- case 'attachment':
45
- return { type: 'string', format: 'uri' };
46
- case 'attachments':
47
- return { type: 'array', items: { type: 'string' } };
48
- // Relation and computed types have no direct JSON representation
49
- case 'hasMany':
50
- case 'children':
51
- case 'manyToMany':
52
- case 'tree':
53
- case 'computed':
54
- return null;
55
- }
56
- }
57
-
58
- /** Build a full JSON Schema object for a model, including all serializable fields. */
59
- export function modelToSchemaComponent(model: ResolvedModel): JsonSchemaObject {
60
- const properties: Record<string, JsonSchemaProperty> = {
61
- id: { type: 'string', description: 'Primary key' },
62
- };
63
- const required: string[] = [];
64
-
65
- for (const field of model.fields) {
66
- const fieldSchema = fieldToJsonSchema(field.config);
67
- if (!fieldSchema) continue;
68
-
69
- const property: JsonSchemaProperty = { ...fieldSchema };
70
- if ('label' in field.config && field.config.label) {
71
- property.description = field.config.label;
72
- }
73
- properties[field.name] = property;
74
-
75
- if ('required' in field.config && field.config.required) {
76
- required.push(field.name);
77
- }
78
- }
79
-
80
- const schema: JsonSchemaObject = { type: 'object', properties };
81
- if (required.length > 0) {
82
- schema.required = required;
83
- }
84
- return schema;
85
- }
86
-
87
- /** Schema for creating a record — same as the full schema but without the `id` field. */
88
- export function modelToCreateSchema(model: ResolvedModel): JsonSchemaObject {
89
- const full = modelToSchemaComponent(model);
90
- const { id: _, ...properties } = full.properties;
91
- return { type: 'object', properties };
92
- }
93
-
94
- /** Schema for updating a record — same as create (all fields optional, no `id`). */
95
- export function modelToUpdateSchema(model: ResolvedModel): JsonSchemaObject {
96
- const full = modelToSchemaComponent(model);
97
- const { id: _, ...properties } = full.properties;
98
- return { type: 'object', properties };
99
- }