@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.
- package/README.md +482 -15
- package/config/environment.js +63 -6
- package/dist/aggregates.d.ts +21 -0
- package/dist/aggregates.js +93 -0
- package/dist/attr.d.ts +2 -0
- package/dist/attr.js +22 -0
- package/dist/belongs-to.d.ts +11 -0
- package/dist/belongs-to.js +59 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +148 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +146 -0
- package/dist/db.d.ts +21 -0
- package/dist/db.js +180 -0
- package/dist/exports/db.d.ts +7 -0
- package/{src → dist}/exports/db.js +2 -4
- package/dist/has-many.d.ts +11 -0
- package/dist/has-many.js +58 -0
- package/dist/hooks.d.ts +75 -0
- package/dist/hooks.js +110 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +34 -0
- package/dist/main.d.ts +46 -0
- package/dist/main.js +181 -0
- package/dist/manage-record.d.ts +13 -0
- package/dist/manage-record.js +123 -0
- package/dist/meta-request.d.ts +6 -0
- package/dist/meta-request.js +52 -0
- package/dist/migrate.d.ts +2 -0
- package/dist/migrate.js +57 -0
- package/dist/model-property.d.ts +9 -0
- package/dist/model-property.js +29 -0
- package/dist/model.d.ts +15 -0
- package/dist/model.js +18 -0
- package/dist/mysql/connection.d.ts +14 -0
- package/dist/mysql/connection.js +24 -0
- package/dist/mysql/migration-generator.d.ts +45 -0
- package/dist/mysql/migration-generator.js +254 -0
- package/dist/mysql/migration-runner.d.ts +12 -0
- package/dist/mysql/migration-runner.js +88 -0
- package/dist/mysql/mysql-db.d.ts +100 -0
- package/dist/mysql/mysql-db.js +425 -0
- package/dist/mysql/query-builder.d.ts +10 -0
- package/dist/mysql/query-builder.js +44 -0
- package/dist/mysql/schema-introspector.d.ts +19 -0
- package/dist/mysql/schema-introspector.js +257 -0
- package/dist/mysql/type-map.d.ts +21 -0
- package/dist/mysql/type-map.js +36 -0
- package/dist/orm-request.d.ts +38 -0
- package/dist/orm-request.js +475 -0
- package/dist/plural-registry.d.ts +4 -0
- package/dist/plural-registry.js +9 -0
- package/dist/postgres/connection.d.ts +15 -0
- package/dist/postgres/connection.js +32 -0
- package/dist/postgres/migration-generator.d.ts +45 -0
- package/dist/postgres/migration-generator.js +280 -0
- package/dist/postgres/migration-runner.d.ts +10 -0
- package/dist/postgres/migration-runner.js +87 -0
- package/dist/postgres/postgres-db.d.ts +119 -0
- package/dist/postgres/postgres-db.js +477 -0
- package/dist/postgres/query-builder.d.ts +27 -0
- package/dist/postgres/query-builder.js +98 -0
- package/dist/postgres/schema-introspector.d.ts +29 -0
- package/dist/postgres/schema-introspector.js +296 -0
- package/dist/postgres/type-map.d.ts +23 -0
- package/dist/postgres/type-map.js +56 -0
- package/dist/record.d.ts +75 -0
- package/dist/record.js +129 -0
- package/dist/relationships.d.ts +10 -0
- package/dist/relationships.js +41 -0
- package/dist/schema-helpers.d.ts +20 -0
- package/dist/schema-helpers.js +48 -0
- package/dist/serializer.d.ts +17 -0
- package/dist/serializer.js +136 -0
- package/dist/setup-rest-server.d.ts +1 -0
- package/dist/setup-rest-server.js +52 -0
- package/dist/standalone-db.d.ts +58 -0
- package/dist/standalone-db.js +142 -0
- package/dist/store.d.ts +62 -0
- package/dist/store.js +286 -0
- package/dist/timescale/query-builder.d.ts +43 -0
- package/dist/timescale/query-builder.js +115 -0
- package/dist/timescale/timescale-db.d.ts +45 -0
- package/dist/timescale/timescale-db.js +84 -0
- package/dist/transforms.d.ts +2 -0
- package/dist/transforms.js +17 -0
- package/dist/types/orm-types.d.ts +153 -0
- package/dist/types/orm-types.js +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +17 -0
- package/dist/view-resolver.d.ts +8 -0
- package/dist/view-resolver.js +171 -0
- package/dist/view.d.ts +11 -0
- package/dist/view.js +18 -0
- package/package.json +64 -11
- package/src/aggregates.ts +109 -0
- package/src/{attr.js → attr.ts} +2 -2
- package/src/belongs-to.ts +90 -0
- package/src/cli.ts +183 -0
- package/src/commands.ts +179 -0
- package/src/db.ts +232 -0
- package/src/exports/db.ts +7 -0
- package/src/has-many.ts +92 -0
- package/src/hooks.ts +151 -0
- package/src/{index.js → index.ts} +12 -2
- package/src/main.ts +229 -0
- package/src/manage-record.ts +161 -0
- package/src/{meta-request.js → meta-request.ts} +17 -14
- package/src/migrate.ts +72 -0
- package/src/model-property.ts +35 -0
- package/src/model.ts +21 -0
- package/src/mysql/connection.ts +43 -0
- package/src/mysql/migration-generator.ts +337 -0
- package/src/mysql/migration-runner.ts +121 -0
- package/src/mysql/mysql-db.ts +543 -0
- package/src/mysql/query-builder.ts +69 -0
- package/src/mysql/schema-introspector.ts +310 -0
- package/src/mysql/type-map.ts +42 -0
- package/src/orm-request.ts +582 -0
- package/src/plural-registry.ts +12 -0
- package/src/postgres/connection.ts +48 -0
- package/src/postgres/migration-generator.ts +370 -0
- package/src/postgres/migration-runner.ts +115 -0
- package/src/postgres/postgres-db.ts +616 -0
- package/src/postgres/query-builder.ts +148 -0
- package/src/postgres/schema-introspector.ts +360 -0
- package/src/postgres/type-map.ts +61 -0
- package/src/record.ts +186 -0
- package/src/relationships.ts +54 -0
- package/src/schema-helpers.ts +59 -0
- package/src/serializer.ts +161 -0
- package/src/setup-rest-server.ts +62 -0
- package/src/standalone-db.ts +185 -0
- package/src/store.ts +373 -0
- package/src/timescale/query-builder.ts +174 -0
- package/src/timescale/timescale-db.ts +119 -0
- package/src/transforms.ts +20 -0
- package/src/types/mysql2.d.ts +49 -0
- package/src/types/orm-types.ts +158 -0
- package/src/types/pg.d.ts +32 -0
- package/src/types/stonyx-cron.d.ts +5 -0
- package/src/types/stonyx-events.d.ts +4 -0
- package/src/types/stonyx-rest-server.d.ts +16 -0
- package/src/types/stonyx-utils.d.ts +33 -0
- package/src/types/stonyx.d.ts +21 -0
- package/src/utils.ts +22 -0
- package/src/view-resolver.ts +211 -0
- package/src/view.ts +22 -0
- package/.claude/project-structure.md +0 -578
- package/.github/workflows/ci.yml +0 -36
- package/.github/workflows/publish.yml +0 -143
- package/src/belongs-to.js +0 -63
- package/src/db.js +0 -80
- package/src/has-many.js +0 -61
- package/src/main.js +0 -119
- package/src/manage-record.js +0 -103
- package/src/model-property.js +0 -29
- package/src/model.js +0 -9
- package/src/orm-request.js +0 -249
- package/src/record.js +0 -100
- package/src/relationships.js +0 -43
- package/src/serializer.js +0 -138
- package/src/setup-rest-server.js +0 -57
- package/src/store.js +0 -211
- package/src/transforms.js +0 -20
- 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
|
+
}
|