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