@stonyx/orm 0.2.5-alpha.0 → 0.3.1

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 (166) hide show
  1. package/README.md +482 -15
  2. package/config/environment.js +63 -6
  3. package/dist/aggregates.d.ts +21 -0
  4. package/dist/aggregates.js +93 -0
  5. package/dist/attr.d.ts +2 -0
  6. package/dist/attr.js +22 -0
  7. package/dist/belongs-to.d.ts +11 -0
  8. package/dist/belongs-to.js +59 -0
  9. package/dist/cli.d.ts +22 -0
  10. package/dist/cli.js +148 -0
  11. package/dist/commands.d.ts +7 -0
  12. package/dist/commands.js +146 -0
  13. package/dist/db.d.ts +21 -0
  14. package/dist/db.js +180 -0
  15. package/dist/exports/db.d.ts +7 -0
  16. package/{src → dist}/exports/db.js +2 -4
  17. package/dist/has-many.d.ts +11 -0
  18. package/dist/has-many.js +58 -0
  19. package/dist/hooks.d.ts +75 -0
  20. package/dist/hooks.js +110 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +34 -0
  23. package/dist/main.d.ts +46 -0
  24. package/dist/main.js +181 -0
  25. package/dist/manage-record.d.ts +13 -0
  26. package/dist/manage-record.js +123 -0
  27. package/dist/meta-request.d.ts +6 -0
  28. package/dist/meta-request.js +52 -0
  29. package/dist/migrate.d.ts +2 -0
  30. package/dist/migrate.js +57 -0
  31. package/dist/model-property.d.ts +9 -0
  32. package/dist/model-property.js +29 -0
  33. package/dist/model.d.ts +15 -0
  34. package/dist/model.js +18 -0
  35. package/dist/mysql/connection.d.ts +14 -0
  36. package/dist/mysql/connection.js +24 -0
  37. package/dist/mysql/migration-generator.d.ts +45 -0
  38. package/dist/mysql/migration-generator.js +254 -0
  39. package/dist/mysql/migration-runner.d.ts +12 -0
  40. package/dist/mysql/migration-runner.js +88 -0
  41. package/dist/mysql/mysql-db.d.ts +100 -0
  42. package/dist/mysql/mysql-db.js +425 -0
  43. package/dist/mysql/query-builder.d.ts +10 -0
  44. package/dist/mysql/query-builder.js +44 -0
  45. package/dist/mysql/schema-introspector.d.ts +19 -0
  46. package/dist/mysql/schema-introspector.js +257 -0
  47. package/dist/mysql/type-map.d.ts +21 -0
  48. package/dist/mysql/type-map.js +36 -0
  49. package/dist/orm-request.d.ts +38 -0
  50. package/dist/orm-request.js +475 -0
  51. package/dist/plural-registry.d.ts +4 -0
  52. package/dist/plural-registry.js +9 -0
  53. package/dist/postgres/connection.d.ts +15 -0
  54. package/dist/postgres/connection.js +32 -0
  55. package/dist/postgres/migration-generator.d.ts +45 -0
  56. package/dist/postgres/migration-generator.js +280 -0
  57. package/dist/postgres/migration-runner.d.ts +10 -0
  58. package/dist/postgres/migration-runner.js +87 -0
  59. package/dist/postgres/postgres-db.d.ts +119 -0
  60. package/dist/postgres/postgres-db.js +477 -0
  61. package/dist/postgres/query-builder.d.ts +27 -0
  62. package/dist/postgres/query-builder.js +98 -0
  63. package/dist/postgres/schema-introspector.d.ts +29 -0
  64. package/dist/postgres/schema-introspector.js +296 -0
  65. package/dist/postgres/type-map.d.ts +23 -0
  66. package/dist/postgres/type-map.js +56 -0
  67. package/dist/record.d.ts +75 -0
  68. package/dist/record.js +129 -0
  69. package/dist/relationships.d.ts +10 -0
  70. package/dist/relationships.js +41 -0
  71. package/dist/schema-helpers.d.ts +20 -0
  72. package/dist/schema-helpers.js +48 -0
  73. package/dist/serializer.d.ts +17 -0
  74. package/dist/serializer.js +136 -0
  75. package/dist/setup-rest-server.d.ts +1 -0
  76. package/dist/setup-rest-server.js +52 -0
  77. package/dist/standalone-db.d.ts +58 -0
  78. package/dist/standalone-db.js +142 -0
  79. package/dist/store.d.ts +62 -0
  80. package/dist/store.js +286 -0
  81. package/dist/timescale/query-builder.d.ts +43 -0
  82. package/dist/timescale/query-builder.js +115 -0
  83. package/dist/timescale/timescale-db.d.ts +45 -0
  84. package/dist/timescale/timescale-db.js +84 -0
  85. package/dist/transforms.d.ts +2 -0
  86. package/dist/transforms.js +17 -0
  87. package/dist/types/orm-types.d.ts +153 -0
  88. package/dist/types/orm-types.js +1 -0
  89. package/dist/utils.d.ts +7 -0
  90. package/dist/utils.js +17 -0
  91. package/dist/view-resolver.d.ts +8 -0
  92. package/dist/view-resolver.js +171 -0
  93. package/dist/view.d.ts +11 -0
  94. package/dist/view.js +18 -0
  95. package/package.json +64 -11
  96. package/src/aggregates.ts +109 -0
  97. package/src/{attr.js → attr.ts} +2 -2
  98. package/src/belongs-to.ts +90 -0
  99. package/src/cli.ts +183 -0
  100. package/src/commands.ts +179 -0
  101. package/src/db.ts +232 -0
  102. package/src/exports/db.ts +7 -0
  103. package/src/has-many.ts +92 -0
  104. package/src/hooks.ts +151 -0
  105. package/src/{index.js → index.ts} +12 -2
  106. package/src/main.ts +229 -0
  107. package/src/manage-record.ts +161 -0
  108. package/src/{meta-request.js → meta-request.ts} +17 -14
  109. package/src/migrate.ts +72 -0
  110. package/src/model-property.ts +35 -0
  111. package/src/model.ts +21 -0
  112. package/src/mysql/connection.ts +43 -0
  113. package/src/mysql/migration-generator.ts +337 -0
  114. package/src/mysql/migration-runner.ts +121 -0
  115. package/src/mysql/mysql-db.ts +543 -0
  116. package/src/mysql/query-builder.ts +69 -0
  117. package/src/mysql/schema-introspector.ts +310 -0
  118. package/src/mysql/type-map.ts +42 -0
  119. package/src/orm-request.ts +582 -0
  120. package/src/plural-registry.ts +12 -0
  121. package/src/postgres/connection.ts +48 -0
  122. package/src/postgres/migration-generator.ts +370 -0
  123. package/src/postgres/migration-runner.ts +115 -0
  124. package/src/postgres/postgres-db.ts +616 -0
  125. package/src/postgres/query-builder.ts +148 -0
  126. package/src/postgres/schema-introspector.ts +360 -0
  127. package/src/postgres/type-map.ts +61 -0
  128. package/src/record.ts +186 -0
  129. package/src/relationships.ts +54 -0
  130. package/src/schema-helpers.ts +59 -0
  131. package/src/serializer.ts +161 -0
  132. package/src/setup-rest-server.ts +62 -0
  133. package/src/standalone-db.ts +185 -0
  134. package/src/store.ts +373 -0
  135. package/src/timescale/query-builder.ts +174 -0
  136. package/src/timescale/timescale-db.ts +119 -0
  137. package/src/transforms.ts +20 -0
  138. package/src/types/mysql2.d.ts +49 -0
  139. package/src/types/orm-types.ts +158 -0
  140. package/src/types/pg.d.ts +32 -0
  141. package/src/types/stonyx-cron.d.ts +5 -0
  142. package/src/types/stonyx-events.d.ts +4 -0
  143. package/src/types/stonyx-rest-server.d.ts +16 -0
  144. package/src/types/stonyx-utils.d.ts +33 -0
  145. package/src/types/stonyx.d.ts +21 -0
  146. package/src/utils.ts +22 -0
  147. package/src/view-resolver.ts +211 -0
  148. package/src/view.ts +22 -0
  149. package/.claude/project-structure.md +0 -578
  150. package/.github/workflows/ci.yml +0 -36
  151. package/.github/workflows/publish.yml +0 -143
  152. package/src/belongs-to.js +0 -63
  153. package/src/db.js +0 -80
  154. package/src/has-many.js +0 -61
  155. package/src/main.js +0 -119
  156. package/src/manage-record.js +0 -103
  157. package/src/model-property.js +0 -29
  158. package/src/model.js +0 -9
  159. package/src/orm-request.js +0 -249
  160. package/src/record.js +0 -100
  161. package/src/relationships.js +0 -43
  162. package/src/serializer.js +0 -138
  163. package/src/setup-rest-server.js +0 -57
  164. package/src/store.js +0 -211
  165. package/src/transforms.js +0 -20
  166. package/stonyx-bootstrap.cjs +0 -30
