@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.
Files changed (166) hide show
  1. package/README.md +482 -15
  2. package/config/environment.js +63 -6
  3. package/dist/aggregates.d.ts +21 -0
  4. package/dist/aggregates.js +93 -0
  5. package/dist/attr.d.ts +2 -0
  6. package/dist/attr.js +22 -0
  7. package/dist/belongs-to.d.ts +11 -0
  8. package/dist/belongs-to.js +59 -0
  9. package/dist/cli.d.ts +22 -0
  10. package/dist/cli.js +148 -0
  11. package/dist/commands.d.ts +7 -0
  12. package/dist/commands.js +146 -0
  13. package/dist/db.d.ts +21 -0
  14. package/dist/db.js +180 -0
  15. package/dist/exports/db.d.ts +7 -0
  16. package/{src → dist}/exports/db.js +2 -4
  17. package/dist/has-many.d.ts +11 -0
  18. package/dist/has-many.js +58 -0
  19. package/dist/hooks.d.ts +75 -0
  20. package/dist/hooks.js +110 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +34 -0
  23. package/dist/main.d.ts +46 -0
  24. package/dist/main.js +181 -0
  25. package/dist/manage-record.d.ts +13 -0
  26. package/dist/manage-record.js +123 -0
  27. package/dist/meta-request.d.ts +6 -0
  28. package/dist/meta-request.js +52 -0
  29. package/dist/migrate.d.ts +2 -0
  30. package/dist/migrate.js +57 -0
  31. package/dist/model-property.d.ts +9 -0
  32. package/dist/model-property.js +29 -0
  33. package/dist/model.d.ts +15 -0
  34. package/dist/model.js +18 -0
  35. package/dist/mysql/connection.d.ts +14 -0
  36. package/dist/mysql/connection.js +24 -0
  37. package/dist/mysql/migration-generator.d.ts +45 -0
  38. package/dist/mysql/migration-generator.js +254 -0
  39. package/dist/mysql/migration-runner.d.ts +12 -0
  40. package/dist/mysql/migration-runner.js +88 -0
  41. package/dist/mysql/mysql-db.d.ts +100 -0
  42. package/dist/mysql/mysql-db.js +425 -0
  43. package/dist/mysql/query-builder.d.ts +10 -0
  44. package/dist/mysql/query-builder.js +44 -0
  45. package/dist/mysql/schema-introspector.d.ts +19 -0
  46. package/dist/mysql/schema-introspector.js +257 -0
  47. package/dist/mysql/type-map.d.ts +21 -0
  48. package/dist/mysql/type-map.js +36 -0
  49. package/dist/orm-request.d.ts +38 -0
  50. package/dist/orm-request.js +475 -0
  51. package/dist/plural-registry.d.ts +4 -0
  52. package/dist/plural-registry.js +9 -0
  53. package/dist/postgres/connection.d.ts +15 -0
  54. package/dist/postgres/connection.js +32 -0
  55. package/dist/postgres/migration-generator.d.ts +45 -0
  56. package/dist/postgres/migration-generator.js +280 -0
  57. package/dist/postgres/migration-runner.d.ts +10 -0
  58. package/dist/postgres/migration-runner.js +87 -0
  59. package/dist/postgres/postgres-db.d.ts +119 -0
  60. package/dist/postgres/postgres-db.js +477 -0
  61. package/dist/postgres/query-builder.d.ts +27 -0
  62. package/dist/postgres/query-builder.js +98 -0
  63. package/dist/postgres/schema-introspector.d.ts +29 -0
  64. package/dist/postgres/schema-introspector.js +296 -0
  65. package/dist/postgres/type-map.d.ts +23 -0
  66. package/dist/postgres/type-map.js +56 -0
  67. package/dist/record.d.ts +75 -0
  68. package/dist/record.js +129 -0
  69. package/dist/relationships.d.ts +10 -0
  70. package/dist/relationships.js +41 -0
  71. package/dist/schema-helpers.d.ts +20 -0
  72. package/dist/schema-helpers.js +48 -0
  73. package/dist/serializer.d.ts +17 -0
  74. package/dist/serializer.js +136 -0
  75. package/dist/setup-rest-server.d.ts +1 -0
  76. package/dist/setup-rest-server.js +52 -0
  77. package/dist/standalone-db.d.ts +58 -0
  78. package/dist/standalone-db.js +142 -0
  79. package/dist/store.d.ts +62 -0
  80. package/dist/store.js +286 -0
  81. package/dist/timescale/query-builder.d.ts +43 -0
  82. package/dist/timescale/query-builder.js +115 -0
  83. package/dist/timescale/timescale-db.d.ts +45 -0
  84. package/dist/timescale/timescale-db.js +84 -0
  85. package/dist/transforms.d.ts +2 -0
  86. package/dist/transforms.js +17 -0
  87. package/dist/types/orm-types.d.ts +153 -0
  88. package/dist/types/orm-types.js +1 -0
  89. package/dist/utils.d.ts +7 -0
  90. package/dist/utils.js +17 -0
  91. package/dist/view-resolver.d.ts +8 -0
  92. package/dist/view-resolver.js +171 -0
  93. package/dist/view.d.ts +11 -0
  94. package/dist/view.js +18 -0
  95. package/package.json +64 -11
  96. package/src/aggregates.ts +109 -0
  97. package/src/{attr.js → attr.ts} +2 -2
  98. package/src/belongs-to.ts +90 -0
  99. package/src/cli.ts +183 -0
  100. package/src/commands.ts +179 -0
  101. package/src/db.ts +232 -0
  102. package/src/exports/db.ts +7 -0
  103. package/src/has-many.ts +92 -0
  104. package/src/hooks.ts +151 -0
  105. package/src/{index.js → index.ts} +12 -2
  106. package/src/main.ts +229 -0
  107. package/src/manage-record.ts +161 -0
  108. package/src/{meta-request.js → meta-request.ts} +17 -14
  109. package/src/migrate.ts +72 -0
  110. package/src/model-property.ts +35 -0
  111. package/src/model.ts +21 -0
  112. package/src/mysql/connection.ts +43 -0
  113. package/src/mysql/migration-generator.ts +337 -0
  114. package/src/mysql/migration-runner.ts +121 -0
  115. package/src/mysql/mysql-db.ts +543 -0
  116. package/src/mysql/query-builder.ts +69 -0
  117. package/src/mysql/schema-introspector.ts +310 -0
  118. package/src/mysql/type-map.ts +42 -0
  119. package/src/orm-request.ts +582 -0
  120. package/src/plural-registry.ts +12 -0
  121. package/src/postgres/connection.ts +48 -0
  122. package/src/postgres/migration-generator.ts +370 -0
  123. package/src/postgres/migration-runner.ts +115 -0
  124. package/src/postgres/postgres-db.ts +616 -0
  125. package/src/postgres/query-builder.ts +148 -0
  126. package/src/postgres/schema-introspector.ts +360 -0
  127. package/src/postgres/type-map.ts +61 -0
  128. package/src/record.ts +186 -0
  129. package/src/relationships.ts +54 -0
  130. package/src/schema-helpers.ts +59 -0
  131. package/src/serializer.ts +161 -0
  132. package/src/setup-rest-server.ts +62 -0
  133. package/src/standalone-db.ts +185 -0
  134. package/src/store.ts +373 -0
  135. package/src/timescale/query-builder.ts +174 -0
  136. package/src/timescale/timescale-db.ts +119 -0
  137. package/src/transforms.ts +20 -0
  138. package/src/types/mysql2.d.ts +49 -0
  139. package/src/types/orm-types.ts +158 -0
  140. package/src/types/pg.d.ts +32 -0
  141. package/src/types/stonyx-cron.d.ts +5 -0
  142. package/src/types/stonyx-events.d.ts +4 -0
  143. package/src/types/stonyx-rest-server.d.ts +16 -0
  144. package/src/types/stonyx-utils.d.ts +33 -0
  145. package/src/types/stonyx.d.ts +21 -0
  146. package/src/utils.ts +22 -0
  147. package/src/view-resolver.ts +211 -0
  148. package/src/view.ts +22 -0
  149. package/.claude/project-structure.md +0 -578
  150. package/.github/workflows/ci.yml +0 -36
  151. package/.github/workflows/publish.yml +0 -143
  152. package/src/belongs-to.js +0 -63
  153. package/src/db.js +0 -80
  154. package/src/has-many.js +0 -61
  155. package/src/main.js +0 -119
  156. package/src/manage-record.js +0 -103
  157. package/src/model-property.js +0 -29
  158. package/src/model.js +0 -9
  159. package/src/orm-request.js +0 -249
  160. package/src/record.js +0 -100
  161. package/src/relationships.js +0 -43
  162. package/src/serializer.js +0 -138
  163. package/src/setup-rest-server.js +0 -57
  164. package/src/store.js +0 -211
  165. package/src/transforms.js +0 -20
  166. package/stonyx-bootstrap.cjs +0 -30
