@edium/halifax 2.2.3 → 2.4.0

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 (61) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/README.md +6 -3
  3. package/README_AUTH.md +2 -2
  4. package/README_AUTOCRUD.md +5 -4
  5. package/README_CACHE.md +6 -0
  6. package/README_CLASSES.md +13 -6
  7. package/README_GRAPHQL.md +352 -0
  8. package/README_INTERFACES.md +19 -14
  9. package/README_MULTITENANCY.md +87 -0
  10. package/README_OPENAPI.md +9 -9
  11. package/README_REPO_ADAPTERS.md +10 -0
  12. package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
  13. package/dist/adapters/orm/drizzle/DrizzleAdapter.js +9 -1
  14. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
  15. package/dist/adapters/orm/prisma/PrismaAdapter.js +106 -34
  16. package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
  17. package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
  18. package/dist/auth/strategies/PassportStrategies.js +3 -9
  19. package/dist/auth/strategies/types.d.ts +7 -0
  20. package/dist/auth/strategies/types.js +13 -1
  21. package/dist/core/cache/CacheStore.d.ts +12 -0
  22. package/dist/core/cache/createCachingRepository.js +10 -1
  23. package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
  24. package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
  25. package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
  26. package/dist/core/cache/redis/RedisCacheStore.js +14 -0
  27. package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
  28. package/dist/core/crudRouter.d.ts +38 -0
  29. package/dist/core/crudRouter.js +55 -21
  30. package/dist/core/fields.d.ts +11 -1
  31. package/dist/core/fields.js +19 -0
  32. package/dist/core/handlerUtils.d.ts +7 -1
  33. package/dist/core/handlerUtils.js +15 -11
  34. package/dist/core/handlers/create.js +4 -3
  35. package/dist/core/handlers/deleteMany.js +1 -1
  36. package/dist/core/handlers/deleteOne.js +1 -1
  37. package/dist/core/handlers/query.js +4 -6
  38. package/dist/core/handlers/readMany.js +4 -6
  39. package/dist/core/handlers/readOne.js +4 -7
  40. package/dist/core/handlers/updateMany.js +4 -5
  41. package/dist/core/handlers/updateOne.js +1 -1
  42. package/dist/core/handlers/upsertOne.js +1 -1
  43. package/dist/core/queryString.d.ts +10 -0
  44. package/dist/core/queryString.js +23 -0
  45. package/dist/core/types.d.ts +22 -0
  46. package/dist/core/validation.js +5 -11
  47. package/dist/graphql/graphiql.d.ts +5 -0
  48. package/dist/graphql/graphiql.js +29 -0
  49. package/dist/graphql/index.d.ts +4 -0
  50. package/dist/graphql/index.js +3 -0
  51. package/dist/graphql/registerGraphqlRoute.d.ts +10 -0
  52. package/dist/graphql/registerGraphqlRoute.js +79 -0
  53. package/dist/graphql/scalars.d.ts +6 -0
  54. package/dist/graphql/scalars.js +32 -0
  55. package/dist/graphql/schema.d.ts +3 -0
  56. package/dist/graphql/schema.js +635 -0
  57. package/dist/graphql/types.d.ts +48 -0
  58. package/dist/graphql/types.js +1 -0
  59. package/dist/index.d.ts +1 -0
  60. package/dist/openapi/specGenerator.js +19 -19
  61. package/package.json +9 -3
