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