@clisma/core 0.1.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/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/migrations/repository.d.ts +19 -0
- package/dist/migrations/repository.d.ts.map +1 -0
- package/dist/migrations/repository.js +142 -0
- package/dist/migrations/runner.d.ts +10 -0
- package/dist/migrations/runner.d.ts.map +1 -0
- package/dist/migrations/runner.js +175 -0
- package/dist/migrations/sql.d.ts +3 -0
- package/dist/migrations/sql.d.ts.map +1 -0
- package/dist/migrations/sql.js +74 -0
- package/dist/migrations/template.d.ts +3 -0
- package/dist/migrations/template.d.ts.map +1 -0
- package/dist/migrations/template.js +5 -0
- package/dist/migrations/types.d.ts +35 -0
- package/dist/migrations/types.d.ts.map +1 -0
- package/dist/migrations/types.js +1 -0
- package/dist/tests/sql.test.d.ts +2 -0
- package/dist/tests/sql.test.d.ts.map +1 -0
- package/dist/tests/sql.test.js +24 -0
- package/package.json +30 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { MigrationRunner, runMigrations } from "./migrations/runner.js";
|
|
2
|
+
export type { MigrationCommand, MigrationRunnerOptions, } from "./migrations/types.js";
|
|
3
|
+
export { findConfigFile, parseConfig } from "@clisma/config";
|
|
4
|
+
export type { ClismaConfig, EnvConfig, MigrationConfig, VariableConfig, } from "@clisma/config";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AACxE,YAAY,EACV,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7D,YAAY,EACV,YAAY,EACZ,SAAS,EACT,eAAe,EACf,cAAc,GACf,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ClickHouseClient } from "@clickhouse/client";
|
|
2
|
+
import type { MigrationContext, MigrationRecord, PendingMigrationsResult } from "./types.js";
|
|
3
|
+
type MigrationRepositoryOptions = {
|
|
4
|
+
client: ClickHouseClient;
|
|
5
|
+
migrationsDir: string;
|
|
6
|
+
tableName: string;
|
|
7
|
+
replicationPath?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare class MigrationRepository {
|
|
10
|
+
#private;
|
|
11
|
+
constructor(options: MigrationRepositoryOptions);
|
|
12
|
+
getContext(): MigrationContext;
|
|
13
|
+
initialize(clusterName?: string): Promise<string | null>;
|
|
14
|
+
ensureMigrationsTable(): Promise<void>;
|
|
15
|
+
getAppliedMigrations(): Promise<Map<string, MigrationRecord>>;
|
|
16
|
+
getPendingMigrations(applied: Map<string, MigrationRecord>): Promise<PendingMigrationsResult>;
|
|
17
|
+
}
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=repository.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repository.d.ts","sourceRoot":"","sources":["../../src/migrations/repository.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EAEf,uBAAuB,EACxB,MAAM,YAAY,CAAC;AAGpB,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,gBAAgB,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF,qBAAa,mBAAmB;;gBAOlB,OAAO,EAAE,0BAA0B;IAO/C,UAAU,IAAI,gBAAgB;IAQxB,UAAU,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IA8DxD,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IA4BtC,oBAAoB,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAgB7D,oBAAoB,CACxB,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,GACpC,OAAO,CAAC,uBAAuB,CAAC;CA8DpC"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import kleur from "kleur";
|
|
4
|
+
import { calculateChecksum } from "./sql.js";
|
|
5
|
+
export class MigrationRepository {
|
|
6
|
+
#client;
|
|
7
|
+
#ctx = null;
|
|
8
|
+
#migrationsDir;
|
|
9
|
+
#tableName;
|
|
10
|
+
#replicationPath;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.#client = options.client;
|
|
13
|
+
this.#migrationsDir = options.migrationsDir;
|
|
14
|
+
this.#tableName = options.tableName;
|
|
15
|
+
this.#replicationPath = options.replicationPath;
|
|
16
|
+
}
|
|
17
|
+
getContext() {
|
|
18
|
+
if (!this.#ctx) {
|
|
19
|
+
throw new Error("Migration repository not initialized");
|
|
20
|
+
}
|
|
21
|
+
return this.#ctx;
|
|
22
|
+
}
|
|
23
|
+
async initialize(clusterName) {
|
|
24
|
+
if (this.#ctx) {
|
|
25
|
+
return this.#ctx.cluster || null;
|
|
26
|
+
}
|
|
27
|
+
const clusters = await this.#listClusters();
|
|
28
|
+
const clusterNames = Array.from(new Set(clusters.map((cluster) => cluster.cluster)));
|
|
29
|
+
const hasNonDefaultCluster = clusterNames.some((name) => name !== "default");
|
|
30
|
+
const defaultClusterRows = clusters.filter((cluster) => cluster.cluster === "default");
|
|
31
|
+
const defaultHasReplicasOrShards = defaultClusterRows.some((row) => row.replica_num > 1 || row.shard_num > 1);
|
|
32
|
+
if (!clusterName) {
|
|
33
|
+
if (hasNonDefaultCluster || defaultHasReplicasOrShards) {
|
|
34
|
+
const available = clusterNames.length
|
|
35
|
+
? clusterNames.join(", ")
|
|
36
|
+
: "none";
|
|
37
|
+
throw new Error(`Cluster detected but no cluster_name provided. ` +
|
|
38
|
+
`Set env.cluster_name in config. Available clusters: ${available}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else if (!clusterNames.includes(clusterName)) {
|
|
42
|
+
const available = clusterNames.length ? clusterNames.join(", ") : "none";
|
|
43
|
+
throw new Error(`Cluster "${clusterName}" not found. Available clusters: ${available}`);
|
|
44
|
+
}
|
|
45
|
+
if (clusterName) {
|
|
46
|
+
const safeClusterName = clusterName.replace(/"/g, '\\"');
|
|
47
|
+
this.#ctx = {
|
|
48
|
+
is_replicated: true,
|
|
49
|
+
create_table_options: `ON CLUSTER "${safeClusterName}"`,
|
|
50
|
+
cluster: clusterName,
|
|
51
|
+
};
|
|
52
|
+
return clusterName;
|
|
53
|
+
}
|
|
54
|
+
this.#ctx = {
|
|
55
|
+
is_replicated: false,
|
|
56
|
+
create_table_options: "",
|
|
57
|
+
cluster: "",
|
|
58
|
+
};
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
async ensureMigrationsTable() {
|
|
62
|
+
const ctx = this.getContext();
|
|
63
|
+
const replicationPath = this.#replicationPath
|
|
64
|
+
? this.#replicationPath
|
|
65
|
+
: `/clickhouse/tables/cluster-{cluster}/shard-{shard}/{database}/${this.#tableName}`;
|
|
66
|
+
const engine = ctx.is_replicated
|
|
67
|
+
? `ReplicatedReplacingMergeTree('${replicationPath}', '{replica}')`
|
|
68
|
+
: "ReplacingMergeTree()";
|
|
69
|
+
const tableOptions = ctx.create_table_options || "";
|
|
70
|
+
await this.#client.command({
|
|
71
|
+
query: `
|
|
72
|
+
CREATE TABLE IF NOT EXISTS ${this.#tableName} ${tableOptions} (
|
|
73
|
+
version String,
|
|
74
|
+
name String,
|
|
75
|
+
checksum String,
|
|
76
|
+
hostname String DEFAULT '',
|
|
77
|
+
applied_by String DEFAULT '',
|
|
78
|
+
cli_version String DEFAULT '',
|
|
79
|
+
applied_at DateTime DEFAULT now()
|
|
80
|
+
) ENGINE = ${engine}
|
|
81
|
+
ORDER BY version
|
|
82
|
+
`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async getAppliedMigrations() {
|
|
86
|
+
const result = await this.#client.query({
|
|
87
|
+
query: `
|
|
88
|
+
SELECT version, name, checksum, hostname, applied_by, cli_version, applied_at
|
|
89
|
+
FROM ${this.#tableName} FINAL
|
|
90
|
+
ORDER BY version`,
|
|
91
|
+
format: "JSONEachRow",
|
|
92
|
+
});
|
|
93
|
+
const migrations = await result.json();
|
|
94
|
+
return new Map(migrations.map((migration) => [migration.version, migration]));
|
|
95
|
+
}
|
|
96
|
+
async getPendingMigrations(applied) {
|
|
97
|
+
const files = (await fs.readdir(this.#migrationsDir))
|
|
98
|
+
.filter((file) => file.endsWith(".sql"))
|
|
99
|
+
.sort();
|
|
100
|
+
const pending = [];
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
const match = file.match(/^(\d+)_(.+)\.sql$/);
|
|
103
|
+
if (!match) {
|
|
104
|
+
console.warn(kleur.yellow(`Skipping invalid filename: ${file}`));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const [, version, name] = match;
|
|
108
|
+
const filePath = path.join(this.#migrationsDir, file);
|
|
109
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
110
|
+
const checksum = calculateChecksum(content);
|
|
111
|
+
const appliedMigration = applied.get(version);
|
|
112
|
+
if (!appliedMigration) {
|
|
113
|
+
pending.push({ version, name, file, content, checksum });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (appliedMigration.checksum !== checksum) {
|
|
117
|
+
return {
|
|
118
|
+
pending: [],
|
|
119
|
+
error: new Error(`Migration ${version}_${name} has been modified. ` +
|
|
120
|
+
`Expected checksum: ${appliedMigration.checksum}. ` +
|
|
121
|
+
`Actual checksum: ${checksum}. `),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { pending, error: null };
|
|
126
|
+
}
|
|
127
|
+
async #listClusters() {
|
|
128
|
+
try {
|
|
129
|
+
const result = await this.#client.query({
|
|
130
|
+
query: `
|
|
131
|
+
SELECT cluster, shard_num, replica_num
|
|
132
|
+
FROM system.clusters
|
|
133
|
+
`,
|
|
134
|
+
format: "JSONEachRow",
|
|
135
|
+
});
|
|
136
|
+
return await result.json();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MigrationCommand, MigrationRunnerOptions } from "./types.js";
|
|
2
|
+
export declare class MigrationRunner {
|
|
3
|
+
#private;
|
|
4
|
+
constructor(options: MigrationRunnerOptions);
|
|
5
|
+
run(): Promise<void>;
|
|
6
|
+
status(): Promise<void>;
|
|
7
|
+
close(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare const runMigrations: (options: MigrationRunnerOptions, command: MigrationCommand) => Promise<void>;
|
|
10
|
+
//# sourceMappingURL=runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/migrations/runner.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AA4B3E,qBAAa,eAAe;;gBASd,OAAO,EAAE,sBAAsB;IAwDrC,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA+EpB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IA4CvB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B;AAED,eAAO,MAAM,aAAa,GACxB,SAAS,sBAAsB,EAC/B,SAAS,gBAAgB,KACxB,OAAO,CAAC,IAAI,CAYd,CAAC"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createClient } from "@clickhouse/client";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import kleur from "kleur";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { splitStatements } from "./sql.js";
|
|
9
|
+
import { renderTemplate } from "./template.js";
|
|
10
|
+
import { MigrationRepository } from "./repository.js";
|
|
11
|
+
const resolveAppliedBy = () => {
|
|
12
|
+
try {
|
|
13
|
+
return os.userInfo().username || process.env.USER || "";
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return process.env.USER || "";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const resolveCliVersion = async () => {
|
|
20
|
+
try {
|
|
21
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const packagePath = path.resolve(currentDir, "../../package.json");
|
|
23
|
+
const contents = await fs.readFile(packagePath, "utf8");
|
|
24
|
+
const parsed = JSON.parse(contents);
|
|
25
|
+
return parsed.version || "";
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
export class MigrationRunner {
|
|
32
|
+
#client;
|
|
33
|
+
#repository;
|
|
34
|
+
#tableName;
|
|
35
|
+
#clusterName;
|
|
36
|
+
#replicationPath;
|
|
37
|
+
#templateVars;
|
|
38
|
+
#initialized = false;
|
|
39
|
+
constructor(options) {
|
|
40
|
+
const url = new URL(options.connectionString);
|
|
41
|
+
if (!url.username) {
|
|
42
|
+
throw new Error("ClickHouse connection string must include username");
|
|
43
|
+
}
|
|
44
|
+
const database = url.pathname.replace("/", "");
|
|
45
|
+
if (!database) {
|
|
46
|
+
throw new Error("ClickHouse connection string must include database name");
|
|
47
|
+
}
|
|
48
|
+
this.#tableName = options.tableName || "schema_migrations";
|
|
49
|
+
this.#clusterName = options.clusterName;
|
|
50
|
+
this.#replicationPath = options.replicationPath;
|
|
51
|
+
this.#templateVars = options.templateVars || {};
|
|
52
|
+
this.#client = createClient({
|
|
53
|
+
url: `${url.protocol}//${url.host}`,
|
|
54
|
+
username: decodeURIComponent(url.username),
|
|
55
|
+
password: decodeURIComponent(url.password),
|
|
56
|
+
database,
|
|
57
|
+
});
|
|
58
|
+
this.#repository = new MigrationRepository({
|
|
59
|
+
client: this.#client,
|
|
60
|
+
migrationsDir: options.migrationsDir,
|
|
61
|
+
tableName: this.#tableName,
|
|
62
|
+
replicationPath: this.#replicationPath,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async #initialize() {
|
|
66
|
+
if (this.#initialized) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const spinner = ora("Detecting cluster configuration...").start();
|
|
70
|
+
const clusterName = await this.#repository.initialize(this.#clusterName);
|
|
71
|
+
if (clusterName) {
|
|
72
|
+
const message = this.#clusterName
|
|
73
|
+
? `Using cluster from config: ${kleur.bold(clusterName)}`
|
|
74
|
+
: `Detected cluster: ${kleur.bold(clusterName)}`;
|
|
75
|
+
spinner.succeed(kleur.green(message));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
spinner.succeed("No cluster detected, using non-replicated mode");
|
|
79
|
+
}
|
|
80
|
+
this.#initialized = true;
|
|
81
|
+
}
|
|
82
|
+
async run() {
|
|
83
|
+
await this.#initialize();
|
|
84
|
+
await this.#repository.ensureMigrationsTable();
|
|
85
|
+
const applied = await this.#repository.getAppliedMigrations();
|
|
86
|
+
const { pending, error } = await this.#repository.getPendingMigrations(applied);
|
|
87
|
+
if (error) {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
if (pending.length === 0) {
|
|
91
|
+
console.log("✓ No pending migrations");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
console.log(kleur.bold(`\nFound ${kleur.yellow(pending.length)} pending migration(s):`));
|
|
95
|
+
pending.forEach((migration) => console.log(kleur.dim(` • ${migration.version}_${migration.name}`)));
|
|
96
|
+
console.log("");
|
|
97
|
+
for (const migration of pending) {
|
|
98
|
+
const spinner = ora(`Applying ${kleur.bold(migration.version)}_${migration.name}`).start();
|
|
99
|
+
try {
|
|
100
|
+
const sql = renderTemplate(migration.content, this.#templateVars);
|
|
101
|
+
const statements = splitStatements(sql);
|
|
102
|
+
for (const statement of statements) {
|
|
103
|
+
await this.#client.command({
|
|
104
|
+
query: statement,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const cliVersion = await resolveCliVersion();
|
|
108
|
+
await this.#client.insert({
|
|
109
|
+
table: this.#tableName,
|
|
110
|
+
values: [
|
|
111
|
+
{
|
|
112
|
+
version: migration.version,
|
|
113
|
+
name: migration.name,
|
|
114
|
+
checksum: migration.checksum,
|
|
115
|
+
hostname: os.hostname(),
|
|
116
|
+
applied_by: resolveAppliedBy(),
|
|
117
|
+
cli_version: cliVersion,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
format: "JSONEachRow",
|
|
121
|
+
});
|
|
122
|
+
spinner.succeed(kleur.green(`Applied ${kleur.bold(migration.version)}_${migration.name}`));
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
spinner.fail(kleur.red(`Failed to apply ${migration.version}_${migration.name}`));
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
console.log(kleur.bold(kleur.green("\n✓ All migrations applied successfully")));
|
|
130
|
+
}
|
|
131
|
+
async status() {
|
|
132
|
+
await this.#initialize();
|
|
133
|
+
await this.#repository.ensureMigrationsTable();
|
|
134
|
+
const applied = await this.#repository.getAppliedMigrations();
|
|
135
|
+
const { pending, error } = await this.#repository.getPendingMigrations(applied);
|
|
136
|
+
if (error) {
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
console.log("");
|
|
140
|
+
console.log(kleur.bold("Migration Status"));
|
|
141
|
+
console.log(`${kleur.green("Applied:")} ${kleur.bold(applied.size.toString())}`);
|
|
142
|
+
console.log(`${kleur.yellow("Pending:")} ${kleur.bold(pending.length.toString())}`);
|
|
143
|
+
console.log("");
|
|
144
|
+
if (applied.size > 0) {
|
|
145
|
+
console.log(kleur.bold("Applied migrations:"));
|
|
146
|
+
Array.from(applied.values())
|
|
147
|
+
.sort((a, b) => a.version.localeCompare(b.version))
|
|
148
|
+
.forEach((migration) => console.log(kleur.dim(` ✓ ${migration.version}_${migration.name}`) +
|
|
149
|
+
kleur.gray(` (${migration.applied_at})`)));
|
|
150
|
+
console.log("");
|
|
151
|
+
}
|
|
152
|
+
if (pending.length > 0) {
|
|
153
|
+
console.log(kleur.bold("Pending migrations:"));
|
|
154
|
+
pending.forEach((migration) => console.log(kleur.yellow(` • ${migration.version}_${migration.name}`)));
|
|
155
|
+
console.log("");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async close() {
|
|
159
|
+
await this.#client.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export const runMigrations = async (options, command) => {
|
|
163
|
+
const runner = new MigrationRunner(options);
|
|
164
|
+
try {
|
|
165
|
+
if (command === "status") {
|
|
166
|
+
await runner.status();
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
await runner.run();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await runner.close();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../src/migrations/sql.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB,GAAI,SAAS,MAAM,KAAG,MAEnD,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,KAAG,MAAM,EAuFnD,CAAC"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
export const calculateChecksum = (content) => {
|
|
3
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
4
|
+
};
|
|
5
|
+
export const splitStatements = (sql) => {
|
|
6
|
+
const statements = [];
|
|
7
|
+
let current = "";
|
|
8
|
+
let isSingleQuoted = false;
|
|
9
|
+
let isDoubleQuoted = false;
|
|
10
|
+
let isLineComment = false;
|
|
11
|
+
let isBlockComment = false;
|
|
12
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
13
|
+
const char = sql[index];
|
|
14
|
+
const next = sql[index + 1];
|
|
15
|
+
if (isLineComment) {
|
|
16
|
+
current += char;
|
|
17
|
+
if (char === "\n") {
|
|
18
|
+
isLineComment = false;
|
|
19
|
+
}
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (isBlockComment) {
|
|
23
|
+
current += char;
|
|
24
|
+
if (char === "*" && next === "/") {
|
|
25
|
+
current += next;
|
|
26
|
+
index += 1;
|
|
27
|
+
isBlockComment = false;
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (!isSingleQuoted && !isDoubleQuoted) {
|
|
32
|
+
if (char === "-" && next === "-") {
|
|
33
|
+
current += char + next;
|
|
34
|
+
index += 1;
|
|
35
|
+
isLineComment = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (char === "/" && next === "*") {
|
|
39
|
+
current += char + next;
|
|
40
|
+
index += 1;
|
|
41
|
+
isBlockComment = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (char === "'" && !isDoubleQuoted) {
|
|
46
|
+
const prev = sql[index - 1];
|
|
47
|
+
const isEscaped = prev === "\\" && sql[index - 2] !== "\\";
|
|
48
|
+
if (!isEscaped) {
|
|
49
|
+
isSingleQuoted = !isSingleQuoted;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (char === '"' && !isSingleQuoted) {
|
|
53
|
+
const prev = sql[index - 1];
|
|
54
|
+
const isEscaped = prev === "\\" && sql[index - 2] !== "\\";
|
|
55
|
+
if (!isEscaped) {
|
|
56
|
+
isDoubleQuoted = !isDoubleQuoted;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (char === ";" && !isSingleQuoted && !isDoubleQuoted) {
|
|
60
|
+
const trimmed = current.trim();
|
|
61
|
+
if (trimmed.length > 0) {
|
|
62
|
+
statements.push(trimmed);
|
|
63
|
+
}
|
|
64
|
+
current = "";
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
current += char;
|
|
68
|
+
}
|
|
69
|
+
const trimmed = current.trim();
|
|
70
|
+
if (trimmed.length > 0) {
|
|
71
|
+
statements.push(trimmed);
|
|
72
|
+
}
|
|
73
|
+
return statements;
|
|
74
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../src/migrations/template.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAEzD,eAAO,MAAM,cAAc,GACzB,SAAS,MAAM,EACf,MAAK,sBAAsB,CAAC,cAAc,CAAM,KAC/C,MAIF,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type MigrationRunnerOptions = {
|
|
2
|
+
migrationsDir: string;
|
|
3
|
+
connectionString: string;
|
|
4
|
+
clusterName?: string;
|
|
5
|
+
tableName?: string;
|
|
6
|
+
replicationPath?: string;
|
|
7
|
+
templateVars?: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
export type MigrationCommand = "run" | "status";
|
|
10
|
+
export type MigrationContext = {
|
|
11
|
+
is_replicated: boolean;
|
|
12
|
+
create_table_options: string;
|
|
13
|
+
cluster: string;
|
|
14
|
+
};
|
|
15
|
+
export type MigrationRecord = {
|
|
16
|
+
version: string;
|
|
17
|
+
name: string;
|
|
18
|
+
checksum: string;
|
|
19
|
+
hostname: string;
|
|
20
|
+
applied_by: string;
|
|
21
|
+
cli_version: string;
|
|
22
|
+
applied_at: string;
|
|
23
|
+
};
|
|
24
|
+
export type PendingMigration = {
|
|
25
|
+
version: string;
|
|
26
|
+
name: string;
|
|
27
|
+
file: string;
|
|
28
|
+
content: string;
|
|
29
|
+
checksum: string;
|
|
30
|
+
};
|
|
31
|
+
export type PendingMigrationsResult = {
|
|
32
|
+
pending: PendingMigration[];
|
|
33
|
+
error: Error | null;
|
|
34
|
+
};
|
|
35
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/migrations/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,sBAAsB,GAAG;IACnC,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEhD,MAAM,MAAM,gBAAgB,GAAG;IAC7B,aAAa,EAAE,OAAO,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql.test.d.ts","sourceRoot":"","sources":["../../src/tests/sql.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { calculateChecksum, splitStatements } from "../migrations/sql.js";
|
|
4
|
+
test("splitStatements handles basic semicolons", () => {
|
|
5
|
+
const sql = "SELECT 1; SELECT 2;";
|
|
6
|
+
const statements = splitStatements(sql);
|
|
7
|
+
assert.deepEqual(statements, ["SELECT 1", "SELECT 2"]);
|
|
8
|
+
});
|
|
9
|
+
test("splitStatements ignores semicolons in strings and comments", () => {
|
|
10
|
+
const sql = `
|
|
11
|
+
-- comment with ;
|
|
12
|
+
SELECT 'value;still_string';
|
|
13
|
+
/* block ; comment */
|
|
14
|
+
SELECT "value;still_string";
|
|
15
|
+
`;
|
|
16
|
+
const statements = splitStatements(sql);
|
|
17
|
+
assert.equal(statements.length, 2);
|
|
18
|
+
assert.ok(statements[0].includes("SELECT 'value;still_string'"));
|
|
19
|
+
assert.ok(statements[1].includes('SELECT "value;still_string"'));
|
|
20
|
+
});
|
|
21
|
+
test("calculateChecksum returns stable sha256", () => {
|
|
22
|
+
const checksum = calculateChecksum("hello");
|
|
23
|
+
assert.equal(checksum, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
|
|
24
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clisma/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core ClickHouse migration logic for clisma",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@clisma/config": "*",
|
|
13
|
+
"@clickhouse/client": "^1.11.2",
|
|
14
|
+
"handlebars": "^4.7.8",
|
|
15
|
+
"kleur": "^4.1.5",
|
|
16
|
+
"ora": "^9.1.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -b tsconfig.json",
|
|
20
|
+
"test": "node --import tsx --test src/tests/*.test.ts",
|
|
21
|
+
"lint": "oxlint src",
|
|
22
|
+
"lint:fix": "oxlint --fix src",
|
|
23
|
+
"format": "oxfmt --check src",
|
|
24
|
+
"format:fix": "oxfmt src"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"oxfmt": "^0.4.0",
|
|
28
|
+
"oxlint": "^0.12.0"
|
|
29
|
+
}
|
|
30
|
+
}
|