@@ -0,0 +1,635 @@
1
+ import { GraphQLSchema, GraphQLObjectType, GraphQLInputObjectType, GraphQLEnumType, GraphQLList, GraphQLNonNull, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLID, GraphQLError } from 'graphql';
2
+ import { GraphQLJSON } from './scalars.js';
3
+ import { defaultCrudPermissions, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '../core/types.js';
4
+ import { SqlOrder } from '@edium/halifax-types';
5
+ import { applyHook, authorizeRequest, filterReadableFields, filterWritableFields, makeReadableFieldFilter, parseId } from '../core/handlerUtils.js';
6
+ import { validateAdvancedQuery, validateIncludes } from '../core/validation.js';
7
+ import { NotFoundError } from '../errors/NotFoundError.js';
8
+ import { NotImplementedError } from '../errors/NotImplementedError.js';
9
+ import { UnprocessableEntityError } from '../errors/UnprocessableEntityError.js';
10
+ import { HttpError } from '../errors/HttpError.js';
11
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
12
+ function toPascalCase(str) {
13
+ return str
14
+ .split(/[-_/\s]+/)
15
+ .filter(Boolean)
16
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
17
+ .join('');
18
+ }
19
+ const httpStatusToGqlCode = {
20
+ 400: 'BAD_REQUEST',
21
+ 401: 'UNAUTHORIZED',
22
+ 403: 'FORBIDDEN',
23
+ 404: 'NOT_FOUND',
24
+ 409: 'CONFLICT',
25
+ 415: 'UNSUPPORTED_MEDIA_TYPE',
26
+ 422: 'UNPROCESSABLE_ENTITY',
27
+ 500: 'INTERNAL_ERROR',
28
+ 501: 'NOT_IMPLEMENTED'
29
+ };
30
+ function toGraphQLError(error) {
31
+ if (error instanceof GraphQLError)
32
+ throw error;
33
+ if (error instanceof HttpError) {
34
+ throw new GraphQLError(error.message, {
35
+ extensions: {
36
+ code: httpStatusToGqlCode[error.status] ?? 'INTERNAL_ERROR',
37
+ status: error.status,
38
+ ...(error.details !== undefined ? { details: error.details } : {})
39
+ }
40
+ });
41
+ }
42
+ throw new GraphQLError('Internal server error', {
43
+ extensions: { code: 'INTERNAL_ERROR', status: 500 }
44
+ });
45
+ }
46
+ function fieldTypeToOutputGQL(type, isId) {
47
+ if (isId)
48
+ return GraphQLID;
49
+ switch (type) {
50
+ case 'integer':
51
+ return GraphQLInt;
52
+ case 'number':
53
+ return GraphQLFloat;
54
+ case 'boolean':
55
+ return GraphQLBoolean;
56
+ case 'object':
57
+ return GraphQLJSON;
58
+ case 'string':
59
+ default:
60
+ return GraphQLString;
61
+ }
62
+ }
63
+ function fieldTypeToInputGQL(type, isId) {
64
+ if (isId)
65
+ return GraphQLID;
66
+ switch (type) {
67
+ case 'integer':
68
+ return GraphQLInt;
69
+ case 'number':
70
+ return GraphQLFloat;
71
+ case 'boolean':
72
+ return GraphQLBoolean;
73
+ case 'object':
74
+ return GraphQLJSON;
75
+ case 'string':
76
+ default:
77
+ return GraphQLString;
78
+ }
79
+ }
80
+ function applyPageLimits(resource, requestedLimit) {
81
+ let limit = requestedLimit;
82
+ const cap = resource.maxLimit ?? MAX_PAGE_LIMIT;
83
+ if (limit === undefined) {
84
+ const fallback = resource.defaultLimit ?? DEFAULT_PAGE_LIMIT;
85
+ if (fallback !== 0)
86
+ limit = fallback;
87
+ }
88
+ if (cap !== 0 && (limit === undefined || limit > cap))
89
+ limit = cap;
90
+ return limit;
91
+ }
92
+ // ─── Shared singleton types (built once, reused across all resources) ─────────
93
+ const SortDirectionEnum = new GraphQLEnumType({
94
+ name: 'SortDirection',
95
+ description: 'Sort direction for orderBy arguments.',
96
+ values: {
97
+ asc: { value: 'asc', description: 'Ascending order.' },
98
+ desc: { value: 'desc', description: 'Descending order.' }
99
+ }
100
+ });
101
+ const OrderByInput = new GraphQLInputObjectType({
102
+ name: 'OrderByInput',
103
+ description: 'A sort expression pairing a field name with a direction.',
104
+ fields: {
105
+ field: { type: new GraphQLNonNull(GraphQLString), description: 'Field name to sort by.' },
106
+ direction: { type: new GraphQLNonNull(SortDirectionEnum), description: 'Sort direction.' }
107
+ }
108
+ });
109
+ // Recursive input — must use a thunk to satisfy GraphQL's circular-ref requirement.
110
+ const QueryFilterInput = new GraphQLInputObjectType({
111
+ name: 'QueryFilterInput',
112
+ description: 'A single filter condition (leaf) or a group of nested conditions. ' +
113
+ 'Leaf: set field, comparison, and value1. ' +
114
+ 'Group: set operator and children.',
115
+ fields: () => ({
116
+ field: { type: GraphQLString, description: 'Field name to filter on (leaf nodes only).' },
117
+ comparison: {
118
+ type: GraphQLString,
119
+ description: 'Comparison operator: =, <>, <, >, <=, >=, IN, NOT IN, BETWEEN, NOT BETWEEN, ' +
120
+ 'LIKE, NOT LIKE, IS NULL, IS NOT NULL, CONTAINS, STARTS WITH, ENDS WITH.'
121
+ },
122
+ value1: {
123
+ type: GraphQLJSON,
124
+ description: 'Primary filter value (scalar or array for IN/BETWEEN operators).'
125
+ },
126
+ value2: {
127
+ type: GraphQLJSON,
128
+ description: 'Secondary value for BETWEEN / NOT BETWEEN operators.'
129
+ },
130
+ operator: {
131
+ type: GraphQLString,
132
+ description: 'Logical combinator for the flat where array: AND or OR. Required on all but the last element.'
133
+ },
134
+ children: {
135
+ type: new GraphQLList(QueryFilterInput),
136
+ description: 'Nested filter conditions combined with the operator.'
137
+ }
138
+ })
139
+ });
140
+ // ─── Main schema builder ──────────────────────────────────────────────────────
141
+ export function buildGraphQLSchema(contexts) {
142
+ const queryFields = {};
143
+ const mutationFields = {};
144
+ for (const { resource, authStrategy, hooks, resolveRepo } of contexts) {
145
+ if (resource.graphql === false)
146
+ continue;
147
+ const permissions = { ...defaultCrudPermissions, ...resource.permissions };
148
+ const idField = resource.repository?.idField ?? 'id';
149
+ const typeName = toPascalCase(resource.routePrefix);
150
+ const allFields = resource.fields ?? [];
151
+ const selectableFields = allFields.filter((f) => f.selectable !== false);
152
+ const writableFields = allFields.filter((f) => f.name !== idField && f.writable !== false);
153
+ const filterableFields = allFields.filter((f) => f.filterable !== false);
154
+ // ─── Per-resource GraphQL types ─────────────────────────────────────────
155
+ const OutputType = new GraphQLObjectType({
156
+ name: typeName,
157
+ description: `A ${resource.name ?? typeName} record.`,
158
+ fields: () => {
159
+ const gqlFields = {};
160
+ for (const f of selectableFields) {
161
+ gqlFields[f.name] = { type: fieldTypeToOutputGQL(f.type, f.name === idField) };
162
+ }
163
+ return gqlFields;
164
+ }
165
+ });
166
+ const ListResultType = new GraphQLObjectType({
167
+ name: `${typeName}ListResult`,
168
+ description: `Paginated list of ${typeName} records.`,
169
+ fields: {
170
+ count: {
171
+ type: new GraphQLNonNull(GraphQLInt),
172
+ description: 'Total matching records (before pagination).'
173
+ },
174
+ results: {
175
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(OutputType))),
176
+ description: 'Records for the current page.'
177
+ }
178
+ }
179
+ });
180
+ const CreateInputType = new GraphQLInputObjectType({
181
+ name: `${typeName}CreateInput`,
182
+ description: `Fields required to create a ${typeName}.`,
183
+ fields: () => {
184
+ const gqlFields = {};
185
+ for (const f of writableFields) {
186
+ gqlFields[f.name] = { type: fieldTypeToInputGQL(f.type, false) };
187
+ }
188
+ return gqlFields;
189
+ }
190
+ });
191
+ const UpdateInputType = new GraphQLInputObjectType({
192
+ name: `${typeName}UpdateInput`,
193
+ description: `Fields to patch on a ${typeName}. All fields are optional.`,
194
+ fields: () => {
195
+ const gqlFields = {};
196
+ for (const f of writableFields) {
197
+ gqlFields[f.name] = { type: fieldTypeToInputGQL(f.type, false) };
198
+ }
199
+ return gqlFields;
200
+ }
201
+ });
202
+ const FilterInputType = new GraphQLInputObjectType({
203
+ name: `${typeName}FilterInput`,
204
+ description: `Simple equality filter for listing ${typeName} records.`,
205
+ fields: () => {
206
+ const gqlFields = {};
207
+ for (const f of filterableFields) {
208
+ gqlFields[f.name] = { type: fieldTypeToInputGQL(f.type, f.name === idField) };
209
+ }
210
+ return gqlFields;
211
+ }
212
+ });
213
+ const UpdateManyResultType = new GraphQLObjectType({
214
+ name: `${typeName}UpdateManyResult`,
215
+ fields: {
216
+ updated: {
217
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLID))),
218
+ description: 'IDs of updated records.'
219
+ },
220
+ results: {
221
+ type: new GraphQLList(OutputType),
222
+ description: 'Updated records (when the repository supports returning them).'
223
+ }
224
+ }
225
+ });
226
+ const DeleteManyResultType = new GraphQLObjectType({
227
+ name: `${typeName}DeleteManyResult`,
228
+ fields: {
229
+ deleted: {
230
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLID))),
231
+ description: 'IDs of deleted records.'
232
+ }
233
+ }
234
+ });
235
+ // ─── Query: get (readOne) ──────────────────────────────────────────────
236
+ if (permissions.allowReadOne) {
237
+ queryFields[`get${typeName}`] = {
238
+ type: OutputType,
239
+ description: `Fetch a single ${typeName} by ID.`,
240
+ args: {
241
+ id: { type: new GraphQLNonNull(GraphQLID), description: 'Record ID.' }
242
+ },
243
+ async resolve(_parent, args, context) {
244
+ try {
245
+ const auth = await authorizeRequest(context.req, resource, 'readOne', authStrategy);
246
+ const repo = await resolveRepo(context.req, auth, 'readOne');
247
+ const id = parseId(args.id);
248
+ const hookCtx = { auth, resource, req: context.req };
249
+ if (hooks?.beforeReadOne)
250
+ await hooks.beforeReadOne(id, hookCtx);
251
+ const rawResult = await repo.getOne(id);
252
+ if (!rawResult)
253
+ throw new NotFoundError();
254
+ const result = await applyHook(hooks?.afterReadOne, rawResult, hookCtx);
255
+ return filterReadableFields(resource, result, auth);
256
+ }
257
+ catch (e) {
258
+ toGraphQLError(e);
259
+ }
260
+ }
261
+ };
262
+ }
263
+ // ─── Query: list (readMany) ────────────────────────────────────────────
264
+ if (permissions.allowReadMany) {
265
+ queryFields[`list${typeName}`] = {
266
+ type: new GraphQLNonNull(ListResultType),
267
+ description: `Fetch a paginated list of ${typeName} records. ` +
268
+ `For advanced filtering use \`query${typeName}\`.`,
269
+ args: {
270
+ filter: {
271
+ type: FilterInputType,
272
+ description: 'Per-field equality filters.'
273
+ },
274
+ limit: { type: GraphQLInt, description: 'Maximum records to return.' },
275
+ offset: { type: GraphQLInt, description: 'Records to skip.' },
276
+ orderBy: {
277
+ type: new GraphQLList(new GraphQLNonNull(OrderByInput)),
278
+ description: 'Sort order.'
279
+ },
280
+ include: {
281
+ type: new GraphQLList(new GraphQLNonNull(GraphQLString)),
282
+ description: 'Relation names to eagerly load.'
283
+ }
284
+ },
285
+ async resolve(_parent, args, context) {
286
+ try {
287
+ const auth = await authorizeRequest(context.req, resource, 'readMany', authStrategy);
288
+ const repo = await resolveRepo(context.req, auth, 'readMany');
289
+ const hookCtx = { auth, resource, req: context.req };
290
+ if (args.include?.length)
291
+ validateIncludes(resource, args.include);
292
+ const where = {};
293
+ if (args.filter) {
294
+ for (const [key, val] of Object.entries(args.filter)) {
295
+ if (val !== undefined && val !== null)
296
+ where[key] = val;
297
+ }
298
+ }
299
+ const listOptions = {
300
+ where,
301
+ limit: applyPageLimits(resource, args.limit),
302
+ offset: args.offset,
303
+ orderBy: args.orderBy?.map((o) => ({
304
+ field: o.field,
305
+ direction: o.direction
306
+ })),
307
+ include: args.include
308
+ };
309
+ const processedOptions = await applyHook(hooks?.beforeReadMany, listOptions, hookCtx);
310
+ const rawResult = await repo.getMany(processedOptions);
311
+ const result = await applyHook(hooks?.afterReadMany, rawResult, hookCtx);
312
+ const filterRecord = makeReadableFieldFilter(resource, auth);
313
+ return { count: result.count, results: result.results.map(filterRecord) };
314
+ }
315
+ catch (e) {
316
+ toGraphQLError(e);
317
+ }
318
+ }
319
+ };
320
+ }
321
+ // ─── Query: query (executeQuery / readManyWithQueryBuilder) ───────────
322
+ if (permissions.allowReadManyWithQueryBuilder) {
323
+ queryFields[`query${typeName}`] = {
324
+ type: new GraphQLNonNull(ListResultType),
325
+ description: `Advanced query for ${typeName} with full filter expressions, sorting, and pagination. ` +
326
+ `Mirrors the REST \`POST /${resource.routePrefix}/query\` endpoint.`,
327
+ args: {
328
+ where: {
329
+ type: new GraphQLList(new GraphQLNonNull(QueryFilterInput)),
330
+ description: 'Filter conditions. Supports all operators (=, IN, LIKE, BETWEEN, …).'
331
+ },
332
+ fields: {
333
+ type: new GraphQLList(new GraphQLNonNull(GraphQLString)),
334
+ description: 'Field names to include. Omit for all selectable fields.'
335
+ },
336
+ distinct: {
337
+ type: new GraphQLList(new GraphQLNonNull(GraphQLString)),
338
+ description: 'Fields to de-duplicate on (SQL DISTINCT ON).'
339
+ },
340
+ limit: { type: GraphQLInt, description: 'Maximum records to return.' },
341
+ offset: { type: GraphQLInt, description: 'Records to skip.' },
342
+ orderBy: {
343
+ type: new GraphQLList(new GraphQLNonNull(OrderByInput)),
344
+ description: 'Sort order.'
345
+ },
346
+ include: {
347
+ type: new GraphQLList(new GraphQLNonNull(GraphQLString)),
348
+ description: 'Relation names to eagerly load.'
349
+ }
350
+ },
351
+ async resolve(_parent, args, context) {
352
+ try {
353
+ const auth = await authorizeRequest(context.req, resource, 'readManyWithQueryBuilder', authStrategy);
354
+ const repo = await resolveRepo(context.req, auth, 'readManyWithQueryBuilder');
355
+ if (!repo.executeQuery)
356
+ throw new NotImplementedError('This resource does not support the query builder.');
357
+ const hookCtx = { auth, resource, req: context.req };
358
+ const queryOrderBy = args.orderBy?.map((o) => ({
359
+ field: o.field,
360
+ order: (o.direction.toUpperCase() === 'DESC' ? SqlOrder.DESC : SqlOrder.ASC)
361
+ }));
362
+ const query = {
363
+ ...(args.where !== undefined
364
+ ? { where: args.where }
365
+ : {}),
366
+ ...(args.fields !== undefined ? { fields: args.fields } : {}),
367
+ ...(args.distinct !== undefined ? { distinct: args.distinct } : {}),
368
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
369
+ ...(args.offset !== undefined ? { offset: args.offset } : {}),
370
+ ...(queryOrderBy !== undefined ? { orderBy: queryOrderBy } : {}),
371
+ ...(args.include !== undefined ? { include: args.include } : {})
372
+ };
373
+ const processedQuery = await applyHook(hooks?.beforeQuery, query, hookCtx);
374
+ validateAdvancedQuery(resource, processedQuery);
375
+ const rawResult = await repo.executeQuery(processedQuery);
376
+ const result = await applyHook(hooks?.afterQuery, rawResult, hookCtx);
377
+ const filterRecord = makeReadableFieldFilter(resource, auth);
378
+ return {
379
+ count: result.count ?? result.results.length,
380
+ results: result.results.map(filterRecord)
381
+ };
382
+ }
383
+ catch (e) {
384
+ toGraphQLError(e);
385
+ }
386
+ }
387
+ };
388
+ }
389
+ // ─── Mutation: create (createOne) ─────────────────────────────────────
390
+ if (permissions.allowCreate) {
391
+ mutationFields[`create${typeName}`] = {
392
+ type: new GraphQLNonNull(OutputType),
393
+ description: `Create a single ${typeName} record.`,
394
+ args: {
395
+ input: { type: new GraphQLNonNull(CreateInputType) }
396
+ },
397
+ async resolve(_parent, args, context) {
398
+ try {
399
+ const auth = await authorizeRequest(context.req, resource, 'create', authStrategy);
400
+ const repo = await resolveRepo(context.req, auth, 'create');
401
+ const hookCtx = { auth, resource, req: context.req };
402
+ const filtered = filterWritableFields(resource, args.input, auth);
403
+ const data = await applyHook(hooks?.beforeCreate, filtered, hookCtx);
404
+ const rawResult = await repo.createOne(data);
405
+ const result = await applyHook(hooks?.afterCreate, rawResult, hookCtx);
406
+ return filterReadableFields(resource, result, auth);
407
+ }
408
+ catch (e) {
409
+ toGraphQLError(e);
410
+ }
411
+ }
412
+ };
413
+ mutationFields[`createMany${typeName}`] = {
414
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(OutputType))),
415
+ description: `Create multiple ${typeName} records.`,
416
+ args: {
417
+ input: {
418
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(CreateInputType)))
419
+ }
420
+ },
421
+ async resolve(_parent, args, context) {
422
+ try {
423
+ const auth = await authorizeRequest(context.req, resource, 'create', authStrategy);
424
+ const repo = await resolveRepo(context.req, auth, 'create');
425
+ const hookCtx = { auth, resource, req: context.req };
426
+ const rawItems = args.input.map((item) => filterWritableFields(resource, item, auth));
427
+ const items = hooks?.beforeCreate
428
+ ? await Promise.all(rawItems.map((d) => applyHook(hooks.beforeCreate, d, hookCtx)))
429
+ : rawItems;
430
+ const rawResults = await repo.createMany(items);
431
+ const results = hooks?.afterCreate
432
+ ? await Promise.all(rawResults.map((r) => applyHook(hooks.afterCreate, r, hookCtx)))
433
+ : rawResults;
434
+ const filterRecord = makeReadableFieldFilter(resource, auth);
435
+ return results.map(filterRecord);
436
+ }
437
+ catch (e) {
438
+ toGraphQLError(e);
439
+ }
440
+ }
441
+ };
442
+ }
443
+ // ─── Mutation: update (updateOne) ─────────────────────────────────────
444
+ if (permissions.allowUpdateOne) {
445
+ mutationFields[`update${typeName}`] = {
446
+ type: OutputType,
447
+ description: `Partially update a single ${typeName} by ID.`,
448
+ args: {
449
+ id: { type: new GraphQLNonNull(GraphQLID) },
450
+ input: { type: new GraphQLNonNull(UpdateInputType) }
451
+ },
452
+ async resolve(_parent, args, context) {
453
+ try {
454
+ const auth = await authorizeRequest(context.req, resource, 'updateOne', authStrategy);
455
+ const repo = await resolveRepo(context.req, auth, 'updateOne');
456
+ const id = parseId(args.id);
457
+ const hookCtx = { auth, resource, req: context.req };
458
+ const rawBody = filterWritableFields(resource, args.input, auth);
459
+ const body = hooks?.beforeUpdateOne
460
+ ? ((await hooks.beforeUpdateOne(id, rawBody, hookCtx)) ?? rawBody)
461
+ : rawBody;
462
+ const rawResult = await repo.updateOne(id, body);
463
+ if (!rawResult)
464
+ throw new NotFoundError();
465
+ const result = await applyHook(hooks?.afterUpdateOne, rawResult, hookCtx);
466
+ return filterReadableFields(resource, result, auth);
467
+ }
468
+ catch (e) {
469
+ toGraphQLError(e);
470
+ }
471
+ }
472
+ };
473
+ }
474
+ // ─── Mutation: updateMany ──────────────────────────────────────────────
475
+ if (permissions.allowUpdateMany) {
476
+ mutationFields[`updateMany${typeName}`] = {
477
+ type: new GraphQLNonNull(UpdateManyResultType),
478
+ description: `Bulk-update ${typeName} records matching the given filter.`,
479
+ args: {
480
+ where: {
481
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(QueryFilterInput))),
482
+ description: 'At least one filter is required to prevent accidental full-table updates.'
483
+ },
484
+ update: {
485
+ type: new GraphQLNonNull(UpdateInputType),
486
+ description: 'Fields to apply to every matched record.'
487
+ }
488
+ },
489
+ async resolve(_parent, args, context) {
490
+ try {
491
+ const auth = await authorizeRequest(context.req, resource, 'updateMany', authStrategy);
492
+ const repo = await resolveRepo(context.req, auth, 'updateMany');
493
+ if (!repo.updateMany)
494
+ throw new NotImplementedError('This resource does not support updateMany.');
495
+ const hookCtx = { auth, resource, req: context.req };
496
+ const filteredUpdate = filterWritableFields(resource, args.update, auth);
497
+ if (!Object.keys(filteredUpdate).length)
498
+ throw new UnprocessableEntityError('updateMany requires at least one writable field in the update payload.');
499
+ const query = {
500
+ where: args.where
501
+ };
502
+ validateAdvancedQuery(resource, query);
503
+ if (!query.where?.length)
504
+ throw new UnprocessableEntityError('updateMany requires at least one WHERE filter to prevent unintended bulk updates.');
505
+ if (hooks?.beforeUpdateMany)
506
+ await hooks.beforeUpdateMany(query, filteredUpdate, hookCtx);
507
+ const rawResult = await repo.updateMany(query, filteredUpdate);
508
+ const result = await applyHook(hooks?.afterUpdateMany, rawResult, hookCtx);
509
+ const filterRecord = makeReadableFieldFilter(resource, auth);
510
+ return {
511
+ updated: result.updated,
512
+ ...(result.results
513
+ ? { results: result.results.map((r) => filterRecord(r)) }
514
+ : {})
515
+ };
516
+ }
517
+ catch (e) {
518
+ toGraphQLError(e);
519
+ }
520
+ }
521
+ };
522
+ }
523
+ // ─── Mutation: upsert (upsertOne) ─────────────────────────────────────
524
+ if (permissions.allowUpsertOne) {
525
+ mutationFields[`upsert${typeName}`] = {
526
+ type: new GraphQLNonNull(OutputType),
527
+ description: `Create or replace a ${typeName} at the given ID.`,
528
+ args: {
529
+ id: { type: new GraphQLNonNull(GraphQLID) },
530
+ input: { type: new GraphQLNonNull(CreateInputType) }
531
+ },
532
+ async resolve(_parent, args, context) {
533
+ try {
534
+ const auth = await authorizeRequest(context.req, resource, 'upsertOne', authStrategy);
535
+ const repo = await resolveRepo(context.req, auth, 'upsertOne');
536
+ if (!repo.upsertOne)
537
+ throw new NotImplementedError('This resource does not support upsert.');
538
+ const id = parseId(args.id);
539
+ const hookCtx = { auth, resource, req: context.req };
540
+ const rawBody = filterWritableFields(resource, args.input, auth);
541
+ const body = hooks?.beforeUpsertOne
542
+ ? ((await hooks.beforeUpsertOne(id, rawBody, hookCtx)) ?? rawBody)
543
+ : rawBody;
544
+ const rawResult = await repo.upsertOne(id, body);
545
+ const result = await applyHook(hooks?.afterUpsertOne, rawResult, hookCtx);
546
+ return filterReadableFields(resource, result, auth);
547
+ }
548
+ catch (e) {
549
+ toGraphQLError(e);
550
+ }
551
+ }
552
+ };
553
+ }
554
+ // ─── Mutation: delete (deleteOne) ─────────────────────────────────────
555
+ if (permissions.allowDeleteOne) {
556
+ mutationFields[`delete${typeName}`] = {
557
+ type: new GraphQLNonNull(GraphQLBoolean),
558
+ description: `Delete a single ${typeName} by ID. Returns true when deleted.`,
559
+ args: {
560
+ id: { type: new GraphQLNonNull(GraphQLID) }
561
+ },
562
+ async resolve(_parent, args, context) {
563
+ try {
564
+ const auth = await authorizeRequest(context.req, resource, 'deleteOne', authStrategy);
565
+ const repo = await resolveRepo(context.req, auth, 'deleteOne');
566
+ const id = parseId(args.id);
567
+ const hookCtx = { auth, resource, req: context.req };
568
+ if (hooks?.beforeDeleteOne)
569
+ await hooks.beforeDeleteOne(id, hookCtx);
570
+ const deleted = await repo.deleteOne(id);
571
+ if (!deleted)
572
+ throw new NotFoundError();
573
+ if (hooks?.afterDeleteOne)
574
+ await hooks.afterDeleteOne(id, hookCtx);
575
+ return true;
576
+ }
577
+ catch (e) {
578
+ toGraphQLError(e);
579
+ }
580
+ }
581
+ };
582
+ }
583
+ // ─── Mutation: deleteMany ──────────────────────────────────────────────
584
+ if (permissions.allowDeleteMany) {
585
+ mutationFields[`deleteMany${typeName}`] = {
586
+ type: new GraphQLNonNull(DeleteManyResultType),
587
+ description: `Bulk-delete ${typeName} records matching the given filter.`,
588
+ args: {
589
+ where: {
590
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(QueryFilterInput))),
591
+ description: 'At least one filter is required to prevent accidental full-table deletes.'
592
+ }
593
+ },
594
+ async resolve(_parent, args, context) {
595
+ try {
596
+ const auth = await authorizeRequest(context.req, resource, 'deleteMany', authStrategy);
597
+ const repo = await resolveRepo(context.req, auth, 'deleteMany');
598
+ if (!repo.deleteMany)
599
+ throw new NotImplementedError('This resource does not support deleteMany.');
600
+ const hookCtx = { auth, resource, req: context.req };
601
+ const query = {
602
+ where: args.where
603
+ };
604
+ validateAdvancedQuery(resource, query);
605
+ if (!query.where?.length)
606
+ throw new UnprocessableEntityError('deleteMany requires at least one WHERE filter to prevent unintended bulk deletes.');
607
+ if (hooks?.beforeDeleteMany)
608
+ await hooks.beforeDeleteMany(query, hookCtx);
609
+ const rawResult = await repo.deleteMany(query);
610
+ const result = await applyHook(hooks?.afterDeleteMany, rawResult, hookCtx);
611
+ return { deleted: result.deleted };
612
+ }
613
+ catch (e) {
614
+ toGraphQLError(e);
615
+ }
616
+ }
617
+ };
618
+ }
619
+ }
620
+ // Require at least one query field — GraphQL schema must have a Query root type.
621
+ if (!Object.keys(queryFields).length) {
622
+ queryFields['_empty'] = {
623
+ type: GraphQLString,
624
+ description: 'Placeholder — no resources are GraphQL-enabled.',
625
+ resolve: () => 'No GraphQL-enabled resources are registered.'
626
+ };
627
+ }
628
+ const hasMutations = Object.keys(mutationFields).length > 0;
629
+ return new GraphQLSchema({
630
+ query: new GraphQLObjectType({ name: 'Query', fields: queryFields }),
631
+ ...(hasMutations
632
+ ? { mutation: new GraphQLObjectType({ name: 'Mutation', fields: mutationFields }) }
633
+ : {})
634
+ });
635
+ }
@@ -0,0 +1,48 @@
1
+ import type { AuthContext } from '../auth/AuthStrategy.js';
2
+ import type { AuthStrategy } from '../auth/strategies/types.js';
3
+ import type { CrudHooks } from '../core/hooks.js';
4
+ import type { CrudAction, HttpRequest, Repository, ResourceDefinition } from '../core/types.js';
5
+ /** Configuration for the Halifax GraphQL endpoint. */
6
+ export interface GraphQLOptions {
7
+ /**
8
+ * Must be explicitly set to `true` to enable the GraphQL endpoint.
9
+ * GraphQL is **disabled by default** — the peer dependency (`graphql`) is optional
10
+ * and will not be imported until you opt in.
11
+ */
12
+ enabled?: boolean;
13
+ /**
14
+ * Path for the `POST /graphql` endpoint.
15
+ * Defaults to `'/graphql'`.
16
+ */
17
+ path?: string;
18
+ /**
19
+ * When `true`, serve GraphiQL IDE at `GET <path>`.
20
+ * Defaults to `true`.
21
+ */
22
+ graphiql?: boolean;
23
+ /**
24
+ * When `true`, the GraphQL endpoint requires authentication before executing any operation,
25
+ * including introspection. Unauthenticated callers receive 401.
26
+ * Defaults to `false`.
27
+ */
28
+ requireAuth?: boolean;
29
+ /** Title shown in the GraphiQL browser tab. Defaults to `'Halifax GraphQL'`. */
30
+ title?: string;
31
+ }
32
+ /** Internal per-resource context threaded into the schema builder and resolvers. */
33
+ export interface GraphQLResourceContext {
34
+ resource: ResourceDefinition;
35
+ authStrategy: AuthStrategy;
36
+ hooks: CrudHooks<Record<string, unknown>, Record<string, unknown>, Record<string, unknown>> | undefined;
37
+ resolveRepo: (req: HttpRequest, auth: AuthContext, action: CrudAction) => Promise<Repository>;
38
+ }
39
+ /** GraphQL resolver context — the `contextValue` passed to every field resolver. */
40
+ export interface GraphQLResolverContext {
41
+ req: HttpRequest;
42
+ }
43
+ /** Standard GraphQL-over-HTTP request body. */
44
+ export interface GraphQLRequestBody {
45
+ query?: string;
46
+ variables?: Record<string, unknown>;
47
+ operationName?: string;
48
+ }