@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
package/src/has-many.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createRecord, store } from '@stonyx/orm';
|
|
2
|
+
import { getRelationships, getGlobalRegistry, getPendingRegistry, getBelongsToRegistry } from './relationships.js';
|
|
3
|
+
import { getOrSet, makeArray } from '@stonyx/utils/object';
|
|
4
|
+
import { dbKey } from './db.js';
|
|
5
|
+
import type { SourceRecord } from './types/orm-types.js';
|
|
6
|
+
|
|
7
|
+
interface HasManyOptions {
|
|
8
|
+
global?: boolean;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PendingItem {
|
|
13
|
+
pendingRelationship: Map<unknown, unknown[][]>;
|
|
14
|
+
id: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type RelationshipHandler = ((sourceRecord: SourceRecord, rawData: unknown, options: HasManyOptions) => unknown[]) & {
|
|
18
|
+
__relatedModelName: string;
|
|
19
|
+
__relationshipType: 'hasMany';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function queuePendingRelationship(
|
|
23
|
+
pendingRelationshipQueue: PendingItem[],
|
|
24
|
+
pendingRelationships: Map<string, Map<unknown, unknown[][]>>,
|
|
25
|
+
modelName: string,
|
|
26
|
+
id: unknown
|
|
27
|
+
): null {
|
|
28
|
+
pendingRelationshipQueue.push({
|
|
29
|
+
pendingRelationship: getOrSet(pendingRelationships, modelName, new Map()),
|
|
30
|
+
id
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function hasMany(modelName: string): RelationshipHandler {
|
|
37
|
+
const globalRelationships = getGlobalRegistry();
|
|
38
|
+
const pendingRelationships = getPendingRegistry();
|
|
39
|
+
|
|
40
|
+
const fn = (sourceRecord: SourceRecord, rawData: unknown, options: HasManyOptions): unknown[] => {
|
|
41
|
+
const { __name: sourceModelName } = sourceRecord.__model;
|
|
42
|
+
const relationshipId = sourceRecord.id;
|
|
43
|
+
const relationship = getRelationships('hasMany', sourceModelName, modelName, relationshipId as string) as Map<unknown, unknown[]>;
|
|
44
|
+
const modelStore = store.get(modelName);
|
|
45
|
+
const pendingRelationshipQueue: PendingItem[] = [];
|
|
46
|
+
|
|
47
|
+
const output: unknown[] = !rawData ? [] : makeArray(rawData).map((elementData: unknown) => {
|
|
48
|
+
let record: unknown;
|
|
49
|
+
|
|
50
|
+
if (typeof elementData !== 'object') {
|
|
51
|
+
if (!modelStore) {
|
|
52
|
+
return queuePendingRelationship(pendingRelationshipQueue, pendingRelationships, modelName, elementData);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
record = modelStore.get(elementData as number | string);
|
|
56
|
+
|
|
57
|
+
if (!record) {
|
|
58
|
+
return queuePendingRelationship(pendingRelationshipQueue, pendingRelationships, modelName, elementData);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
if (elementData !== Object(elementData)) {
|
|
62
|
+
return queuePendingRelationship(pendingRelationshipQueue, pendingRelationships, modelName, elementData);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
record = createRecord(modelName, elementData as Record<string, unknown>, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Populate belongTo side if the relationship is defined
|
|
69
|
+
const recordWithId = typeof record === 'object' && record !== null && 'id' in record ? record as SourceRecord : undefined;
|
|
70
|
+
const otherSide = recordWithId ? getBelongsToRegistry()
|
|
71
|
+
.get(modelName)?.get(sourceModelName)?.get(recordWithId.id) : undefined;
|
|
72
|
+
|
|
73
|
+
if (otherSide) Object.assign(otherSide, sourceRecord);
|
|
74
|
+
|
|
75
|
+
return record;
|
|
76
|
+
}).filter((value: unknown) => value);
|
|
77
|
+
|
|
78
|
+
relationship.set(relationshipId, output);
|
|
79
|
+
|
|
80
|
+
// Assign global relationship
|
|
81
|
+
if (options.global || sourceModelName === dbKey) getOrSet(globalRelationships, modelName, []).push(output);
|
|
82
|
+
|
|
83
|
+
// Assign pending relationships
|
|
84
|
+
for (const { pendingRelationship, id } of pendingRelationshipQueue) getOrSet(pendingRelationship, id, []).push(output);
|
|
85
|
+
|
|
86
|
+
return output;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
Object.defineProperty(fn, '__relatedModelName', { value: modelName });
|
|
90
|
+
Object.defineProperty(fn, '__relationshipType', { value: 'hasMany' as const });
|
|
91
|
+
return fn as RelationshipHandler;
|
|
92
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 Stone Costa
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the 'License');
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Middleware-based hooks registry for ORM operations.
|
|
19
|
+
* Unlike event-based hooks, middleware hooks run sequentially and can halt operations.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Context object passed to before/after hook handlers. */
|
|
23
|
+
export interface HookContext {
|
|
24
|
+
/** Model name (e.g. 'user', 'animal'). */
|
|
25
|
+
model: string;
|
|
26
|
+
/** Operation name: 'create', 'update', 'delete', 'get', or 'list'. */
|
|
27
|
+
operation: string;
|
|
28
|
+
/** The incoming HTTP request object. */
|
|
29
|
+
request?: unknown;
|
|
30
|
+
/** URL route parameters (e.g. { id: '42' }). */
|
|
31
|
+
params?: Record<string, string>;
|
|
32
|
+
/** Parsed request body for create/update operations. */
|
|
33
|
+
body?: Record<string, unknown>;
|
|
34
|
+
/** URL query string parameters. */
|
|
35
|
+
query?: Record<string, string>;
|
|
36
|
+
/** Mutable state bag shared across hooks within a single request. */
|
|
37
|
+
state?: Record<string, unknown>;
|
|
38
|
+
/** Previous record state (available in update hooks). */
|
|
39
|
+
oldState?: unknown;
|
|
40
|
+
/** Target record ID for single-record operations. */
|
|
41
|
+
recordId?: string | number;
|
|
42
|
+
/** Response data (available in after hooks). */
|
|
43
|
+
response?: unknown;
|
|
44
|
+
/** The affected record (available in after hooks for create/update/delete). */
|
|
45
|
+
record?: unknown;
|
|
46
|
+
/** The affected records (available in after hooks for list operations). */
|
|
47
|
+
records?: unknown[];
|
|
48
|
+
[key: string]: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type HookHandler = (context: HookContext) => unknown | Promise<unknown>;
|
|
52
|
+
|
|
53
|
+
// Map of "operation:model" -> handler[]
|
|
54
|
+
const beforeHooks: Map<string, HookHandler[]> = new Map();
|
|
55
|
+
const afterHooks: Map<string, HookHandler[]> = new Map();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register a before hook middleware that runs before the operation executes.
|
|
59
|
+
*
|
|
60
|
+
* @param operation - Operation name: 'create', 'update', 'delete', 'get', or 'list'
|
|
61
|
+
* @param model - Model name (e.g., 'user', 'animal')
|
|
62
|
+
* @param handler - Middleware function (context) => any
|
|
63
|
+
* - Return undefined to continue to next hook/handler
|
|
64
|
+
* - Return any value to halt operation (integer = HTTP status, object = response body)
|
|
65
|
+
* @returns Unsubscribe function
|
|
66
|
+
*/
|
|
67
|
+
export function beforeHook(operation: string, model: string, handler: HookHandler): () => void {
|
|
68
|
+
const key = `${operation}:${model}`;
|
|
69
|
+
if (!beforeHooks.has(key)) {
|
|
70
|
+
beforeHooks.set(key, []);
|
|
71
|
+
}
|
|
72
|
+
const hooks = beforeHooks.get(key);
|
|
73
|
+
if (hooks) hooks.push(handler);
|
|
74
|
+
|
|
75
|
+
// Return unsubscribe function
|
|
76
|
+
return () => {
|
|
77
|
+
const hooks = beforeHooks.get(key);
|
|
78
|
+
if (hooks) {
|
|
79
|
+
const index = hooks.indexOf(handler);
|
|
80
|
+
if (index > -1) hooks.splice(index, 1);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Register an after hook middleware that runs after the operation completes.
|
|
87
|
+
* After hooks cannot halt operations (they run after completion).
|
|
88
|
+
*
|
|
89
|
+
* @param operation - Operation name
|
|
90
|
+
* @param model - Model name
|
|
91
|
+
* @param handler - Middleware function (context) => void
|
|
92
|
+
* @returns Unsubscribe function
|
|
93
|
+
*/
|
|
94
|
+
export function afterHook(operation: string, model: string, handler: HookHandler): () => void {
|
|
95
|
+
const key = `${operation}:${model}`;
|
|
96
|
+
if (!afterHooks.has(key)) {
|
|
97
|
+
afterHooks.set(key, []);
|
|
98
|
+
}
|
|
99
|
+
const hooks = afterHooks.get(key);
|
|
100
|
+
if (hooks) hooks.push(handler);
|
|
101
|
+
|
|
102
|
+
// Return unsubscribe function
|
|
103
|
+
return () => {
|
|
104
|
+
const hooks = afterHooks.get(key);
|
|
105
|
+
if (hooks) {
|
|
106
|
+
const index = hooks.indexOf(handler);
|
|
107
|
+
if (index > -1) hooks.splice(index, 1);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get all before hooks for an operation:model combination.
|
|
114
|
+
*/
|
|
115
|
+
export function getBeforeHooks(operation: string, model: string): HookHandler[] {
|
|
116
|
+
const key = `${operation}:${model}`;
|
|
117
|
+
return beforeHooks.get(key) || [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get all after hooks for an operation:model combination.
|
|
122
|
+
*/
|
|
123
|
+
export function getAfterHooks(operation: string, model: string): HookHandler[] {
|
|
124
|
+
const key = `${operation}:${model}`;
|
|
125
|
+
return afterHooks.get(key) || [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Clear registered hooks for a specific operation:model.
|
|
130
|
+
*
|
|
131
|
+
* @param operation - Operation name
|
|
132
|
+
* @param model - Model name
|
|
133
|
+
* @param type - 'before' or 'after' (if omitted, clears both)
|
|
134
|
+
*/
|
|
135
|
+
export function clearHook(operation: string, model: string, type?: 'before' | 'after'): void {
|
|
136
|
+
const key = `${operation}:${model}`;
|
|
137
|
+
if (!type || type === 'before') {
|
|
138
|
+
beforeHooks.set(key, []);
|
|
139
|
+
}
|
|
140
|
+
if (!type || type === 'after') {
|
|
141
|
+
afterHooks.set(key, []);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Clear all hooks (useful for testing).
|
|
147
|
+
*/
|
|
148
|
+
export function clearAllHooks(): void {
|
|
149
|
+
beforeHooks.clear();
|
|
150
|
+
afterHooks.clear();
|
|
151
|
+
}
|
|
@@ -15,14 +15,24 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import Model from './model.js';
|
|
18
|
+
import View from './view.js';
|
|
18
19
|
import Serializer from './serializer.js';
|
|
19
20
|
|
|
20
21
|
import attr from './attr.js';
|
|
21
22
|
import belongsTo from './belongs-to.js';
|
|
22
23
|
import hasMany from './has-many.js';
|
|
23
24
|
import { createRecord, updateRecord } from './manage-record.js';
|
|
25
|
+
import { count, avg, sum, min, max } from './aggregates.js';
|
|
24
26
|
|
|
25
27
|
export { default } from './main.js';
|
|
26
28
|
export { store, relationships } from './main.js';
|
|
27
|
-
export { Model, Serializer }; // base classes
|
|
28
|
-
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
29
|
+
export { Model, View, Serializer }; // base classes
|
|
30
|
+
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
31
|
+
export { count, avg, sum, min, max }; // aggregate helpers
|
|
32
|
+
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
|
|
33
|
+
|
|
34
|
+
// Store API:
|
|
35
|
+
// store.get(model, id) -- sync, memory-only
|
|
36
|
+
// store.find(model, id) -- async, MySQL for memory:false models
|
|
37
|
+
// store.findAll(model) -- async, all records
|
|
38
|
+
// store.query(model, conditions) -- async, always hits MySQL
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 Stone Costa
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import DB from './db.js';
|
|
18
|
+
import config from 'stonyx/config';
|
|
19
|
+
import log from 'stonyx/log';
|
|
20
|
+
import { forEachFileImport } from '@stonyx/utils/file';
|
|
21
|
+
import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
|
|
22
|
+
import { registerPluralName } from './plural-registry.js';
|
|
23
|
+
import setupRestServer from './setup-rest-server.js';
|
|
24
|
+
import baseTransforms from './transforms.js';
|
|
25
|
+
import Store from './store.js';
|
|
26
|
+
import Serializer from './serializer.js';
|
|
27
|
+
import { setup } from '@stonyx/events';
|
|
28
|
+
|
|
29
|
+
interface OrmOptions {
|
|
30
|
+
dbType?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SqlDb {
|
|
34
|
+
init(): Promise<unknown>;
|
|
35
|
+
startup(): Promise<void>;
|
|
36
|
+
shutdown(): Promise<void>;
|
|
37
|
+
persist(operation: string, model: string, context: unknown, response: unknown): Promise<void>;
|
|
38
|
+
findRecord(modelName: string, id: unknown): Promise<unknown>;
|
|
39
|
+
findAll(modelName: string, conditions?: Record<string, unknown>): Promise<unknown[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OrmDB {
|
|
43
|
+
record: unknown;
|
|
44
|
+
save(): Promise<void>;
|
|
45
|
+
init(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const defaultOptions: OrmOptions = {
|
|
49
|
+
dbType: 'json'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default class Orm {
|
|
53
|
+
static initialized: boolean = false;
|
|
54
|
+
static relationships: Map<string, Map<string, unknown>> = new Map();
|
|
55
|
+
static store: Store = new Store();
|
|
56
|
+
static instance: Orm;
|
|
57
|
+
static ready: unknown[];
|
|
58
|
+
|
|
59
|
+
models: Record<string, unknown> = {};
|
|
60
|
+
serializers: Record<string, unknown> = {};
|
|
61
|
+
views: Record<string, unknown> = {};
|
|
62
|
+
transforms: Record<string, (value: unknown) => unknown> = { ...baseTransforms };
|
|
63
|
+
warnings: Set<string> = new Set();
|
|
64
|
+
options!: OrmOptions;
|
|
65
|
+
sqlDb?: SqlDb;
|
|
66
|
+
db?: OrmDB | SqlDb;
|
|
67
|
+
|
|
68
|
+
constructor(options: OrmOptions = {}) {
|
|
69
|
+
if (Orm.instance) return Orm.instance;
|
|
70
|
+
|
|
71
|
+
const { relationships } = Orm;
|
|
72
|
+
|
|
73
|
+
// Declare relationship maps
|
|
74
|
+
for (const key of ['hasMany', 'belongsTo', 'global', 'pending', 'pendingBelongsTo']) {
|
|
75
|
+
relationships.set(key, new Map());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.options = { ...defaultOptions, ...options };
|
|
79
|
+
|
|
80
|
+
Orm.instance = this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async init(): Promise<void> {
|
|
84
|
+
const { paths, restServer } = config.orm;
|
|
85
|
+
|
|
86
|
+
const promises: Promise<unknown>[] = ['Model', 'Serializer', 'Transform'].map(type => {
|
|
87
|
+
const lowerCaseType = type.toLowerCase();
|
|
88
|
+
const path = paths[lowerCaseType];
|
|
89
|
+
|
|
90
|
+
if (!path) throw new Error(`Configuration Error: ORM path for "${type}" must be defined.`);
|
|
91
|
+
|
|
92
|
+
return forEachFileImport(path, (exported: unknown, { name }: { name: string }) => {
|
|
93
|
+
// Transforms keep their original name, everything else gets converted to PascalCase with the type suffix
|
|
94
|
+
const alias = type === 'Transform' ? name : `${kebabCaseToPascalCase(name)}${type}`;
|
|
95
|
+
|
|
96
|
+
if (type === 'Model') {
|
|
97
|
+
Orm.store.set(name, new Map());
|
|
98
|
+
registerPluralName(name, exported as { pluralName?: string });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const collection = this[pluralize(lowerCaseType) as keyof this] as Record<string, unknown>;
|
|
102
|
+
return collection[alias] = exported;
|
|
103
|
+
}, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Wait for imports before db & rest server setup
|
|
107
|
+
await Promise.all(promises);
|
|
108
|
+
|
|
109
|
+
// Discover views from paths.view (separate from model/serializer/transform)
|
|
110
|
+
if (paths.view) {
|
|
111
|
+
await forEachFileImport(paths.view, (exported: unknown, { name }: { name: string }) => {
|
|
112
|
+
const alias = `${kebabCaseToPascalCase(name)}View`;
|
|
113
|
+
Orm.store.set(name, new Map());
|
|
114
|
+
registerPluralName(name, exported as { pluralName?: string });
|
|
115
|
+
this.views[alias] = exported;
|
|
116
|
+
}, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Setup event names for hooks after models are loaded
|
|
120
|
+
const eventNames: string[] = [];
|
|
121
|
+
const operations = ['list', 'get', 'create', 'update', 'delete'];
|
|
122
|
+
const viewOperations = ['list', 'get'];
|
|
123
|
+
const timings = ['before', 'after'];
|
|
124
|
+
|
|
125
|
+
for (const modelName of Orm.store.data.keys()) {
|
|
126
|
+
const isView = this.isView(modelName);
|
|
127
|
+
const ops = isView ? viewOperations : operations;
|
|
128
|
+
|
|
129
|
+
for (const timing of timings) {
|
|
130
|
+
for (const operation of ops) {
|
|
131
|
+
eventNames.push(`${timing}:${operation}:${modelName}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setup(eventNames);
|
|
137
|
+
|
|
138
|
+
if (config.orm.timescale) {
|
|
139
|
+
const { default: TimescaleDB } = await import('./timescale/timescale-db.js');
|
|
140
|
+
this.sqlDb = new TimescaleDB() as SqlDb;
|
|
141
|
+
this.db = this.sqlDb;
|
|
142
|
+
promises.push(this.sqlDb.init());
|
|
143
|
+
} else if (config.orm.postgres) {
|
|
144
|
+
const { default: PostgresDB } = await import('./postgres/postgres-db.js');
|
|
145
|
+
this.sqlDb = new PostgresDB() as SqlDb;
|
|
146
|
+
this.db = this.sqlDb;
|
|
147
|
+
promises.push(this.sqlDb.init());
|
|
148
|
+
} else if (config.orm.mysql) {
|
|
149
|
+
const { default: MysqlDB } = await import('./mysql/mysql-db.js');
|
|
150
|
+
this.sqlDb = new MysqlDB() as SqlDb;
|
|
151
|
+
this.db = this.sqlDb;
|
|
152
|
+
promises.push(this.sqlDb.init());
|
|
153
|
+
} else if (this.options.dbType !== 'none') {
|
|
154
|
+
const db = new DB();
|
|
155
|
+
this.db = db;
|
|
156
|
+
|
|
157
|
+
promises.push(db.init());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (restServer.enabled === 'true') {
|
|
161
|
+
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Wire up memory resolver so store.find() can check model memory flags
|
|
165
|
+
Orm.store._memoryResolver = (modelName: string): boolean => {
|
|
166
|
+
const { modelClass } = this.getRecordClasses(modelName);
|
|
167
|
+
return (modelClass as { memory?: boolean })?.memory === true;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Wire up SQL adapter reference for on-demand queries from store.find()/findAll()
|
|
171
|
+
if (this.sqlDb) {
|
|
172
|
+
Orm.store._sqlDb = this.sqlDb;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
Orm.ready = await Promise.all(promises);
|
|
176
|
+
Orm.initialized = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async startup(): Promise<void> {
|
|
180
|
+
if (this.sqlDb) await this.sqlDb.startup();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async shutdown(): Promise<void> {
|
|
184
|
+
if (this.sqlDb) await this.sqlDb.shutdown();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
static get db(): OrmDB | SqlDb {
|
|
188
|
+
if (!Orm.initialized) throw new Error('ORM has not been initialized yet');
|
|
189
|
+
|
|
190
|
+
if (!Orm.instance.db) throw new Error('ORM database has not been initialized');
|
|
191
|
+
return Orm.instance.db;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
getRecordClasses(modelName: string): { modelClass: unknown; serializerClass: unknown } {
|
|
195
|
+
const modelClassPrefix = kebabCaseToPascalCase(modelName);
|
|
196
|
+
|
|
197
|
+
// Check views first, then models
|
|
198
|
+
const viewClass = this.views[`${modelClassPrefix}View`];
|
|
199
|
+
if (viewClass) {
|
|
200
|
+
return {
|
|
201
|
+
modelClass: viewClass,
|
|
202
|
+
serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
modelClass: this.models[`${modelClassPrefix}Model`],
|
|
208
|
+
serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
isView(modelName: string): boolean {
|
|
213
|
+
const modelClassPrefix = kebabCaseToPascalCase(modelName);
|
|
214
|
+
return !!this.views[`${modelClassPrefix}View`];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Queue warnings to avoid the same error from being logged in the same iteration
|
|
218
|
+
warn(message: string): void {
|
|
219
|
+
this.warnings.add(message);
|
|
220
|
+
|
|
221
|
+
setTimeout(() => {
|
|
222
|
+
this.warnings.forEach(warning => log.warn?.(warning));
|
|
223
|
+
this.warnings.clear();
|
|
224
|
+
}, 0);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const store = Orm.store;
|
|
229
|
+
export const relationships = Orm.relationships;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import Orm, { store } from '@stonyx/orm';
|
|
2
|
+
import OrmRecord from './record.js';
|
|
3
|
+
import { getGlobalRegistry, getPendingRegistry, getPendingBelongsToRegistry, getBelongsToRegistry, getHasManyRegistry } from './relationships.js';
|
|
4
|
+
import type Serializer from './serializer.js';
|
|
5
|
+
import { isOrmRecord } from './utils.js';
|
|
6
|
+
|
|
7
|
+
interface CreateRecordOptions {
|
|
8
|
+
isDbRecord?: boolean;
|
|
9
|
+
serialize?: boolean;
|
|
10
|
+
transform?: boolean;
|
|
11
|
+
update?: boolean;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PendingBelongsToEntry {
|
|
16
|
+
sourceRecord: OrmRecord;
|
|
17
|
+
sourceModelName: string;
|
|
18
|
+
relationshipKey: string;
|
|
19
|
+
relationshipId: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaultOptions: CreateRecordOptions = {
|
|
23
|
+
isDbRecord: false,
|
|
24
|
+
serialize: true,
|
|
25
|
+
transform: true
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function createRecord(modelName: string, rawData: { [key: string]: unknown } = {}, userOptions: CreateRecordOptions = {}): OrmRecord {
|
|
29
|
+
const orm = Orm.instance;
|
|
30
|
+
const { initialized } = Orm;
|
|
31
|
+
const options = { ...defaultOptions, ...userOptions };
|
|
32
|
+
|
|
33
|
+
if (!initialized && !options.isDbRecord) throw new Error('ORM is not ready');
|
|
34
|
+
|
|
35
|
+
// Guard: read-only views cannot have records created directly
|
|
36
|
+
if (orm?.isView?.(modelName) && !options.isDbRecord) {
|
|
37
|
+
throw new Error(`Cannot create records for read-only view '${modelName}'`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const modelStore = store.get(modelName);
|
|
41
|
+
const globalRelationships = getGlobalRegistry();
|
|
42
|
+
const pendingRelationships = getPendingRegistry();
|
|
43
|
+
|
|
44
|
+
if (!modelStore) throw new Error(`Model store for '${modelName}' is not registered. Ensure the model is defined before creating records.`);
|
|
45
|
+
|
|
46
|
+
assignRecordId(modelName, rawData);
|
|
47
|
+
const existingRecord = modelStore.get(rawData.id as number | string);
|
|
48
|
+
|
|
49
|
+
if (existingRecord instanceof OrmRecord) {
|
|
50
|
+
// Update the existing record with new data so the last entry wins
|
|
51
|
+
updateRecord(existingRecord, rawData, { ...options, update: true });
|
|
52
|
+
return existingRecord;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const recordClasses = orm.getRecordClasses(modelName);
|
|
56
|
+
const modelClass = recordClasses.modelClass as (new (name: string) => { __name: string; [key: string]: unknown }) | undefined;
|
|
57
|
+
const serializerClass = recordClasses.serializerClass as new (model: { [key: string]: unknown }) => Serializer;
|
|
58
|
+
|
|
59
|
+
if (!modelClass) throw new Error(`A model named '${modelName}' does not exist`);
|
|
60
|
+
|
|
61
|
+
const model = new modelClass(modelName);
|
|
62
|
+
const serializer = new serializerClass(model);
|
|
63
|
+
const record = new OrmRecord(model, serializer);
|
|
64
|
+
|
|
65
|
+
record.serialize(rawData, options);
|
|
66
|
+
modelStore.set(record.id as number | string, record);
|
|
67
|
+
|
|
68
|
+
// populate global hasMany relationships
|
|
69
|
+
const globalHasMany = globalRelationships.get(modelName);
|
|
70
|
+
if (globalHasMany) for (const relationship of globalHasMany) relationship.push(record);
|
|
71
|
+
|
|
72
|
+
// populate pending hasMany relationships and clear the queue
|
|
73
|
+
const pendingHasMany = pendingRelationships.get(modelName)?.get(record.id);
|
|
74
|
+
if (pendingHasMany) {
|
|
75
|
+
for (const relationship of pendingHasMany) relationship.push(record);
|
|
76
|
+
pendingHasMany.splice(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fulfill pending belongsTo relationships
|
|
80
|
+
const pendingBelongsToQueue = getPendingBelongsToRegistry();
|
|
81
|
+
const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
|
|
82
|
+
const pendingBelongsTo = Array.isArray(pendingBelongsToRaw) ? pendingBelongsToRaw as PendingBelongsToEntry[] : undefined;
|
|
83
|
+
|
|
84
|
+
if (pendingBelongsTo) {
|
|
85
|
+
const belongsToReg = getBelongsToRegistry();
|
|
86
|
+
const hasManyReg = getHasManyRegistry();
|
|
87
|
+
|
|
88
|
+
for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
|
|
89
|
+
// Update the belongsTo relationship on the source record
|
|
90
|
+
sourceRecord.__relationships[relationshipKey] = record;
|
|
91
|
+
sourceRecord[relationshipKey] = record; // Also update the direct property
|
|
92
|
+
|
|
93
|
+
// Update the belongsTo relationship registry
|
|
94
|
+
const sourceModelReg = belongsToReg.get(sourceModelName);
|
|
95
|
+
if (sourceModelReg) {
|
|
96
|
+
const targetModelReg = sourceModelReg.get(modelName);
|
|
97
|
+
if (targetModelReg) {
|
|
98
|
+
targetModelReg.set(relationshipId, record);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Wire inverse hasMany if it exists
|
|
103
|
+
const inverseHasMany = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
|
|
104
|
+
|
|
105
|
+
if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
|
|
106
|
+
inverseHasMany.push(sourceRecord);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Clear the pending queue
|
|
111
|
+
pendingBelongsTo.length = 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return record;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: CreateRecordOptions = {}): void {
|
|
118
|
+
if (!rawData) throw new Error('rawData must be passed in to updateRecord call');
|
|
119
|
+
|
|
120
|
+
// Guard: read-only views cannot be updated
|
|
121
|
+
const modelName = record?.__model?.__name;
|
|
122
|
+
if (modelName && Orm.instance?.isView?.(modelName)) {
|
|
123
|
+
throw new Error(`Cannot update records for read-only view '${modelName}'`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const options = { ...defaultOptions, ...userOptions, update: true };
|
|
127
|
+
|
|
128
|
+
record.serialize(rawData, options);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* gets the next available id based on last record entry.
|
|
133
|
+
*
|
|
134
|
+
* In MySQL mode with numeric IDs, assigns a temporary pending ID.
|
|
135
|
+
* MySQL's AUTO_INCREMENT provides the real ID after INSERT.
|
|
136
|
+
*/
|
|
137
|
+
function assignRecordId(modelName: string, rawData: { [key: string]: unknown }): void {
|
|
138
|
+
if (rawData.id) return;
|
|
139
|
+
|
|
140
|
+
// In SQL mode with numeric IDs, defer to database auto-increment
|
|
141
|
+
if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
|
|
142
|
+
rawData.id = `__pending_${Date.now()}_${Math.random()}`;
|
|
143
|
+
rawData.__pendingSqlId = true;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const storeMap = store.get(modelName);
|
|
148
|
+
if (!storeMap) throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
|
|
149
|
+
const modelStore = Array.from(storeMap.values()).filter(isOrmRecord);
|
|
150
|
+
const lastRecord = modelStore.at(-1);
|
|
151
|
+
rawData.id = lastRecord ? (lastRecord.id as number) + 1 : 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isStringIdModel(modelName: string): boolean {
|
|
155
|
+
const modelClass = Orm.instance.getRecordClasses(modelName).modelClass as (new (name: string) => { [key: string]: unknown }) | undefined;
|
|
156
|
+
if (!modelClass) return false;
|
|
157
|
+
|
|
158
|
+
const model = new modelClass(modelName);
|
|
159
|
+
|
|
160
|
+
return (model.id as { type?: string } | undefined)?.type === 'string';
|
|
161
|
+
}
|