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.
@@ -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
+ };
@@ -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
- 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: ${rel.foreignKey})`)}`);
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)();
@@ -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 index_js_1 = require("../query/index.js");
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, index_js_1.quoteIdent)(TRACKING_TABLE);
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 result = await client.query(`SELECT pg_try_advisory_lock($1) AS locked`, [lockId]);
256
- return result.rows[0]?.locked ?? false;
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
- await client.query(`SELECT pg_advisory_unlock($1)`, [lockId]);
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 advisory lock to prevent concurrent migrations
327
- const gotLock = await acquireLock(client, lockId);
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 gotLock = await acquireLock(client, lockId);
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 {