@@ -0,0 +1,148 @@
1
+ interface QueryResult {
2
+ sql: string;
3
+ values: unknown[];
4
+ }
5
+
6
+ interface VectorSearchOptions {
7
+ limit?: number;
8
+ where?: Record<string, unknown>;
9
+ }
10
+
11
+ interface HybridSearchOptions {
12
+ limit?: number;
13
+ where?: Record<string, unknown>;
14
+ }
15
+
16
+ const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
17
+
18
+ export function validateIdentifier(name: string, context: string = 'identifier'): string {
19
+ if (!name || typeof name !== 'string' || !SAFE_IDENTIFIER.test(name)) {
20
+ throw new Error(`Invalid SQL ${context}: "${name}". Identifiers must match ${SAFE_IDENTIFIER}`);
21
+ }
22
+
23
+ return name;
24
+ }
25
+
26
+ export function buildInsert(table: string, data: Record<string, unknown>): QueryResult {
27
+ validateIdentifier(table, 'table name');
28
+
29
+ const keys = Object.keys(data);
30
+ keys.forEach(k => validateIdentifier(k, 'column name'));
31
+
32
+ const placeholders = keys.map((_, i) => `$${i + 1}`);
33
+ const values = keys.map(k => data[k]);
34
+
35
+ const sql = `INSERT INTO "${table}" (${keys.map(k => `"${k}"`).join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING "id"`;
36
+
37
+ return { sql, values };
38
+ }
39
+
40
+ export function buildUpdate(table: string, id: unknown, data: Record<string, unknown>): QueryResult {
41
+ validateIdentifier(table, 'table name');
42
+
43
+ const keys = Object.keys(data);
44
+ keys.forEach(k => validateIdentifier(k, 'column name'));
45
+
46
+ const setClauses = keys.map((k, i) => `"${k}" = $${i + 1}`);
47
+ const values: unknown[] = [...keys.map(k => data[k]), id];
48
+
49
+ const sql = `UPDATE "${table}" SET ${setClauses.join(', ')} WHERE "id" = $${keys.length + 1}`;
50
+
51
+ return { sql, values };
52
+ }
53
+
54
+ export function buildDelete(table: string, id: unknown): QueryResult {
55
+ validateIdentifier(table, 'table name');
56
+
57
+ return {
58
+ sql: `DELETE FROM "${table}" WHERE "id" = $1`,
59
+ values: [id],
60
+ };
61
+ }
62
+
63
+ export function buildSelect(table: string, conditions?: Record<string, unknown>): QueryResult {
64
+ validateIdentifier(table, 'table name');
65
+
66
+ if (!conditions || Object.keys(conditions).length === 0) {
67
+ return { sql: `SELECT * FROM "${table}"`, values: [] };
68
+ }
69
+
70
+ const keys = Object.keys(conditions);
71
+ keys.forEach(k => validateIdentifier(k, 'column name'));
72
+
73
+ const whereClauses = keys.map((k, i) => `"${k}" = $${i + 1}`);
74
+ const values = keys.map(k => conditions[k]);
75
+
76
+ const sql = `SELECT * FROM "${table}" WHERE ${whereClauses.join(' AND ')}`;
77
+
78
+ return { sql, values };
79
+ }
80
+
81
+ /**
82
+ * Build a vector similarity search query using cosine distance (<=>).
83
+ */
84
+ export function buildVectorSearch(table: string, vectorColumn: string, queryVector: number[], options: VectorSearchOptions = {}): QueryResult {
85
+ validateIdentifier(table, 'table name');
86
+ validateIdentifier(vectorColumn, 'column name');
87
+
88
+ const { limit = 10, where } = options;
89
+ const values: unknown[] = [];
90
+ let paramIndex = 1;
91
+
92
+ // Vector parameter as a formatted string for pgvector
93
+ const vectorStr = `[${queryVector.join(',')}]`;
94
+ values.push(vectorStr);
95
+ const vectorParam = `$${paramIndex++}`;
96
+
97
+ const whereClauses: string[] = [];
98
+ if (where) {
99
+ for (const [k, v] of Object.entries(where)) {
100
+ validateIdentifier(k, 'column name');
101
+ whereClauses.push(`"${k}" = $${paramIndex++}`);
102
+ values.push(v);
103
+ }
104
+ }
105
+
106
+ const whereStr = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
107
+ values.push(limit);
108
+
109
+ const sql = `SELECT *, ("${vectorColumn}" <=> ${vectorParam}::vector) AS distance FROM "${table}"${whereStr} ORDER BY "${vectorColumn}" <=> ${vectorParam}::vector LIMIT $${paramIndex}`;
110
+
111
+ return { sql, values };
112
+ }
113
+
114
+ /**
115
+ * Build a hybrid search query combining vector similarity with text filtering.
116
+ * Uses cosine distance for vector ranking and ILIKE for text matching.
117
+ */
118
+ export function buildHybridSearch(table: string, vectorColumn: string, queryVector: number[], textColumn: string, textQuery: string, options: HybridSearchOptions = {}): QueryResult {
119
+ validateIdentifier(table, 'table name');
120
+ validateIdentifier(vectorColumn, 'column name');
121
+ validateIdentifier(textColumn, 'column name');
122
+
123
+ const { limit = 10, where } = options;
124
+ const values: unknown[] = [];
125
+ let paramIndex = 1;
126
+
127
+ const vectorStr = `[${queryVector.join(',')}]`;
128
+ values.push(vectorStr);
129
+ const vectorParam = `$${paramIndex++}`;
130
+
131
+ values.push(`%${textQuery}%`);
132
+ const textParam = `$${paramIndex++}`;
133
+
134
+ const whereClauses: string[] = [`"${textColumn}" ILIKE ${textParam}`];
135
+ if (where) {
136
+ for (const [k, v] of Object.entries(where)) {
137
+ validateIdentifier(k, 'column name');
138
+ whereClauses.push(`"${k}" = $${paramIndex++}`);
139
+ values.push(v);
140
+ }
141
+ }
142
+
143
+ values.push(limit);
144
+
145
+ const sql = `SELECT *, ("${vectorColumn}" <=> ${vectorParam}::vector) AS distance FROM "${table}" WHERE ${whereClauses.join(' AND ')} ORDER BY "${vectorColumn}" <=> ${vectorParam}::vector LIMIT $${paramIndex}`;
146
+
147
+ return { sql, values };
148
+ }
@@ -0,0 +1,360 @@
1
+ import Orm from '@stonyx/orm';
2
+ import { getPgType, getVectorType } from './type-map.js';
3
+ import { camelCaseToKebabCase } from '@stonyx/utils/string';
4
+ import { getPluralName } from '../plural-registry.js';
5
+ import { dbKey } from '../db.js';
6
+ import { AggregateProperty } from '../aggregates.js';
7
+ import { getRelationshipInfo, sanitizeTableName } from '../schema-helpers.js';
8
+ import type { ForeignKeyDef, HypertableConfig, ModelSchema, ViewSchema } from '../types/orm-types.js';
9
+ import ModelProperty from '../model-property.js';
10
+
11
+ interface ViewSnapshotEntry {
12
+ viewName: string;
13
+ source: string;
14
+ groupBy?: string;
15
+ columns: Record<string, string>;
16
+ foreignKeys: Record<string, ForeignKeyDef>;
17
+ isView: boolean;
18
+ viewQuery: string;
19
+ }
20
+
21
+ interface ModelSnapshotEntry {
22
+ table: string;
23
+ idType: string;
24
+ columns: Record<string, string>;
25
+ foreignKeys: Record<string, ForeignKeyDef>;
26
+ vectorColumns?: Record<string, number>;
27
+ hypertable?: HypertableConfig;
28
+ }
29
+
30
+ interface JoinDef {
31
+ table: string;
32
+ condition: string;
33
+ }
34
+
35
+ export function introspectModels(): Record<string, ModelSchema> {
36
+ const { models } = Orm.instance as { models: Record<string, unknown> };
37
+ const schemas: Record<string, ModelSchema> = {};
38
+
39
+ for (const [modelKey, modelClass] of Object.entries(models)) {
40
+ const name = camelCaseToKebabCase(modelKey.slice(0, -5));
41
+
42
+ if (name === dbKey) continue;
43
+
44
+ const model = new (modelClass as new (key: string) => Record<string, unknown>)(modelKey);
45
+ const columns: Record<string, string> = {};
46
+ const foreignKeys: Record<string, ForeignKeyDef> = {};
47
+ const relationships: { belongsTo: Record<string, string | null>; hasMany: Record<string, string | null> } = { belongsTo: {}, hasMany: {} };
48
+ const vectorColumns: Record<string, number> = {};
49
+ let idType = 'number';
50
+
51
+ const transforms = (Orm.instance as { transforms: Record<string, unknown> }).transforms;
52
+
53
+ for (const [key, property] of Object.entries(model)) {
54
+ if (key.startsWith('__')) continue;
55
+
56
+ const relInfo = getRelationshipInfo(property);
57
+
58
+ if (relInfo?.type === 'belongsTo') {
59
+ relationships.belongsTo[key] = relInfo.modelName;
60
+ } else if (relInfo?.type === 'hasMany') {
61
+ relationships.hasMany[key] = relInfo.modelName;
62
+ } else if (property instanceof ModelProperty) {
63
+ const prop = property as { type: string; dimensions?: number };
64
+ if (key === 'id') {
65
+ idType = prop.type;
66
+ } else if (prop.type === 'vector') {
67
+ const dimensions = prop.dimensions || 1536;
68
+ columns[key] = getVectorType(dimensions);
69
+ vectorColumns[key] = dimensions;
70
+ } else {
71
+ columns[key] = getPgType(prop.type, transforms[prop.type] as undefined);
72
+ }
73
+ }
74
+ }
75
+
76
+ // Build foreign keys from belongsTo relationships
77
+ for (const [relName, targetModelName] of Object.entries(relationships.belongsTo)) {
78
+ if (!targetModelName) continue;
79
+ const fkColumn = `${relName}_id`;
80
+ foreignKeys[fkColumn] = {
81
+ references: sanitizeTableName(getPluralName(targetModelName)),
82
+ column: 'id',
83
+ };
84
+ }
85
+
86
+ const hypertable = (modelClass as { hypertable?: HypertableConfig }).hypertable;
87
+
88
+ schemas[name] = {
89
+ table: sanitizeTableName(getPluralName(name)),
90
+ idType,
91
+ columns,
92
+ foreignKeys,
93
+ relationships,
94
+ vectorColumns,
95
+ hypertable: hypertable || undefined,
96
+ memory: (modelClass as { memory?: boolean }).memory === true,
97
+ };
98
+ }
99
+
100
+ return schemas;
101
+ }
102
+
103
+ export function buildTableDDL(name: string, schema: ModelSchema, allSchemas: Record<string, ModelSchema> = {}): string {
104
+ const { idType, columns, foreignKeys, hypertable } = schema;
105
+ const table = sanitizeTableName(schema.table);
106
+ const lines: string[] = [];
107
+ const useCompositePK = hypertable && idType !== 'string';
108
+
109
+ // Primary key
110
+ if (idType === 'string') {
111
+ lines.push(' "id" VARCHAR(255) PRIMARY KEY');
112
+ } else if (useCompositePK) {
113
+ lines.push(' "id" INTEGER GENERATED ALWAYS AS IDENTITY');
114
+ } else {
115
+ lines.push(' "id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY');
116
+ }
117
+
118
+ // Attribute columns
119
+ for (const [col, pgType] of Object.entries(columns)) {
120
+ lines.push(` "${col}" ${pgType}`);
121
+ }
122
+
123
+ // Foreign key columns
124
+ for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
125
+ const refIdType = getReferencedIdType(fkDef.references, allSchemas);
126
+ lines.push(` "${fkCol}" ${refIdType}`);
127
+ }
128
+
129
+ // Timestamps
130
+ if (useCompositePK) {
131
+ lines.push(' "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
132
+ } else {
133
+ lines.push(' "created_at" TIMESTAMPTZ DEFAULT NOW()');
134
+ }
135
+ lines.push(' "updated_at" TIMESTAMPTZ DEFAULT NOW()');
136
+
137
+ // Foreign key constraints
138
+ for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
139
+ const refTable = sanitizeTableName(fkDef.references);
140
+ lines.push(` FOREIGN KEY ("${fkCol}") REFERENCES "${refTable}"("${fkDef.column}") ON DELETE SET NULL`);
141
+ }
142
+
143
+ // Composite primary key for hypertable models
144
+ if (useCompositePK) {
145
+ lines.push(` PRIMARY KEY ("id", "${hypertable.timeColumn}")`);
146
+ }
147
+
148
+ return `CREATE TABLE IF NOT EXISTS "${table}" (\n${lines.join(',\n')}\n)`;
149
+ }
150
+
151
+ /**
152
+ * Build HNSW index DDL for vector columns on a model.
153
+ */
154
+ export function buildVectorIndexDDL(name: string, schema: ModelSchema): string[] {
155
+ const table = sanitizeTableName(schema.table);
156
+ const statements: string[] = [];
157
+
158
+ for (const [col] of Object.entries(schema.vectorColumns || {})) {
159
+ statements.push(
160
+ `CREATE INDEX IF NOT EXISTS "idx_${table}_${col}_hnsw" ON "${table}" USING hnsw ("${col}" vector_cosine_ops) WITH (m = 16, ef_construction = 200)`
161
+ );
162
+ }
163
+
164
+ return statements;
165
+ }
166
+
167
+ function getReferencedIdType(tableName: string, allSchemas: Record<string, ModelSchema>): string {
168
+ for (const schema of Object.values(allSchemas)) {
169
+ if (schema.table === tableName) {
170
+ return schema.idType === 'string' ? 'VARCHAR(255)' : 'INTEGER';
171
+ }
172
+ }
173
+
174
+ return 'INTEGER';
175
+ }
176
+
177
+ export { getTopologicalOrder } from '../schema-helpers.js';
178
+
179
+ export function introspectViews(): Record<string, ViewSchema> {
180
+ const orm = Orm.instance as { views?: Record<string, unknown> };
181
+ if (!orm.views) return {};
182
+
183
+ const schemas: Record<string, ViewSchema> = {};
184
+
185
+ for (const [viewKey, viewClass] of Object.entries(orm.views)) {
186
+ const name = camelCaseToKebabCase(viewKey.slice(0, -4)); // Remove 'View' suffix
187
+
188
+ const source = (viewClass as { source?: string }).source;
189
+ if (!source) continue;
190
+
191
+ const model = new (viewClass as new (name: string) => Record<string, unknown>)(name);
192
+ const columns: Record<string, string> = {};
193
+ const foreignKeys: Record<string, ForeignKeyDef> = {};
194
+ const aggregates: Record<string, AggregateProperty> = {};
195
+ const relationships: { belongsTo: Record<string, string | null>; hasMany: Record<string, string | null> } = { belongsTo: {}, hasMany: {} };
196
+
197
+ for (const [key, property] of Object.entries(model)) {
198
+ if (key.startsWith('__')) continue;
199
+ if (key === 'id') continue;
200
+
201
+ if (property instanceof AggregateProperty) {
202
+ aggregates[key] = property;
203
+ continue;
204
+ }
205
+
206
+ const relInfo = getRelationshipInfo(property);
207
+
208
+ if (relInfo?.type === 'belongsTo') {
209
+ relationships.belongsTo[key] = relInfo.modelName;
210
+ if (relInfo.modelName) {
211
+ const fkColumn = `${key}_id`;
212
+ foreignKeys[fkColumn] = {
213
+ references: sanitizeTableName(getPluralName(relInfo.modelName)),
214
+ column: 'id',
215
+ };
216
+ }
217
+ } else if (relInfo?.type === 'hasMany') {
218
+ relationships.hasMany[key] = relInfo.modelName;
219
+ } else if (property instanceof ModelProperty) {
220
+ const transforms = (Orm.instance as { transforms: Record<string, unknown> }).transforms;
221
+ const prop = property as { type: string };
222
+ columns[key] = getPgType(prop.type, transforms[prop.type] as undefined);
223
+ }
224
+ }
225
+
226
+ schemas[name] = {
227
+ viewName: sanitizeTableName(getPluralName(name)),
228
+ source,
229
+ groupBy: (viewClass as { groupBy?: string }).groupBy || undefined,
230
+ columns,
231
+ foreignKeys,
232
+ aggregates,
233
+ relationships,
234
+ isView: true,
235
+ memory: false, // Views default to memory:false
236
+ };
237
+ }
238
+
239
+ return schemas;
240
+ }
241
+
242
+ export function buildViewDDL(name: string, viewSchema: ViewSchema, modelSchemas: Record<string, ModelSchema> = {}): string {
243
+ if (!viewSchema.source) {
244
+ throw new Error(`View '${name}' must define a source model`);
245
+ }
246
+
247
+ const sourceModelName = viewSchema.source;
248
+ const sourceSchema = modelSchemas[sourceModelName];
249
+ const sourceTable = sanitizeTableName(sourceSchema
250
+ ? sourceSchema.table
251
+ : getPluralName(sourceModelName));
252
+
253
+ const selectColumns: string[] = [];
254
+ const joins: JoinDef[] = [];
255
+ const hasAggregates = Object.keys(viewSchema.aggregates || {}).length > 0;
256
+ const groupByField = viewSchema.groupBy;
257
+
258
+ // ID column: groupBy field or source table PK
259
+ if (groupByField) {
260
+ selectColumns.push(`"${sourceTable}"."${groupByField}" AS "id"`);
261
+ } else {
262
+ selectColumns.push(`"${sourceTable}"."id" AS "id"`);
263
+ }
264
+
265
+ // Aggregate columns
266
+ for (const [key, aggProp] of Object.entries(viewSchema.aggregates || {})) {
267
+ // Use pgFunction if available, fall back to mysqlFunction
268
+ const fn = (aggProp as AggregateProperty & { pgFunction?: string }).pgFunction || aggProp.mysqlFunction;
269
+
270
+ if (aggProp.relationship === undefined) {
271
+ // Field-level aggregate (groupBy views)
272
+ if (aggProp.aggregateType === 'count') {
273
+ selectColumns.push(`COUNT(*) AS "${key}"`);
274
+ } else {
275
+ selectColumns.push(`${fn}("${sourceTable}"."${aggProp.field}") AS "${key}"`);
276
+ }
277
+ } else {
278
+ // Relationship aggregate
279
+ const relName = aggProp.relationship;
280
+ const relTable = sanitizeTableName(getPluralName(relName));
281
+
282
+ if (aggProp.aggregateType === 'count') {
283
+ selectColumns.push(`${fn}("${relTable}"."id") AS "${key}"`);
284
+ } else {
285
+ const field = aggProp.field;
286
+ selectColumns.push(`${fn}("${relTable}"."${field}") AS "${key}"`);
287
+ }
288
+
289
+ // Add LEFT JOIN for the relationship if not already added
290
+ const joinKey = `${relTable}`;
291
+ if (!joins.find(j => j.table === joinKey)) {
292
+ const fkColumn = `${sourceModelName}_id`;
293
+ joins.push({
294
+ table: relTable,
295
+ condition: `"${relTable}"."${fkColumn}" = "${sourceTable}"."id"`
296
+ });
297
+ }
298
+ }
299
+ }
300
+
301
+ // Regular columns
302
+ for (const [key] of Object.entries(viewSchema.columns || {})) {
303
+ selectColumns.push(`"${sourceTable}"."${key}" AS "${key}"`);
304
+ }
305
+
306
+ // Build JOIN clauses
307
+ const joinClauses = joins.map(j =>
308
+ `LEFT JOIN "${j.table}" ON ${j.condition}`
309
+ ).join('\n ');
310
+
311
+ // Build GROUP BY
312
+ let groupBy = '';
313
+ if (groupByField) {
314
+ groupBy = `\nGROUP BY "${sourceTable}"."${groupByField}"`;
315
+ } else if (hasAggregates) {
316
+ groupBy = `\nGROUP BY "${sourceTable}"."id"`;
317
+ }
318
+
319
+ const viewName = sanitizeTableName(viewSchema.viewName);
320
+ const sql = `CREATE OR REPLACE VIEW "${viewName}" AS\nSELECT\n ${selectColumns.join(',\n ')}\nFROM "${sourceTable}"${joinClauses ? '\n ' + joinClauses : ''}${groupBy}`;
321
+
322
+ return sql;
323
+ }
324
+
325
+ export function viewSchemasToSnapshot(viewSchemas: Record<string, ViewSchema>): Record<string, ViewSnapshotEntry> {
326
+ const snapshot: Record<string, ViewSnapshotEntry> = {};
327
+
328
+ for (const [name, schema] of Object.entries(viewSchemas)) {
329
+ snapshot[name] = {
330
+ viewName: schema.viewName,
331
+ source: schema.source,
332
+ ...(schema.groupBy ? { groupBy: schema.groupBy } : {}),
333
+ columns: { ...schema.columns },
334
+ foreignKeys: { ...schema.foreignKeys },
335
+ isView: true,
336
+ viewQuery: buildViewDDL(name, schema),
337
+ };
338
+ }
339
+
340
+ return snapshot;
341
+ }
342
+
343
+ export function schemasToSnapshot(schemas: Record<string, ModelSchema>): Record<string, ModelSnapshotEntry> {
344
+ const snapshot: Record<string, ModelSnapshotEntry> = {};
345
+
346
+ for (const [name, schema] of Object.entries(schemas)) {
347
+ snapshot[name] = {
348
+ table: schema.table,
349
+ idType: schema.idType,
350
+ columns: { ...schema.columns },
351
+ foreignKeys: { ...schema.foreignKeys },
352
+ ...(schema.vectorColumns && Object.keys(schema.vectorColumns).length > 0
353
+ ? { vectorColumns: { ...schema.vectorColumns } }
354
+ : {}),
355
+ ...(schema.hypertable ? { hypertable: schema.hypertable } : {}),
356
+ };
357
+ }
358
+
359
+ return snapshot;
360
+ }
@@ -0,0 +1,61 @@
1
+ interface TransformFn {
2
+ pgType?: string;
3
+ mysqlType?: string;
4
+ (...args: unknown[]): unknown;
5
+ }
6
+
7
+ const typeMap: Record<string, string> = {
8
+ string: 'VARCHAR(255)',
9
+ number: 'INTEGER',
10
+ float: 'REAL',
11
+ boolean: 'BOOLEAN',
12
+ date: 'TIMESTAMPTZ',
13
+ timestamp: 'BIGINT',
14
+ passthrough: 'TEXT',
15
+ trim: 'VARCHAR(255)',
16
+ uppercase: 'VARCHAR(255)',
17
+ ceil: 'INTEGER',
18
+ floor: 'INTEGER',
19
+ round: 'INTEGER',
20
+ };
21
+
22
+ /**
23
+ * Resolves a Stonyx ORM attribute type to a PostgreSQL column type.
24
+ *
25
+ * For built-in types, returns the mapped PostgreSQL type directly.
26
+ *
27
+ * For custom transforms (e.g. an `animal` transform that maps strings to ints):
28
+ * - If the transform function exports a `pgType` property, that value is used.
29
+ * - Otherwise, if `mysqlType` is defined, it is mapped to a PG equivalent.
30
+ * - Otherwise, defaults to JSONB. Values are JSON-stringified on write and
31
+ * JSON-parsed on read by PostgreSQL natively.
32
+ */
33
+ export function getPgType(attrType: string, transformFn?: TransformFn): string {
34
+ if (typeMap[attrType]) return typeMap[attrType];
35
+ if (transformFn?.pgType) return transformFn.pgType;
36
+ if (transformFn?.mysqlType) return mysqlTypeToPg(transformFn.mysqlType);
37
+
38
+ return 'JSONB';
39
+ }
40
+
41
+ /**
42
+ * Returns a vector column type for the given dimensions.
43
+ */
44
+ export function getVectorType(dimensions: number): string {
45
+ if (!Number.isInteger(dimensions) || dimensions < 1 || dimensions > 16000) {
46
+ throw new Error(`Invalid vector dimensions: ${dimensions}. Must be an integer between 1 and 16000.`);
47
+ }
48
+
49
+ return `vector(${dimensions})`;
50
+ }
51
+
52
+ function mysqlTypeToPg(mysqlType: string): string {
53
+ const upper = mysqlType.toUpperCase();
54
+ if (upper === 'TINYINT(1)') return 'BOOLEAN';
55
+ if (upper === 'INT' || upper === 'INT AUTO_INCREMENT') return 'INTEGER';
56
+ if (upper === 'DATETIME') return 'TIMESTAMPTZ';
57
+ if (upper === 'JSON') return 'JSONB';
58
+ return mysqlType;
59
+ }
60
+
61
+ export default typeMap;