@stonyx/orm 0.2.1-beta.81 → 0.2.1-beta.83

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.
@@ -0,0 +1,354 @@
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
+
8
+ function getRelationshipInfo(property) {
9
+ if (typeof property !== 'function') return null;
10
+ const fnStr = property.toString();
11
+ const modelName = property.__relatedModelName || null;
12
+
13
+ if (fnStr.includes(`getRelationships('belongsTo',`)) return { type: 'belongsTo', modelName };
14
+ if (fnStr.includes(`getRelationships('hasMany',`)) return { type: 'hasMany', modelName };
15
+
16
+ return null;
17
+ }
18
+
19
+ function sanitizeTableName(name) {
20
+ return name.replace(/[-/]/g, '_');
21
+ }
22
+
23
+ export function introspectModels() {
24
+ const { models } = Orm.instance;
25
+ const schemas = {};
26
+
27
+ for (const [modelKey, modelClass] of Object.entries(models)) {
28
+ const name = camelCaseToKebabCase(modelKey.slice(0, -5));
29
+
30
+ if (name === dbKey) continue;
31
+
32
+ const model = new modelClass(modelKey);
33
+ const columns = {};
34
+ const foreignKeys = {};
35
+ const relationships = { belongsTo: {}, hasMany: {} };
36
+ const vectorColumns = {};
37
+ let idType = 'number';
38
+
39
+ const transforms = Orm.instance.transforms;
40
+
41
+ for (const [key, property] of Object.entries(model)) {
42
+ if (key.startsWith('__')) continue;
43
+
44
+ const relInfo = getRelationshipInfo(property);
45
+
46
+ if (relInfo?.type === 'belongsTo') {
47
+ relationships.belongsTo[key] = relInfo.modelName;
48
+ } else if (relInfo?.type === 'hasMany') {
49
+ relationships.hasMany[key] = relInfo.modelName;
50
+ } else if (property?.constructor?.name === 'ModelProperty') {
51
+ if (key === 'id') {
52
+ idType = property.type;
53
+ } else if (property.type === 'vector') {
54
+ const dimensions = property.dimensions || 1536;
55
+ columns[key] = getVectorType(dimensions);
56
+ vectorColumns[key] = dimensions;
57
+ } else {
58
+ columns[key] = getPgType(property.type, transforms[property.type]);
59
+ }
60
+ }
61
+ }
62
+
63
+ // Build foreign keys from belongsTo relationships
64
+ for (const [relName, targetModelName] of Object.entries(relationships.belongsTo)) {
65
+ const fkColumn = `${relName}_id`;
66
+ foreignKeys[fkColumn] = {
67
+ references: sanitizeTableName(getPluralName(targetModelName)),
68
+ column: 'id',
69
+ };
70
+ }
71
+
72
+ schemas[name] = {
73
+ table: sanitizeTableName(getPluralName(name)),
74
+ idType,
75
+ columns,
76
+ foreignKeys,
77
+ relationships,
78
+ vectorColumns,
79
+ memory: modelClass.memory === true,
80
+ };
81
+ }
82
+
83
+ return schemas;
84
+ }
85
+
86
+ export function buildTableDDL(name, schema, allSchemas = {}) {
87
+ const { idType, columns, foreignKeys } = schema;
88
+ const table = sanitizeTableName(schema.table);
89
+ const lines = [];
90
+
91
+ // Primary key
92
+ if (idType === 'string') {
93
+ lines.push(' "id" VARCHAR(255) PRIMARY KEY');
94
+ } else {
95
+ lines.push(' "id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY');
96
+ }
97
+
98
+ // Attribute columns
99
+ for (const [col, pgType] of Object.entries(columns)) {
100
+ lines.push(` "${col}" ${pgType}`);
101
+ }
102
+
103
+ // Foreign key columns
104
+ for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
105
+ const refIdType = getReferencedIdType(fkDef.references, allSchemas);
106
+ lines.push(` "${fkCol}" ${refIdType}`);
107
+ }
108
+
109
+ // Timestamps
110
+ lines.push(' "created_at" TIMESTAMPTZ DEFAULT NOW()');
111
+ lines.push(' "updated_at" TIMESTAMPTZ DEFAULT NOW()');
112
+
113
+ // Foreign key constraints
114
+ for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
115
+ const refTable = sanitizeTableName(fkDef.references);
116
+ lines.push(` FOREIGN KEY ("${fkCol}") REFERENCES "${refTable}"("${fkDef.column}") ON DELETE SET NULL`);
117
+ }
118
+
119
+ return `CREATE TABLE IF NOT EXISTS "${table}" (\n${lines.join(',\n')}\n)`;
120
+ }
121
+
122
+ /**
123
+ * Build HNSW index DDL for vector columns on a model.
124
+ * @param {string} name - Model name
125
+ * @param {Object} schema - Model schema with vectorColumns
126
+ * @returns {string[]} Array of CREATE INDEX statements
127
+ */
128
+ export function buildVectorIndexDDL(name, schema) {
129
+ const table = sanitizeTableName(schema.table);
130
+ const statements = [];
131
+
132
+ for (const [col] of Object.entries(schema.vectorColumns || {})) {
133
+ statements.push(
134
+ `CREATE INDEX IF NOT EXISTS "idx_${table}_${col}_hnsw" ON "${table}" USING hnsw ("${col}" vector_cosine_ops) WITH (m = 16, ef_construction = 200)`
135
+ );
136
+ }
137
+
138
+ return statements;
139
+ }
140
+
141
+ function getReferencedIdType(tableName, allSchemas) {
142
+ for (const schema of Object.values(allSchemas)) {
143
+ if (schema.table === tableName) {
144
+ return schema.idType === 'string' ? 'VARCHAR(255)' : 'INTEGER';
145
+ }
146
+ }
147
+
148
+ return 'INTEGER';
149
+ }
150
+
151
+ export function getTopologicalOrder(schemas) {
152
+ const visited = new Set();
153
+ const order = [];
154
+
155
+ function visit(name) {
156
+ if (visited.has(name)) return;
157
+ visited.add(name);
158
+
159
+ const schema = schemas[name];
160
+ if (!schema) return;
161
+
162
+ // Visit dependencies (belongsTo targets) first
163
+ for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
164
+ visit(targetModelName);
165
+ }
166
+
167
+ order.push(name);
168
+ }
169
+
170
+ for (const name of Object.keys(schemas)) {
171
+ visit(name);
172
+ }
173
+
174
+ return order;
175
+ }
176
+
177
+ export function introspectViews() {
178
+ const orm = Orm.instance;
179
+ if (!orm.views) return {};
180
+
181
+ const schemas = {};
182
+
183
+ for (const [viewKey, viewClass] of Object.entries(orm.views)) {
184
+ const name = camelCaseToKebabCase(viewKey.slice(0, -4)); // Remove 'View' suffix
185
+
186
+ const source = viewClass.source;
187
+ if (!source) continue;
188
+
189
+ const model = new viewClass(name);
190
+ const columns = {};
191
+ const foreignKeys = {};
192
+ const aggregates = {};
193
+ const relationships = { belongsTo: {}, hasMany: {} };
194
+
195
+ for (const [key, property] of Object.entries(model)) {
196
+ if (key.startsWith('__')) continue;
197
+ if (key === 'id') continue;
198
+
199
+ if (property instanceof AggregateProperty) {
200
+ aggregates[key] = property;
201
+ continue;
202
+ }
203
+
204
+ const relInfo = getRelationshipInfo(property);
205
+
206
+ if (relInfo?.type === 'belongsTo') {
207
+ relationships.belongsTo[key] = relInfo.modelName;
208
+ const fkColumn = `${key}_id`;
209
+ foreignKeys[fkColumn] = {
210
+ references: sanitizeTableName(getPluralName(relInfo.modelName)),
211
+ column: 'id',
212
+ };
213
+ } else if (relInfo?.type === 'hasMany') {
214
+ relationships.hasMany[key] = relInfo.modelName;
215
+ } else if (property?.constructor?.name === 'ModelProperty') {
216
+ const transforms = Orm.instance.transforms;
217
+ columns[key] = getPgType(property.type, transforms[property.type]);
218
+ }
219
+ }
220
+
221
+ schemas[name] = {
222
+ viewName: sanitizeTableName(getPluralName(name)),
223
+ source,
224
+ groupBy: viewClass.groupBy || undefined,
225
+ columns,
226
+ foreignKeys,
227
+ aggregates,
228
+ relationships,
229
+ isView: true,
230
+ memory: viewClass.memory !== false ? false : false, // Views default to memory:false
231
+ };
232
+ }
233
+
234
+ return schemas;
235
+ }
236
+
237
+ export function buildViewDDL(name, viewSchema, modelSchemas = {}) {
238
+ if (!viewSchema.source) {
239
+ throw new Error(`View '${name}' must define a source model`);
240
+ }
241
+
242
+ const sourceModelName = viewSchema.source;
243
+ const sourceSchema = modelSchemas[sourceModelName];
244
+ const sourceTable = sanitizeTableName(sourceSchema
245
+ ? sourceSchema.table
246
+ : getPluralName(sourceModelName));
247
+
248
+ const selectColumns = [];
249
+ const joins = [];
250
+ const hasAggregates = Object.keys(viewSchema.aggregates || {}).length > 0;
251
+ const groupByField = viewSchema.groupBy;
252
+
253
+ // ID column: groupBy field or source table PK
254
+ if (groupByField) {
255
+ selectColumns.push(`"${sourceTable}"."${groupByField}" AS "id"`);
256
+ } else {
257
+ selectColumns.push(`"${sourceTable}"."id" AS "id"`);
258
+ }
259
+
260
+ // Aggregate columns
261
+ for (const [key, aggProp] of Object.entries(viewSchema.aggregates || {})) {
262
+ // Use pgFunction if available, fall back to mysqlFunction
263
+ const fn = aggProp.pgFunction || aggProp.mysqlFunction;
264
+
265
+ if (aggProp.relationship === undefined) {
266
+ // Field-level aggregate (groupBy views)
267
+ if (aggProp.aggregateType === 'count') {
268
+ selectColumns.push(`COUNT(*) AS "${key}"`);
269
+ } else {
270
+ selectColumns.push(`${fn}("${sourceTable}"."${aggProp.field}") AS "${key}"`);
271
+ }
272
+ } else {
273
+ // Relationship aggregate
274
+ const relName = aggProp.relationship;
275
+ const relTable = sanitizeTableName(getPluralName(relName));
276
+
277
+ if (aggProp.aggregateType === 'count') {
278
+ selectColumns.push(`${fn}("${relTable}"."id") AS "${key}"`);
279
+ } else {
280
+ const field = aggProp.field;
281
+ selectColumns.push(`${fn}("${relTable}"."${field}") AS "${key}"`);
282
+ }
283
+
284
+ // Add LEFT JOIN for the relationship if not already added
285
+ const joinKey = `${relTable}`;
286
+ if (!joins.find(j => j.table === joinKey)) {
287
+ const fkColumn = `${sourceModelName}_id`;
288
+ joins.push({
289
+ table: relTable,
290
+ condition: `"${relTable}"."${fkColumn}" = "${sourceTable}"."id"`
291
+ });
292
+ }
293
+ }
294
+ }
295
+
296
+ // Regular columns
297
+ for (const [key] of Object.entries(viewSchema.columns || {})) {
298
+ selectColumns.push(`"${sourceTable}"."${key}" AS "${key}"`);
299
+ }
300
+
301
+ // Build JOIN clauses
302
+ const joinClauses = joins.map(j =>
303
+ `LEFT JOIN "${j.table}" ON ${j.condition}`
304
+ ).join('\n ');
305
+
306
+ // Build GROUP BY
307
+ let groupBy = '';
308
+ if (groupByField) {
309
+ groupBy = `\nGROUP BY "${sourceTable}"."${groupByField}"`;
310
+ } else if (hasAggregates) {
311
+ groupBy = `\nGROUP BY "${sourceTable}"."id"`;
312
+ }
313
+
314
+ const viewName = sanitizeTableName(viewSchema.viewName);
315
+ const sql = `CREATE OR REPLACE VIEW "${viewName}" AS\nSELECT\n ${selectColumns.join(',\n ')}\nFROM "${sourceTable}"${joinClauses ? '\n ' + joinClauses : ''}${groupBy}`;
316
+
317
+ return sql;
318
+ }
319
+
320
+ export function viewSchemasToSnapshot(viewSchemas) {
321
+ const snapshot = {};
322
+
323
+ for (const [name, schema] of Object.entries(viewSchemas)) {
324
+ snapshot[name] = {
325
+ viewName: schema.viewName,
326
+ source: schema.source,
327
+ ...(schema.groupBy ? { groupBy: schema.groupBy } : {}),
328
+ columns: { ...schema.columns },
329
+ foreignKeys: { ...schema.foreignKeys },
330
+ isView: true,
331
+ viewQuery: buildViewDDL(name, schema),
332
+ };
333
+ }
334
+
335
+ return snapshot;
336
+ }
337
+
338
+ export function schemasToSnapshot(schemas) {
339
+ const snapshot = {};
340
+
341
+ for (const [name, schema] of Object.entries(schemas)) {
342
+ snapshot[name] = {
343
+ table: schema.table,
344
+ idType: schema.idType,
345
+ columns: { ...schema.columns },
346
+ foreignKeys: { ...schema.foreignKeys },
347
+ ...(schema.vectorColumns && Object.keys(schema.vectorColumns).length > 0
348
+ ? { vectorColumns: { ...schema.vectorColumns } }
349
+ : {}),
350
+ };
351
+ }
352
+
353
+ return snapshot;
354
+ }
@@ -0,0 +1,53 @@
1
+ const typeMap = {
2
+ string: 'VARCHAR(255)',
3
+ number: 'INTEGER',
4
+ float: 'REAL',
5
+ boolean: 'BOOLEAN',
6
+ date: 'TIMESTAMPTZ',
7
+ timestamp: 'BIGINT',
8
+ passthrough: 'TEXT',
9
+ trim: 'VARCHAR(255)',
10
+ uppercase: 'VARCHAR(255)',
11
+ ceil: 'INTEGER',
12
+ floor: 'INTEGER',
13
+ round: 'INTEGER',
14
+ };
15
+
16
+ /**
17
+ * Resolves a Stonyx ORM attribute type to a PostgreSQL column type.
18
+ *
19
+ * For built-in types, returns the mapped PostgreSQL type directly.
20
+ *
21
+ * For custom transforms (e.g. an `animal` transform that maps strings to ints):
22
+ * - If the transform function exports a `pgType` property, that value is used.
23
+ * - Otherwise, if `mysqlType` is defined, it is mapped to a PG equivalent.
24
+ * - Otherwise, defaults to JSONB. Values are JSON-stringified on write and
25
+ * JSON-parsed on read by PostgreSQL natively.
26
+ */
27
+ export function getPgType(attrType, transformFn) {
28
+ if (typeMap[attrType]) return typeMap[attrType];
29
+ if (transformFn?.pgType) return transformFn.pgType;
30
+ if (transformFn?.mysqlType) return mysqlTypeToPg(transformFn.mysqlType);
31
+
32
+ return 'JSONB';
33
+ }
34
+
35
+ /**
36
+ * Returns a vector column type for the given dimensions.
37
+ * @param {number} dimensions - Vector dimensionality (e.g. 768, 1536)
38
+ * @returns {string}
39
+ */
40
+ export function getVectorType(dimensions) {
41
+ return `vector(${dimensions})`;
42
+ }
43
+
44
+ function mysqlTypeToPg(mysqlType) {
45
+ const upper = mysqlType.toUpperCase();
46
+ if (upper === 'TINYINT(1)') return 'BOOLEAN';
47
+ if (upper === 'INT' || upper === 'INT AUTO_INCREMENT') return 'INTEGER';
48
+ if (upper === 'DATETIME') return 'TIMESTAMPTZ';
49
+ if (upper === 'JSON') return 'JSONB';
50
+ return mysqlType;
51
+ }
52
+
53
+ export default typeMap;
@@ -0,0 +1,142 @@
1
+ // Re-export all base PostgreSQL query builders
2
+ export { validateIdentifier, buildInsert, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
3
+
4
+ import { validateIdentifier } from '../postgres/query-builder.js';
5
+
6
+ /**
7
+ * Build a CREATE TABLE + hypertable conversion statement.
8
+ * TimescaleDB hypertables are regular tables converted via create_hypertable().
9
+ * @param {string} table - Table name
10
+ * @param {string} timeColumn - The time-partitioning column (must be a timestamp type)
11
+ * @param {Object} [options]
12
+ * @param {string} [options.chunkInterval='7 days'] - Chunk time interval
13
+ * @returns {{ sql: string, values: any[] }}
14
+ */
15
+ export function buildCreateHypertable(table, timeColumn, options = {}) {
16
+ validateIdentifier(table, 'table name');
17
+ validateIdentifier(timeColumn, 'column name');
18
+
19
+ const { chunkInterval = '7 days' } = options;
20
+
21
+ const sql = `SELECT create_hypertable('"${table}"', '${timeColumn}', chunk_time_interval => INTERVAL '${chunkInterval}', if_not_exists => TRUE)`;
22
+
23
+ return { sql, values: [] };
24
+ }
25
+
26
+ /**
27
+ * Build a time_bucket aggregation query.
28
+ * @param {string} table - Table name
29
+ * @param {string} timeColumn - Timestamp column to bucket
30
+ * @param {string} bucketSize - Bucket interval (e.g. '1 hour', '5 minutes', '1 day')
31
+ * @param {Object} [options]
32
+ * @param {string[]} [options.aggregates] - Aggregate expressions (e.g. ['AVG("value") AS avg_value'])
33
+ * @param {Object} [options.where] - WHERE conditions
34
+ * @param {string} [options.orderBy='bucket'] - ORDER BY clause
35
+ * @param {number} [options.limit] - LIMIT
36
+ * @returns {{ sql: string, values: any[] }}
37
+ */
38
+ export function buildTimeBucket(table, timeColumn, bucketSize, options = {}) {
39
+ validateIdentifier(table, 'table name');
40
+ validateIdentifier(timeColumn, 'column name');
41
+
42
+ const { aggregates = [], where, orderBy = 'bucket', limit } = options;
43
+ const values = [];
44
+ let paramIndex = 1;
45
+
46
+ const selectCols = [`time_bucket($${paramIndex++}, "${timeColumn}") AS bucket`];
47
+ values.push(bucketSize);
48
+
49
+ for (const agg of aggregates) {
50
+ selectCols.push(agg);
51
+ }
52
+
53
+ let whereClauses = [];
54
+ if (where) {
55
+ for (const [k, v] of Object.entries(where)) {
56
+ validateIdentifier(k, 'column name');
57
+ whereClauses.push(`"${k}" = $${paramIndex++}`);
58
+ values.push(v);
59
+ }
60
+ }
61
+
62
+ const whereStr = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
63
+ const orderStr = orderBy ? ` ORDER BY ${orderBy}` : '';
64
+ let limitStr = '';
65
+ if (limit != null) {
66
+ limitStr = ` LIMIT $${paramIndex++}`;
67
+ values.push(limit);
68
+ }
69
+
70
+ const sql = `SELECT ${selectCols.join(', ')} FROM "${table}"${whereStr} GROUP BY bucket${orderStr}${limitStr}`;
71
+
72
+ return { sql, values };
73
+ }
74
+
75
+ /**
76
+ * Build a continuous aggregate creation statement.
77
+ * @param {string} viewName - Name for the continuous aggregate view
78
+ * @param {string} table - Source hypertable
79
+ * @param {string} timeColumn - Timestamp column
80
+ * @param {string} bucketSize - Bucket interval
81
+ * @param {string[]} aggregates - Aggregate expressions
82
+ * @param {Object} [options]
83
+ * @param {boolean} [options.withNoData=false] - Create without materializing data initially
84
+ * @returns {{ sql: string }}
85
+ */
86
+ export function buildContinuousAggregate(viewName, table, timeColumn, bucketSize, aggregates, options = {}) {
87
+ validateIdentifier(viewName, 'view name');
88
+ validateIdentifier(table, 'table name');
89
+ validateIdentifier(timeColumn, 'column name');
90
+
91
+ const { withNoData = false } = options;
92
+
93
+ const selectCols = [
94
+ `time_bucket('${bucketSize}', "${timeColumn}") AS bucket`,
95
+ ...aggregates,
96
+ ];
97
+
98
+ const withClause = withNoData ? ' WITH NO DATA' : '';
99
+
100
+ const sql = `CREATE MATERIALIZED VIEW "${viewName}" WITH (timescaledb.continuous) AS SELECT ${selectCols.join(', ')} FROM "${table}" GROUP BY bucket${withClause}`;
101
+
102
+ return { sql };
103
+ }
104
+
105
+ /**
106
+ * Build an ADD compression policy statement.
107
+ * @param {string} table - Hypertable name
108
+ * @param {string} compressAfter - Interval after which to compress (e.g. '7 days')
109
+ * @returns {{ sql: string }}
110
+ */
111
+ export function buildCompressionPolicy(table, compressAfter) {
112
+ validateIdentifier(table, 'table name');
113
+
114
+ const sql = `SELECT add_compression_policy('"${table}"', INTERVAL '${compressAfter}', if_not_exists => TRUE)`;
115
+
116
+ return { sql };
117
+ }
118
+
119
+ /**
120
+ * Build an ALTER TABLE to enable compression on a hypertable.
121
+ * @param {string} table - Hypertable name
122
+ * @param {string} segmentBy - Column to segment by (usually the non-time dimension)
123
+ * @param {string} orderBy - Column to order compressed data by (usually the time column)
124
+ * @returns {{ sql: string }}
125
+ */
126
+ export function buildEnableCompression(table, segmentBy, orderBy) {
127
+ validateIdentifier(table, 'table name');
128
+
129
+ let opts = `timescaledb.compress`;
130
+ if (segmentBy) {
131
+ validateIdentifier(segmentBy, 'column name');
132
+ opts += `, timescaledb.compress_segmentby = '"${segmentBy}"'`;
133
+ }
134
+ if (orderBy) {
135
+ validateIdentifier(orderBy, 'column name');
136
+ opts += `, timescaledb.compress_orderby = '"${orderBy}"'`;
137
+ }
138
+
139
+ const sql = `ALTER TABLE "${table}" SET (${opts})`;
140
+
141
+ return { sql };
142
+ }
@@ -0,0 +1,111 @@
1
+ import PostgresDB from '../postgres/postgres-db.js';
2
+ import { buildCreateHypertable, buildTimeBucket, buildContinuousAggregate, buildCompressionPolicy, buildEnableCompression } from './query-builder.js';
3
+
4
+ export default class TimescaleDB extends PostgresDB {
5
+ static extensions = ['timescaledb'];
6
+ static configKey = 'timescale';
7
+
8
+ constructor(deps = {}) {
9
+ super({
10
+ ...deps,
11
+ buildCreateHypertable,
12
+ buildTimeBucket,
13
+ buildContinuousAggregate,
14
+ buildCompressionPolicy,
15
+ buildEnableCompression,
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Convert a table to a TimescaleDB hypertable.
21
+ * Should be called after the table is created (e.g. after initial migration).
22
+ * @param {string} modelName
23
+ * @param {string} timeColumn - The time-partitioning column
24
+ * @param {Object} [options]
25
+ * @param {string} [options.chunkInterval='7 days']
26
+ */
27
+ async createHypertable(modelName, timeColumn, options = {}) {
28
+ const schemas = this.deps.introspectModels();
29
+ const schema = schemas[modelName];
30
+ if (!schema) throw new Error(`Model '${modelName}' not found`);
31
+
32
+ const { sql } = this.deps.buildCreateHypertable(schema.table, timeColumn, options);
33
+ await this.pool.query(sql);
34
+ }
35
+
36
+ /**
37
+ * Query time-bucketed aggregations on a hypertable.
38
+ * @param {string} modelName
39
+ * @param {string} timeColumn - Timestamp column to bucket
40
+ * @param {string} bucketSize - Bucket interval (e.g. '1 hour', '5 minutes')
41
+ * @param {Object} [options]
42
+ * @param {string[]} [options.aggregates] - Aggregate expressions
43
+ * @param {Object} [options.where] - WHERE conditions
44
+ * @param {number} [options.limit]
45
+ * @returns {Promise<Object[]>} Rows with bucket + aggregate columns
46
+ */
47
+ async timeBucket(modelName, timeColumn, bucketSize, options = {}) {
48
+ const schemas = this.deps.introspectModels();
49
+ const schema = schemas[modelName];
50
+ if (!schema) return [];
51
+
52
+ const { sql, values } = this.deps.buildTimeBucket(schema.table, timeColumn, bucketSize, options);
53
+
54
+ try {
55
+ const result = await this.pool.query(sql, values);
56
+ return result.rows;
57
+ } catch (error) {
58
+ if (error.code === '42P01') return [];
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Create a continuous aggregate view on a hypertable.
65
+ * @param {string} viewName - Name for the materialized view
66
+ * @param {string} modelName - Source hypertable model
67
+ * @param {string} timeColumn - Timestamp column
68
+ * @param {string} bucketSize - Bucket interval
69
+ * @param {string[]} aggregates - Aggregate expressions
70
+ * @param {Object} [options]
71
+ * @param {boolean} [options.withNoData=false]
72
+ */
73
+ async createContinuousAggregate(viewName, modelName, timeColumn, bucketSize, aggregates, options = {}) {
74
+ const schemas = this.deps.introspectModels();
75
+ const schema = schemas[modelName];
76
+ if (!schema) throw new Error(`Model '${modelName}' not found`);
77
+
78
+ const { sql } = this.deps.buildContinuousAggregate(viewName, schema.table, timeColumn, bucketSize, aggregates, options);
79
+ await this.pool.query(sql);
80
+ }
81
+
82
+ /**
83
+ * Enable compression on a hypertable.
84
+ * @param {string} modelName
85
+ * @param {Object} [options]
86
+ * @param {string} [options.segmentBy] - Column to segment by
87
+ * @param {string} [options.orderBy] - Column to order by
88
+ */
89
+ async enableCompression(modelName, options = {}) {
90
+ const schemas = this.deps.introspectModels();
91
+ const schema = schemas[modelName];
92
+ if (!schema) throw new Error(`Model '${modelName}' not found`);
93
+
94
+ const { sql } = this.deps.buildEnableCompression(schema.table, options.segmentBy, options.orderBy);
95
+ await this.pool.query(sql);
96
+ }
97
+
98
+ /**
99
+ * Add a compression policy to a hypertable.
100
+ * @param {string} modelName
101
+ * @param {string} compressAfter - Interval after which to compress (e.g. '7 days')
102
+ */
103
+ async addCompressionPolicy(modelName, compressAfter) {
104
+ const schemas = this.deps.introspectModels();
105
+ const schema = schemas[modelName];
106
+ if (!schema) throw new Error(`Model '${modelName}' not found`);
107
+
108
+ const { sql } = this.deps.buildCompressionPolicy(schema.table, compressAfter);
109
+ await this.pool.query(sql);
110
+ }
111
+ }