rads-db 3.2.20 → 3.2.22

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/dist/config.cjs CHANGED
@@ -459,7 +459,8 @@ function verifyDefaultValueType(field, ctx2) {
459
459
  throw new TypeError(`Default value type is different from field type: '${type}'`);
460
460
  }
461
461
  } else {
462
- if (supportedPrimitiveTypes.includes(type) && getPrimitiveTypeFromDefaultValue(defaultValue2) !== type) {
462
+ const normalizedType = type.startsWith("Record<") ? "object" : type;
463
+ if (supportedPrimitiveTypes.includes(type) && getPrimitiveTypeFromDefaultValue(defaultValue2) !== normalizedType) {
463
464
  throw new Error(`Default value type is different from field type: '${type}'`);
464
465
  }
465
466
  if (!ctx2.result[type] && ctx2.typeNodesMap[type])
package/dist/config.mjs CHANGED
@@ -451,7 +451,8 @@ function verifyDefaultValueType(field, ctx2) {
451
451
  throw new TypeError(`Default value type is different from field type: '${type}'`);
452
452
  }
453
453
  } else {
454
- if (supportedPrimitiveTypes.includes(type) && getPrimitiveTypeFromDefaultValue(defaultValue2) !== type) {
454
+ const normalizedType = type.startsWith("Record<") ? "object" : type;
455
+ if (supportedPrimitiveTypes.includes(type) && getPrimitiveTypeFromDefaultValue(defaultValue2) !== normalizedType) {
455
456
  throw new Error(`Default value type is different from field type: '${type}'`);
456
457
  }
457
458
  if (!ctx2.result[type] && ctx2.typeNodesMap[type])
@@ -27,24 +27,74 @@ function verifyEventSourcingSetup(schema, effects, options) {
27
27
  effects[eventEntityName].push(effect);
28
28
  }
29
29
  }
30
+ function groupEventsByAggId(docs, aggregateRelationField, eventEntityName) {
31
+ const docsByAggId = {};
32
+ const docsById = {};
33
+ for (const d of docs) {
34
+ if (!d.doc.id || docsById[d.doc.id]) continue;
35
+ docsById[d.doc.id] = d.doc;
36
+ const aggId = d.doc[aggregateRelationField].id;
37
+ if (!aggId) throw new Error(`Missing ${eventEntityName}.${aggregateRelationField}.id`);
38
+ if (d.oldDoc && aggId !== d.oldDoc[aggregateRelationField].id) {
39
+ throw new Error(`Field ${eventEntityName}.${aggregateRelationField}.id cannot be changed`);
40
+ }
41
+ if (!docsByAggId[aggId]) docsByAggId[aggId] = [];
42
+ docsByAggId[aggId].push(d.doc);
43
+ }
44
+ return {
45
+ docsByAggId,
46
+ docsById
47
+ };
48
+ }
49
+ function applyEventToAggregate(ev, aggDoc, aggId, p) {
50
+ const mergedDoc = (0, _radsDb.merge)(aggDoc || {
51
+ id: aggId
52
+ }, ev.change);
53
+ const newAggDoc = p.context.validators[p.entityName](mergedDoc);
54
+ if (!p.options.freezeEvent?.(ev, p.context, p.ctx)) {
55
+ ev.beforeChange = aggDoc;
56
+ const originalChange = ev.change;
57
+ ev.change = (0, _radsDb.diff)(newAggDoc, aggDoc);
58
+ handleKeepHistory(p.schema[p.entityName].keepHistoryFields, originalChange, ev);
59
+ }
60
+ if (p.ctx.skipPrecomputed) {
61
+ for (const field of p.schema[p.entityName].precomputedFields || []) {
62
+ if (p.ctx.skipPrecomputed.includes(field) && mergedDoc[field] !== void 0) {
63
+ newAggDoc[field] = mergedDoc[field];
64
+ }
65
+ }
66
+ }
67
+ return newAggDoc;
68
+ }
69
+ function buildAggregateFromEvents(events, aggId, docsById, existingAggregatesById, p) {
70
+ if (events[0].type !== "creation") throw new Error(`First event must have type = "creation". (type: ${events[0].type}, id: ${events[0].id})`);
71
+ if (events.slice(1).some(ev => ev.type === "creation")) {
72
+ throw new Error(`Only first event may have type = "creation"`);
73
+ }
74
+ let aggDoc;
75
+ for (const ev of events) {
76
+ const newAggDoc = applyEventToAggregate(ev, aggDoc, aggId, p);
77
+ if (p.options.applyEventIf && !p.options.applyEventIf(ev, p.context, p.ctx)) continue;
78
+ aggDoc = newAggDoc;
79
+ if (!p.context.options.keepNulls) (0, _radsDb.cleanEntity)(aggDoc);
80
+ }
81
+ if (!aggDoc) return null;
82
+ return {
83
+ aggDoc,
84
+ events,
85
+ updatedEvents: events.filter(ev => docsById[ev.id]),
86
+ oldAggDoc: existingAggregatesById[aggDoc.id]
87
+ };
88
+ }
30
89
  function getEffectFor(entityName, aggregateRelationField, eventEntityName, schema, options) {
31
90
  return {
32
91
  featureName: "eventSourcing",
33
92
  async beforePut(context, docs, ctx) {
34
- const docsByAggId = {};
35
- const docsById = {};
36
- for (const d of docs) {
37
- if (!d.doc.id || docsById[d.doc.id]) continue;
38
- docsById[d.doc.id] = d.doc;
39
- const aggId = d.doc[aggregateRelationField].id;
40
- if (!aggId) throw new Error(`Missing ${eventEntityName}.${aggregateRelationField}.id`);
41
- if (d.oldDoc && aggId !== d.oldDoc[aggregateRelationField].id) {
42
- throw new Error(`Field ${eventEntityName}.${aggregateRelationField}.id cannot be changed`);
43
- }
44
- if (!docsByAggId[aggId]) docsByAggId[aggId] = [];
45
- docsByAggId[aggId].push(d.doc);
46
- }
47
- const existingEvents = await context.drivers[eventEntityName].getAll({
93
+ const {
94
+ docsByAggId,
95
+ docsById
96
+ } = groupEventsByAggId(docs, aggregateRelationField, eventEntityName);
97
+ const [existingEvents, existingAggregates] = await Promise.all([context.drivers[eventEntityName].getAll({
48
98
  where: {
49
99
  [aggregateRelationField]: {
50
100
  id_in: _lodash.default.keys(docsByAggId)
@@ -53,47 +103,24 @@ function getEffectFor(entityName, aggregateRelationField, eventEntityName, schem
53
103
  id_in: docs.map(d => d.doc.id)
54
104
  }
55
105
  }
56
- }, ctx);
57
- const existingEventsByAggId = _lodash.default.groupBy(existingEvents, ev => ev[aggregateRelationField].id);
58
- const existingAggregates = await context.drivers[entityName].getAll({
106
+ }, ctx), context.drivers[entityName].getAll({
59
107
  where: {
60
108
  id_in: _lodash.default.keys(docsByAggId)
61
109
  }
62
- }, ctx);
110
+ }, ctx)]);
111
+ const existingEventsByAggId = _lodash.default.groupBy(existingEvents, ev => ev[aggregateRelationField].id);
63
112
  const existingAggregatesById = _lodash.default.keyBy(existingAggregates, "id");
64
- const result = [];
65
- for (const aggId in docsByAggId) {
113
+ const processor = {
114
+ context,
115
+ entityName,
116
+ schema,
117
+ options,
118
+ ctx
119
+ };
120
+ const result = _lodash.default.compact(_lodash.default.keys(docsByAggId).map(aggId => {
66
121
  const events = _lodash.default.orderBy([...docsByAggId[aggId], ...(existingEventsByAggId[aggId] || [])], ["date"], "asc");
67
- if (events[0].type !== "creation") throw new Error(`First event must have type = "creation". (type: ${events[0].type}, id: ${events[0].id})`);
68
- if (events.slice(1).some(ev => ev.type === "creation")) {
69
- throw new Error(`Only first event may have type = "creation"`);
70
- }
71
- let aggDoc;
72
- for (const ev of events) {
73
- const newAggDoc = context.validators[entityName]((0, _radsDb.merge)(aggDoc || {
74
- id: aggId
75
- }, ev.change));
76
- if (!options.freezeEvent?.(ev, context, ctx)) {
77
- ev.beforeChange = aggDoc;
78
- const originalChange = ev.change;
79
- ev.change = (0, _radsDb.diff)(newAggDoc, aggDoc);
80
- handleKeepHistory(schema[entityName].keepHistoryFields, originalChange, ev);
81
- }
82
- if (options.applyEventIf && !options.applyEventIf(ev, context, ctx)) {
83
- continue;
84
- }
85
- aggDoc = newAggDoc;
86
- if (!context.options.keepNulls) (0, _radsDb.cleanEntity)(aggDoc);
87
- }
88
- if (aggDoc) {
89
- result.push({
90
- aggDoc,
91
- events,
92
- updatedEvents: events.filter(ev => docsById[ev.id]),
93
- oldAggDoc: existingAggregatesById[aggDoc.id]
94
- });
95
- }
96
- }
122
+ return buildAggregateFromEvents(events, aggId, docsById, existingAggregatesById, processor);
123
+ }));
97
124
  await (0, _radsDb.handlePrecomputed)(
98
125
  // @ts-expect-error TODO wrong types
99
126
  {
@@ -107,7 +134,7 @@ function getEffectFor(entityName, aggregateRelationField, eventEntityName, schem
107
134
  })), ctx);
108
135
  return result;
109
136
  },
110
- async afterPut(context, docs, beforePutResult, ctx) {
137
+ async afterPut(context, _docs, beforePutResult, ctx) {
111
138
  const aggs = beforePutResult.map(d => d.aggDoc);
112
139
  const hookItems = beforePutResult.map(v => ({
113
140
  doc: v.aggDoc,
@@ -19,70 +19,87 @@ function verifyEventSourcingSetup(schema, effects, options) {
19
19
  effects[eventEntityName].push(effect);
20
20
  }
21
21
  }
22
+ function groupEventsByAggId(docs, aggregateRelationField, eventEntityName) {
23
+ const docsByAggId = {};
24
+ const docsById = {};
25
+ for (const d of docs) {
26
+ if (!d.doc.id || docsById[d.doc.id]) continue;
27
+ docsById[d.doc.id] = d.doc;
28
+ const aggId = d.doc[aggregateRelationField].id;
29
+ if (!aggId) throw new Error(`Missing ${eventEntityName}.${aggregateRelationField}.id`);
30
+ if (d.oldDoc && aggId !== d.oldDoc[aggregateRelationField].id) {
31
+ throw new Error(`Field ${eventEntityName}.${aggregateRelationField}.id cannot be changed`);
32
+ }
33
+ if (!docsByAggId[aggId]) docsByAggId[aggId] = [];
34
+ docsByAggId[aggId].push(d.doc);
35
+ }
36
+ return { docsByAggId, docsById };
37
+ }
38
+ function applyEventToAggregate(ev, aggDoc, aggId, p) {
39
+ const mergedDoc = merge(aggDoc || { id: aggId }, ev.change);
40
+ const newAggDoc = p.context.validators[p.entityName](mergedDoc);
41
+ if (!p.options.freezeEvent?.(ev, p.context, p.ctx)) {
42
+ ev.beforeChange = aggDoc;
43
+ const originalChange = ev.change;
44
+ ev.change = diff(newAggDoc, aggDoc);
45
+ handleKeepHistory(p.schema[p.entityName].keepHistoryFields, originalChange, ev);
46
+ }
47
+ if (p.ctx.skipPrecomputed) {
48
+ for (const field of p.schema[p.entityName].precomputedFields || []) {
49
+ if (p.ctx.skipPrecomputed.includes(field) && mergedDoc[field] !== void 0) {
50
+ newAggDoc[field] = mergedDoc[field];
51
+ }
52
+ }
53
+ }
54
+ return newAggDoc;
55
+ }
56
+ function buildAggregateFromEvents(events, aggId, docsById, existingAggregatesById, p) {
57
+ if (events[0].type !== "creation")
58
+ throw new Error(`First event must have type = "creation". (type: ${events[0].type}, id: ${events[0].id})`);
59
+ if (events.slice(1).some((ev) => ev.type === "creation")) {
60
+ throw new Error(`Only first event may have type = "creation"`);
61
+ }
62
+ let aggDoc;
63
+ for (const ev of events) {
64
+ const newAggDoc = applyEventToAggregate(ev, aggDoc, aggId, p);
65
+ if (p.options.applyEventIf && !p.options.applyEventIf(ev, p.context, p.ctx)) continue;
66
+ aggDoc = newAggDoc;
67
+ if (!p.context.options.keepNulls) cleanEntity(aggDoc);
68
+ }
69
+ if (!aggDoc) return null;
70
+ return {
71
+ aggDoc,
72
+ events,
73
+ updatedEvents: events.filter((ev) => docsById[ev.id]),
74
+ oldAggDoc: existingAggregatesById[aggDoc.id]
75
+ };
76
+ }
22
77
  function getEffectFor(entityName, aggregateRelationField, eventEntityName, schema, options) {
23
78
  return {
24
79
  featureName: "eventSourcing",
25
80
  async beforePut(context, docs, ctx) {
26
- const docsByAggId = {};
27
- const docsById = {};
28
- for (const d of docs) {
29
- if (!d.doc.id || docsById[d.doc.id]) continue;
30
- docsById[d.doc.id] = d.doc;
31
- const aggId = d.doc[aggregateRelationField].id;
32
- if (!aggId) throw new Error(`Missing ${eventEntityName}.${aggregateRelationField}.id`);
33
- if (d.oldDoc && aggId !== d.oldDoc[aggregateRelationField].id) {
34
- throw new Error(`Field ${eventEntityName}.${aggregateRelationField}.id cannot be changed`);
35
- }
36
- if (!docsByAggId[aggId]) docsByAggId[aggId] = [];
37
- docsByAggId[aggId].push(d.doc);
38
- }
39
- const existingEvents = await context.drivers[eventEntityName].getAll(
40
- {
41
- where: {
42
- [aggregateRelationField]: { id_in: _.keys(docsByAggId) },
43
- _not: { id_in: docs.map((d) => d.doc.id) }
44
- }
45
- },
46
- ctx
47
- );
81
+ const { docsByAggId, docsById } = groupEventsByAggId(docs, aggregateRelationField, eventEntityName);
82
+ const [existingEvents, existingAggregates] = await Promise.all([
83
+ context.drivers[eventEntityName].getAll(
84
+ {
85
+ where: {
86
+ [aggregateRelationField]: { id_in: _.keys(docsByAggId) },
87
+ _not: { id_in: docs.map((d) => d.doc.id) }
88
+ }
89
+ },
90
+ ctx
91
+ ),
92
+ context.drivers[entityName].getAll({ where: { id_in: _.keys(docsByAggId) } }, ctx)
93
+ ]);
48
94
  const existingEventsByAggId = _.groupBy(existingEvents, (ev) => ev[aggregateRelationField].id);
49
- const existingAggregates = await context.drivers[entityName].getAll(
50
- { where: { id_in: _.keys(docsByAggId) } },
51
- ctx
52
- );
53
95
  const existingAggregatesById = _.keyBy(existingAggregates, "id");
54
- const result = [];
55
- for (const aggId in docsByAggId) {
56
- const events = _.orderBy([...docsByAggId[aggId], ...existingEventsByAggId[aggId] || []], ["date"], "asc");
57
- if (events[0].type !== "creation")
58
- throw new Error(`First event must have type = "creation". (type: ${events[0].type}, id: ${events[0].id})`);
59
- if (events.slice(1).some((ev) => ev.type === "creation")) {
60
- throw new Error(`Only first event may have type = "creation"`);
61
- }
62
- let aggDoc;
63
- for (const ev of events) {
64
- const newAggDoc = context.validators[entityName](merge(aggDoc || { id: aggId }, ev.change));
65
- if (!options.freezeEvent?.(ev, context, ctx)) {
66
- ev.beforeChange = aggDoc;
67
- const originalChange = ev.change;
68
- ev.change = diff(newAggDoc, aggDoc);
69
- handleKeepHistory(schema[entityName].keepHistoryFields, originalChange, ev);
70
- }
71
- if (options.applyEventIf && !options.applyEventIf(ev, context, ctx)) {
72
- continue;
73
- }
74
- aggDoc = newAggDoc;
75
- if (!context.options.keepNulls) cleanEntity(aggDoc);
76
- }
77
- if (aggDoc) {
78
- result.push({
79
- aggDoc,
80
- events,
81
- updatedEvents: events.filter((ev) => docsById[ev.id]),
82
- oldAggDoc: existingAggregatesById[aggDoc.id]
83
- });
84
- }
85
- }
96
+ const processor = { context, entityName, schema, options, ctx };
97
+ const result = _.compact(
98
+ _.keys(docsByAggId).map((aggId) => {
99
+ const events = _.orderBy([...docsByAggId[aggId], ...existingEventsByAggId[aggId] || []], ["date"], "asc");
100
+ return buildAggregateFromEvents(events, aggId, docsById, existingAggregatesById, processor);
101
+ })
102
+ );
86
103
  await handlePrecomputed(
87
104
  // @ts-expect-error TODO wrong types
88
105
  { ...context, typeName: entityName },
@@ -91,7 +108,7 @@ function getEffectFor(entityName, aggregateRelationField, eventEntityName, schem
91
108
  );
92
109
  return result;
93
110
  },
94
- async afterPut(context, docs, beforePutResult, ctx) {
111
+ async afterPut(context, _docs, beforePutResult, ctx) {
95
112
  const aggs = beforePutResult.map((d) => d.aggDoc);
96
113
  const hookItems = beforePutResult.map((v) => ({
97
114
  doc: v.aggDoc,
@@ -0,0 +1,357 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.getOpenApiSpec = getOpenApiSpec;
7
+ function primitiveToOas(type) {
8
+ switch (type) {
9
+ case "string":
10
+ return {
11
+ type: "string"
12
+ };
13
+ case "number":
14
+ return {
15
+ type: "number"
16
+ };
17
+ case "boolean":
18
+ return {
19
+ type: "boolean"
20
+ };
21
+ case "Record<string, string>":
22
+ return {
23
+ type: "object",
24
+ additionalProperties: {
25
+ type: "string"
26
+ }
27
+ };
28
+ case "Record<string, any>":
29
+ return {
30
+ type: "object",
31
+ additionalProperties: true
32
+ };
33
+ default:
34
+ return {};
35
+ }
36
+ }
37
+ function fieldToOas(field, schema) {
38
+ const entry = schema[field.type];
39
+ let itemSchema;
40
+ if (entry) {
41
+ itemSchema = entry.enumValues ? {
42
+ type: "string",
43
+ enum: Object.keys(entry.enumValues)
44
+ } : {
45
+ $ref: `#/components/schemas/${field.type}`
46
+ };
47
+ } else {
48
+ itemSchema = primitiveToOas(field.type);
49
+ }
50
+ return field.isArray ? {
51
+ type: "array",
52
+ items: itemSchema
53
+ } : itemSchema;
54
+ }
55
+ function buildObjectSchema(entity, schema, allOptional = false) {
56
+ const properties = {};
57
+ const required = [];
58
+ for (const [name, field] of Object.entries(entity.fields ?? {})) {
59
+ if (field.isInverseRelation) continue;
60
+ properties[name] = {
61
+ ...fieldToOas(field, schema),
62
+ ...(field.comment ? {
63
+ description: field.comment
64
+ } : {})
65
+ };
66
+ if (!allOptional && field.isRequired) required.push(name);
67
+ }
68
+ return {
69
+ type: "object",
70
+ ...(entity.comment ? {
71
+ description: entity.comment
72
+ } : {}),
73
+ properties,
74
+ ...(required.length > 0 ? {
75
+ required
76
+ } : {})
77
+ };
78
+ }
79
+ const whereSchema = {
80
+ type: "object",
81
+ additionalProperties: true
82
+ };
83
+ const includeSchema = {
84
+ type: "object",
85
+ additionalProperties: true
86
+ };
87
+ const paginationProps = {
88
+ cursor: {
89
+ type: "string"
90
+ },
91
+ maxItemCount: {
92
+ type: "integer"
93
+ },
94
+ orderBy: {
95
+ type: "string",
96
+ description: 'Single order-by expression, e.g. "name_asc"'
97
+ },
98
+ orderByArray: {
99
+ type: "array",
100
+ items: {
101
+ type: "string"
102
+ },
103
+ description: "Multiple order-by expressions"
104
+ }
105
+ };
106
+ const getRequestSchema = {
107
+ type: "object",
108
+ properties: {
109
+ where: whereSchema,
110
+ include: includeSchema
111
+ }
112
+ };
113
+ const getManyRequestSchema = {
114
+ type: "object",
115
+ properties: {
116
+ where: whereSchema,
117
+ include: includeSchema,
118
+ ...paginationProps
119
+ }
120
+ };
121
+ const getAggRequestSchema = {
122
+ type: "object",
123
+ required: ["agg"],
124
+ properties: {
125
+ where: whereSchema,
126
+ agg: {
127
+ type: "array",
128
+ items: {
129
+ type: "string"
130
+ },
131
+ description: 'Aggregation operations: "_count", "field_min", "field_max", "field_sum"'
132
+ },
133
+ ...paginationProps
134
+ }
135
+ };
136
+ function jsonBody(s) {
137
+ return {
138
+ required: true,
139
+ content: {
140
+ "application/json": {
141
+ schema: s
142
+ }
143
+ }
144
+ };
145
+ }
146
+ function jsonOk(s, description = "Success") {
147
+ return {
148
+ "200": {
149
+ description,
150
+ content: {
151
+ "application/json": {
152
+ schema: s
153
+ }
154
+ }
155
+ }
156
+ };
157
+ }
158
+ function getOpenApiSpec(options, openApiOptions) {
159
+ const {
160
+ db,
161
+ prefix = "/api/"
162
+ } = options;
163
+ const schema = db._schema;
164
+ const base = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
165
+ const paths = {};
166
+ const schemas = {};
167
+ paths[`${base}/me`] = {
168
+ get: {
169
+ summary: "Get current user",
170
+ tags: ["Auth"],
171
+ responses: jsonOk({
172
+ type: "object",
173
+ properties: {
174
+ userId: {
175
+ type: "string",
176
+ nullable: true
177
+ }
178
+ }
179
+ })
180
+ }
181
+ };
182
+ paths[`${base}/uploadFile`] = {
183
+ post: {
184
+ summary: "Upload a file",
185
+ tags: ["Files"],
186
+ requestBody: jsonBody({
187
+ type: "object",
188
+ required: ["blobDataUrl"],
189
+ properties: {
190
+ blobDataUrl: {
191
+ type: "string",
192
+ description: "Base64 data URL: data:<mime>;base64,<data>"
193
+ },
194
+ filename: {
195
+ type: "string"
196
+ },
197
+ containerName: {
198
+ type: "string"
199
+ }
200
+ }
201
+ }),
202
+ responses: jsonOk({
203
+ type: "object",
204
+ required: ["url"],
205
+ properties: {
206
+ url: {
207
+ type: "string"
208
+ }
209
+ }
210
+ })
211
+ }
212
+ };
213
+ paths[`${base}/radsTunnel`] = {
214
+ post: {
215
+ summary: "Direct database method access",
216
+ tags: ["Tunnel"],
217
+ requestBody: jsonBody({
218
+ type: "object",
219
+ properties: {
220
+ method: {
221
+ type: "string",
222
+ description: "Method to call (getMany, put, _schema, uploadFile, \u2026)"
223
+ },
224
+ entity: {
225
+ type: "string",
226
+ description: "Entity class name, e.g. TcUser"
227
+ },
228
+ args: {
229
+ description: "Arguments forwarded to the method"
230
+ }
231
+ }
232
+ }),
233
+ responses: jsonOk({})
234
+ }
235
+ };
236
+ for (const key in schema) {
237
+ const entity = schema[key];
238
+ if (!entity.decorators?.entity) continue;
239
+ const {
240
+ name,
241
+ handle,
242
+ handlePlural
243
+ } = entity;
244
+ const tag = name;
245
+ schemas[name] = buildObjectSchema(entity, schema);
246
+ schemas[`${name}Put`] = buildObjectSchema(entity, schema, true);
247
+ schemas[`${name}Put`].required = ["id"];
248
+ const ref = n => ({
249
+ $ref: `#/components/schemas/${n}`
250
+ });
251
+ paths[`${base}/${handle}`] = {
252
+ post: {
253
+ summary: `Get ${name}`,
254
+ tags: [tag],
255
+ requestBody: jsonBody(getRequestSchema),
256
+ responses: jsonOk(ref(name), `${name} object`)
257
+ },
258
+ put: {
259
+ summary: `Create or update ${name}`,
260
+ tags: [tag],
261
+ requestBody: jsonBody({
262
+ type: "object",
263
+ required: ["data"],
264
+ properties: {
265
+ data: ref(`${name}Put`)
266
+ }
267
+ }),
268
+ responses: jsonOk(ref(name), `Updated ${name}`)
269
+ }
270
+ };
271
+ paths[`${base}/${handlePlural}`] = {
272
+ post: {
273
+ summary: `Get many ${name} objects`,
274
+ tags: [tag],
275
+ requestBody: jsonBody(getManyRequestSchema),
276
+ responses: jsonOk({
277
+ type: "object",
278
+ required: ["nodes"],
279
+ properties: {
280
+ nodes: {
281
+ type: "array",
282
+ items: ref(name)
283
+ },
284
+ cursor: {
285
+ type: "string",
286
+ nullable: true
287
+ }
288
+ }
289
+ }, `Paginated list of ${name}`)
290
+ },
291
+ put: {
292
+ summary: `Create or update many ${name} objects`,
293
+ tags: [tag],
294
+ requestBody: jsonBody({
295
+ type: "object",
296
+ required: ["data"],
297
+ properties: {
298
+ data: {
299
+ type: "array",
300
+ items: ref(`${name}Put`)
301
+ }
302
+ }
303
+ }),
304
+ responses: jsonOk({
305
+ type: "array",
306
+ items: ref(name)
307
+ }, `Updated ${name} list`)
308
+ }
309
+ };
310
+ paths[`${base}/${handle}/agg`] = {
311
+ post: {
312
+ summary: `Aggregate ${name} data`,
313
+ tags: [tag],
314
+ requestBody: jsonBody(getAggRequestSchema),
315
+ responses: jsonOk({
316
+ type: "object",
317
+ additionalProperties: {
318
+ type: "number",
319
+ nullable: true
320
+ }
321
+ }, `${name} aggregation result`)
322
+ }
323
+ };
324
+ }
325
+ for (const key in schema) {
326
+ const entry = schema[key];
327
+ if (entry.decorators?.entity || schemas[key]) continue;
328
+ if (entry.enumValues) {
329
+ schemas[key] = {
330
+ type: "string",
331
+ enum: Object.keys(entry.enumValues),
332
+ ...(entry.comment ? {
333
+ description: entry.comment
334
+ } : {})
335
+ };
336
+ } else if (entry.fields) {
337
+ schemas[key] = buildObjectSchema(entry, schema);
338
+ }
339
+ }
340
+ return {
341
+ openapi: "3.0.3",
342
+ info: {
343
+ title: openApiOptions?.title ?? "Rads DB API",
344
+ version: openApiOptions?.version ?? "1.0.0",
345
+ ...(openApiOptions?.description ? {
346
+ description: openApiOptions.description
347
+ } : {})
348
+ },
349
+ ...(openApiOptions?.servers ? {
350
+ servers: openApiOptions.servers
351
+ } : {}),
352
+ paths,
353
+ components: {
354
+ schemas
355
+ }
356
+ };
357
+ }
@@ -0,0 +1,11 @@
1
+ import type { GetRestRoutesOptions } from '../types';
2
+ export interface OpenApiSpecOptions {
3
+ title?: string;
4
+ version?: string;
5
+ description?: string;
6
+ servers?: {
7
+ url: string;
8
+ description?: string;
9
+ }[];
10
+ }
11
+ export declare function getOpenApiSpec(options: GetRestRoutesOptions, openApiOptions?: OpenApiSpecOptions): object;
@@ -0,0 +1,222 @@
1
+ function primitiveToOas(type) {
2
+ switch (type) {
3
+ case "string":
4
+ return { type: "string" };
5
+ case "number":
6
+ return { type: "number" };
7
+ case "boolean":
8
+ return { type: "boolean" };
9
+ case "Record<string, string>":
10
+ return { type: "object", additionalProperties: { type: "string" } };
11
+ case "Record<string, any>":
12
+ return { type: "object", additionalProperties: true };
13
+ default:
14
+ return {};
15
+ }
16
+ }
17
+ function fieldToOas(field, schema) {
18
+ const entry = schema[field.type];
19
+ let itemSchema;
20
+ if (entry) {
21
+ itemSchema = entry.enumValues ? { type: "string", enum: Object.keys(entry.enumValues) } : { $ref: `#/components/schemas/${field.type}` };
22
+ } else {
23
+ itemSchema = primitiveToOas(field.type);
24
+ }
25
+ return field.isArray ? { type: "array", items: itemSchema } : itemSchema;
26
+ }
27
+ function buildObjectSchema(entity, schema, allOptional = false) {
28
+ const properties = {};
29
+ const required = [];
30
+ for (const [name, field] of Object.entries(entity.fields ?? {})) {
31
+ if (field.isInverseRelation) continue;
32
+ properties[name] = {
33
+ ...fieldToOas(field, schema),
34
+ ...field.comment ? { description: field.comment } : {}
35
+ };
36
+ if (!allOptional && field.isRequired) required.push(name);
37
+ }
38
+ return {
39
+ type: "object",
40
+ ...entity.comment ? { description: entity.comment } : {},
41
+ properties,
42
+ ...required.length > 0 ? { required } : {}
43
+ };
44
+ }
45
+ const whereSchema = { type: "object", additionalProperties: true };
46
+ const includeSchema = { type: "object", additionalProperties: true };
47
+ const paginationProps = {
48
+ cursor: { type: "string" },
49
+ maxItemCount: { type: "integer" },
50
+ orderBy: { type: "string", description: 'Single order-by expression, e.g. "name_asc"' },
51
+ orderByArray: { type: "array", items: { type: "string" }, description: "Multiple order-by expressions" }
52
+ };
53
+ const getRequestSchema = {
54
+ type: "object",
55
+ properties: { where: whereSchema, include: includeSchema }
56
+ };
57
+ const getManyRequestSchema = {
58
+ type: "object",
59
+ properties: { where: whereSchema, include: includeSchema, ...paginationProps }
60
+ };
61
+ const getAggRequestSchema = {
62
+ type: "object",
63
+ required: ["agg"],
64
+ properties: {
65
+ where: whereSchema,
66
+ agg: {
67
+ type: "array",
68
+ items: { type: "string" },
69
+ description: 'Aggregation operations: "_count", "field_min", "field_max", "field_sum"'
70
+ },
71
+ ...paginationProps
72
+ }
73
+ };
74
+ function jsonBody(s) {
75
+ return { required: true, content: { "application/json": { schema: s } } };
76
+ }
77
+ function jsonOk(s, description = "Success") {
78
+ return { "200": { description, content: { "application/json": { schema: s } } } };
79
+ }
80
+ export function getOpenApiSpec(options, openApiOptions) {
81
+ const { db, prefix = "/api/" } = options;
82
+ const schema = db._schema;
83
+ const base = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
84
+ const paths = {};
85
+ const schemas = {};
86
+ paths[`${base}/me`] = {
87
+ get: {
88
+ summary: "Get current user",
89
+ tags: ["Auth"],
90
+ responses: jsonOk({
91
+ type: "object",
92
+ properties: { userId: { type: "string", nullable: true } }
93
+ })
94
+ }
95
+ };
96
+ paths[`${base}/uploadFile`] = {
97
+ post: {
98
+ summary: "Upload a file",
99
+ tags: ["Files"],
100
+ requestBody: jsonBody({
101
+ type: "object",
102
+ required: ["blobDataUrl"],
103
+ properties: {
104
+ blobDataUrl: { type: "string", description: "Base64 data URL: data:<mime>;base64,<data>" },
105
+ filename: { type: "string" },
106
+ containerName: { type: "string" }
107
+ }
108
+ }),
109
+ responses: jsonOk({
110
+ type: "object",
111
+ required: ["url"],
112
+ properties: { url: { type: "string" } }
113
+ })
114
+ }
115
+ };
116
+ paths[`${base}/radsTunnel`] = {
117
+ post: {
118
+ summary: "Direct database method access",
119
+ tags: ["Tunnel"],
120
+ requestBody: jsonBody({
121
+ type: "object",
122
+ properties: {
123
+ method: { type: "string", description: "Method to call (getMany, put, _schema, uploadFile, \u2026)" },
124
+ entity: { type: "string", description: "Entity class name, e.g. TcUser" },
125
+ args: { description: "Arguments forwarded to the method" }
126
+ }
127
+ }),
128
+ responses: jsonOk({})
129
+ }
130
+ };
131
+ for (const key in schema) {
132
+ const entity = schema[key];
133
+ if (!entity.decorators?.entity) continue;
134
+ const { name, handle, handlePlural } = entity;
135
+ const tag = name;
136
+ schemas[name] = buildObjectSchema(entity, schema);
137
+ schemas[`${name}Put`] = buildObjectSchema(entity, schema, true);
138
+ schemas[`${name}Put`].required = ["id"];
139
+ const ref = (n) => ({ $ref: `#/components/schemas/${n}` });
140
+ paths[`${base}/${handle}`] = {
141
+ post: {
142
+ summary: `Get ${name}`,
143
+ tags: [tag],
144
+ requestBody: jsonBody(getRequestSchema),
145
+ responses: jsonOk(ref(name), `${name} object`)
146
+ },
147
+ put: {
148
+ summary: `Create or update ${name}`,
149
+ tags: [tag],
150
+ requestBody: jsonBody({
151
+ type: "object",
152
+ required: ["data"],
153
+ properties: { data: ref(`${name}Put`) }
154
+ }),
155
+ responses: jsonOk(ref(name), `Updated ${name}`)
156
+ }
157
+ };
158
+ paths[`${base}/${handlePlural}`] = {
159
+ post: {
160
+ summary: `Get many ${name} objects`,
161
+ tags: [tag],
162
+ requestBody: jsonBody(getManyRequestSchema),
163
+ responses: jsonOk(
164
+ {
165
+ type: "object",
166
+ required: ["nodes"],
167
+ properties: {
168
+ nodes: { type: "array", items: ref(name) },
169
+ cursor: { type: "string", nullable: true }
170
+ }
171
+ },
172
+ `Paginated list of ${name}`
173
+ )
174
+ },
175
+ put: {
176
+ summary: `Create or update many ${name} objects`,
177
+ tags: [tag],
178
+ requestBody: jsonBody({
179
+ type: "object",
180
+ required: ["data"],
181
+ properties: { data: { type: "array", items: ref(`${name}Put`) } }
182
+ }),
183
+ responses: jsonOk({ type: "array", items: ref(name) }, `Updated ${name} list`)
184
+ }
185
+ };
186
+ paths[`${base}/${handle}/agg`] = {
187
+ post: {
188
+ summary: `Aggregate ${name} data`,
189
+ tags: [tag],
190
+ requestBody: jsonBody(getAggRequestSchema),
191
+ responses: jsonOk(
192
+ { type: "object", additionalProperties: { type: "number", nullable: true } },
193
+ `${name} aggregation result`
194
+ )
195
+ }
196
+ };
197
+ }
198
+ for (const key in schema) {
199
+ const entry = schema[key];
200
+ if (entry.decorators?.entity || schemas[key]) continue;
201
+ if (entry.enumValues) {
202
+ schemas[key] = {
203
+ type: "string",
204
+ enum: Object.keys(entry.enumValues),
205
+ ...entry.comment ? { description: entry.comment } : {}
206
+ };
207
+ } else if (entry.fields) {
208
+ schemas[key] = buildObjectSchema(entry, schema);
209
+ }
210
+ }
211
+ return {
212
+ openapi: "3.0.3",
213
+ info: {
214
+ title: openApiOptions?.title ?? "Rads DB API",
215
+ version: openApiOptions?.version ?? "1.0.0",
216
+ ...openApiOptions?.description ? { description: openApiOptions.description } : {}
217
+ },
218
+ ...openApiOptions?.servers ? { servers: openApiOptions.servers } : {},
219
+ paths,
220
+ components: { schemas }
221
+ };
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rads-db",
3
- "version": "3.2.20",
3
+ "version": "3.2.22",
4
4
  "description": "Say goodbye to boilerplate code and hello to efficient and elegant syntax.",
5
5
  "author": "",
6
6
  "license": "ISC",