Overview#
| Property | Value |
|---|
| Dialect name | timescaledb |
| Extends | PostgresDialect (postgres) |
| Driver | pg |
| Cloud SDK | none |
| Default port | 5432 |
| Local runtime | prorm db up --dialect timescaledb (timescale/timescaledb:2.16.1-pg16) |
TimescaleDB is not a separate database engine. It is a PostgreSQL extension
(CREATE EXTENSION timescaledb) that layers time-series capabilities on top of a
stock PostgreSQL server. Because of that, TimescaleDBDialect extends
PostgresDialect and reuses the exact same driver, wire protocol, and default
port:Base class: PostgresDialect (src/dialects/postgres/index.ts)
Driver / library: 'pg' — inherited unchanged from the Postgres base
Default port: 5432 — set in the TimescaleDBDialect constructor
name: reports 'timescaledb' at runtime
The subclass overrides only the dialect name and adds a handful of thin,
pure synchronous SQL builders for TimescaleDB-specific features. Every
standard SQL operation — CRUD, DDL, upserts, joins, pagination, type mapping —
is plain PostgreSQL, inherited without modification. See
Inherited PostgreSQL behavior.Identity, verified by tests:const dialect = new TimescaleDBDialect({});
dialect.name; // 'timescaledb'
dialect.library; // 'pg'
Implementation note on name: the base types name as the string literal
'postgres', so the override is written readonly name = 'timescaledb' as unknown as 'postgres'.
The runtime value is 'timescaledb'; the cast only satisfies the property-override
type check without editing the base class.
Quick start#
Install#
$ npm install ts-prorm-orm pg
$ pnpm add ts-prorm-orm pg
$ yarn add ts-prorm-orm pg
$ bun add ts-prorm-orm pg
pg is the same driver the postgres dialect uses. pg-copy-streams is optional: only the features that need it load it.Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'timescaledb',
host: 'localhost',
database: 'metrics',
username: 'postgres',
password: process.env.DB_PASSWORD,
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'timescaledb',
url: 'timescaledb://postgres:secret@localhost:5432/metrics',
});
A URL is folded into host, port, database, username and password before the dialect is built; any of those passed alongside it wins.Define a model#
import { DataTypes } from 'ts-prorm-orm';
const User = db.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
name: { type: DataTypes.STRING(100) },
meta: { type: DataTypes.JSONB() },
});
await db.sync(); // CREATE TABLE — see Generated SQL below
meta is JSONB, which the containment query below needs.Query#
import { Op } from 'ts-prorm-orm';
await User.create({ email: 'ada@example.com', name: 'Ada' });
const page = await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Generated SQL#
Each block is the statement the model call above it hands the dialect's query(), captured with the driver stubbed out. The model layer inlines the values.Create table#
db.sync() for the model in the Quick start:CREATE TABLE
Insert, update and delete#
await User.create({ email: 'ada@example.com', name: 'Ada' });
await User.update({ name: 'Grace' }, { where: { email: 'ada@example.com' } });
await User.destroy({ where: { id: 1 } });
Upsert#
await User.upsert({ id: 1, email: 'ada@example.com', name: 'Ada Lovelace' });
upsert() also reads the row by primary key before and after the write, to return the instance.await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
JSON#
Containment on the meta column:import { Op } from 'ts-prorm-orm';
await User.findAll({ where: { meta: { [Op.contains]: { plan: 'pro' } } } });
Transaction with a savepoint#
await db.transaction(async (t) => {
await User.create({ email: 'grace@example.com', name: 'Grace' }, { transaction: t });
// A nested transaction is a SAVEPOINT; its failure undoes only its own work.
await db
.transaction(async (sp) => {
await User.destroy({ where: { id: 1 }, transaction: sp });
throw new Error('keep the row');
}, { transaction: t })
.catch(() => {});
});
Skip locked rows (SKIP LOCKED)#
A worker that claims rows from a queue table should pass over rows another transaction holds rather than wait for them. lock with skipLocked inside a transaction:await db.transaction(async (t) => {
await User.findAll({ where: { name: 'Ada' }, lock: { level: 'UPDATE', skipLocked: true }, transaction: t });
});
Type mapping for the portable descriptors: STRING → VARCHAR(255), auto-increment INTEGER → SERIAL, BOOLEAN → BOOLEAN, DATE → TIMESTAMP, UUID → UUID, JSON → JSON. The type comparison lines the major engines up side by side.Connection#
Because TimescaleDB speaks the PostgreSQL wire protocol through the same pg
driver, connection options are identical to PostgreSQL
(TimescaleDBDialectOptions is an alias of PostgresDialectOptions). The only
difference is the constructor defaults port to 5432 (still overridable).import { Prorm } from 'ts-prorm-orm';
const orm = new Prorm({
dialect: 'timescaledb',
host: 'localhost',
port: 5432, // default; TimescaleDB runs inside PostgreSQL
database: 'metrics',
username: 'postgres',
password: 'secret',
});
TimescaleDB features require the extension to be enabled once per database. The
dialect exposes a dedicated synchronous SQL builder for this:dialect.createExtensionSQL();
// CREATE EXTENSION IF NOT EXISTS "timescaledb"
dialect.createExtensionSQL('timescaledb_toolkit');
// CREATE EXTENSION IF NOT EXISTS "timescaledb_toolkit"
The inherited async createExtension() from the Postgres base also works and
emits the same statement:await dialect.createExtension('timescaledb', { ifNotExists: true });
// runs: CREATE EXTENSION IF NOT EXISTS "timescaledb"
3. Time-series helpers#
These methods are pure, synchronous SQL builders — they return the SQL
string so callers can run it through the inherited query(). They do not touch
the database themselves. All string arguments are escaped through the inherited
escape() / escapeId() helpers (single quotes are doubled, identifiers are
double-quoted).createHypertable(table, timeColumn, opts?)#
Builds a SELECT create_hypertable(...) call that converts a regular table into
a hypertable partitioned on a time column.Options (CreateHypertableOptions):chunkTimeInterval?: string | number — default '7 days'. A string is
emitted as a PostgreSQL interval literal (chunk_time_interval => INTERVAL '...');
a number is emitted as a bare integer with no INTERVAL keyword (for
hypertables partitioned on an integer/bigint time column).
ifNotExists?: boolean — appends if_not_exists => TRUE.
migrateData?: boolean — appends migrate_data => TRUE (moves existing rows
into chunks).
dialect.createHypertable('conditions', 'time');
dialect.createHypertable('metrics', 'ts', { chunkTimeInterval: '1 hour' });
dialect.createHypertable('metrics', 'ts', {
chunkTimeInterval: '1 day',
ifNotExists: true,
migrateData: true,
});
Integer-time hypertable (bare integer, no INTERVAL):dialect.createHypertable('events', 'seq', { chunkTimeInterval: 86400000 });
Single quotes in arguments are escaped defensively — "o'brien" produces
create_hypertable('o''brien', 'time', ...).enableCompression(table, opts?)#
Builds an ALTER TABLE ... SET (timescaledb.compress, ...) statement enabling
native compression on a hypertable.Options (EnableCompressionOptions):segmentBy?: string | string[] → timescaledb.compress_segmentby. An array is
joined into a single comma-separated literal.
orderBy?: string | string[] → timescaledb.compress_orderby (e.g. 'time DESC').
The table name is quoted via quoteTable().dialect.enableCompression('conditions');
dialect.enableCompression('conditions', { segmentBy: 'device_id', orderBy: 'time DESC' });
Array forms are joined into one literal:dialect.enableCompression('conditions', {
segmentBy: ['device_id', 'location'],
orderBy: ['time DESC', 'device_id'],
});
addCompressionPolicy(table, olderThan)#
Builds a SELECT add_compression_policy(...) that schedules automatic
compression of chunks older than olderThan. The table must have compression
enabled first (see enableCompression). A string is a PostgreSQL interval
literal; a number is a bare integer threshold (for integer-time hypertables).dialect.addCompressionPolicy('conditions', '30 days');
dialect.addCompressionPolicy('events', 1000000);
addRetentionPolicy(table, olderThan)#
Builds a SELECT add_retention_policy(...) that schedules automatic dropping of
chunks older than olderThan. Same string/number interval rules as above.dialect.addRetentionPolicy('conditions', '90 days');
dialect.addRetentionPolicy('events', 5000000);
createContinuousAggregate(viewName, query)#
Builds a CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) AS ...
statement for a continuous aggregate — a materialized view that TimescaleDB
refreshes incrementally as new data arrives. The view name is double-quoted; the
query is inserted verbatim (typically built with time_bucket(...)).dialect.createContinuousAggregate(
'daily_avg',
"SELECT time_bucket('1 day', time) AS bucket, avg(temperature) FROM conditions GROUP BY bucket",
);
4. Inherited PostgreSQL behavior#
TimescaleDB adds nothing to standard SQL generation — all CRUD and DDL is
inherited from PostgresDialect unchanged. This means double-quoted
identifiers, $N positional parameters, and PostgreSQL ON CONFLICT upserts.
The following are all verified against TimescaleDBDialect in
timescaledb-crud.test.ts.INSERT#
dialect.buildInsertQuery('readings', { device_id: 'sensor-1', temperature: 21.5 });
// sql: INSERT INTO "readings" ("device_id", "temperature") VALUES ($1, $2)
// values: ['sensor-1', 21.5]
{ returning: true } appends RETURNING *. Multi-row bulkInsert() is also
inherited (it routes its SQL through query()), producing
INSERT INTO "readings" ("device_id", "temperature") VALUES with one
parenthesised value tuple per row.SELECT#
dialect.buildSelectQuery({ tableName: 'readings' });
// SELECT * FROM "readings"
WHERE with $or / operators ($gt) → WHERE ... OR ..., "temperature" > $N
order / limit / offset → ORDER BY "temperature" DESC, LIMIT 10, OFFSET 20
include with required: true → INNER JOIN onto "devices" "device"
attributes accept raw expressions such as
"time_bucket('1 hour', time) AS bucket", with group / having producing
GROUP BY / HAVING — useful for time-series aggregation.
UPDATE / DELETE#
dialect.buildUpdateQuery('readings', { temperature: 25 }, { device_id: 'sensor-1' });
// sql: UPDATE "readings" SET "temperature" = $1 ... WHERE "device_id" = $...
// values: [25, 'sensor-1']
dialect.buildDeleteQuery('readings', { device_id: 'x' });
// sql: DELETE FROM "readings" WHERE "device_id" = $1
// values: ['x']
UPSERT (ON CONFLICT)#
dialect.buildUpsertQuery('readings', { device_id: 'a', temperature: 5 }, { conflictFields: ['device_id'] });
// INSERT INTO "readings" ... ON CONFLICT ("device_id") DO UPDATE SET "temperature" = excluded."temperature"
conflictFields is required — omitting it throws conflictFields is required
(inherited PostgreSQL safeguard).DDL#
All routed through the inherited async methods (verified via a query() stub):await dialect.createTable('conditions', { /* ... */ }, { ifNotExists: true });
// CREATE TABLE IF NOT EXISTS "conditions" (... "id" SERIAL ... "time" ...)
await dialect.dropTable('conditions', { ifExists: true, cascade: true });
// DROP TABLE IF EXISTS "conditions" CASCADE
await dialect.addColumn('conditions', 'humidity', { type: 'DOUBLE PRECISION' });
// ALTER TABLE "conditions" ADD COLUMN "humidity" ...
await dialect.removeColumn('conditions', 'humidity');
// ALTER TABLE "conditions" DROP COLUMN "humidity"
await dialect.addIndex('conditions', 'idx_device', ['device_id'], { unique: true });
// CREATE UNIQUE INDEX "idx_device" ON "conditions" ("device_id")
await dialect.removeIndex('conditions', 'idx_device');
// DROP INDEX "idx_device"
await dialect.addForeignKey('readings', 'device_id', 'devices', 'id');
// ALTER TABLE "readings" ADD CONSTRAINT ... FOREIGN KEY ("device_id") REFERENCES "devices"("id")
Pagination is inherited too: dialect.buildLimitOffset(5, 15) → ' LIMIT 5 OFFSET 15'.Inherited PostgreSQL quirks#
These are behaviors of the Postgres base builder, surfaced here only for
accuracy — they are not TimescaleDB-specific and are documented as-is in the
tests:bulkInsert placeholder numbering is non-sequential across rows (the base
advances values.length while emitting a row).
buildUpdateQuery does not re-offset the WHERE placeholder past the SET
params (it can emit $1 again); the ordered values array is what the driver
actually binds.
include joins omit a space between the join keyword and the table identifier
(e.g. INNER JOIN"devices").
5. Type mapping#
Type mapping is identical to PostgreSQL — getDataTypeSql() is inherited
unchanged. For example (verified on TimescaleDBDialect):dialect.getDataTypeSql({ key: 'INTEGER', autoIncrement: true }); // 'SERIAL'
dialect.getDataTypeSql({ key: 'DATE' }); // 'TIMESTAMP'
dialect.getDataTypeSql({ key: 'BOOLEAN' }); // 'BOOLEAN'
Column types like TIMESTAMPTZ and DOUBLE PRECISION pass straight through, as
they do in the Postgres dialect. Refer to the PostgreSQL dialect documentation
for the full type table — there are no TimescaleDB overrides.
6. Not-yet-verified#
Be honest about the boundary of what has been tested:SQL-generation verified (no database required). Every method and inherited
behavior above is exercised by connection-free unit tests that assert the exact
emitted SQL string. The time-series builders are pure functions, and the CRUD/DDL
tests either call the pure { sql, values } builders directly or capture SQL via
a stubbed query(). No real database is touched anywhere in the test suite.Requires a live TimescaleDB (not yet verified). The following are not
covered by the current tests and would need a running TimescaleDB instance:Actually executing CREATE EXTENSION timescaledb and confirming the extension
loads.
Executing create_hypertable(...) and confirming a real hypertable is created
and partitioned into chunks.
Runtime behavior of compression (enableCompression + add_compression_policy),
retention policies (add_retention_policy), and continuous aggregates
(CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)) — including
whether the generated SQL is accepted by the server for a given schema and
interval type, and whether background jobs run as scheduled.
End-to-end correctness of the inherited PostgreSQL quirks noted above against a
real pg connection.
In short: the dialect is confirmed to generate the correct TimescaleDB SQL;
it has not been confirmed to execute against a live TimescaleDB server.What differs from Postgres#
TimescaleDBDialect extends PostgresDialect (postgres) and
overrides 21 of its methods (Object.getOwnPropertyNames(TimescaleDBDialect.prototype),
computed by the scratch test); everything else is inherited unchanged.| Area | Overridden |
|---|
| DDL and schema | addCompressionPolicy, addContinuousAggregatePolicy, addDimension, addRetentionPolicy, createContinuousAggregate, createExtensionSQL, createHypertable, dropChunks, dropContinuousAggregate, enableCompression, refreshContinuousAggregate, removeCompressionPolicy, removeContinuousAggregatePolicy, removeRetentionPolicy |
| Introspection | showChunks |
| Other | compressChunk, decompressChunk, formatInterval, formatIntervalOrNull, reorderChunk, timeBucket |
Capabilities#
Computed from the code — capabilitiesOf() (src/transaction-support.ts),
whereSupportOf() (src/query-builders/expression-render.ts),
sqlObjectCapabilitiesOf() (src/sql-objects/capabilities.ts) and the
builders — by the scratch test described in the
capability matrix.| Capability | timescaledb |
|---|
| Transactions atomic | yes |
| Savepoints | yes |
| Isolation | up to SERIALIZABLE |
| Transaction modes | read-only yes; deferrable yes; constraint deferral yes |
| RETURNING | RETURNING on INSERT |
| Upsert | INSERT … ON CONFLICT (…) DO UPDATE |
| JSON column | JSON (JSONB → JSONB) |
| Vector column | VECTOR(3) |
| Array column | INTEGER[] |
| WHERE builder | 62 operator keys; $and/$or/$not; $exists: yes; $in subquery: yes; expression operands: yes |
| SQL objects | family postgres: triggers yes; procedures yes; functions multiStatement; materialized views native; sequences yes; enums named; RLS policy |
| Streaming | queryStream() present; LISTEN present; createExtension() present |
Testing#
tests/dialects/timescaledb.test.ts exercises the dialect with a mocked driver, so nothing touches a server. A live instance: prorm db up --dialect timescaledb (see Local databases).Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16