@@ -0,0 +1,582 @@
1
+ import { Request } from '@stonyx/rest-server';
2
+ import Orm, { store, createRecord, updateRecord } from '@stonyx/orm';
3
+ import { camelCaseToKebabCase } from '@stonyx/utils/string';
4
+ import { getPluralName } from './plural-registry.js';
5
+ import { getBeforeHooks, getAfterHooks } from './hooks.js';
6
+ import type { HookContext } from './hooks.js';
7
+ import config from 'stonyx/config';
8
+ import type { OrmRecord } from './types/orm-types.js';
9
+ import { isOrmRecord } from './utils.js';
10
+
11
+ interface OrmRequest$ extends Request {
12
+ protocol?: string;
13
+ method: string;
14
+ params: { [key: string]: string };
15
+ body?: { [key: string]: unknown };
16
+ query?: { [key: string]: string };
17
+ get(header: string): string;
18
+ }
19
+
20
+ interface RelationshipInfo {
21
+ type: 'belongsTo' | 'hasMany';
22
+ isArray: boolean;
23
+ }
24
+
25
+ interface Filter {
26
+ path: string[];
27
+ value: string;
28
+ }
29
+
30
+ interface JsonApiResponse {
31
+ data: unknown;
32
+ links?: { [key: string]: string };
33
+ included?: unknown[];
34
+ }
35
+
36
+ type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
37
+ type HandlerFn = (request: OrmRequest$, state: { [key: string]: unknown }) => unknown | Promise<unknown>;
38
+
39
+ const methodAccessMap: { [key: string]: string } = {
40
+ GET: 'read',
41
+ POST: 'create',
42
+ DELETE: 'delete',
43
+ PATCH: 'update',
44
+ };
45
+
46
+ const WRITE_OPERATIONS = new Set(['create', 'update', 'delete']);
47
+
48
+ // Helper to detect relationship type from function
49
+ function getRelationshipInfo(property: unknown): RelationshipInfo | null {
50
+ if (typeof property !== 'function') return null;
51
+ const relType = (property as { __relationshipType?: string }).__relationshipType;
52
+ if (relType === 'belongsTo') {
53
+ return { type: 'belongsTo', isArray: false };
54
+ }
55
+ if (relType === 'hasMany') {
56
+ return { type: 'hasMany', isArray: true };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // Helper to introspect model relationships
62
+ function getModelRelationships(modelName: string): { [key: string]: RelationshipInfo } {
63
+ const { modelClass } = Orm.instance.getRecordClasses(modelName);
64
+ if (!modelClass) return {};
65
+
66
+ const model = new (modelClass as new (name: string) => { [key: string]: unknown })(modelName);
67
+ const relationships: { [key: string]: RelationshipInfo } = {};
68
+
69
+ for (const [key, property] of Object.entries(model)) {
70
+ if (key.startsWith('__')) continue;
71
+ const info = getRelationshipInfo(property);
72
+ if (info) {
73
+ relationships[key] = info;
74
+ }
75
+ }
76
+
77
+ return relationships;
78
+ }
79
+
80
+ // Helper to build base URL from request
81
+ function getBaseUrl(request: OrmRequest$): string {
82
+ const protocol = request.protocol || 'http';
83
+ const host = request.get('host');
84
+ return `${protocol}://${host}`;
85
+ }
86
+
87
+ function getId(params: { id?: string; [key: string]: unknown }): string | number {
88
+ const id = params.id;
89
+ if (!id) return '';
90
+ if (isNaN(id as unknown as number)) return id;
91
+
92
+ return parseInt(id);
93
+ }
94
+
95
+ function buildResponse(
96
+ data: unknown,
97
+ includeParam: string | undefined,
98
+ recordOrRecords: OrmRecord | OrmRecord[],
99
+ options: { links?: { [key: string]: string }; baseUrl?: string } = {}
100
+ ): JsonApiResponse {
101
+ const { links, baseUrl } = options;
102
+ const response: JsonApiResponse = { data };
103
+
104
+ // Add top-level links
105
+ if (links) {
106
+ response.links = links;
107
+ }
108
+
109
+ if (!includeParam) return response;
110
+
111
+ const includes = parseInclude(includeParam);
112
+ if (includes.length === 0) return response;
113
+
114
+ const includedRecords = collectIncludedRecords(recordOrRecords, includes);
115
+ if (includedRecords.length > 0) {
116
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
117
+ }
118
+
119
+ return response;
120
+ }
121
+
122
+ /**
123
+ * Recursively traverse an include path and collect related records
124
+ */
125
+ function traverseIncludePath(
126
+ currentRecords: OrmRecord[],
127
+ includePath: string[],
128
+ depth: number,
129
+ seen: Map<string, Set<string | number>>,
130
+ included: OrmRecord[]
131
+ ): void {
132
+ if (depth >= includePath.length) return; // Reached end of path
133
+
134
+ const relationshipName = includePath[depth];
135
+ const nextRecords: OrmRecord[] = [];
136
+
137
+ for (const record of currentRecords) {
138
+ if (!record.__relationships) continue;
139
+ if (!(relationshipName in record.__relationships)) continue;
140
+
141
+ const relatedRecords = record.__relationships[relationshipName];
142
+ if (!relatedRecords) continue;
143
+
144
+ // Handle both belongsTo (single) and hasMany (array)
145
+ const recordsToProcess: OrmRecord[] = Array.isArray(relatedRecords)
146
+ ? relatedRecords.filter(isOrmRecord)
147
+ : isOrmRecord(relatedRecords) ? [relatedRecords] : [];
148
+
149
+ for (const relatedRecord of recordsToProcess) {
150
+ if (!relatedRecord) continue;
151
+
152
+ if (!relatedRecord.__model) continue;
153
+ const type = relatedRecord.__model.__name;
154
+ const id = relatedRecord.id as string | number;
155
+
156
+ // Initialize Set for this type if needed
157
+ let seenIds = seen.get(type);
158
+ if (!seenIds) {
159
+ seenIds = new Set();
160
+ seen.set(type, seenIds);
161
+ }
162
+
163
+ // Check if we've already seen this type+id combination
164
+ if (!seenIds.has(id)) {
165
+ seenIds.add(id);
166
+ included.push(relatedRecord);
167
+ nextRecords.push(relatedRecord); // Prepare for next depth level
168
+ } else if (depth < includePath.length - 1) {
169
+ // Even if we've seen this record, we might need it for deeper traversal
170
+ nextRecords.push(relatedRecord);
171
+ }
172
+ }
173
+ }
174
+
175
+ // If there are more segments in the path, recursively process
176
+ if (depth < includePath.length - 1 && nextRecords.length > 0) {
177
+ traverseIncludePath(nextRecords, includePath, depth + 1, seen, included);
178
+ }
179
+ }
180
+
181
+ function collectIncludedRecords(data: OrmRecord | OrmRecord[], includes: string[][]): OrmRecord[] {
182
+ if (!includes || includes.length === 0) return [];
183
+ if (!data) return [];
184
+
185
+ const seen = new Map<string, Set<string | number>>(); // Map<type, Set<id>> for deduplication
186
+ const included: OrmRecord[] = [];
187
+
188
+ // Normalize to array for consistent processing
189
+ const records: OrmRecord[] = Array.isArray(data) ? data : [data];
190
+
191
+ // Process each include path
192
+ for (const includePath of includes) {
193
+ traverseIncludePath(records, includePath, 0, seen, included);
194
+ }
195
+
196
+ return included;
197
+ }
198
+
199
+ function parseInclude(includeParam: string | undefined): string[][] {
200
+ if (!includeParam || typeof includeParam !== 'string') return [];
201
+
202
+ return includeParam
203
+ .split(',')
204
+ .map(rel => rel.trim())
205
+ .filter(rel => rel.length > 0)
206
+ .map(rel => rel.split('.')); // Parse nested paths: "owner.pets" -> ["owner", "pets"]
207
+ }
208
+
209
+ function parseFields(query: { [key: string]: string } | undefined): Map<string, Set<string>> {
210
+ const fields = new Map<string, Set<string>>();
211
+ if (!query) return fields;
212
+
213
+ for (const [key, value] of Object.entries(query)) {
214
+ const match = key.match(/^fields\[(\w+)\]$/);
215
+ if (match && typeof value === 'string') {
216
+ const modelName = match[1];
217
+ const fieldNames = value.split(',').map(f => f.trim()).filter(f => f);
218
+ fields.set(modelName, new Set(fieldNames));
219
+ }
220
+ }
221
+
222
+ return fields;
223
+ }
224
+
225
+ function parseFilters(query: { [key: string]: string } | undefined): Filter[] {
226
+ const filters: Filter[] = [];
227
+ if (!query) return filters;
228
+
229
+ for (const [key, value] of Object.entries(query)) {
230
+ const match = key.match(/^filter\[(.+)\]$/);
231
+ if (match && typeof value === 'string') {
232
+ filters.push({ path: match[1].split('.'), value });
233
+ }
234
+ }
235
+
236
+ return filters;
237
+ }
238
+
239
+ function createFilterPredicate(filters: Filter[]): ((record: { [key: string]: unknown }) => boolean) | null {
240
+ if (filters.length === 0) return null;
241
+
242
+ return (record: { [key: string]: unknown }) => filters.every(({ path, value }) => {
243
+ let current: unknown = record;
244
+
245
+ for (const segment of path) {
246
+ if (current == null) return false;
247
+ current = (current as { [key: string]: unknown })[segment];
248
+ }
249
+
250
+ return String(current) === value;
251
+ });
252
+ }
253
+
254
+ export default class OrmRequest extends Request {
255
+ model: string;
256
+ access: (request: unknown) => AccessMethod;
257
+ handlers: { [key: string]: { [key: string]: HandlerFn } };
258
+
259
+ constructor({ model, access }: { model: string; access: (request: unknown) => AccessMethod }) {
260
+ super(...arguments as unknown as unknown[]);
261
+
262
+ this.model = model;
263
+ this.access = access;
264
+ const pluralizedModel = getPluralName(model);
265
+
266
+ const modelRelationships = getModelRelationships(model);
267
+
268
+ // Define raw handlers first
269
+ const getCollectionHandler: HandlerFn = async (request, { filter: accessFilter }) => {
270
+ const allRecords = (await store.findAll(model)).filter(isOrmRecord);
271
+
272
+ const queryFilters = parseFilters(request.query);
273
+ const queryFilterPredicate = createFilterPredicate(queryFilters);
274
+ const fieldsMap = parseFields(request.query);
275
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
276
+
277
+ let recordsToReturn = allRecords;
278
+ if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter as (record: OrmRecord) => boolean);
279
+ if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
280
+
281
+ const baseUrl = getBaseUrl(request);
282
+ const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
283
+
284
+ return buildResponse(data, request.query?.include, recordsToReturn, {
285
+ links: { self: `${baseUrl}/${pluralizedModel}` },
286
+ baseUrl
287
+ });
288
+ };
289
+
290
+ const getSingleHandler: HandlerFn = async (request) => {
291
+ const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
292
+ if (!record) return 404;
293
+
294
+ const fieldsMap = parseFields(request.query);
295
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
296
+
297
+ const baseUrl = getBaseUrl(request);
298
+ return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
299
+ links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
300
+ baseUrl
301
+ });
302
+ };
303
+
304
+ const createHandler: HandlerFn = async ({ body, query }) => {
305
+ const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
306
+ type?: string;
307
+ id?: string | number;
308
+ attributes?: { [key: string]: unknown };
309
+ relationships?: { [key: string]: { data?: { id?: string | number } } };
310
+ };
311
+
312
+ if (!type) return 400; // Bad request
313
+
314
+ const fieldsMap = parseFields(query);
315
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
316
+
317
+ // Check for duplicate ID
318
+ if (id !== undefined && await store.find(model, id)) return 409; // Conflict
319
+
320
+ const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
321
+
322
+ // Extract relationship IDs from JSON:API relationships object
323
+ if (rels) {
324
+ for (const [key, value] of Object.entries(rels)) {
325
+ const relData = value?.data;
326
+ if (relData && relData.id !== undefined) {
327
+ (sanitizedAttributes as { [key: string]: unknown })[key] = relData.id;
328
+ }
329
+ }
330
+ }
331
+
332
+ const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
333
+ const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false });
334
+ const record = isOrmRecord(created) ? created : null;
335
+ if (!record) return 500;
336
+
337
+ return { data: record.toJSON?.({ fields: modelFields }) };
338
+ };
339
+
340
+ const updateHandler: HandlerFn = async ({ body, params }) => {
341
+ const found = await store.find(model, getId(params));
342
+ if (!found || !isOrmRecord(found)) return 404;
343
+ const record = found;
344
+ const { attributes, relationships: rels } = (body?.data || {}) as {
345
+ attributes?: { [key: string]: unknown };
346
+ relationships?: { [key: string]: { data?: { id?: string | number } } };
347
+ };
348
+
349
+ if (!attributes && !rels) return 400; // Bad request
350
+
351
+ // Apply attribute updates 1 by 1 to utilize built-in transform logic, ignore id key
352
+ if (attributes) {
353
+ for (const [key, value] of Object.entries(attributes)) {
354
+ if (!Object.hasOwn(record, key)) continue;
355
+ if (key === 'id') continue;
356
+
357
+ record[key] = value
358
+ };
359
+ }
360
+
361
+ // Apply relationship updates via updateRecord to properly resolve references
362
+ if (rels) {
363
+ const relUpdates: { [key: string]: unknown } = {};
364
+ for (const [key, value] of Object.entries(rels)) {
365
+ const relData = value?.data;
366
+ if (relData && relData.id !== undefined) {
367
+ relUpdates[key] = relData.id;
368
+ }
369
+ }
370
+ if (Object.keys(relUpdates).length > 0) {
371
+ updateRecord(record as never, relUpdates);
372
+ }
373
+ }
374
+
375
+ return { data: record.toJSON?.() };
376
+ };
377
+
378
+ const deleteHandler: HandlerFn = ({ params }) => {
379
+ store.remove(model, getId(params));
380
+ return 204;
381
+ };
382
+
383
+ // Wrap handlers with hooks
384
+ const isView = Orm.instance?.isView?.(model);
385
+
386
+ this.handlers = {
387
+ get: {
388
+ '/': this._withHooks('list', getCollectionHandler),
389
+ '/:id': this._withHooks('get', getSingleHandler),
390
+ ...this._generateRelationshipRoutes(model, pluralizedModel, modelRelationships)
391
+ },
392
+ };
393
+
394
+ // Views are read-only -- no write endpoints
395
+ if (!isView) {
396
+ this.handlers.patch = {
397
+ '/:id': this._withHooks('update', updateHandler)
398
+ };
399
+ this.handlers.post = {
400
+ '/': this._withHooks('create', createHandler)
401
+ };
402
+ this.handlers.delete = {
403
+ '/:id': this._withHooks('delete', deleteHandler)
404
+ };
405
+ }
406
+ }
407
+
408
+ // Wraps a handler with before/after hook execution
409
+ private _withHooks(operation: string, handler: HandlerFn): HandlerFn {
410
+ return async (request: OrmRequest$, state: { [key: string]: unknown }) => {
411
+ // Build context object for hooks
412
+ const context: HookContext = {
413
+ model: this.model,
414
+ operation,
415
+ request,
416
+ params: request.params,
417
+ body: request.body,
418
+ query: request.query,
419
+ state,
420
+ };
421
+
422
+ // Capture old state for operations that modify data
423
+ if (operation === 'update' || operation === 'delete') {
424
+ const existingRecord = await store.find(this.model, getId(request.params)) as OrmRecord | undefined;
425
+ if (existingRecord) {
426
+ // Deep copy the record's data to preserve old state
427
+ context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
428
+ }
429
+ if (operation === 'delete') {
430
+ context.recordId = getId(request.params);
431
+ }
432
+ }
433
+
434
+ // Run before hooks sequentially (can halt by returning a value)
435
+ for (const hook of getBeforeHooks(operation, this.model)) {
436
+ const result = await hook(context);
437
+ if (result !== undefined) {
438
+ // Hook returned a value - halt operation and return result
439
+ return result;
440
+ }
441
+ }
442
+
443
+ // Execute main handler
444
+ const response = await handler(request, state);
445
+
446
+ // Persist to SQL database for write operations
447
+ const sqlDb = Orm.instance.sqlDb;
448
+ if (sqlDb && WRITE_OPERATIONS.has(operation)) {
449
+ await sqlDb.persist(operation, this.model, context, response);
450
+ }
451
+
452
+ // Add response and relevant records to context
453
+ context.response = response;
454
+
455
+ if (operation === 'get' && (response as JsonApiResponse)?.data && !Array.isArray((response as JsonApiResponse).data)) {
456
+ context.record = await store.find(this.model, getId(request.params));
457
+ } else if (operation === 'list' && (response as JsonApiResponse)?.data) {
458
+ context.records = await store.findAll(this.model);
459
+ } else if (operation === 'create' && (response as JsonApiResponse)?.data && ((response as { data: { id?: unknown } }).data.id)) {
460
+ // For create, get the record from store using the ID from the response
461
+ const responseData = (response as { data: { id: string | number } }).data;
462
+ const recordId = isNaN(responseData.id as unknown as number) ? responseData.id : parseInt(responseData.id as string);
463
+ context.record = store.get(this.model, recordId);
464
+ } else if (operation === 'update' && (response as JsonApiResponse)?.data) {
465
+ context.record = store.get(this.model, getId(request.params));
466
+ } else if (operation === 'delete') {
467
+ // For delete, the record may no longer exist, but we have oldState
468
+ context.recordId = getId(request.params);
469
+ }
470
+
471
+ // Run after hooks sequentially
472
+ for (const hook of getAfterHooks(operation, this.model)) {
473
+ await hook(context);
474
+ }
475
+
476
+ // Auto-save DB after write operations when configured
477
+ if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation)) {
478
+ await (Orm.db as { save(): Promise<void> }).save();
479
+ }
480
+
481
+ return response;
482
+ };
483
+ }
484
+
485
+ private _generateRelationshipRoutes(
486
+ model: string,
487
+ pluralizedModel: string,
488
+ modelRelationships: { [key: string]: RelationshipInfo }
489
+ ): { [key: string]: HandlerFn } {
490
+ const routes: { [key: string]: HandlerFn } = {};
491
+
492
+ for (const [relationshipName, info] of Object.entries(modelRelationships)) {
493
+ // Dasherize the relationship name for URL paths (e.g., accessLinks -> access-links)
494
+ const dasherizedName = camelCaseToKebabCase(relationshipName);
495
+
496
+ // Related resource route: GET /:id/{relationship}
497
+ routes[`/:id/${dasherizedName}`] = async (request: OrmRequest$) => {
498
+ const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
499
+ if (!record) return 404;
500
+
501
+ const relatedData = record.__relationships[relationshipName];
502
+ const baseUrl = getBaseUrl(request);
503
+
504
+ let data: unknown;
505
+ if (info.isArray) {
506
+ // hasMany - return array
507
+ const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
508
+ data = related.map(r => r.toJSON?.({ baseUrl }));
509
+ } else {
510
+ // belongsTo - return single or null
511
+ data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
512
+ }
513
+
514
+ return {
515
+ links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
516
+ data
517
+ };
518
+ };
519
+
520
+ // Relationship linkage route: GET /:id/relationships/{relationship}
521
+ routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$) => {
522
+ const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
523
+ if (!record) return 404;
524
+
525
+ const relatedData = record.__relationships[relationshipName];
526
+ const baseUrl = getBaseUrl(request);
527
+
528
+ let data: unknown;
529
+ if (info.isArray) {
530
+ // hasMany - return array of linkage objects
531
+ const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
532
+ data = related
533
+ .filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
534
+ .map(r => ({ type: r.__model.__name, id: r.id }));
535
+ } else {
536
+ // belongsTo - return single linkage or null
537
+ if (isOrmRecord(relatedData) && relatedData.__model) {
538
+ data = { type: relatedData.__model.__name, id: relatedData.id };
539
+ } else {
540
+ data = null;
541
+ }
542
+ }
543
+
544
+ return {
545
+ links: {
546
+ self: `${baseUrl}/${pluralizedModel}/${request.params.id}/relationships/${dasherizedName}`,
547
+ related: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}`
548
+ },
549
+ data
550
+ };
551
+ };
552
+ }
553
+
554
+ // Catch-all for invalid relationship names on related resource route
555
+ routes[`/:id/:relationship`] = async (request: OrmRequest$) => {
556
+ const record = await store.find(model, getId(request.params));
557
+ if (!record) return 404;
558
+
559
+ // If we reach here, relationship doesn't exist (valid ones were registered above)
560
+ return 404;
561
+ };
562
+
563
+ // Catch-all for invalid relationship names on relationship linkage route
564
+ routes[`/:id/relationships/:relationship`] = async (request: OrmRequest$) => {
565
+ const record = await store.find(model, getId(request.params));
566
+ if (!record) return 404;
567
+
568
+ return 404;
569
+ };
570
+
571
+ return routes;
572
+ }
573
+
574
+ auth(request: OrmRequest$, state: { [key: string]: unknown }): number | undefined {
575
+ const access = this.access(request);
576
+
577
+ if (!access) return 403;
578
+ if (Array.isArray(access) && !access.includes(methodAccessMap[request.method])) return 403;
579
+ if (typeof access === 'function') state.filter = access;
580
+ return undefined;
581
+ }
582
+ }
@@ -0,0 +1,12 @@
1
+ import { pluralize } from './utils.js';
2
+
3
+ const registry: Map<string, string> = new Map();
4
+
5
+ export function registerPluralName(modelName: string, modelClass: { pluralName?: string }): void {
6
+ const plural = modelClass.pluralName || pluralize(modelName);
7
+ registry.set(modelName, plural);
8
+ }
9
+
10
+ export function getPluralName(modelName: string): string {
11
+ return registry.get(modelName) || pluralize(modelName);
12
+ }
@@ -0,0 +1,48 @@
1
+ import type { Pool as PgPool } from 'pg';
2
+ import { validateIdentifier } from './query-builder.js';
3
+
4
+ interface PgConfig {
5
+ host: string;
6
+ port: number;
7
+ user: string;
8
+ password: string;
9
+ database: string;
10
+ connectionLimit: number;
11
+ }
12
+
13
+ let pool: PgPool | null = null;
14
+
15
+ /**
16
+ * Create or return the singleton pg Pool.
17
+ */
18
+ export async function getPool(pgConfig: PgConfig, extensions: string[] = ['vector']): Promise<PgPool> {
19
+ if (pool) return pool;
20
+
21
+ const { default: pg } = await import('pg');
22
+
23
+ pool = new pg.Pool({
24
+ host: pgConfig.host,
25
+ port: pgConfig.port,
26
+ user: pgConfig.user,
27
+ password: pgConfig.password,
28
+ database: pgConfig.database,
29
+ max: pgConfig.connectionLimit,
30
+ idleTimeoutMillis: 30000,
31
+ connectionTimeoutMillis: 10000,
32
+ });
33
+
34
+ // Enable requested PostgreSQL extensions
35
+ for (const ext of extensions) {
36
+ validateIdentifier(ext, 'extension name');
37
+ await pool.query(`CREATE EXTENSION IF NOT EXISTS "${ext}"`);
38
+ }
39
+
40
+ return pool;
41
+ }
42
+
43
+ export async function closePool(): Promise<void> {
44
+ if (!pool) return;
45
+
46
+ await pool.end();
47
+ pool = null;
48
+ }