@rangka/core 0.1.1 → 0.1.3
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/package.json +6 -2
- package/.claude/skills/extend-core/SKILL.md +0 -133
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -25
- package/CLAUDE.md +0 -180
- package/src/__tests__/coerce.test.ts +0 -154
- package/src/__tests__/context.test.ts +0 -111
- package/src/__tests__/helpers.ts +0 -21
- package/src/__tests__/index.test.ts +0 -7
- package/src/__tests__/widgets.test.ts +0 -197
- package/src/api/__tests__/handlers.test.ts +0 -389
- package/src/api/__tests__/include-resolver.test.ts +0 -393
- package/src/api/__tests__/middleware.test.ts +0 -100
- package/src/api/__tests__/openapi-schema.test.ts +0 -210
- package/src/api/__tests__/query-parser.test.ts +0 -291
- package/src/api/__tests__/route-generator.test.ts +0 -137
- package/src/api/__tests__/server.test.ts +0 -73
- package/src/api/__tests__/swagger.test.ts +0 -166
- package/src/api/handlers.ts +0 -274
- package/src/api/include-resolver.ts +0 -27
- package/src/api/index.ts +0 -4
- package/src/api/meta-handler.ts +0 -254
- package/src/api/openapi-schema.ts +0 -99
- package/src/api/query-parser.ts +0 -315
- package/src/api/route-generator.ts +0 -448
- package/src/api/server.ts +0 -147
- package/src/api/types.ts +0 -16
- package/src/audit/__tests__/audit.test.ts +0 -144
- package/src/audit/index.ts +0 -3
- package/src/audit/record.ts +0 -69
- package/src/audit/tables.ts +0 -48
- package/src/audit/types.ts +0 -26
- package/src/auth/__tests__/core-module.test.ts +0 -54
- package/src/auth/__tests__/debug.test.ts +0 -47
- package/src/auth/__tests__/field-permissions.test.ts +0 -245
- package/src/auth/__tests__/integration.test.ts +0 -208
- package/src/auth/__tests__/meta-boot.test.ts +0 -538
- package/src/auth/__tests__/model-permissions.test.ts +0 -205
- package/src/auth/__tests__/password.test.ts +0 -29
- package/src/auth/__tests__/permission-registry.test.ts +0 -313
- package/src/auth/__tests__/scope-hook.test.ts +0 -509
- package/src/auth/__tests__/scope-registry.test.ts +0 -297
- package/src/auth/__tests__/scopes.test.ts +0 -66
- package/src/auth/__tests__/session.test.ts +0 -214
- package/src/auth/core-models.ts +0 -52
- package/src/auth/core-module.ts +0 -59
- package/src/auth/debug.ts +0 -157
- package/src/auth/field-permissions.ts +0 -116
- package/src/auth/index.ts +0 -37
- package/src/auth/model-permissions.ts +0 -59
- package/src/auth/password.ts +0 -22
- package/src/auth/permission-registry.ts +0 -171
- package/src/auth/scope-filters.ts +0 -11
- package/src/auth/scope-registry.ts +0 -121
- package/src/auth/scopes.ts +0 -146
- package/src/auth/seed.ts +0 -44
- package/src/auth/session.ts +0 -178
- package/src/auth/types.ts +0 -50
- package/src/boot/__tests__/page-scanning.test.ts +0 -170
- package/src/boot/__tests__/page-utils.test.ts +0 -225
- package/src/boot/__tests__/project-scanner.test.ts +0 -88
- package/src/boot/dependency-sort.ts +0 -82
- package/src/boot/discovery.ts +0 -85
- package/src/boot/index.ts +0 -457
- package/src/boot/page-utils.ts +0 -110
- package/src/boot/project-scanner.ts +0 -397
- package/src/boot/schema-loader.ts +0 -26
- package/src/boot/schema-merger.ts +0 -125
- package/src/boot/traits.ts +0 -25
- package/src/boot/types.ts +0 -73
- package/src/context.ts +0 -105
- package/src/db/__tests__/cascade-delete.test.ts +0 -182
- package/src/db/__tests__/desired-state.test.ts +0 -136
- package/src/db/__tests__/diff-engine.test.ts +0 -635
- package/src/db/__tests__/field-mapper.test.ts +0 -355
- package/src/db/__tests__/introspect.test.ts +0 -70
- package/src/db/__tests__/search-filter.test.ts +0 -45
- package/src/db/__tests__/sequence.test.ts +0 -221
- package/src/db/auto-sync.ts +0 -133
- package/src/db/client.ts +0 -147
- package/src/db/desired-state.ts +0 -98
- package/src/db/diff-engine.ts +0 -305
- package/src/db/field-mapper.ts +0 -504
- package/src/db/filter-applier.ts +0 -89
- package/src/db/include-resolver.ts +0 -40
- package/src/db/index.ts +0 -23
- package/src/db/introspect.ts +0 -265
- package/src/db/model-include-resolver.ts +0 -327
- package/src/db/model-ops.ts +0 -281
- package/src/db/scope-enforcer.ts +0 -37
- package/src/db/types.ts +0 -98
- package/src/errors.ts +0 -41
- package/src/events/__tests__/bus.test.ts +0 -105
- package/src/events/bus.ts +0 -89
- package/src/events/index.ts +0 -2
- package/src/events/types.ts +0 -9
- package/src/external-model/__tests__/computed-fields.test.ts +0 -106
- package/src/external-model/__tests__/field-mapper.test.ts +0 -160
- package/src/external-model/__tests__/in-memory-ops.test.ts +0 -247
- package/src/external-model/__tests__/mutation-executor.test.ts +0 -160
- package/src/external-model/__tests__/query-executor.test.ts +0 -284
- package/src/external-model/__tests__/schema-converter.test.ts +0 -174
- package/src/external-model/computed-fields.ts +0 -15
- package/src/external-model/define.ts +0 -5
- package/src/external-model/external-model-ops.ts +0 -108
- package/src/external-model/field-mapper.ts +0 -66
- package/src/external-model/in-memory-ops.ts +0 -107
- package/src/external-model/index.ts +0 -7
- package/src/external-model/mutation-executor.ts +0 -71
- package/src/external-model/query-executor.ts +0 -100
- package/src/external-model/schema-converter.ts +0 -53
- package/src/external-model/types.ts +0 -32
- package/src/fixtures/__tests__/fixtures.test.ts +0 -203
- package/src/fixtures/index.ts +0 -10
- package/src/fixtures/loader.ts +0 -196
- package/src/fixtures/registry.ts +0 -125
- package/src/fixtures/types.ts +0 -33
- package/src/helpers/assert-ownership.ts +0 -19
- package/src/helpers/coerce.ts +0 -28
- package/src/helpers/stamping.ts +0 -28
- package/src/helpers/validation.ts +0 -14
- package/src/hooks/__tests__/context.test.ts +0 -73
- package/src/hooks/__tests__/executor.test.ts +0 -433
- package/src/hooks/__tests__/middleware.test.ts +0 -224
- package/src/hooks/__tests__/registry.test.ts +0 -50
- package/src/hooks/context.ts +0 -89
- package/src/hooks/errors.ts +0 -11
- package/src/hooks/executor.ts +0 -115
- package/src/hooks/index.ts +0 -10
- package/src/hooks/middleware.ts +0 -220
- package/src/hooks/registry.ts +0 -20
- package/src/hooks/types.ts +0 -32
- package/src/index.ts +0 -172
- package/src/jobs/__tests__/enqueue.test.ts +0 -77
- package/src/jobs/__tests__/integration.test.ts +0 -71
- package/src/jobs/__tests__/registry.test.ts +0 -103
- package/src/jobs/__tests__/scheduler.test.ts +0 -92
- package/src/jobs/__tests__/worker-execution.test.ts +0 -202
- package/src/jobs/__tests__/worker.test.ts +0 -119
- package/src/jobs/enqueue.ts +0 -93
- package/src/jobs/index.ts +0 -14
- package/src/jobs/registry.ts +0 -92
- package/src/jobs/scheduler.ts +0 -205
- package/src/jobs/tables.ts +0 -132
- package/src/jobs/types.ts +0 -62
- package/src/jobs/worker.ts +0 -272
- package/src/model-api/__tests__/cross-boundary-includes.test.ts +0 -366
- package/src/model-api/__tests__/extended-api.test.ts +0 -244
- package/src/model-api/__tests__/filter-applier.test.ts +0 -177
- package/src/model-api/__tests__/filter-translator.test.ts +0 -186
- package/src/model-api/__tests__/include-resolver.test.ts +0 -226
- package/src/model-api/__tests__/model-access.test.ts +0 -284
- package/src/model-api/__tests__/query-builder.test.ts +0 -224
- package/src/model-api/__tests__/scope-enforcer.test.ts +0 -268
- package/src/model-api/field-access.ts +0 -28
- package/src/model-api/filter-applier.ts +0 -1
- package/src/model-api/filter-translator.ts +0 -67
- package/src/model-api/include-resolver.ts +0 -2
- package/src/model-api/index.ts +0 -86
- package/src/model-api/query-builder.ts +0 -155
- package/src/model-api/scope-enforcer.ts +0 -3
- package/src/model-api/types.ts +0 -139
- package/src/plugins/__tests__/adapter-registry.test.ts +0 -92
- package/src/plugins/__tests__/lifecycle.test.ts +0 -96
- package/src/plugins/__tests__/loader.test.ts +0 -273
- package/src/plugins/__tests__/validator.test.ts +0 -275
- package/src/plugins/adapter-registry.ts +0 -42
- package/src/plugins/define.ts +0 -5
- package/src/plugins/index.ts +0 -28
- package/src/plugins/lifecycle.ts +0 -27
- package/src/plugins/loader.ts +0 -126
- package/src/plugins/types.ts +0 -76
- package/src/plugins/validator.ts +0 -141
- package/src/schema/__tests__/registry-models-by-module.test.ts +0 -58
- package/src/schema/registry.ts +0 -93
- package/src/schema/relationships.ts +0 -93
- package/src/schema/types.ts +0 -43
- package/src/services/__tests__/integration.test.ts +0 -63
- package/src/services/__tests__/registry.test.ts +0 -175
- package/src/services/index.ts +0 -13
- package/src/services/registry.ts +0 -156
- package/src/services/types.ts +0 -27
- package/src/validation/__tests__/field-validator.test.ts +0 -195
- package/src/validation/field-validator.ts +0 -113
- package/src/validation/index.ts +0 -1
- package/src/widgets/index.ts +0 -3
- package/src/widgets/slot-validator.ts +0 -87
- package/src/widgets/widget-registry.ts +0 -32
- package/tests/boot.test.ts +0 -323
- package/tests/dependency-sort.test.ts +0 -99
- package/tests/discovery.test.ts +0 -126
- package/tests/registry.test.ts +0 -216
- package/tests/schema-loader.test.ts +0 -52
- package/tests/schema-merger.test.ts +0 -180
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -14
package/src/api/handlers.ts
DELETED
|
@@ -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
package/src/api/meta-handler.ts
DELETED
|
@@ -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
|
-
}
|