turbine-orm 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -16
- package/dist/adapters/cockroachdb.d.ts +40 -0
- package/dist/adapters/cockroachdb.js +172 -0
- package/dist/adapters/index.d.ts +107 -0
- package/dist/adapters/index.js +83 -0
- package/dist/adapters/yugabytedb.d.ts +52 -0
- package/dist/adapters/yugabytedb.js +156 -0
- package/dist/cjs/adapters/cockroachdb.js +174 -0
- package/dist/cjs/adapters/index.js +87 -0
- package/dist/cjs/adapters/yugabytedb.js +158 -0
- package/dist/cjs/cli/index.js +2 -1
- package/dist/cjs/cli/migrate.js +18 -12
- package/dist/cjs/cli/studio.js +5 -4
- package/dist/cjs/generate.js +8 -1
- package/dist/cjs/index.js +10 -3
- package/dist/cjs/introspect.js +46 -18
- package/dist/cjs/query/builder.js +22 -5
- package/dist/cjs/query/index.js +2 -1
- package/dist/cjs/query/utils.js +18 -0
- package/dist/cjs/schema.js +8 -0
- package/dist/cli/config.d.ts +11 -0
- package/dist/cli/index.js +2 -1
- package/dist/cli/migrate.d.ts +3 -0
- package/dist/cli/migrate.js +16 -10
- package/dist/cli/studio.d.ts +4 -0
- package/dist/cli/studio.js +5 -4
- package/dist/generate.js +8 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/introspect.js +46 -18
- package/dist/query/builder.js +23 -6
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/utils.d.ts +8 -0
- package/dist/query/utils.js +17 -0
- package/dist/schema.d.ts +6 -4
- package/dist/schema.js +7 -0
- package/package.json +7 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm — YugabyteDB adapter
|
|
3
|
+
*
|
|
4
|
+
* YugabyteDB is a distributed SQL database that speaks the PostgreSQL wire
|
|
5
|
+
* protocol. It supports most PostgreSQL features including json_agg,
|
|
6
|
+
* subqueries, CTEs, and the standard information_schema.
|
|
7
|
+
*
|
|
8
|
+
* Key differences from PostgreSQL that this adapter addresses:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Advisory locks are per-node** — `pg_try_advisory_lock()` is supported
|
|
11
|
+
* but only scoped to the tserver node handling the connection. In a
|
|
12
|
+
* multi-node cluster, two concurrent `turbine migrate` runs routed to
|
|
13
|
+
* different nodes would both acquire the "same" advisory lock. This adapter
|
|
14
|
+
* provides a table-based distributed lock using `SELECT FOR UPDATE NOWAIT`
|
|
15
|
+
* which is cluster-wide via YugabyteDB's distributed transactions.
|
|
16
|
+
*
|
|
17
|
+
* 2. **Sequences may have gaps** — YugabyteDB uses distributed sequences.
|
|
18
|
+
* SERIAL/BIGSERIAL columns work correctly but may produce non-contiguous
|
|
19
|
+
* IDs under concurrent inserts. This is purely cosmetic and does not affect
|
|
20
|
+
* Turbine's behavior.
|
|
21
|
+
*
|
|
22
|
+
* 3. **pg_catalog** — Mostly complete. `pg_indexes`, `pg_type`, `pg_enum`,
|
|
23
|
+
* `information_schema.columns` all work. Row estimate via `pg_class.reltuples`
|
|
24
|
+
* may be stale or zero on recently created tables (YugabyteDB's stats
|
|
25
|
+
* collection is asynchronous). This adapter provides an override that
|
|
26
|
+
* falls back to `yb_table_properties` when available.
|
|
27
|
+
*
|
|
28
|
+
* Features that work identically to PostgreSQL (no adapter override needed):
|
|
29
|
+
* - `json_agg` / `json_build_object` — fully supported
|
|
30
|
+
* - Correlated subqueries — fully supported
|
|
31
|
+
* - `COALESCE`, `LIMIT`, `OFFSET`, `ORDER BY` — fully supported
|
|
32
|
+
* - `information_schema` for table/column/constraint introspection
|
|
33
|
+
* - Extended query protocol (parameterized queries, pipeline batching)
|
|
34
|
+
* - Transactions with `SAVEPOINT` (nested transactions)
|
|
35
|
+
* - `FOR UPDATE` / `FOR SHARE` row-level locking
|
|
36
|
+
* - All WHERE operators (LIKE, ILIKE, IN, etc.)
|
|
37
|
+
* - Array and JSON column types
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { yugabytedb } from 'turbine-orm/adapters';
|
|
42
|
+
*
|
|
43
|
+
* // In turbine.config.ts:
|
|
44
|
+
* export default {
|
|
45
|
+
* url: process.env.DATABASE_URL,
|
|
46
|
+
* adapter: yugabytedb,
|
|
47
|
+
* };
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Lock table SQL
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
const LOCK_TABLE = '_turbine_lock';
|
|
54
|
+
/**
|
|
55
|
+
* DDL for the table-based lock mechanism. Uses a single row per lock ID.
|
|
56
|
+
* `SELECT ... FOR UPDATE NOWAIT` on this row provides a cluster-wide mutex
|
|
57
|
+
* via YugabyteDB's distributed transaction layer.
|
|
58
|
+
*/
|
|
59
|
+
const CREATE_LOCK_TABLE_SQL = `
|
|
60
|
+
CREATE TABLE IF NOT EXISTS "${LOCK_TABLE}" (
|
|
61
|
+
lock_id INT PRIMARY KEY,
|
|
62
|
+
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
63
|
+
acquired_by TEXT
|
|
64
|
+
)
|
|
65
|
+
`;
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Introspection query overrides
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Row estimates override. YugabyteDB's pg_class.reltuples can be stale or
|
|
71
|
+
* zero. We still query it (it works) but the results may lag behind actual
|
|
72
|
+
* row counts. This query matches the PostgreSQL format so the introspection
|
|
73
|
+
* consumer doesn't need special handling.
|
|
74
|
+
*
|
|
75
|
+
* Note: YugabyteDB also exposes `yb_table_properties(oid)` but it returns
|
|
76
|
+
* tablet count, not row estimates. For most Turbine use cases (Studio UI
|
|
77
|
+
* row counts), pg_class is acceptable.
|
|
78
|
+
*/
|
|
79
|
+
const SQL_ROW_ESTIMATES_YBDB = `
|
|
80
|
+
SELECT
|
|
81
|
+
c.relname,
|
|
82
|
+
COALESCE(c.reltuples, 0)::text AS reltuples
|
|
83
|
+
FROM pg_class c
|
|
84
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
85
|
+
WHERE n.nspname = $1
|
|
86
|
+
AND c.relkind IN ('r', 'p')
|
|
87
|
+
`;
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Adapter implementation
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
export const yugabytedb = {
|
|
92
|
+
name: 'yugabytedb',
|
|
93
|
+
createLockTableSQL() {
|
|
94
|
+
return CREATE_LOCK_TABLE_SQL;
|
|
95
|
+
},
|
|
96
|
+
async acquireLock(client, lockId) {
|
|
97
|
+
// Ensure the lock table exists (idempotent)
|
|
98
|
+
await client.query(CREATE_LOCK_TABLE_SQL);
|
|
99
|
+
// Ensure the lock row exists (idempotent)
|
|
100
|
+
await client.query(`INSERT INTO "${LOCK_TABLE}" (lock_id) VALUES ($1) ON CONFLICT (lock_id) DO NOTHING`, [lockId]);
|
|
101
|
+
// Attempt to acquire the row-level lock with NOWAIT
|
|
102
|
+
// This is cluster-wide because YugabyteDB's row locks are distributed
|
|
103
|
+
try {
|
|
104
|
+
await client.query('BEGIN');
|
|
105
|
+
await client.query(`SELECT lock_id FROM "${LOCK_TABLE}" WHERE lock_id = $1 FOR UPDATE NOWAIT`, [lockId]);
|
|
106
|
+
// Update acquisition metadata for observability
|
|
107
|
+
await client.query(`UPDATE "${LOCK_TABLE}" SET acquired_at = now(), acquired_by = current_user WHERE lock_id = $1`, [lockId]);
|
|
108
|
+
// Leave the transaction open — lock is held until releaseLock()
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const pgErr = err;
|
|
113
|
+
// 55P03 = lock_not_available (NOWAIT couldn't acquire)
|
|
114
|
+
if (pgErr.code === '55P03') {
|
|
115
|
+
try {
|
|
116
|
+
await client.query('ROLLBACK');
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* ignore rollback errors */
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
// Any other error — rollback and re-throw
|
|
124
|
+
try {
|
|
125
|
+
await client.query('ROLLBACK');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* ignore */
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
async releaseLock(client, _lockId) {
|
|
134
|
+
// Commit the transaction to release the FOR UPDATE lock
|
|
135
|
+
try {
|
|
136
|
+
await client.query('COMMIT');
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// If commit fails, try rollback — either way the lock is released
|
|
140
|
+
try {
|
|
141
|
+
await client.query('ROLLBACK');
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* ignore */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
introspectionOverrides: {
|
|
149
|
+
rowEstimates: SQL_ROW_ESTIMATES_YBDB,
|
|
150
|
+
},
|
|
151
|
+
statementTimeout(seconds) {
|
|
152
|
+
// YugabyteDB supports standard PostgreSQL statement_timeout
|
|
153
|
+
return `SET LOCAL statement_timeout = '${seconds}s'`;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
//# sourceMappingURL=yugabytedb.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm — CockroachDB adapter
|
|
4
|
+
*
|
|
5
|
+
* CockroachDB speaks the PostgreSQL wire protocol but has key differences:
|
|
6
|
+
*
|
|
7
|
+
* 1. **No advisory locks** — `pg_try_advisory_lock()` is not supported.
|
|
8
|
+
* This adapter uses a `_turbine_lock` table with `SELECT FOR UPDATE NOWAIT`
|
|
9
|
+
* as a concurrency mechanism for migrations.
|
|
10
|
+
*
|
|
11
|
+
* 2. **No `SET LOCAL statement_timeout`** — CockroachDB uses
|
|
12
|
+
* `SET transaction_timeout` (v23.1+) for per-transaction time limits.
|
|
13
|
+
*
|
|
14
|
+
* 3. **`pg_indexes` view** — CockroachDB supports `pg_indexes` since v22.1
|
|
15
|
+
* but the `indexdef` column may not match Postgres exactly. We use
|
|
16
|
+
* `SHOW INDEXES` as a more reliable alternative.
|
|
17
|
+
*
|
|
18
|
+
* 4. **`pg_class.reltuples`** — Not reliable in CockroachDB. We use
|
|
19
|
+
* `crdb_internal.table_row_statistics` for row estimates.
|
|
20
|
+
*
|
|
21
|
+
* Known limitations with Turbine on CockroachDB:
|
|
22
|
+
* - `json_agg` works but NULL ordering within aggregates may differ
|
|
23
|
+
* - `SERIAL` columns use `unique_rowid()` instead of sequences
|
|
24
|
+
* - Schema introspection via information_schema works for tables, columns,
|
|
25
|
+
* constraints; pg_catalog has gaps for some metadata
|
|
26
|
+
* - Pipeline batching works (extended query protocol is supported)
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* import { cockroachdb } from 'turbine-orm/adapters';
|
|
31
|
+
*
|
|
32
|
+
* // In turbine.config.ts:
|
|
33
|
+
* export default {
|
|
34
|
+
* url: process.env.DATABASE_URL,
|
|
35
|
+
* adapter: cockroachdb,
|
|
36
|
+
* };
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.cockroachdb = void 0;
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Lock table SQL
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const LOCK_TABLE = '_turbine_lock';
|
|
45
|
+
/**
|
|
46
|
+
* DDL for the table-based lock mechanism. The table holds a single row per
|
|
47
|
+
* lock ID. `SELECT ... FOR UPDATE NOWAIT` on this row serves as a mutex.
|
|
48
|
+
*/
|
|
49
|
+
const CREATE_LOCK_TABLE_SQL = `
|
|
50
|
+
CREATE TABLE IF NOT EXISTS "${LOCK_TABLE}" (
|
|
51
|
+
lock_id INT PRIMARY KEY,
|
|
52
|
+
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
53
|
+
acquired_by TEXT
|
|
54
|
+
)
|
|
55
|
+
`;
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Introspection query overrides
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
/**
|
|
60
|
+
* CockroachDB index introspection via SHOW INDEXES.
|
|
61
|
+
* This returns a different shape than pg_indexes, so the consumer
|
|
62
|
+
* needs to handle the transformation. However, since we're providing this
|
|
63
|
+
* as a drop-in SQL string for the existing introspection flow, we use
|
|
64
|
+
* CockroachDB's pg_indexes compatibility view but with a fallback query
|
|
65
|
+
* that produces the same columns.
|
|
66
|
+
*
|
|
67
|
+
* CockroachDB's pg_indexes is compatible since v22.1 — we keep this
|
|
68
|
+
* override for older versions or when indexdef is incomplete.
|
|
69
|
+
*/
|
|
70
|
+
const SQL_INDEXES_CRDB = `
|
|
71
|
+
SELECT
|
|
72
|
+
tablename,
|
|
73
|
+
indexname,
|
|
74
|
+
indexdef
|
|
75
|
+
FROM pg_indexes
|
|
76
|
+
WHERE schemaname = $1
|
|
77
|
+
`;
|
|
78
|
+
/**
|
|
79
|
+
* Row estimates using crdb_internal. Falls back gracefully if the view
|
|
80
|
+
* doesn't exist (permissions issue) — returns 0 rows in that case.
|
|
81
|
+
*/
|
|
82
|
+
const SQL_ROW_ESTIMATES_CRDB = `
|
|
83
|
+
SELECT
|
|
84
|
+
t.name AS relname,
|
|
85
|
+
COALESCE(s.estimated_row_count, 0)::text AS reltuples
|
|
86
|
+
FROM crdb_internal.tables t
|
|
87
|
+
LEFT JOIN crdb_internal.table_row_statistics s
|
|
88
|
+
ON s.table_id = t.table_id
|
|
89
|
+
WHERE t.schema_name = $1
|
|
90
|
+
AND t.database_name = current_database()
|
|
91
|
+
`;
|
|
92
|
+
/**
|
|
93
|
+
* Enum introspection — CockroachDB supports pg_type/pg_enum since v20.2.
|
|
94
|
+
* The standard query works, but we include it explicitly so the override
|
|
95
|
+
* mechanism is complete.
|
|
96
|
+
*/
|
|
97
|
+
const SQL_ENUMS_CRDB = `
|
|
98
|
+
SELECT t.typname, e.enumlabel
|
|
99
|
+
FROM pg_type t
|
|
100
|
+
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
101
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
102
|
+
WHERE n.nspname = $1
|
|
103
|
+
ORDER BY t.typname, e.enumsortorder
|
|
104
|
+
`;
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Adapter implementation
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
exports.cockroachdb = {
|
|
109
|
+
name: 'cockroachdb',
|
|
110
|
+
createLockTableSQL() {
|
|
111
|
+
return CREATE_LOCK_TABLE_SQL;
|
|
112
|
+
},
|
|
113
|
+
async acquireLock(client, lockId) {
|
|
114
|
+
// Ensure the lock table exists
|
|
115
|
+
await client.query(CREATE_LOCK_TABLE_SQL);
|
|
116
|
+
// Insert the lock row if it doesn't exist (idempotent)
|
|
117
|
+
await client.query(`INSERT INTO "${LOCK_TABLE}" (lock_id) VALUES ($1) ON CONFLICT (lock_id) DO NOTHING`, [lockId]);
|
|
118
|
+
// Try to acquire the row lock with NOWAIT — fails immediately if held
|
|
119
|
+
try {
|
|
120
|
+
await client.query('BEGIN');
|
|
121
|
+
await client.query(`SELECT lock_id FROM "${LOCK_TABLE}" WHERE lock_id = $1 FOR UPDATE NOWAIT`, [lockId]);
|
|
122
|
+
// Update the acquired metadata
|
|
123
|
+
await client.query(`UPDATE "${LOCK_TABLE}" SET acquired_at = now(), acquired_by = current_user WHERE lock_id = $1`, [lockId]);
|
|
124
|
+
// Note: we leave the transaction OPEN — the lock is held until
|
|
125
|
+
// releaseLock() commits or rolls back.
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// NOWAIT throws error code 55P03 (lock_not_available) if the row is locked
|
|
130
|
+
const pgErr = err;
|
|
131
|
+
if (pgErr.code === '55P03') {
|
|
132
|
+
try {
|
|
133
|
+
await client.query('ROLLBACK');
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* ignore rollback errors */
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
// Any other error — rollback and re-throw
|
|
141
|
+
try {
|
|
142
|
+
await client.query('ROLLBACK');
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* ignore */
|
|
146
|
+
}
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
async releaseLock(client, _lockId) {
|
|
151
|
+
// Commit the transaction opened in acquireLock to release the FOR UPDATE lock
|
|
152
|
+
try {
|
|
153
|
+
await client.query('COMMIT');
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// If commit fails, try rollback (either way, lock is released)
|
|
157
|
+
try {
|
|
158
|
+
await client.query('ROLLBACK');
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* ignore */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
introspectionOverrides: {
|
|
166
|
+
indexes: SQL_INDEXES_CRDB,
|
|
167
|
+
enums: SQL_ENUMS_CRDB,
|
|
168
|
+
rowEstimates: SQL_ROW_ESTIMATES_CRDB,
|
|
169
|
+
},
|
|
170
|
+
statementTimeout(seconds) {
|
|
171
|
+
// CockroachDB v23.1+ supports transaction_timeout
|
|
172
|
+
return `SET transaction_timeout = '${seconds}s'`;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm — Database adapter interface
|
|
4
|
+
*
|
|
5
|
+
* Adapters allow Turbine to work with PostgreSQL-compatible databases that
|
|
6
|
+
* have subtle differences (e.g. CockroachDB, YugabyteDB). The default
|
|
7
|
+
* behavior remains standard PostgreSQL — adapters only override specific
|
|
8
|
+
* operations where compatibility gaps exist.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { cockroachdb } from 'turbine-orm/adapters';
|
|
13
|
+
*
|
|
14
|
+
* // Pass to TurbineCliConfig or migration functions
|
|
15
|
+
* const config = { url: process.env.DATABASE_URL, adapter: cockroachdb };
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.yugabytedb = exports.cockroachdb = exports.timescale = exports.alloydb = exports.postgresql = void 0;
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Default PostgreSQL adapter (no-op — standard behavior)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/**
|
|
24
|
+
* The default PostgreSQL adapter. Uses pg_try_advisory_lock, standard
|
|
25
|
+
* pg_catalog queries, and SET LOCAL statement_timeout.
|
|
26
|
+
*/
|
|
27
|
+
exports.postgresql = {
|
|
28
|
+
name: 'postgresql',
|
|
29
|
+
async acquireLock(client, lockId) {
|
|
30
|
+
const result = await client.query(`SELECT pg_try_advisory_lock($1) AS locked`, [lockId]);
|
|
31
|
+
return result.rows[0]?.locked ?? false;
|
|
32
|
+
},
|
|
33
|
+
async releaseLock(client, lockId) {
|
|
34
|
+
await client.query(`SELECT pg_advisory_unlock($1)`, [lockId]);
|
|
35
|
+
},
|
|
36
|
+
statementTimeout(seconds) {
|
|
37
|
+
return `SET LOCAL statement_timeout = '${seconds}s'`;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// AlloyDB — fully PostgreSQL-compatible, no adapter logic needed
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
/**
|
|
44
|
+
* Google AlloyDB adapter. AlloyDB is PostgreSQL with Google's columnar storage
|
|
45
|
+
* engine. It is wire-protocol and catalog-compatible — no adapter overrides
|
|
46
|
+
* are needed. All Turbine features (json_agg, advisory locks, introspection,
|
|
47
|
+
* migrations) work identically to standard PostgreSQL.
|
|
48
|
+
*
|
|
49
|
+
* This export exists for documentation and explicit configuration:
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* import { alloydb } from 'turbine-orm/adapters';
|
|
53
|
+
* const config = { url: process.env.DATABASE_URL, adapter: alloydb };
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
exports.alloydb = {
|
|
57
|
+
...exports.postgresql,
|
|
58
|
+
name: 'alloydb',
|
|
59
|
+
};
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// TimescaleDB — PostgreSQL extension, fully compatible
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* TimescaleDB adapter. Timescale is a PostgreSQL extension that adds
|
|
65
|
+
* hypertables, continuous aggregates, and time-series optimizations.
|
|
66
|
+
* Standard tables and hypertables both introspect via information_schema
|
|
67
|
+
* identically. Advisory locks, json_agg, and all other Turbine features
|
|
68
|
+
* work without modification.
|
|
69
|
+
*
|
|
70
|
+
* This export exists for documentation and explicit configuration:
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* import { timescale } from 'turbine-orm/adapters';
|
|
74
|
+
* const config = { url: process.env.DATABASE_URL, adapter: timescale };
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
exports.timescale = {
|
|
78
|
+
...exports.postgresql,
|
|
79
|
+
name: 'timescale',
|
|
80
|
+
};
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Re-exports
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
var cockroachdb_js_1 = require("./cockroachdb.js");
|
|
85
|
+
Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return cockroachdb_js_1.cockroachdb; } });
|
|
86
|
+
var yugabytedb_js_1 = require("./yugabytedb.js");
|
|
87
|
+
Object.defineProperty(exports, "yugabytedb", { enumerable: true, get: function () { return yugabytedb_js_1.yugabytedb; } });
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm — YugabyteDB adapter
|
|
4
|
+
*
|
|
5
|
+
* YugabyteDB is a distributed SQL database that speaks the PostgreSQL wire
|
|
6
|
+
* protocol. It supports most PostgreSQL features including json_agg,
|
|
7
|
+
* subqueries, CTEs, and the standard information_schema.
|
|
8
|
+
*
|
|
9
|
+
* Key differences from PostgreSQL that this adapter addresses:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Advisory locks are per-node** — `pg_try_advisory_lock()` is supported
|
|
12
|
+
* but only scoped to the tserver node handling the connection. In a
|
|
13
|
+
* multi-node cluster, two concurrent `turbine migrate` runs routed to
|
|
14
|
+
* different nodes would both acquire the "same" advisory lock. This adapter
|
|
15
|
+
* provides a table-based distributed lock using `SELECT FOR UPDATE NOWAIT`
|
|
16
|
+
* which is cluster-wide via YugabyteDB's distributed transactions.
|
|
17
|
+
*
|
|
18
|
+
* 2. **Sequences may have gaps** — YugabyteDB uses distributed sequences.
|
|
19
|
+
* SERIAL/BIGSERIAL columns work correctly but may produce non-contiguous
|
|
20
|
+
* IDs under concurrent inserts. This is purely cosmetic and does not affect
|
|
21
|
+
* Turbine's behavior.
|
|
22
|
+
*
|
|
23
|
+
* 3. **pg_catalog** — Mostly complete. `pg_indexes`, `pg_type`, `pg_enum`,
|
|
24
|
+
* `information_schema.columns` all work. Row estimate via `pg_class.reltuples`
|
|
25
|
+
* may be stale or zero on recently created tables (YugabyteDB's stats
|
|
26
|
+
* collection is asynchronous). This adapter provides an override that
|
|
27
|
+
* falls back to `yb_table_properties` when available.
|
|
28
|
+
*
|
|
29
|
+
* Features that work identically to PostgreSQL (no adapter override needed):
|
|
30
|
+
* - `json_agg` / `json_build_object` — fully supported
|
|
31
|
+
* - Correlated subqueries — fully supported
|
|
32
|
+
* - `COALESCE`, `LIMIT`, `OFFSET`, `ORDER BY` — fully supported
|
|
33
|
+
* - `information_schema` for table/column/constraint introspection
|
|
34
|
+
* - Extended query protocol (parameterized queries, pipeline batching)
|
|
35
|
+
* - Transactions with `SAVEPOINT` (nested transactions)
|
|
36
|
+
* - `FOR UPDATE` / `FOR SHARE` row-level locking
|
|
37
|
+
* - All WHERE operators (LIKE, ILIKE, IN, etc.)
|
|
38
|
+
* - Array and JSON column types
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { yugabytedb } from 'turbine-orm/adapters';
|
|
43
|
+
*
|
|
44
|
+
* // In turbine.config.ts:
|
|
45
|
+
* export default {
|
|
46
|
+
* url: process.env.DATABASE_URL,
|
|
47
|
+
* adapter: yugabytedb,
|
|
48
|
+
* };
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
|
+
exports.yugabytedb = void 0;
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Lock table SQL
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
const LOCK_TABLE = '_turbine_lock';
|
|
57
|
+
/**
|
|
58
|
+
* DDL for the table-based lock mechanism. Uses a single row per lock ID.
|
|
59
|
+
* `SELECT ... FOR UPDATE NOWAIT` on this row provides a cluster-wide mutex
|
|
60
|
+
* via YugabyteDB's distributed transaction layer.
|
|
61
|
+
*/
|
|
62
|
+
const CREATE_LOCK_TABLE_SQL = `
|
|
63
|
+
CREATE TABLE IF NOT EXISTS "${LOCK_TABLE}" (
|
|
64
|
+
lock_id INT PRIMARY KEY,
|
|
65
|
+
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
66
|
+
acquired_by TEXT
|
|
67
|
+
)
|
|
68
|
+
`;
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Introspection query overrides
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
/**
|
|
73
|
+
* Row estimates override. YugabyteDB's pg_class.reltuples can be stale or
|
|
74
|
+
* zero. We still query it (it works) but the results may lag behind actual
|
|
75
|
+
* row counts. This query matches the PostgreSQL format so the introspection
|
|
76
|
+
* consumer doesn't need special handling.
|
|
77
|
+
*
|
|
78
|
+
* Note: YugabyteDB also exposes `yb_table_properties(oid)` but it returns
|
|
79
|
+
* tablet count, not row estimates. For most Turbine use cases (Studio UI
|
|
80
|
+
* row counts), pg_class is acceptable.
|
|
81
|
+
*/
|
|
82
|
+
const SQL_ROW_ESTIMATES_YBDB = `
|
|
83
|
+
SELECT
|
|
84
|
+
c.relname,
|
|
85
|
+
COALESCE(c.reltuples, 0)::text AS reltuples
|
|
86
|
+
FROM pg_class c
|
|
87
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
88
|
+
WHERE n.nspname = $1
|
|
89
|
+
AND c.relkind IN ('r', 'p')
|
|
90
|
+
`;
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Adapter implementation
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
exports.yugabytedb = {
|
|
95
|
+
name: 'yugabytedb',
|
|
96
|
+
createLockTableSQL() {
|
|
97
|
+
return CREATE_LOCK_TABLE_SQL;
|
|
98
|
+
},
|
|
99
|
+
async acquireLock(client, lockId) {
|
|
100
|
+
// Ensure the lock table exists (idempotent)
|
|
101
|
+
await client.query(CREATE_LOCK_TABLE_SQL);
|
|
102
|
+
// Ensure the lock row exists (idempotent)
|
|
103
|
+
await client.query(`INSERT INTO "${LOCK_TABLE}" (lock_id) VALUES ($1) ON CONFLICT (lock_id) DO NOTHING`, [lockId]);
|
|
104
|
+
// Attempt to acquire the row-level lock with NOWAIT
|
|
105
|
+
// This is cluster-wide because YugabyteDB's row locks are distributed
|
|
106
|
+
try {
|
|
107
|
+
await client.query('BEGIN');
|
|
108
|
+
await client.query(`SELECT lock_id FROM "${LOCK_TABLE}" WHERE lock_id = $1 FOR UPDATE NOWAIT`, [lockId]);
|
|
109
|
+
// Update acquisition metadata for observability
|
|
110
|
+
await client.query(`UPDATE "${LOCK_TABLE}" SET acquired_at = now(), acquired_by = current_user WHERE lock_id = $1`, [lockId]);
|
|
111
|
+
// Leave the transaction open — lock is held until releaseLock()
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const pgErr = err;
|
|
116
|
+
// 55P03 = lock_not_available (NOWAIT couldn't acquire)
|
|
117
|
+
if (pgErr.code === '55P03') {
|
|
118
|
+
try {
|
|
119
|
+
await client.query('ROLLBACK');
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* ignore rollback errors */
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
// Any other error — rollback and re-throw
|
|
127
|
+
try {
|
|
128
|
+
await client.query('ROLLBACK');
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* ignore */
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
async releaseLock(client, _lockId) {
|
|
137
|
+
// Commit the transaction to release the FOR UPDATE lock
|
|
138
|
+
try {
|
|
139
|
+
await client.query('COMMIT');
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// If commit fails, try rollback — either way the lock is released
|
|
143
|
+
try {
|
|
144
|
+
await client.query('ROLLBACK');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
introspectionOverrides: {
|
|
152
|
+
rowEstimates: SQL_ROW_ESTIMATES_YBDB,
|
|
153
|
+
},
|
|
154
|
+
statementTimeout(seconds) {
|
|
155
|
+
// YugabyteDB supports standard PostgreSQL statement_timeout
|
|
156
|
+
return `SET LOCAL statement_timeout = '${seconds}s'`;
|
|
157
|
+
},
|
|
158
|
+
};
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -923,7 +923,8 @@ async function cmdStatus(_args, config) {
|
|
|
923
923
|
const isLast = i === rels.length - 1;
|
|
924
924
|
const prefix = isLast ? ui_js_1.symbols.teeEnd : ui_js_1.symbols.tee;
|
|
925
925
|
const relColor = rel.type === 'hasMany' ? ui_js_1.blue : ui_js_1.yellow;
|
|
926
|
-
|
|
926
|
+
const fkDisplay = Array.isArray(rel.foreignKey) ? rel.foreignKey.join(', ') : rel.foreignKey;
|
|
927
|
+
console.log(` ${(0, ui_js_1.dim)(prefix)} ${relColor(relName)} ${(0, ui_js_1.dim)(ui_js_1.symbols.arrow)} ${rel.to} ${(0, ui_js_1.dim)(`(${rel.type}, FK: ${fkDisplay})`)}`);
|
|
927
928
|
}
|
|
928
929
|
}
|
|
929
930
|
(0, ui_js_1.newline)();
|
package/dist/cjs/cli/migrate.js
CHANGED
|
@@ -32,13 +32,14 @@ const node_crypto_1 = require("node:crypto");
|
|
|
32
32
|
const node_fs_1 = require("node:fs");
|
|
33
33
|
const node_path_1 = require("node:path");
|
|
34
34
|
const pg_1 = __importDefault(require("pg"));
|
|
35
|
+
const index_js_1 = require("../adapters/index.js");
|
|
35
36
|
const errors_js_1 = require("../errors.js");
|
|
36
|
-
const
|
|
37
|
+
const index_js_2 = require("../query/index.js");
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
// Tracking table management
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
41
|
const TRACKING_TABLE = '_turbine_migrations';
|
|
41
|
-
const QUOTED_TRACKING_TABLE = (0,
|
|
42
|
+
const QUOTED_TRACKING_TABLE = (0, index_js_2.quoteIdent)(TRACKING_TABLE);
|
|
42
43
|
const CREATE_TRACKING_TABLE = `
|
|
43
44
|
CREATE TABLE IF NOT EXISTS ${QUOTED_TRACKING_TABLE} (
|
|
44
45
|
id SERIAL PRIMARY KEY,
|
|
@@ -251,12 +252,14 @@ async function getCurrentDatabaseName(client) {
|
|
|
251
252
|
const result = await client.query(`SELECT current_database()`);
|
|
252
253
|
return result.rows[0]?.current_database ?? '';
|
|
253
254
|
}
|
|
254
|
-
async function acquireLock(client, lockId) {
|
|
255
|
-
const
|
|
256
|
-
|
|
255
|
+
async function acquireLock(client, lockId, adapter) {
|
|
256
|
+
const a = adapter ?? index_js_1.postgresql;
|
|
257
|
+
// pg.Client satisfies PgCompatPoolClient (query + release)
|
|
258
|
+
return a.acquireLock(client, lockId);
|
|
257
259
|
}
|
|
258
|
-
async function releaseLock(client, lockId) {
|
|
259
|
-
|
|
260
|
+
async function releaseLock(client, lockId, adapter) {
|
|
261
|
+
const a = adapter ?? index_js_1.postgresql;
|
|
262
|
+
await a.releaseLock(client, lockId);
|
|
260
263
|
}
|
|
261
264
|
/**
|
|
262
265
|
* Validate that applied migration files have not been modified or deleted since they were run.
|
|
@@ -323,8 +326,10 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
323
326
|
// sibling databases on the same Postgres cluster do not contend.
|
|
324
327
|
const dbName = await getCurrentDatabaseName(client);
|
|
325
328
|
const lockId = deriveLockId(dbName);
|
|
326
|
-
// Acquire
|
|
327
|
-
|
|
329
|
+
// Acquire lock to prevent concurrent migrations.
|
|
330
|
+
// The adapter determines the strategy (advisory lock vs table lock).
|
|
331
|
+
const adapter = options?.adapter;
|
|
332
|
+
const gotLock = await acquireLock(client, lockId, adapter);
|
|
328
333
|
if (!gotLock) {
|
|
329
334
|
throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
|
|
330
335
|
}
|
|
@@ -396,7 +401,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
396
401
|
return { applied: results, errors };
|
|
397
402
|
}
|
|
398
403
|
finally {
|
|
399
|
-
await releaseLock(client, lockId);
|
|
404
|
+
await releaseLock(client, lockId, adapter);
|
|
400
405
|
}
|
|
401
406
|
}
|
|
402
407
|
finally {
|
|
@@ -419,7 +424,8 @@ async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
419
424
|
// sibling databases on the same cluster do not contend.
|
|
420
425
|
const dbName = await getCurrentDatabaseName(client);
|
|
421
426
|
const lockId = deriveLockId(dbName);
|
|
422
|
-
const
|
|
427
|
+
const adapter = options?.adapter;
|
|
428
|
+
const gotLock = await acquireLock(client, lockId, adapter);
|
|
423
429
|
if (!gotLock) {
|
|
424
430
|
throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
|
|
425
431
|
}
|
|
@@ -466,7 +472,7 @@ async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
466
472
|
return { rolledBack: results, errors };
|
|
467
473
|
}
|
|
468
474
|
finally {
|
|
469
|
-
await releaseLock(client, lockId);
|
|
475
|
+
await releaseLock(client, lockId, adapter);
|
|
470
476
|
}
|
|
471
477
|
}
|
|
472
478
|
finally {
|