@zmdb/cockroach 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +111 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +97 -0
- package/dist/index.js.map +1 -0
- package/dist/introspect.d.ts +2 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.js +246 -0
- package/dist/introspect.js.map +1 -0
- package/dist/migrations.d.ts +7 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +118 -0
- package/dist/migrations.js.map +1 -0
- package/package.json +54 -0
- package/src/index.ts +146 -0
- package/src/introspect.ts +289 -0
- package/src/migrations.ts +145 -0
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zmdb/cockroach",
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"description": "CockroachDB vertical for zmdb: PostgreSQL-family dialect overrides, migrations, catalog introspection, retries, and a pg-protocol driver.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cockroachdb",
|
|
7
|
+
"database",
|
|
8
|
+
"migrations",
|
|
9
|
+
"orm",
|
|
10
|
+
"postgresql",
|
|
11
|
+
"typescript",
|
|
12
|
+
"zmdb"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/ambasta/zmdb#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/ambasta/zmdb/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "GPL-3.0-or-later",
|
|
19
|
+
"author": "zmdb contributors",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/ambasta/zmdb.git",
|
|
23
|
+
"directory": "packages/cockroach"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"tag": "beta"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "node ../../scripts/build-package.mjs",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@zmdb/migrations": "1.0.0-beta.1",
|
|
43
|
+
"@zmdb/postgres": "1.0.0-beta.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@zmdb/orm": "1.0.0-beta.1",
|
|
47
|
+
"@zmdb/sql": "1.0.0-beta.1"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=26"
|
|
51
|
+
},
|
|
52
|
+
"main": "./dist/index.js",
|
|
53
|
+
"types": "./dist/index.d.ts"
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type DatabaseVertical, type TransactionalDriver } from '@zmdb/orm';
|
|
2
|
+
import { postgres, postgresFamilyDriver, type PgConnection, type PgOptions, type PgQueryable } from '@zmdb/postgres';
|
|
3
|
+
import { extendSqlDialect, UnsupportedFeatureError, type SqlDialect } from '@zmdb/sql';
|
|
4
|
+
|
|
5
|
+
import { cockroachIntrospector } from './introspect.js';
|
|
6
|
+
import { COCKROACH_TYPE_OVERRIDES, cockroachMigrations } from './migrations.js';
|
|
7
|
+
|
|
8
|
+
export type { PgConnection, PgOptions, PgQueryable };
|
|
9
|
+
export { cockroachIntrospector, cockroachMigrations };
|
|
10
|
+
|
|
11
|
+
interface PgPoolLike extends PgQueryable {
|
|
12
|
+
readonly totalCount: number;
|
|
13
|
+
readonly idleCount: number;
|
|
14
|
+
connect(): Promise<PgConnection>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type PgQueryConfig = {
|
|
18
|
+
readonly name?: string;
|
|
19
|
+
readonly queryMode?: 'extended';
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly values?: readonly unknown[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function isPoolLike(client: PgQueryable): client is PgPoolLike {
|
|
25
|
+
return (
|
|
26
|
+
typeof client.connect === 'function' &&
|
|
27
|
+
typeof Reflect.get(client, 'totalCount') === 'number' &&
|
|
28
|
+
typeof Reflect.get(client, 'idleCount') === 'number'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeBackendPid(
|
|
33
|
+
input: string | PgQueryConfig,
|
|
34
|
+
result: { rows: Record<string, unknown>[] },
|
|
35
|
+
): { rows: Record<string, unknown>[] } {
|
|
36
|
+
const text = typeof input === 'string' ? input : input.text;
|
|
37
|
+
if (text.trim().toLowerCase() !== 'select pg_backend_pid() as pid') return result;
|
|
38
|
+
return {
|
|
39
|
+
rows: result.rows.map(row => {
|
|
40
|
+
const pid = Reflect.get(row, 'pid');
|
|
41
|
+
if (typeof pid !== 'string' || !/^\d+$/.test(pid)) return row;
|
|
42
|
+
const numeric = Number(pid);
|
|
43
|
+
return Number.isSafeInteger(numeric) && numeric > 0 ? { ...row, pid: numeric } : row;
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class CockroachClient implements PgConnection {
|
|
49
|
+
private readonly target: PgConnection;
|
|
50
|
+
|
|
51
|
+
constructor(target: PgConnection) {
|
|
52
|
+
this.target = target;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
query(text: string, params?: readonly unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
|
|
56
|
+
query(config: PgQueryConfig): Promise<{ rows: Record<string, unknown>[] }>;
|
|
57
|
+
async query(
|
|
58
|
+
input: string | PgQueryConfig,
|
|
59
|
+
params?: readonly unknown[],
|
|
60
|
+
): Promise<{ rows: Record<string, unknown>[] }> {
|
|
61
|
+
const result = typeof input === 'string' ? await this.target.query(input, params) : await this.target.query(input);
|
|
62
|
+
return normalizeBackendPid(input, result);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
release(): void {
|
|
66
|
+
const release = this.target.release;
|
|
67
|
+
if (release !== undefined) release.call(this.target);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class CockroachQueryable implements PgQueryable {
|
|
72
|
+
private readonly target: PgQueryable;
|
|
73
|
+
|
|
74
|
+
constructor(target: PgQueryable) {
|
|
75
|
+
this.target = target;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
query(text: string, params?: readonly unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
|
|
79
|
+
query(config: PgQueryConfig): Promise<{ rows: Record<string, unknown>[] }>;
|
|
80
|
+
async query(
|
|
81
|
+
input: string | PgQueryConfig,
|
|
82
|
+
params?: readonly unknown[],
|
|
83
|
+
): Promise<{ rows: Record<string, unknown>[] }> {
|
|
84
|
+
const result = typeof input === 'string' ? await this.target.query(input, params) : await this.target.query(input);
|
|
85
|
+
return normalizeBackendPid(input, result);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class CockroachPool extends CockroachQueryable implements PgPoolLike {
|
|
90
|
+
private readonly pool: PgPoolLike;
|
|
91
|
+
|
|
92
|
+
constructor(pool: PgPoolLike) {
|
|
93
|
+
super(pool);
|
|
94
|
+
this.pool = pool;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get totalCount(): number {
|
|
98
|
+
return this.pool.totalCount;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get idleCount(): number {
|
|
102
|
+
return this.pool.idleCount;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async connect(): Promise<PgConnection> {
|
|
106
|
+
return new CockroachClient(await this.pool.connect());
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cockroachQueryable(client: PgQueryable): PgQueryable {
|
|
111
|
+
return isPoolLike(client) ? new CockroachPool(client) : new CockroachQueryable(client);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const cockroach: SqlDialect<'cockroach'> = extendSqlDialect(postgres, {
|
|
115
|
+
name: 'cockroach',
|
|
116
|
+
traits: {
|
|
117
|
+
types: COCKROACH_TYPE_OVERRIDES,
|
|
118
|
+
fts: 'none',
|
|
119
|
+
retryableCodes: ['40001'],
|
|
120
|
+
vectorDistance: false,
|
|
121
|
+
spatialPredicates: false,
|
|
122
|
+
},
|
|
123
|
+
capabilities: {
|
|
124
|
+
transactionalDdl: false,
|
|
125
|
+
rowLevelSecurity: false,
|
|
126
|
+
cancellation: false,
|
|
127
|
+
},
|
|
128
|
+
migrations: cockroachMigrations,
|
|
129
|
+
introspector: cockroachIntrospector,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export function cockroachDriver(client: PgQueryable, options?: PgOptions): TransactionalDriver<'cockroach'> {
|
|
133
|
+
if (options?.cancelVia !== undefined) {
|
|
134
|
+
throw new UnsupportedFeatureError(
|
|
135
|
+
'server-side cancellation',
|
|
136
|
+
'cockroach',
|
|
137
|
+
'cockroach does not provide PostgreSQL pg_cancel_backend(); omit PgOptions.cancelVia',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return postgresFamilyDriver(cockroach, cockroachQueryable(client), options);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const cockroachVertical: DatabaseVertical<'cockroach', PgQueryable, PgOptions> = Object.freeze({
|
|
144
|
+
dialect: cockroach,
|
|
145
|
+
driver: cockroachDriver,
|
|
146
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CatalogRowError,
|
|
3
|
+
integerField,
|
|
4
|
+
query,
|
|
5
|
+
textField,
|
|
6
|
+
type CatalogColumnSnapshot,
|
|
7
|
+
type CatalogIndexColumn,
|
|
8
|
+
type CatalogIndexSnapshot,
|
|
9
|
+
type CatalogSchemaSnapshot,
|
|
10
|
+
type CatalogTableSnapshot,
|
|
11
|
+
type IntrospectionDriver,
|
|
12
|
+
} from '@zmdb/migrations/introspect/runtime';
|
|
13
|
+
import { postgresFamilyIntrospector } from '@zmdb/postgres';
|
|
14
|
+
import { type IntrospectOptions } from '@zmdb/sql';
|
|
15
|
+
|
|
16
|
+
interface CockroachIndexRow {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly nonUnique: boolean;
|
|
19
|
+
readonly sequence: number;
|
|
20
|
+
readonly column: string;
|
|
21
|
+
readonly definition: string;
|
|
22
|
+
readonly storing: boolean;
|
|
23
|
+
readonly implicit: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function quoteIdentifier(identifier: string): string {
|
|
27
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function selectedSchemas(options: IntrospectOptions | undefined): readonly string[] {
|
|
31
|
+
const schemas = options?.schemas ?? ['public'];
|
|
32
|
+
return schemas.length === 0 ? ['public'] : [...new Set(schemas)].toSorted();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function placeholders(count: number): string {
|
|
36
|
+
return Array.from({ length: count }, (_, index) => `$${String(index + 1)}`).join(', ');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function booleanField(row: Readonly<Record<string, unknown>>, field: string, catalog: string, index: number): boolean {
|
|
40
|
+
const value = Reflect.get(row, field);
|
|
41
|
+
if (typeof value === 'boolean') return value;
|
|
42
|
+
if (value === 't' || value === 'true' || value === 1 || value === '1') return true;
|
|
43
|
+
if (value === 'f' || value === 'false' || value === 0 || value === '0') return false;
|
|
44
|
+
throw new CatalogRowError(catalog, index, field, 'a boolean', value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseIndexRow(row: Readonly<Record<string, unknown>>, index: number): CockroachIndexRow {
|
|
48
|
+
const catalog = 'cockroach SHOW INDEXES';
|
|
49
|
+
return {
|
|
50
|
+
name: textField(row, 'index_name', catalog, index),
|
|
51
|
+
nonUnique: booleanField(row, 'non_unique', catalog, index),
|
|
52
|
+
sequence: integerField(row, 'seq_in_index', catalog, index),
|
|
53
|
+
column: textField(row, 'column_name', catalog, index),
|
|
54
|
+
definition: textField(row, 'definition', catalog, index),
|
|
55
|
+
storing: booleanField(row, 'storing', catalog, index),
|
|
56
|
+
implicit: booleanField(row, 'implicit', catalog, index),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unquoteIdentifier(value: string): string {
|
|
61
|
+
return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replaceAll('""', '"') : value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function balancedOuterParentheses(value: string): boolean {
|
|
65
|
+
if (!value.startsWith('(') || !value.endsWith(')')) return false;
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let quoteCharacter: "'" | '"' | undefined;
|
|
68
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
69
|
+
const character = value[index];
|
|
70
|
+
if (quoteCharacter !== undefined) {
|
|
71
|
+
if (character === quoteCharacter) {
|
|
72
|
+
if (value[index + 1] === quoteCharacter) index += 1;
|
|
73
|
+
else quoteCharacter = undefined;
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (character === "'" || character === '"') {
|
|
78
|
+
quoteCharacter = character;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (character === '(') depth += 1;
|
|
82
|
+
if (character === ')') depth -= 1;
|
|
83
|
+
if (depth === 0 && index < value.length - 1) return false;
|
|
84
|
+
}
|
|
85
|
+
return depth === 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stripOuterParentheses(value: string): string {
|
|
89
|
+
let current = value.trim();
|
|
90
|
+
while (balancedOuterParentheses(current)) current = current.slice(1, -1).trim();
|
|
91
|
+
return current;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function indexColumn(row: CockroachIndexRow, tableColumns: ReadonlySet<string>): CatalogIndexColumn {
|
|
95
|
+
if (!row.column.startsWith('crdb_internal_') && tableColumns.has(row.column)) return row.column;
|
|
96
|
+
const definition = row.definition.trim();
|
|
97
|
+
const identifier = /^("(?:[^"]|"")*"|[A-Za-z_][A-Za-z0-9_$]*)$/.exec(definition)?.[1];
|
|
98
|
+
if (identifier !== undefined) {
|
|
99
|
+
const column = unquoteIdentifier(identifier);
|
|
100
|
+
if (tableColumns.has(column)) return column;
|
|
101
|
+
}
|
|
102
|
+
return { expr: stripOuterParentheses(definition) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function identifierAt(value: string, start: number): { readonly name: string; readonly end: number } | undefined {
|
|
106
|
+
if (value[start] === '"') {
|
|
107
|
+
let index = start + 1;
|
|
108
|
+
let name = '';
|
|
109
|
+
while (index < value.length) {
|
|
110
|
+
const character = value[index];
|
|
111
|
+
if (character === '"') {
|
|
112
|
+
if (value[index + 1] === '"') {
|
|
113
|
+
name += '"';
|
|
114
|
+
index += 2;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
return { name, end: index + 1 };
|
|
118
|
+
}
|
|
119
|
+
if (character === undefined) return undefined;
|
|
120
|
+
name += character;
|
|
121
|
+
index += 1;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const match = /^[A-Za-z_][A-Za-z0-9_$]*/.exec(value.slice(start));
|
|
126
|
+
const name = match?.[0];
|
|
127
|
+
return name === undefined ? undefined : { name, end: start + name.length };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function matchingParenthesis(value: string, open: number): number | undefined {
|
|
131
|
+
let depth = 0;
|
|
132
|
+
let quoteCharacter: "'" | '"' | undefined;
|
|
133
|
+
for (let index = open; index < value.length; index += 1) {
|
|
134
|
+
const character = value[index];
|
|
135
|
+
if (quoteCharacter !== undefined) {
|
|
136
|
+
if (character === quoteCharacter) {
|
|
137
|
+
if (value[index + 1] === quoteCharacter) index += 1;
|
|
138
|
+
else quoteCharacter = undefined;
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (character === "'" || character === '"') {
|
|
143
|
+
quoteCharacter = character;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (character === '(') depth += 1;
|
|
147
|
+
if (character === ')') {
|
|
148
|
+
depth -= 1;
|
|
149
|
+
if (depth === 0) return index;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function indexPredicates(createStatement: string): ReadonlyMap<string, string> {
|
|
156
|
+
const predicates = new Map<string, string>();
|
|
157
|
+
for (const rawLine of createStatement.split('\n')) {
|
|
158
|
+
const line = rawLine.trim().replace(/,$/, '');
|
|
159
|
+
const prefix = /^(?:UNIQUE\s+)?INDEX\s+/i.exec(line)?.[0];
|
|
160
|
+
if (prefix === undefined) continue;
|
|
161
|
+
const identifier = identifierAt(line, prefix.length);
|
|
162
|
+
if (identifier === undefined) continue;
|
|
163
|
+
const open = line.indexOf('(', identifier.end);
|
|
164
|
+
if (open === -1) continue;
|
|
165
|
+
const close = matchingParenthesis(line, open);
|
|
166
|
+
if (close === undefined) continue;
|
|
167
|
+
const suffix = line.slice(close + 1);
|
|
168
|
+
const where = /\sWHERE\s+(.+)$/i.exec(suffix)?.[1]?.trim();
|
|
169
|
+
if (where !== undefined && where.length > 0) predicates.set(identifier.name, where);
|
|
170
|
+
}
|
|
171
|
+
return predicates;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function indexes(
|
|
175
|
+
rows: readonly CockroachIndexRow[],
|
|
176
|
+
primaryIndexes: ReadonlySet<string>,
|
|
177
|
+
tableColumns: ReadonlySet<string>,
|
|
178
|
+
predicates: ReadonlyMap<string, string>,
|
|
179
|
+
): readonly CatalogIndexSnapshot[] {
|
|
180
|
+
const grouped = new Map<string, CockroachIndexRow[]>();
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
if (primaryIndexes.has(row.name) || row.storing || row.implicit) continue;
|
|
183
|
+
const values = grouped.get(row.name);
|
|
184
|
+
if (values === undefined) grouped.set(row.name, [row]);
|
|
185
|
+
else values.push(row);
|
|
186
|
+
}
|
|
187
|
+
return [...grouped]
|
|
188
|
+
.map(([name, values]) => {
|
|
189
|
+
const sorted = values.toSorted((left, right) => left.sequence - right.sequence);
|
|
190
|
+
const first = sorted[0];
|
|
191
|
+
if (first === undefined) throw new Error(`cockroach index "${name}" has no key columns`);
|
|
192
|
+
const where = predicates.get(name);
|
|
193
|
+
return {
|
|
194
|
+
name,
|
|
195
|
+
columns: sorted.map(row => indexColumn(row, tableColumns)),
|
|
196
|
+
unique: !first.nonUnique,
|
|
197
|
+
...(where === undefined ? {} : { where }),
|
|
198
|
+
};
|
|
199
|
+
})
|
|
200
|
+
.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function schemasByTable(
|
|
204
|
+
driver: IntrospectionDriver,
|
|
205
|
+
options: IntrospectOptions | undefined,
|
|
206
|
+
): Promise<ReadonlyMap<string, string>> {
|
|
207
|
+
const schemas = selectedSchemas(options);
|
|
208
|
+
const rows = await driver.execute(
|
|
209
|
+
query(
|
|
210
|
+
`SELECT table_schema, table_name FROM information_schema.tables ` +
|
|
211
|
+
`WHERE table_type = 'BASE TABLE' AND table_schema IN (${placeholders(schemas.length)}) ` +
|
|
212
|
+
'ORDER BY table_schema, table_name',
|
|
213
|
+
schemas,
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
return new Map(
|
|
217
|
+
rows.map((row, index) => [
|
|
218
|
+
textField(row, 'table_name', 'cockroach information_schema.tables', index),
|
|
219
|
+
textField(row, 'table_schema', 'cockroach information_schema.tables', index),
|
|
220
|
+
]),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cockroachColumn(column: CatalogColumnSnapshot): CatalogColumnSnapshot {
|
|
225
|
+
const defaultValue = column.default?.trim().toLowerCase();
|
|
226
|
+
if (
|
|
227
|
+
column.type !== 'bigint' ||
|
|
228
|
+
defaultValue === undefined ||
|
|
229
|
+
!/^unique_rowid\(\)(?:::[a-z0-9_ ]+)?$/.test(defaultValue)
|
|
230
|
+
) {
|
|
231
|
+
return column;
|
|
232
|
+
}
|
|
233
|
+
const { default: ignoredDefault, ...withoutDefault } = column;
|
|
234
|
+
void ignoredDefault;
|
|
235
|
+
return { ...withoutDefault, type: 'serial' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function cockroachTable(
|
|
239
|
+
driver: IntrospectionDriver,
|
|
240
|
+
schema: string,
|
|
241
|
+
table: CatalogTableSnapshot,
|
|
242
|
+
): Promise<CatalogTableSnapshot> {
|
|
243
|
+
const qualified = `${quoteIdentifier(schema)}.${quoteIdentifier(table.name)}`;
|
|
244
|
+
const [indexRows, constraintRows, createRows] = await Promise.all([
|
|
245
|
+
driver.execute(query(`SHOW INDEXES FROM ${qualified}`)),
|
|
246
|
+
driver.execute(query(`SHOW CONSTRAINTS FROM ${qualified}`)),
|
|
247
|
+
driver.execute(query(`SHOW CREATE TABLE ${qualified}`)),
|
|
248
|
+
]);
|
|
249
|
+
const primaryIndexes = new Set(
|
|
250
|
+
constraintRows
|
|
251
|
+
.filter((row, index) => textField(row, 'constraint_type', 'cockroach SHOW CONSTRAINTS', index) === 'PRIMARY KEY')
|
|
252
|
+
.map((row, index) => textField(row, 'constraint_name', 'cockroach SHOW CONSTRAINTS', index)),
|
|
253
|
+
);
|
|
254
|
+
const createRow = createRows[0];
|
|
255
|
+
if (createRow === undefined) throw new Error(`cockroach SHOW CREATE returned no row for ${qualified}`);
|
|
256
|
+
const predicates = indexPredicates(textField(createRow, 'create_statement', 'cockroach SHOW CREATE TABLE', 0));
|
|
257
|
+
const columns = table.columns.map(cockroachColumn);
|
|
258
|
+
return {
|
|
259
|
+
...table,
|
|
260
|
+
columns,
|
|
261
|
+
indexes: indexes(
|
|
262
|
+
indexRows.map(parseIndexRow),
|
|
263
|
+
primaryIndexes,
|
|
264
|
+
new Set(columns.map(column => column.name)),
|
|
265
|
+
predicates,
|
|
266
|
+
),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function cockroachSnapshot(
|
|
271
|
+
parent: (driver: IntrospectionDriver, options?: IntrospectOptions) => Promise<CatalogSchemaSnapshot>,
|
|
272
|
+
driver: IntrospectionDriver,
|
|
273
|
+
options?: IntrospectOptions,
|
|
274
|
+
): Promise<CatalogSchemaSnapshot> {
|
|
275
|
+
const base = await parent(driver, options);
|
|
276
|
+
const schemaByTable = await schemasByTable(driver, options);
|
|
277
|
+
const tables: CatalogTableSnapshot[] = [];
|
|
278
|
+
for (const table of base.tables) {
|
|
279
|
+
const schema = schemaByTable.get(table.name);
|
|
280
|
+
if (schema === undefined)
|
|
281
|
+
throw new Error(`cockroach introspection could not resolve the schema for "${table.name}"`);
|
|
282
|
+
tables.push(await cockroachTable(driver, schema, table));
|
|
283
|
+
}
|
|
284
|
+
return { ...base, tables };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const cockroachIntrospector = postgresFamilyIntrospector('cockroach', {
|
|
288
|
+
snapshot: cockroachSnapshot,
|
|
289
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ChangeOp, ColumnSnapshot, ExtensionType, SchemaSnapshot } from '@zmdb/migrations';
|
|
2
|
+
import { postgresFamilyMigrations } from '@zmdb/postgres';
|
|
3
|
+
import {
|
|
4
|
+
UnsupportedFeatureError,
|
|
5
|
+
type MigrationConnection,
|
|
6
|
+
type MigrationDialect,
|
|
7
|
+
type MigrationDriver,
|
|
8
|
+
type MigrationPlan,
|
|
9
|
+
type MigrationTableOptions,
|
|
10
|
+
type SchemaObjectOperation,
|
|
11
|
+
} from '@zmdb/sql';
|
|
12
|
+
import { type IndexColumn } from '@zmdb/sql/schema-objects';
|
|
13
|
+
|
|
14
|
+
export const COCKROACH_TYPE_OVERRIDES = Object.freeze({
|
|
15
|
+
serial: 'INT8 DEFAULT unique_rowid()',
|
|
16
|
+
integer: 'INT4',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const parent = postgresFamilyMigrations('cockroach', {
|
|
20
|
+
types: COCKROACH_TYPE_OVERRIDES,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function extensionName(type: ExtensionType): string {
|
|
24
|
+
return `${type.extension}.${type.name}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function refuseExtension(feature: string): never {
|
|
28
|
+
throw new UnsupportedFeatureError(
|
|
29
|
+
feature,
|
|
30
|
+
'cockroach',
|
|
31
|
+
`cockroach does not expose PostgreSQL extension installation or extension-backed column types (${feature})`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateColumn(column: ColumnSnapshot): void {
|
|
36
|
+
if (typeof column.type !== 'string') refuseExtension(`extension type ${extensionName(column.type)}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateSnapshot(snapshot: SchemaSnapshot): void {
|
|
40
|
+
const extension = snapshot.extensions[0];
|
|
41
|
+
if (extension !== undefined) refuseExtension(`extension "${extension.name}"`);
|
|
42
|
+
for (const table of snapshot.tables) {
|
|
43
|
+
for (const column of table.columns) validateColumn(column);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateOperation(operation: ChangeOp): void {
|
|
48
|
+
switch (operation.kind) {
|
|
49
|
+
case 'create_extension':
|
|
50
|
+
refuseExtension(`extension "${operation.name}"`);
|
|
51
|
+
case 'create_table':
|
|
52
|
+
for (const column of operation.columns) validateColumn(column);
|
|
53
|
+
return;
|
|
54
|
+
case 'add_column':
|
|
55
|
+
validateColumn(operation.column);
|
|
56
|
+
return;
|
|
57
|
+
case 'alter_column_type':
|
|
58
|
+
if (typeof operation.from !== 'string') refuseExtension(`extension type ${extensionName(operation.from)}`);
|
|
59
|
+
if (typeof operation.to !== 'string') refuseExtension(`extension type ${extensionName(operation.to)}`);
|
|
60
|
+
return;
|
|
61
|
+
default:
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function opclass(column: IndexColumn): string | undefined {
|
|
67
|
+
return typeof column === 'string' ? undefined : column.opclass;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateSchemaObject(operation: SchemaObjectOperation): void {
|
|
71
|
+
switch (operation.kind) {
|
|
72
|
+
case 'enable_rls':
|
|
73
|
+
case 'create_policy':
|
|
74
|
+
throw new UnsupportedFeatureError(
|
|
75
|
+
'row-level security',
|
|
76
|
+
'cockroach',
|
|
77
|
+
'cockroach row-level-security support varies by server release; @zmdb/cockroach refuses PostgreSQL policy DDL',
|
|
78
|
+
);
|
|
79
|
+
case 'create_extension':
|
|
80
|
+
refuseExtension(`extension "${operation.definition.name}"`);
|
|
81
|
+
case 'create_index': {
|
|
82
|
+
const method = operation.definition.method;
|
|
83
|
+
if (method !== undefined) {
|
|
84
|
+
throw new UnsupportedFeatureError(
|
|
85
|
+
`index method ${method}`,
|
|
86
|
+
'cockroach',
|
|
87
|
+
`cockroach indexes are emitted without a PostgreSQL USING method ("${operation.definition.name}")`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const column = operation.definition.columns.find(value => opclass(value) !== undefined);
|
|
91
|
+
const selectedOpclass = column === undefined ? undefined : opclass(column);
|
|
92
|
+
if (selectedOpclass !== undefined) {
|
|
93
|
+
throw new UnsupportedFeatureError(
|
|
94
|
+
`index operator class ${selectedOpclass}`,
|
|
95
|
+
'cockroach',
|
|
96
|
+
`cockroach indexes do not accept PostgreSQL operator classes ("${operation.definition.name}")`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const cockroachMigrations: MigrationDialect<'cockroach'> = Object.freeze({
|
|
107
|
+
name: 'cockroach',
|
|
108
|
+
foreignKeyMode: 'deferred',
|
|
109
|
+
embedded: false,
|
|
110
|
+
validateSnapshot(snapshot: SchemaSnapshot): void {
|
|
111
|
+
parent.validateSnapshot(snapshot);
|
|
112
|
+
validateSnapshot(snapshot);
|
|
113
|
+
},
|
|
114
|
+
validatePlan(plan: MigrationPlan): void {
|
|
115
|
+
parent.validatePlan(plan);
|
|
116
|
+
validateSnapshot(plan.before);
|
|
117
|
+
validateSnapshot(plan.after);
|
|
118
|
+
for (const operation of plan.operations) validateOperation(operation);
|
|
119
|
+
},
|
|
120
|
+
ddlType(column: ColumnSnapshot): string {
|
|
121
|
+
validateColumn(column);
|
|
122
|
+
return parent.ddlType(column);
|
|
123
|
+
},
|
|
124
|
+
emitUp(operation: ChangeOp): string {
|
|
125
|
+
validateOperation(operation);
|
|
126
|
+
return parent.emitUp(operation);
|
|
127
|
+
},
|
|
128
|
+
emitDown(operation: ChangeOp): string {
|
|
129
|
+
validateOperation(operation);
|
|
130
|
+
return parent.emitDown(operation);
|
|
131
|
+
},
|
|
132
|
+
emitSchemaObject(operation: SchemaObjectOperation): readonly string[] {
|
|
133
|
+
validateSchemaObject(operation);
|
|
134
|
+
return parent.emitSchemaObject(operation);
|
|
135
|
+
},
|
|
136
|
+
connection(driver: MigrationDriver<'cockroach'>, options?: MigrationTableOptions): MigrationConnection<'cockroach'> {
|
|
137
|
+
const connection = parent.connection(driver, options);
|
|
138
|
+
const { transaction: ignoredTransaction, ...nonTransactional } = connection;
|
|
139
|
+
void ignoredTransaction;
|
|
140
|
+
return Object.freeze({
|
|
141
|
+
...nonTransactional,
|
|
142
|
+
transactionalDdl: false,
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
});
|