@stonyx/orm 0.2.1-beta.90 → 0.2.1-beta.92
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/dist/hooks.d.ts +13 -0
- package/dist/mysql/schema-introspector.d.ts +1 -1
- package/dist/mysql/schema-introspector.js +2 -36
- package/dist/orm-request.js +1 -0
- package/dist/postgres/migration-generator.js +19 -0
- package/dist/postgres/schema-introspector.d.ts +3 -3
- package/dist/postgres/schema-introspector.js +20 -38
- package/dist/schema-helpers.d.ts +20 -0
- package/dist/schema-helpers.js +48 -0
- package/dist/store.js +3 -3
- package/dist/types/orm-types.d.ts +11 -0
- package/package.json +1 -1
- package/src/hooks.ts +13 -0
- package/src/mysql/schema-introspector.ts +2 -50
- package/src/orm-request.ts +1 -0
- package/src/postgres/migration-generator.ts +22 -0
- package/src/postgres/schema-introspector.ts +22 -48
- package/src/schema-helpers.ts +59 -0
- package/src/store.ts +3 -3
- package/src/types/orm-types.ts +12 -0
package/dist/hooks.d.ts
CHANGED
|
@@ -2,18 +2,31 @@
|
|
|
2
2
|
* Middleware-based hooks registry for ORM operations.
|
|
3
3
|
* Unlike event-based hooks, middleware hooks run sequentially and can halt operations.
|
|
4
4
|
*/
|
|
5
|
+
/** Context object passed to before/after hook handlers. */
|
|
5
6
|
export interface HookContext {
|
|
7
|
+
/** Model name (e.g. 'user', 'animal'). */
|
|
6
8
|
model: string;
|
|
9
|
+
/** Operation name: 'create', 'update', 'delete', 'get', or 'list'. */
|
|
7
10
|
operation: string;
|
|
11
|
+
/** The incoming HTTP request object. */
|
|
8
12
|
request?: unknown;
|
|
13
|
+
/** URL route parameters (e.g. { id: '42' }). */
|
|
9
14
|
params?: Record<string, string>;
|
|
15
|
+
/** Parsed request body for create/update operations. */
|
|
10
16
|
body?: Record<string, unknown>;
|
|
17
|
+
/** URL query string parameters. */
|
|
11
18
|
query?: Record<string, string>;
|
|
19
|
+
/** Mutable state bag shared across hooks within a single request. */
|
|
12
20
|
state?: Record<string, unknown>;
|
|
21
|
+
/** Previous record state (available in update hooks). */
|
|
13
22
|
oldState?: unknown;
|
|
23
|
+
/** Target record ID for single-record operations. */
|
|
14
24
|
recordId?: string | number;
|
|
25
|
+
/** Response data (available in after hooks). */
|
|
15
26
|
response?: unknown;
|
|
27
|
+
/** The affected record (available in after hooks for create/update/delete). */
|
|
16
28
|
record?: unknown;
|
|
29
|
+
/** The affected records (available in after hooks for list operations). */
|
|
17
30
|
records?: unknown[];
|
|
18
31
|
[key: string]: unknown;
|
|
19
32
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ForeignKeyDef, ModelSchema, ViewSchema, SnapshotEntry } from '../types/orm-types.js';
|
|
2
2
|
export declare function introspectModels(): Record<string, ModelSchema>;
|
|
3
3
|
export declare function buildTableDDL(name: string, schema: ModelSchema, allSchemas?: Record<string, ModelSchema>): string;
|
|
4
|
-
export
|
|
4
|
+
export { getTopologicalOrder } from '../schema-helpers.js';
|
|
5
5
|
export declare function introspectViews(): Record<string, ViewSchema>;
|
|
6
6
|
export declare function buildViewDDL(name: string, viewSchema: ViewSchema, modelSchemas?: Record<string, ModelSchema>): string;
|
|
7
7
|
export declare function viewSchemasToSnapshot(viewSchemas: Record<string, ViewSchema>): Record<string, ViewSnapshotEntry>;
|
|
@@ -4,21 +4,8 @@ import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
|
4
4
|
import { getPluralName } from '../plural-registry.js';
|
|
5
5
|
import { dbKey } from '../db.js';
|
|
6
6
|
import { AggregateProperty } from '../aggregates.js';
|
|
7
|
+
import { getRelationshipInfo, sanitizeTableName } from '../schema-helpers.js';
|
|
7
8
|
import ModelProperty from '../model-property.js';
|
|
8
|
-
function getRelationshipInfo(property) {
|
|
9
|
-
if (typeof property !== 'function')
|
|
10
|
-
return null;
|
|
11
|
-
const relType = property.__relationshipType;
|
|
12
|
-
const modelName = property.__relatedModelName || null;
|
|
13
|
-
if (relType === 'belongsTo')
|
|
14
|
-
return { type: 'belongsTo', modelName };
|
|
15
|
-
if (relType === 'hasMany')
|
|
16
|
-
return { type: 'hasMany', modelName };
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
function sanitizeTableName(name) {
|
|
20
|
-
return name.replace(/[-/]/g, '_');
|
|
21
|
-
}
|
|
22
9
|
export function introspectModels() {
|
|
23
10
|
const { models } = Orm.instance;
|
|
24
11
|
const schemas = {};
|
|
@@ -112,28 +99,7 @@ function getReferencedIdType(tableName, allSchemas) {
|
|
|
112
99
|
// Default to INT if referenced table not found in schemas
|
|
113
100
|
return 'INT';
|
|
114
101
|
}
|
|
115
|
-
export
|
|
116
|
-
const visited = new Set();
|
|
117
|
-
const order = [];
|
|
118
|
-
function visit(name) {
|
|
119
|
-
if (visited.has(name))
|
|
120
|
-
return;
|
|
121
|
-
visited.add(name);
|
|
122
|
-
const schema = schemas[name];
|
|
123
|
-
if (!schema)
|
|
124
|
-
return;
|
|
125
|
-
// Visit dependencies (belongsTo targets) first
|
|
126
|
-
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
127
|
-
if (targetModelName)
|
|
128
|
-
visit(targetModelName);
|
|
129
|
-
}
|
|
130
|
-
order.push(name);
|
|
131
|
-
}
|
|
132
|
-
for (const name of Object.keys(schemas)) {
|
|
133
|
-
visit(name);
|
|
134
|
-
}
|
|
135
|
-
return order;
|
|
136
|
-
}
|
|
102
|
+
export { getTopologicalOrder } from '../schema-helpers.js';
|
|
137
103
|
export function introspectViews() {
|
|
138
104
|
const orm = Orm.instance;
|
|
139
105
|
if (!orm.views)
|
package/dist/orm-request.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { introspectModels, introspectViews, buildTableDDL, buildViewDDL, buildVectorIndexDDL, schemasToSnapshot, viewSchemasToSnapshot, getTopologicalOrder } from './schema-introspector.js';
|
|
2
|
+
import { buildCreateHypertable, buildEnableCompression, buildCompressionPolicy } from '../timescale/query-builder.js';
|
|
2
3
|
import { readFile, createFile, createDirectory, fileExists } from '@stonyx/utils/file';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import config from 'stonyx/config';
|
|
@@ -41,6 +42,24 @@ export async function generateMigration(description = 'migration', configKey = '
|
|
|
41
42
|
}
|
|
42
43
|
downStatements.unshift(`DROP TABLE IF EXISTS "${schemas[name].table}" CASCADE;`);
|
|
43
44
|
}
|
|
45
|
+
// Hypertable conversion + compression (TimescaleDB only)
|
|
46
|
+
if (configKey === 'timescale') {
|
|
47
|
+
for (const name of addedOrdered) {
|
|
48
|
+
const schema = schemas[name];
|
|
49
|
+
if (!schema.hypertable)
|
|
50
|
+
continue;
|
|
51
|
+
const { timeColumn, chunkInterval } = schema.hypertable;
|
|
52
|
+
upStatements.push(buildCreateHypertable(schema.table, timeColumn, { chunkInterval }).sql + ';');
|
|
53
|
+
if (schema.hypertable.compress) {
|
|
54
|
+
const { segmentBy, orderBy, after } = schema.hypertable.compress;
|
|
55
|
+
upStatements.push(buildEnableCompression(schema.table, segmentBy, orderBy).sql + ';');
|
|
56
|
+
if (after) {
|
|
57
|
+
upStatements.push(buildCompressionPolicy(schema.table, after).sql + ';');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
downStatements.unshift('-- Hypertable conversion is not reversible; table drop handles cleanup');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
44
63
|
// Removed tables (warn only, commented out)
|
|
45
64
|
for (const name of diff.removedModels) {
|
|
46
65
|
upStatements.push(`-- WARNING: Model '${name}' was removed. Uncomment to drop table:`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ForeignKeyDef, ModelSchema, ViewSchema } from '../types/orm-types.js';
|
|
1
|
+
import type { ForeignKeyDef, HypertableConfig, ModelSchema, ViewSchema } from '../types/orm-types.js';
|
|
2
2
|
interface ViewSnapshotEntry {
|
|
3
3
|
viewName: string;
|
|
4
4
|
source: string;
|
|
@@ -14,6 +14,7 @@ interface ModelSnapshotEntry {
|
|
|
14
14
|
columns: Record<string, string>;
|
|
15
15
|
foreignKeys: Record<string, ForeignKeyDef>;
|
|
16
16
|
vectorColumns?: Record<string, number>;
|
|
17
|
+
hypertable?: HypertableConfig;
|
|
17
18
|
}
|
|
18
19
|
export declare function introspectModels(): Record<string, ModelSchema>;
|
|
19
20
|
export declare function buildTableDDL(name: string, schema: ModelSchema, allSchemas?: Record<string, ModelSchema>): string;
|
|
@@ -21,9 +22,8 @@ export declare function buildTableDDL(name: string, schema: ModelSchema, allSche
|
|
|
21
22
|
* Build HNSW index DDL for vector columns on a model.
|
|
22
23
|
*/
|
|
23
24
|
export declare function buildVectorIndexDDL(name: string, schema: ModelSchema): string[];
|
|
24
|
-
export
|
|
25
|
+
export { getTopologicalOrder } from '../schema-helpers.js';
|
|
25
26
|
export declare function introspectViews(): Record<string, ViewSchema>;
|
|
26
27
|
export declare function buildViewDDL(name: string, viewSchema: ViewSchema, modelSchemas?: Record<string, ModelSchema>): string;
|
|
27
28
|
export declare function viewSchemasToSnapshot(viewSchemas: Record<string, ViewSchema>): Record<string, ViewSnapshotEntry>;
|
|
28
29
|
export declare function schemasToSnapshot(schemas: Record<string, ModelSchema>): Record<string, ModelSnapshotEntry>;
|
|
29
|
-
export {};
|
|
@@ -4,21 +4,8 @@ import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
|
4
4
|
import { getPluralName } from '../plural-registry.js';
|
|
5
5
|
import { dbKey } from '../db.js';
|
|
6
6
|
import { AggregateProperty } from '../aggregates.js';
|
|
7
|
+
import { getRelationshipInfo, sanitizeTableName } from '../schema-helpers.js';
|
|
7
8
|
import ModelProperty from '../model-property.js';
|
|
8
|
-
function getRelationshipInfo(property) {
|
|
9
|
-
if (typeof property !== 'function')
|
|
10
|
-
return null;
|
|
11
|
-
const relType = property.__relationshipType;
|
|
12
|
-
const modelName = property.__relatedModelName || null;
|
|
13
|
-
if (relType === 'belongsTo')
|
|
14
|
-
return { type: 'belongsTo', modelName };
|
|
15
|
-
if (relType === 'hasMany')
|
|
16
|
-
return { type: 'hasMany', modelName };
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
function sanitizeTableName(name) {
|
|
20
|
-
return name.replace(/[-/]/g, '_');
|
|
21
|
-
}
|
|
22
9
|
export function introspectModels() {
|
|
23
10
|
const { models } = Orm.instance;
|
|
24
11
|
const schemas = {};
|
|
@@ -68,6 +55,7 @@ export function introspectModels() {
|
|
|
68
55
|
column: 'id',
|
|
69
56
|
};
|
|
70
57
|
}
|
|
58
|
+
const hypertable = modelClass.hypertable;
|
|
71
59
|
schemas[name] = {
|
|
72
60
|
table: sanitizeTableName(getPluralName(name)),
|
|
73
61
|
idType,
|
|
@@ -75,19 +63,24 @@ export function introspectModels() {
|
|
|
75
63
|
foreignKeys,
|
|
76
64
|
relationships,
|
|
77
65
|
vectorColumns,
|
|
66
|
+
hypertable: hypertable || undefined,
|
|
78
67
|
memory: modelClass.memory === true,
|
|
79
68
|
};
|
|
80
69
|
}
|
|
81
70
|
return schemas;
|
|
82
71
|
}
|
|
83
72
|
export function buildTableDDL(name, schema, allSchemas = {}) {
|
|
84
|
-
const { idType, columns, foreignKeys } = schema;
|
|
73
|
+
const { idType, columns, foreignKeys, hypertable } = schema;
|
|
85
74
|
const table = sanitizeTableName(schema.table);
|
|
86
75
|
const lines = [];
|
|
76
|
+
const useCompositePK = hypertable && idType !== 'string';
|
|
87
77
|
// Primary key
|
|
88
78
|
if (idType === 'string') {
|
|
89
79
|
lines.push(' "id" VARCHAR(255) PRIMARY KEY');
|
|
90
80
|
}
|
|
81
|
+
else if (useCompositePK) {
|
|
82
|
+
lines.push(' "id" INTEGER GENERATED ALWAYS AS IDENTITY');
|
|
83
|
+
}
|
|
91
84
|
else {
|
|
92
85
|
lines.push(' "id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY');
|
|
93
86
|
}
|
|
@@ -101,13 +94,22 @@ export function buildTableDDL(name, schema, allSchemas = {}) {
|
|
|
101
94
|
lines.push(` "${fkCol}" ${refIdType}`);
|
|
102
95
|
}
|
|
103
96
|
// Timestamps
|
|
104
|
-
|
|
97
|
+
if (useCompositePK) {
|
|
98
|
+
lines.push(' "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
lines.push(' "created_at" TIMESTAMPTZ DEFAULT NOW()');
|
|
102
|
+
}
|
|
105
103
|
lines.push(' "updated_at" TIMESTAMPTZ DEFAULT NOW()');
|
|
106
104
|
// Foreign key constraints
|
|
107
105
|
for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
|
|
108
106
|
const refTable = sanitizeTableName(fkDef.references);
|
|
109
107
|
lines.push(` FOREIGN KEY ("${fkCol}") REFERENCES "${refTable}"("${fkDef.column}") ON DELETE SET NULL`);
|
|
110
108
|
}
|
|
109
|
+
// Composite primary key for hypertable models
|
|
110
|
+
if (useCompositePK) {
|
|
111
|
+
lines.push(` PRIMARY KEY ("id", "${hypertable.timeColumn}")`);
|
|
112
|
+
}
|
|
111
113
|
return `CREATE TABLE IF NOT EXISTS "${table}" (\n${lines.join(',\n')}\n)`;
|
|
112
114
|
}
|
|
113
115
|
/**
|
|
@@ -129,28 +131,7 @@ function getReferencedIdType(tableName, allSchemas) {
|
|
|
129
131
|
}
|
|
130
132
|
return 'INTEGER';
|
|
131
133
|
}
|
|
132
|
-
export
|
|
133
|
-
const visited = new Set();
|
|
134
|
-
const order = [];
|
|
135
|
-
function visit(name) {
|
|
136
|
-
if (visited.has(name))
|
|
137
|
-
return;
|
|
138
|
-
visited.add(name);
|
|
139
|
-
const schema = schemas[name];
|
|
140
|
-
if (!schema)
|
|
141
|
-
return;
|
|
142
|
-
// Visit dependencies (belongsTo targets) first
|
|
143
|
-
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
144
|
-
if (targetModelName)
|
|
145
|
-
visit(targetModelName);
|
|
146
|
-
}
|
|
147
|
-
order.push(name);
|
|
148
|
-
}
|
|
149
|
-
for (const name of Object.keys(schemas)) {
|
|
150
|
-
visit(name);
|
|
151
|
-
}
|
|
152
|
-
return order;
|
|
153
|
-
}
|
|
134
|
+
export { getTopologicalOrder } from '../schema-helpers.js';
|
|
154
135
|
export function introspectViews() {
|
|
155
136
|
const orm = Orm.instance;
|
|
156
137
|
if (!orm.views)
|
|
@@ -308,6 +289,7 @@ export function schemasToSnapshot(schemas) {
|
|
|
308
289
|
...(schema.vectorColumns && Object.keys(schema.vectorColumns).length > 0
|
|
309
290
|
? { vectorColumns: { ...schema.vectorColumns } }
|
|
310
291
|
: {}),
|
|
292
|
+
...(schema.hypertable ? { hypertable: schema.hypertable } : {}),
|
|
311
293
|
};
|
|
312
294
|
}
|
|
313
295
|
return snapshot;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ModelSchema } from './types/orm-types.js';
|
|
2
|
+
export interface SchemaRelationshipInfo {
|
|
3
|
+
type: 'belongsTo' | 'hasMany';
|
|
4
|
+
modelName: string | null;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Detect relationship type and target model from a model property function.
|
|
8
|
+
* Returns null if the property is not a relationship.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getRelationshipInfo(property: unknown): SchemaRelationshipInfo | null;
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize a model/table name for use in SQL identifiers.
|
|
13
|
+
* Replaces hyphens and slashes with underscores.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sanitizeTableName(name: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Sort model schemas in dependency order (belongsTo targets before dependents).
|
|
18
|
+
* Uses depth-first traversal to ensure referenced tables are created first.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getTopologicalOrder(schemas: Record<string, ModelSchema>): string[];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect relationship type and target model from a model property function.
|
|
3
|
+
* Returns null if the property is not a relationship.
|
|
4
|
+
*/
|
|
5
|
+
export function getRelationshipInfo(property) {
|
|
6
|
+
if (typeof property !== 'function')
|
|
7
|
+
return null;
|
|
8
|
+
const relType = property.__relationshipType;
|
|
9
|
+
const modelName = property.__relatedModelName || null;
|
|
10
|
+
if (relType === 'belongsTo')
|
|
11
|
+
return { type: 'belongsTo', modelName };
|
|
12
|
+
if (relType === 'hasMany')
|
|
13
|
+
return { type: 'hasMany', modelName };
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Sanitize a model/table name for use in SQL identifiers.
|
|
18
|
+
* Replaces hyphens and slashes with underscores.
|
|
19
|
+
*/
|
|
20
|
+
export function sanitizeTableName(name) {
|
|
21
|
+
return name.replace(/[-/]/g, '_');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Sort model schemas in dependency order (belongsTo targets before dependents).
|
|
25
|
+
* Uses depth-first traversal to ensure referenced tables are created first.
|
|
26
|
+
*/
|
|
27
|
+
export function getTopologicalOrder(schemas) {
|
|
28
|
+
const visited = new Set();
|
|
29
|
+
const order = [];
|
|
30
|
+
function visit(name) {
|
|
31
|
+
if (visited.has(name))
|
|
32
|
+
return;
|
|
33
|
+
visited.add(name);
|
|
34
|
+
const schema = schemas[name];
|
|
35
|
+
if (!schema)
|
|
36
|
+
return;
|
|
37
|
+
// Visit dependencies (belongsTo targets) first
|
|
38
|
+
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
39
|
+
if (targetModelName)
|
|
40
|
+
visit(targetModelName);
|
|
41
|
+
}
|
|
42
|
+
order.push(name);
|
|
43
|
+
}
|
|
44
|
+
for (const name of Object.keys(schemas)) {
|
|
45
|
+
visit(name);
|
|
46
|
+
}
|
|
47
|
+
return order;
|
|
48
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -119,14 +119,14 @@ export default class Store {
|
|
|
119
119
|
unloadRecord(model, id, options = {}) {
|
|
120
120
|
const modelStore = this.data.get(model);
|
|
121
121
|
if (!modelStore) {
|
|
122
|
-
console.warn(`[Store] Cannot unload record: model "${model}" not found in store`);
|
|
122
|
+
console.warn(`[Store] Cannot unload record: model "${model}" not found in store — ensure the model is registered before unloading`);
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
125
|
if (typeof id !== 'string' && typeof id !== 'number')
|
|
126
126
|
return;
|
|
127
127
|
const raw = modelStore.get(id);
|
|
128
128
|
if (!raw || !isStoreRecord(raw)) {
|
|
129
|
-
console.warn(`[Store] Cannot unload record: ${model}:${id} not found in store`);
|
|
129
|
+
console.warn(`[Store] Cannot unload record: ${model}:${id} not found in store — it may have already been unloaded`);
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
const record = raw;
|
|
@@ -145,7 +145,7 @@ export default class Store {
|
|
|
145
145
|
unloadAllRecords(model, options = {}) {
|
|
146
146
|
const modelStore = this.data.get(model);
|
|
147
147
|
if (!modelStore) {
|
|
148
|
-
console.warn(`[Store] Cannot unload all records: model "${model}" not found in store`);
|
|
148
|
+
console.warn(`[Store] Cannot unload all records: model "${model}" not found in store — ensure the model is registered before unloading`);
|
|
149
149
|
return;
|
|
150
150
|
}
|
|
151
151
|
const recordIds = Array.from(modelStore.keys());
|
|
@@ -86,6 +86,15 @@ export interface ForeignKeyDef {
|
|
|
86
86
|
references: string;
|
|
87
87
|
column: string;
|
|
88
88
|
}
|
|
89
|
+
export interface HypertableConfig {
|
|
90
|
+
timeColumn: string;
|
|
91
|
+
chunkInterval?: string;
|
|
92
|
+
compress?: {
|
|
93
|
+
segmentBy?: string;
|
|
94
|
+
orderBy?: string;
|
|
95
|
+
after?: string;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
89
98
|
export interface ModelSchema {
|
|
90
99
|
table: string;
|
|
91
100
|
idType: string;
|
|
@@ -96,6 +105,7 @@ export interface ModelSchema {
|
|
|
96
105
|
hasMany: Record<string, string | null>;
|
|
97
106
|
};
|
|
98
107
|
vectorColumns?: Record<string, number>;
|
|
108
|
+
hypertable?: HypertableConfig;
|
|
99
109
|
memory: boolean;
|
|
100
110
|
}
|
|
101
111
|
export interface ViewSchema {
|
|
@@ -135,6 +145,7 @@ export interface SnapshotEntry {
|
|
|
135
145
|
columns?: Record<string, string>;
|
|
136
146
|
foreignKeys?: Record<string, ForeignKeyDef>;
|
|
137
147
|
vectorColumns?: Record<string, number>;
|
|
148
|
+
hypertable?: HypertableConfig;
|
|
138
149
|
isView?: boolean;
|
|
139
150
|
viewName?: string;
|
|
140
151
|
source?: string;
|
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -19,18 +19,31 @@
|
|
|
19
19
|
* Unlike event-based hooks, middleware hooks run sequentially and can halt operations.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
/** Context object passed to before/after hook handlers. */
|
|
22
23
|
export interface HookContext {
|
|
24
|
+
/** Model name (e.g. 'user', 'animal'). */
|
|
23
25
|
model: string;
|
|
26
|
+
/** Operation name: 'create', 'update', 'delete', 'get', or 'list'. */
|
|
24
27
|
operation: string;
|
|
28
|
+
/** The incoming HTTP request object. */
|
|
25
29
|
request?: unknown;
|
|
30
|
+
/** URL route parameters (e.g. { id: '42' }). */
|
|
26
31
|
params?: Record<string, string>;
|
|
32
|
+
/** Parsed request body for create/update operations. */
|
|
27
33
|
body?: Record<string, unknown>;
|
|
34
|
+
/** URL query string parameters. */
|
|
28
35
|
query?: Record<string, string>;
|
|
36
|
+
/** Mutable state bag shared across hooks within a single request. */
|
|
29
37
|
state?: Record<string, unknown>;
|
|
38
|
+
/** Previous record state (available in update hooks). */
|
|
30
39
|
oldState?: unknown;
|
|
40
|
+
/** Target record ID for single-record operations. */
|
|
31
41
|
recordId?: string | number;
|
|
42
|
+
/** Response data (available in after hooks). */
|
|
32
43
|
response?: unknown;
|
|
44
|
+
/** The affected record (available in after hooks for create/update/delete). */
|
|
33
45
|
record?: unknown;
|
|
46
|
+
/** The affected records (available in after hooks for list operations). */
|
|
34
47
|
records?: unknown[];
|
|
35
48
|
[key: string]: unknown;
|
|
36
49
|
}
|
|
@@ -4,39 +4,15 @@ import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
|
4
4
|
import { getPluralName } from '../plural-registry.js';
|
|
5
5
|
import { dbKey } from '../db.js';
|
|
6
6
|
import { AggregateProperty } from '../aggregates.js';
|
|
7
|
+
import { getRelationshipInfo, sanitizeTableName } from '../schema-helpers.js';
|
|
7
8
|
import type { ForeignKeyDef, ModelSchema, ViewSchema, SnapshotEntry } from '../types/orm-types.js';
|
|
8
9
|
import ModelProperty from '../model-property.js';
|
|
9
10
|
|
|
10
|
-
interface RelationshipInfo {
|
|
11
|
-
type: 'belongsTo' | 'hasMany';
|
|
12
|
-
modelName: string | null;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
11
|
interface JoinClause {
|
|
16
12
|
table: string;
|
|
17
13
|
condition: string;
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
interface RelationshipProperty {
|
|
21
|
-
__relatedModelName?: string | null;
|
|
22
|
-
__relationshipType?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getRelationshipInfo(property: unknown): RelationshipInfo | null {
|
|
26
|
-
if (typeof property !== 'function') return null;
|
|
27
|
-
const relType = (property as RelationshipProperty).__relationshipType;
|
|
28
|
-
const modelName = (property as RelationshipProperty).__relatedModelName || null;
|
|
29
|
-
|
|
30
|
-
if (relType === 'belongsTo') return { type: 'belongsTo', modelName };
|
|
31
|
-
if (relType === 'hasMany') return { type: 'hasMany', modelName };
|
|
32
|
-
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function sanitizeTableName(name: string): string {
|
|
37
|
-
return name.replace(/[-/]/g, '_');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
16
|
export function introspectModels(): Record<string, ModelSchema> {
|
|
41
17
|
const { models } = (Orm as unknown as { instance: { models: Record<string, unknown>; transforms: Record<string, unknown> } }).instance;
|
|
42
18
|
const schemas: Record<string, ModelSchema> = {};
|
|
@@ -143,31 +119,7 @@ function getReferencedIdType(tableName: string, allSchemas: Record<string, Model
|
|
|
143
119
|
return 'INT';
|
|
144
120
|
}
|
|
145
121
|
|
|
146
|
-
export
|
|
147
|
-
const visited = new Set<string>();
|
|
148
|
-
const order: string[] = [];
|
|
149
|
-
|
|
150
|
-
function visit(name: string): void {
|
|
151
|
-
if (visited.has(name)) return;
|
|
152
|
-
visited.add(name);
|
|
153
|
-
|
|
154
|
-
const schema = schemas[name];
|
|
155
|
-
if (!schema) return;
|
|
156
|
-
|
|
157
|
-
// Visit dependencies (belongsTo targets) first
|
|
158
|
-
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
159
|
-
if (targetModelName) visit(targetModelName);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
order.push(name);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const name of Object.keys(schemas)) {
|
|
166
|
-
visit(name);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return order;
|
|
170
|
-
}
|
|
122
|
+
export { getTopologicalOrder } from '../schema-helpers.js';
|
|
171
123
|
|
|
172
124
|
export function introspectViews(): Record<string, ViewSchema> {
|
|
173
125
|
const orm = (Orm as unknown as { instance: { views?: Record<string, unknown>; transforms: Record<string, unknown> } }).instance;
|
package/src/orm-request.ts
CHANGED
|
@@ -577,5 +577,6 @@ export default class OrmRequest extends Request {
|
|
|
577
577
|
if (!access) return 403;
|
|
578
578
|
if (Array.isArray(access) && !access.includes(methodAccessMap[request.method])) return 403;
|
|
579
579
|
if (typeof access === 'function') state.filter = access;
|
|
580
|
+
return undefined;
|
|
580
581
|
}
|
|
581
582
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { introspectModels, introspectViews, buildTableDDL, buildViewDDL, buildVectorIndexDDL, schemasToSnapshot, viewSchemasToSnapshot, getTopologicalOrder } from './schema-introspector.js';
|
|
2
|
+
import { buildCreateHypertable, buildEnableCompression, buildCompressionPolicy } from '../timescale/query-builder.js';
|
|
2
3
|
import { readFile, createFile, createDirectory, fileExists } from '@stonyx/utils/file';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import config from 'stonyx/config';
|
|
@@ -97,6 +98,27 @@ export async function generateMigration(description: string = 'migration', confi
|
|
|
97
98
|
downStatements.unshift(`DROP TABLE IF EXISTS "${schemas[name].table}" CASCADE;`);
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
// Hypertable conversion + compression (TimescaleDB only)
|
|
102
|
+
if (configKey === 'timescale') {
|
|
103
|
+
for (const name of addedOrdered) {
|
|
104
|
+
const schema = schemas[name];
|
|
105
|
+
if (!schema.hypertable) continue;
|
|
106
|
+
|
|
107
|
+
const { timeColumn, chunkInterval } = schema.hypertable;
|
|
108
|
+
upStatements.push(buildCreateHypertable(schema.table, timeColumn, { chunkInterval }).sql + ';');
|
|
109
|
+
|
|
110
|
+
if (schema.hypertable.compress) {
|
|
111
|
+
const { segmentBy, orderBy, after } = schema.hypertable.compress;
|
|
112
|
+
upStatements.push(buildEnableCompression(schema.table, segmentBy, orderBy).sql + ';');
|
|
113
|
+
if (after) {
|
|
114
|
+
upStatements.push(buildCompressionPolicy(schema.table, after).sql + ';');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
downStatements.unshift('-- Hypertable conversion is not reversible; table drop handles cleanup');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
// Removed tables (warn only, commented out)
|
|
101
123
|
for (const name of diff.removedModels) {
|
|
102
124
|
upStatements.push(`-- WARNING: Model '${name}' was removed. Uncomment to drop table:`);
|
|
@@ -4,14 +4,10 @@ import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
|
4
4
|
import { getPluralName } from '../plural-registry.js';
|
|
5
5
|
import { dbKey } from '../db.js';
|
|
6
6
|
import { AggregateProperty } from '../aggregates.js';
|
|
7
|
-
import
|
|
7
|
+
import { getRelationshipInfo, sanitizeTableName } from '../schema-helpers.js';
|
|
8
|
+
import type { ForeignKeyDef, HypertableConfig, ModelSchema, ViewSchema } from '../types/orm-types.js';
|
|
8
9
|
import ModelProperty from '../model-property.js';
|
|
9
10
|
|
|
10
|
-
interface RelationshipInfo {
|
|
11
|
-
type: 'belongsTo' | 'hasMany';
|
|
12
|
-
modelName: string | null;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
11
|
interface ViewSnapshotEntry {
|
|
16
12
|
viewName: string;
|
|
17
13
|
source: string;
|
|
@@ -28,6 +24,7 @@ interface ModelSnapshotEntry {
|
|
|
28
24
|
columns: Record<string, string>;
|
|
29
25
|
foreignKeys: Record<string, ForeignKeyDef>;
|
|
30
26
|
vectorColumns?: Record<string, number>;
|
|
27
|
+
hypertable?: HypertableConfig;
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
interface JoinDef {
|
|
@@ -35,21 +32,6 @@ interface JoinDef {
|
|
|
35
32
|
condition: string;
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
function getRelationshipInfo(property: unknown): RelationshipInfo | null {
|
|
39
|
-
if (typeof property !== 'function') return null;
|
|
40
|
-
const relType = (property as { __relationshipType?: string }).__relationshipType;
|
|
41
|
-
const modelName = (property as { __relatedModelName?: string }).__relatedModelName || null;
|
|
42
|
-
|
|
43
|
-
if (relType === 'belongsTo') return { type: 'belongsTo', modelName };
|
|
44
|
-
if (relType === 'hasMany') return { type: 'hasMany', modelName };
|
|
45
|
-
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function sanitizeTableName(name: string): string {
|
|
50
|
-
return name.replace(/[-/]/g, '_');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
35
|
export function introspectModels(): Record<string, ModelSchema> {
|
|
54
36
|
const { models } = Orm.instance as { models: Record<string, unknown> };
|
|
55
37
|
const schemas: Record<string, ModelSchema> = {};
|
|
@@ -101,6 +83,8 @@ export function introspectModels(): Record<string, ModelSchema> {
|
|
|
101
83
|
};
|
|
102
84
|
}
|
|
103
85
|
|
|
86
|
+
const hypertable = (modelClass as { hypertable?: HypertableConfig }).hypertable;
|
|
87
|
+
|
|
104
88
|
schemas[name] = {
|
|
105
89
|
table: sanitizeTableName(getPluralName(name)),
|
|
106
90
|
idType,
|
|
@@ -108,6 +92,7 @@ export function introspectModels(): Record<string, ModelSchema> {
|
|
|
108
92
|
foreignKeys,
|
|
109
93
|
relationships,
|
|
110
94
|
vectorColumns,
|
|
95
|
+
hypertable: hypertable || undefined,
|
|
111
96
|
memory: (modelClass as { memory?: boolean }).memory === true,
|
|
112
97
|
};
|
|
113
98
|
}
|
|
@@ -116,13 +101,16 @@ export function introspectModels(): Record<string, ModelSchema> {
|
|
|
116
101
|
}
|
|
117
102
|
|
|
118
103
|
export function buildTableDDL(name: string, schema: ModelSchema, allSchemas: Record<string, ModelSchema> = {}): string {
|
|
119
|
-
const { idType, columns, foreignKeys } = schema;
|
|
104
|
+
const { idType, columns, foreignKeys, hypertable } = schema;
|
|
120
105
|
const table = sanitizeTableName(schema.table);
|
|
121
106
|
const lines: string[] = [];
|
|
107
|
+
const useCompositePK = hypertable && idType !== 'string';
|
|
122
108
|
|
|
123
109
|
// Primary key
|
|
124
110
|
if (idType === 'string') {
|
|
125
111
|
lines.push(' "id" VARCHAR(255) PRIMARY KEY');
|
|
112
|
+
} else if (useCompositePK) {
|
|
113
|
+
lines.push(' "id" INTEGER GENERATED ALWAYS AS IDENTITY');
|
|
126
114
|
} else {
|
|
127
115
|
lines.push(' "id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY');
|
|
128
116
|
}
|
|
@@ -139,7 +127,11 @@ export function buildTableDDL(name: string, schema: ModelSchema, allSchemas: Rec
|
|
|
139
127
|
}
|
|
140
128
|
|
|
141
129
|
// Timestamps
|
|
142
|
-
|
|
130
|
+
if (useCompositePK) {
|
|
131
|
+
lines.push(' "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
|
|
132
|
+
} else {
|
|
133
|
+
lines.push(' "created_at" TIMESTAMPTZ DEFAULT NOW()');
|
|
134
|
+
}
|
|
143
135
|
lines.push(' "updated_at" TIMESTAMPTZ DEFAULT NOW()');
|
|
144
136
|
|
|
145
137
|
// Foreign key constraints
|
|
@@ -148,6 +140,11 @@ export function buildTableDDL(name: string, schema: ModelSchema, allSchemas: Rec
|
|
|
148
140
|
lines.push(` FOREIGN KEY ("${fkCol}") REFERENCES "${refTable}"("${fkDef.column}") ON DELETE SET NULL`);
|
|
149
141
|
}
|
|
150
142
|
|
|
143
|
+
// Composite primary key for hypertable models
|
|
144
|
+
if (useCompositePK) {
|
|
145
|
+
lines.push(` PRIMARY KEY ("id", "${hypertable.timeColumn}")`);
|
|
146
|
+
}
|
|
147
|
+
|
|
151
148
|
return `CREATE TABLE IF NOT EXISTS "${table}" (\n${lines.join(',\n')}\n)`;
|
|
152
149
|
}
|
|
153
150
|
|
|
@@ -177,31 +174,7 @@ function getReferencedIdType(tableName: string, allSchemas: Record<string, Model
|
|
|
177
174
|
return 'INTEGER';
|
|
178
175
|
}
|
|
179
176
|
|
|
180
|
-
export
|
|
181
|
-
const visited = new Set<string>();
|
|
182
|
-
const order: string[] = [];
|
|
183
|
-
|
|
184
|
-
function visit(name: string): void {
|
|
185
|
-
if (visited.has(name)) return;
|
|
186
|
-
visited.add(name);
|
|
187
|
-
|
|
188
|
-
const schema = schemas[name];
|
|
189
|
-
if (!schema) return;
|
|
190
|
-
|
|
191
|
-
// Visit dependencies (belongsTo targets) first
|
|
192
|
-
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
193
|
-
if (targetModelName) visit(targetModelName);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
order.push(name);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
for (const name of Object.keys(schemas)) {
|
|
200
|
-
visit(name);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return order;
|
|
204
|
-
}
|
|
177
|
+
export { getTopologicalOrder } from '../schema-helpers.js';
|
|
205
178
|
|
|
206
179
|
export function introspectViews(): Record<string, ViewSchema> {
|
|
207
180
|
const orm = Orm.instance as { views?: Record<string, unknown> };
|
|
@@ -379,6 +352,7 @@ export function schemasToSnapshot(schemas: Record<string, ModelSchema>): Record<
|
|
|
379
352
|
...(schema.vectorColumns && Object.keys(schema.vectorColumns).length > 0
|
|
380
353
|
? { vectorColumns: { ...schema.vectorColumns } }
|
|
381
354
|
: {}),
|
|
355
|
+
...(schema.hypertable ? { hypertable: schema.hypertable } : {}),
|
|
382
356
|
};
|
|
383
357
|
}
|
|
384
358
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ModelSchema } from './types/orm-types.js';
|
|
2
|
+
|
|
3
|
+
export interface SchemaRelationshipInfo {
|
|
4
|
+
type: 'belongsTo' | 'hasMany';
|
|
5
|
+
modelName: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect relationship type and target model from a model property function.
|
|
10
|
+
* Returns null if the property is not a relationship.
|
|
11
|
+
*/
|
|
12
|
+
export function getRelationshipInfo(property: unknown): SchemaRelationshipInfo | null {
|
|
13
|
+
if (typeof property !== 'function') return null;
|
|
14
|
+
const relType = (property as { __relationshipType?: string }).__relationshipType;
|
|
15
|
+
const modelName = (property as { __relatedModelName?: string }).__relatedModelName || null;
|
|
16
|
+
|
|
17
|
+
if (relType === 'belongsTo') return { type: 'belongsTo', modelName };
|
|
18
|
+
if (relType === 'hasMany') return { type: 'hasMany', modelName };
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sanitize a model/table name for use in SQL identifiers.
|
|
25
|
+
* Replaces hyphens and slashes with underscores.
|
|
26
|
+
*/
|
|
27
|
+
export function sanitizeTableName(name: string): string {
|
|
28
|
+
return name.replace(/[-/]/g, '_');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sort model schemas in dependency order (belongsTo targets before dependents).
|
|
33
|
+
* Uses depth-first traversal to ensure referenced tables are created first.
|
|
34
|
+
*/
|
|
35
|
+
export function getTopologicalOrder(schemas: Record<string, ModelSchema>): string[] {
|
|
36
|
+
const visited = new Set<string>();
|
|
37
|
+
const order: string[] = [];
|
|
38
|
+
|
|
39
|
+
function visit(name: string): void {
|
|
40
|
+
if (visited.has(name)) return;
|
|
41
|
+
visited.add(name);
|
|
42
|
+
|
|
43
|
+
const schema = schemas[name];
|
|
44
|
+
if (!schema) return;
|
|
45
|
+
|
|
46
|
+
// Visit dependencies (belongsTo targets) first
|
|
47
|
+
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
48
|
+
if (targetModelName) visit(targetModelName);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
order.push(name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const name of Object.keys(schemas)) {
|
|
55
|
+
visit(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return order;
|
|
59
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -185,14 +185,14 @@ export default class Store {
|
|
|
185
185
|
const modelStore = this.data.get(model);
|
|
186
186
|
|
|
187
187
|
if (!modelStore) {
|
|
188
|
-
console.warn(`[Store] Cannot unload record: model "${model}" not found in store`);
|
|
188
|
+
console.warn(`[Store] Cannot unload record: model "${model}" not found in store — ensure the model is registered before unloading`);
|
|
189
189
|
return;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
if (typeof id !== 'string' && typeof id !== 'number') return;
|
|
193
193
|
const raw = modelStore.get(id);
|
|
194
194
|
if (!raw || !isStoreRecord(raw)) {
|
|
195
|
-
console.warn(`[Store] Cannot unload record: ${model}:${id} not found in store`);
|
|
195
|
+
console.warn(`[Store] Cannot unload record: ${model}:${id} not found in store — it may have already been unloaded`);
|
|
196
196
|
return;
|
|
197
197
|
}
|
|
198
198
|
const record = raw;
|
|
@@ -217,7 +217,7 @@ export default class Store {
|
|
|
217
217
|
const modelStore = this.data.get(model);
|
|
218
218
|
|
|
219
219
|
if (!modelStore) {
|
|
220
|
-
console.warn(`[Store] Cannot unload all records: model "${model}" not found in store`);
|
|
220
|
+
console.warn(`[Store] Cannot unload all records: model "${model}" not found in store — ensure the model is registered before unloading`);
|
|
221
221
|
return;
|
|
222
222
|
}
|
|
223
223
|
|
package/src/types/orm-types.ts
CHANGED
|
@@ -86,6 +86,16 @@ export interface ForeignKeyDef {
|
|
|
86
86
|
column: string;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
export interface HypertableConfig {
|
|
90
|
+
timeColumn: string;
|
|
91
|
+
chunkInterval?: string;
|
|
92
|
+
compress?: {
|
|
93
|
+
segmentBy?: string;
|
|
94
|
+
orderBy?: string;
|
|
95
|
+
after?: string;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
89
99
|
export interface ModelSchema {
|
|
90
100
|
table: string;
|
|
91
101
|
idType: string;
|
|
@@ -96,6 +106,7 @@ export interface ModelSchema {
|
|
|
96
106
|
hasMany: Record<string, string | null>;
|
|
97
107
|
};
|
|
98
108
|
vectorColumns?: Record<string, number>;
|
|
109
|
+
hypertable?: HypertableConfig;
|
|
99
110
|
memory: boolean;
|
|
100
111
|
}
|
|
101
112
|
|
|
@@ -139,6 +150,7 @@ export interface SnapshotEntry {
|
|
|
139
150
|
columns?: Record<string, string>;
|
|
140
151
|
foreignKeys?: Record<string, ForeignKeyDef>;
|
|
141
152
|
vectorColumns?: Record<string, number>;
|
|
153
|
+
hypertable?: HypertableConfig;
|
|
142
154
|
isView?: boolean;
|
|
143
155
|
viewName?: string;
|
|
144
156
|
source?: string;
|