@devxcrew/framework 0.1.10 → 0.1.12
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 +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/modules/database/common/database.schema.d.ts +44 -0
- package/dist/modules/database/common/database.schema.js +49 -0
- package/dist/modules/database/common/database.types.d.ts +26 -0
- package/dist/modules/database/common/database.types.js +1 -0
- package/dist/modules/database/common/index.d.ts +2 -0
- package/dist/modules/database/common/index.js +1 -0
- package/dist/modules/database/database.connections.d.ts +45 -0
- package/dist/modules/database/database.connections.js +196 -0
- package/dist/modules/database/database.controller.d.ts +1 -0
- package/dist/modules/database/database.controller.js +1 -0
- package/dist/modules/database/database.execution.d.ts +30 -0
- package/dist/modules/database/database.execution.js +109 -0
- package/dist/modules/database/database.migration.d.ts +11 -0
- package/dist/modules/database/database.migration.js +80 -0
- package/dist/modules/database/database.provider.d.ts +38 -0
- package/dist/modules/database/database.provider.js +39 -0
- package/dist/modules/database/database.repository.d.ts +1 -0
- package/dist/modules/database/database.repository.js +1 -0
- package/dist/modules/database/database.routes.d.ts +1 -0
- package/dist/modules/database/database.routes.js +1 -0
- package/dist/modules/database/database.schema.d.ts +1 -0
- package/dist/modules/database/database.schema.js +1 -0
- package/dist/modules/database/database.seed.d.ts +3 -0
- package/dist/modules/database/database.seed.js +7 -0
- package/dist/modules/database/database.service.d.ts +7 -0
- package/dist/modules/database/database.service.js +24 -0
- package/dist/modules/database/database.transfer.d.ts +17 -0
- package/dist/modules/database/database.transfer.js +79 -0
- package/dist/modules/database/database.types.d.ts +1 -0
- package/dist/modules/database/database.types.js +1 -0
- package/dist/modules/database/drivers/mariadb/index.d.ts +5 -0
- package/dist/modules/database/drivers/mariadb/index.js +5 -0
- package/dist/modules/database/drivers/mariadb/mariadb.backup.d.ts +3 -0
- package/dist/modules/database/drivers/mariadb/mariadb.backup.js +141 -0
- package/dist/modules/database/drivers/mariadb/mariadb.compatibility.d.ts +6 -0
- package/dist/modules/database/drivers/mariadb/mariadb.compatibility.js +88 -0
- package/dist/modules/database/drivers/mariadb/mariadb.connection.d.ts +3 -0
- package/dist/modules/database/drivers/mariadb/mariadb.connection.js +22 -0
- package/dist/modules/database/drivers/mariadb/mariadb.schema.d.ts +14 -0
- package/dist/modules/database/drivers/mariadb/mariadb.schema.js +29 -0
- package/dist/modules/database/drivers/sqlite/index.d.ts +3 -0
- package/dist/modules/database/drivers/sqlite/index.js +3 -0
- package/dist/modules/database/drivers/sqlite/sqlite.backup.d.ts +2 -0
- package/dist/modules/database/drivers/sqlite/sqlite.backup.js +45 -0
- package/dist/modules/database/drivers/sqlite/sqlite.connection.d.ts +13 -0
- package/dist/modules/database/drivers/sqlite/sqlite.connection.js +41 -0
- package/dist/modules/database/drivers/sqlite/sqlite.schema.d.ts +3 -0
- package/dist/modules/database/drivers/sqlite/sqlite.schema.js +9 -0
- package/dist/modules/database/index.d.ts +12 -0
- package/dist/modules/database/index.js +11 -0
- package/dist/modules/database/operations/database.backup.d.ts +3 -0
- package/dist/modules/database/operations/database.backup.js +24 -0
- package/dist/modules/database/operations/sqlite-import.d.ts +8 -0
- package/dist/modules/database/operations/sqlite-import.js +96 -0
- package/dist/modules/settings/index.d.ts +2 -0
- package/dist/modules/settings/index.js +1 -0
- package/dist/modules/settings/settings.controller.d.ts +1 -0
- package/dist/modules/settings/settings.controller.js +1 -0
- package/dist/modules/settings/settings.migration.d.ts +1 -0
- package/dist/modules/settings/settings.migration.js +1 -0
- package/dist/modules/settings/settings.provider.d.ts +2 -0
- package/dist/modules/settings/settings.provider.js +9 -0
- package/dist/modules/settings/settings.repository.d.ts +1 -0
- package/dist/modules/settings/settings.repository.js +1 -0
- package/dist/modules/settings/settings.routes.d.ts +1 -0
- package/dist/modules/settings/settings.routes.js +1 -0
- package/dist/modules/settings/settings.schema.d.ts +1 -0
- package/dist/modules/settings/settings.schema.js +1 -0
- package/dist/modules/settings/settings.seed.d.ts +1 -0
- package/dist/modules/settings/settings.seed.js +1 -0
- package/dist/modules/settings/settings.service.d.ts +13 -0
- package/dist/modules/settings/settings.service.js +93 -0
- package/dist/modules/settings/settings.types.d.ts +11 -0
- package/dist/modules/settings/settings.types.js +1 -0
- package/dist/runtime/config.js +3 -2
- package/package.json +5 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
import { DatabaseExecution } from "./database.execution.js";
|
|
3
|
+
import { ResumableTransfer } from "./database.transfer.js";
|
|
4
|
+
import type { ConnectionTarget, DatabaseInfrastructureSchema, DatabaseProviderOptions } from "./common/database.types.js";
|
|
5
|
+
export declare function createDatabaseProvider<Schema extends DatabaseInfrastructureSchema = DatabaseInfrastructureSchema>(environment?: NodeJS.ProcessEnv, options?: DatabaseProviderOptions<Schema>): {
|
|
6
|
+
database: Kysely<Schema>;
|
|
7
|
+
driver: "mariadb" | "sqlite";
|
|
8
|
+
masterData: DatabaseExecution<Schema>;
|
|
9
|
+
masterTransfers: ResumableTransfer<Schema>;
|
|
10
|
+
withConnection: <T>(target: ConnectionTarget, work: (database: Kysely<Schema>) => Promise<T>) => Promise<T>;
|
|
11
|
+
provisionConnection: (target: ConnectionTarget) => Promise<import("kysely/migration").MigrationResult[]>;
|
|
12
|
+
poolStats: () => {
|
|
13
|
+
capacity: number;
|
|
14
|
+
pools: number;
|
|
15
|
+
leased: number;
|
|
16
|
+
};
|
|
17
|
+
verify: () => Promise<void>;
|
|
18
|
+
connections: () => ({
|
|
19
|
+
state: string;
|
|
20
|
+
host: string;
|
|
21
|
+
port: number;
|
|
22
|
+
database: string;
|
|
23
|
+
name: string;
|
|
24
|
+
driver: "mariadb" | "sqlite";
|
|
25
|
+
} | {
|
|
26
|
+
state: string;
|
|
27
|
+
path: string;
|
|
28
|
+
name: string;
|
|
29
|
+
driver: "mariadb" | "sqlite";
|
|
30
|
+
})[];
|
|
31
|
+
execute: <T>(work: (connection: Kysely<Schema>) => Promise<T>) => Promise<T>;
|
|
32
|
+
transaction: <T>(work: (connection: Kysely<Schema>) => Promise<T>) => Promise<T>;
|
|
33
|
+
migrationStatus: () => Promise<readonly import("kysely/migration").MigrationInfo[]>;
|
|
34
|
+
migrate(): Promise<import("kysely/migration").MigrationResult[]>;
|
|
35
|
+
seed(): Promise<void>;
|
|
36
|
+
close: () => Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
export type DatabaseProvider<Schema extends DatabaseInfrastructureSchema = DatabaseInfrastructureSchema> = ReturnType<typeof createDatabaseProvider<Schema>>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { DatabaseConnections } from "./database.connections.js";
|
|
2
|
+
import { DatabaseExecution } from "./database.execution.js";
|
|
3
|
+
import { ResumableTransfer } from "./database.transfer.js";
|
|
4
|
+
import { createDatabaseMigrator, prepareMigrationStorage, } from "./database.migration.js";
|
|
5
|
+
import { seedDatabase } from "./database.seed.js";
|
|
6
|
+
export function createDatabaseProvider(environment = process.env, options = {}) {
|
|
7
|
+
const registry = new DatabaseConnections(environment);
|
|
8
|
+
// Drivers know infrastructure tables; the caller supplies its owner schema and migrations.
|
|
9
|
+
const database = registry.getMaster();
|
|
10
|
+
const migrator = createDatabaseMigrator(database, options.migrations);
|
|
11
|
+
return {
|
|
12
|
+
database,
|
|
13
|
+
driver: registry.driver,
|
|
14
|
+
masterData: new DatabaseExecution(database),
|
|
15
|
+
masterTransfers: new ResumableTransfer(database),
|
|
16
|
+
withConnection: (target, work) => registry.withConnection(target, (connection) => work(connection)),
|
|
17
|
+
provisionConnection: (target) => registry.provision(target),
|
|
18
|
+
poolStats: () => registry.poolStats(),
|
|
19
|
+
verify: () => registry.verify(),
|
|
20
|
+
connections: () => registry.list(),
|
|
21
|
+
execute: (work) => work(registry.getMaster()),
|
|
22
|
+
transaction: (work) => registry.getMaster()
|
|
23
|
+
.transaction()
|
|
24
|
+
.execute(work),
|
|
25
|
+
migrationStatus: () => migrator.getMigrations(),
|
|
26
|
+
async migrate() {
|
|
27
|
+
await prepareMigrationStorage(database, registry.driver);
|
|
28
|
+
const result = await migrator.migrateToLatest();
|
|
29
|
+
if (result.error)
|
|
30
|
+
throw result.error;
|
|
31
|
+
return result.results ?? [];
|
|
32
|
+
},
|
|
33
|
+
async seed() {
|
|
34
|
+
await seedDatabase(database, environment.APP_NAME ?? "Application");
|
|
35
|
+
await options.seed?.(database, environment);
|
|
36
|
+
},
|
|
37
|
+
close: () => registry.close(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DatabaseInfrastructureSchema, DatabaseProviderOptions } from "./common/database.types.js";
|
|
2
|
+
/** Read-only engine readiness. Owner readiness is composed by the application. */
|
|
3
|
+
export declare function smokeDatabase<Schema extends DatabaseInfrastructureSchema = DatabaseInfrastructureSchema>(environment?: NodeJS.ProcessEnv, options?: DatabaseProviderOptions<Schema>): Promise<{
|
|
4
|
+
readonly driver: "mariadb" | "sqlite";
|
|
5
|
+
readonly migrations: number;
|
|
6
|
+
readonly status: "ready";
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { createDatabaseProvider } from "./database.provider.js";
|
|
3
|
+
import { readDatabaseConfiguration } from "./common/index.js";
|
|
4
|
+
/** Read-only engine readiness. Owner readiness is composed by the application. */
|
|
5
|
+
export async function smokeDatabase(environment = process.env, options = {}) {
|
|
6
|
+
const configuration = readDatabaseConfiguration(environment);
|
|
7
|
+
if (configuration.driver === "sqlite")
|
|
8
|
+
await access(configuration.path);
|
|
9
|
+
const provider = createDatabaseProvider(environment, options);
|
|
10
|
+
try {
|
|
11
|
+
const migrations = await provider.migrationStatus();
|
|
12
|
+
if (migrations.some((migration) => !migration.executedAt))
|
|
13
|
+
throw new Error("Database has pending migrations; run the application's migration command.");
|
|
14
|
+
await provider.verify();
|
|
15
|
+
return {
|
|
16
|
+
driver: provider.driver,
|
|
17
|
+
migrations: migrations.length,
|
|
18
|
+
status: "ready",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
await provider.close();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Kysely, Transaction } from "kysely";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { DatabaseSchema } from "./common/index.js";
|
|
4
|
+
/** Explicit chunk commits; data and checkpoint always commit together. */
|
|
5
|
+
export declare class ResumableTransfer<Schema extends DatabaseSchema = DatabaseSchema> {
|
|
6
|
+
private readonly database;
|
|
7
|
+
constructor(database: Kysely<Schema>);
|
|
8
|
+
run<Row>(input: {
|
|
9
|
+
jobId: string;
|
|
10
|
+
sourceSha256: string;
|
|
11
|
+
batchSize?: number;
|
|
12
|
+
}, schema: z.ZodType<Row>, source: (cursor: number) => AsyncIterable<unknown> | Iterable<unknown>, write: (transaction: Transaction<Schema>, rows: readonly Row[]) => Promise<void>, signal?: AbortSignal): Promise<{
|
|
13
|
+
cursor: number;
|
|
14
|
+
committed: number;
|
|
15
|
+
jobId: string;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { iterateTransfer, DatabaseValidationError, } from "./database.execution.js";
|
|
3
|
+
/** Explicit chunk commits; data and checkpoint always commit together. */
|
|
4
|
+
export class ResumableTransfer {
|
|
5
|
+
database;
|
|
6
|
+
constructor(database) {
|
|
7
|
+
this.database = database;
|
|
8
|
+
}
|
|
9
|
+
async run(input, schema, source, write, signal) {
|
|
10
|
+
const job = z
|
|
11
|
+
.strictObject({
|
|
12
|
+
jobId: z.string().min(1).max(255),
|
|
13
|
+
sourceSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
14
|
+
batchSize: z.number().int().min(1).max(500).default(100),
|
|
15
|
+
})
|
|
16
|
+
.parse(input);
|
|
17
|
+
signal?.throwIfAborted();
|
|
18
|
+
const checkpoints = this.database;
|
|
19
|
+
await checkpoints
|
|
20
|
+
.insertInto("transfer_checkpoints")
|
|
21
|
+
.values({ job_id: job.jobId, source_sha256: job.sourceSha256, cursor: 0 })
|
|
22
|
+
.onConflict((conflict) => conflict.column("job_id").doNothing())
|
|
23
|
+
.execute();
|
|
24
|
+
const checkpoint = await checkpoints
|
|
25
|
+
.selectFrom("transfer_checkpoints")
|
|
26
|
+
.selectAll()
|
|
27
|
+
.where("job_id", "=", job.jobId)
|
|
28
|
+
.executeTakeFirstOrThrow();
|
|
29
|
+
if (checkpoint.source_sha256 !== job.sourceSha256)
|
|
30
|
+
throw new Error("Transfer source fingerprint changed.");
|
|
31
|
+
let cursor = Number(checkpoint.cursor);
|
|
32
|
+
let version = Number(checkpoint.version);
|
|
33
|
+
let committed = 0;
|
|
34
|
+
let batch = [];
|
|
35
|
+
const flush = async () => {
|
|
36
|
+
const rows = batch;
|
|
37
|
+
await this.database.transaction().execute(async (transaction) => {
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
const checkpointTransaction = transaction;
|
|
40
|
+
const current = await checkpointTransaction
|
|
41
|
+
.selectFrom("transfer_checkpoints")
|
|
42
|
+
.selectAll()
|
|
43
|
+
.where("job_id", "=", job.jobId)
|
|
44
|
+
.executeTakeFirstOrThrow();
|
|
45
|
+
if (Number(current.cursor) !== cursor ||
|
|
46
|
+
Number(current.version) !== version ||
|
|
47
|
+
current.source_sha256 !== job.sourceSha256)
|
|
48
|
+
throw new Error("Transfer checkpoint changed; resume from persisted state.");
|
|
49
|
+
await write(transaction, rows);
|
|
50
|
+
signal?.throwIfAborted();
|
|
51
|
+
const result = await checkpointTransaction
|
|
52
|
+
.updateTable("transfer_checkpoints")
|
|
53
|
+
.set({ cursor: cursor + rows.length, version: version + 1 })
|
|
54
|
+
.where("job_id", "=", job.jobId)
|
|
55
|
+
.where("version", "=", version)
|
|
56
|
+
.where("cursor", "=", cursor)
|
|
57
|
+
.executeTakeFirst();
|
|
58
|
+
if (result.numUpdatedRows !== 1n)
|
|
59
|
+
throw new Error("Concurrent transfer checkpoint update.");
|
|
60
|
+
});
|
|
61
|
+
cursor += rows.length;
|
|
62
|
+
committed += rows.length;
|
|
63
|
+
version++;
|
|
64
|
+
batch = [];
|
|
65
|
+
};
|
|
66
|
+
for await (const row of iterateTransfer(source(cursor), signal ?? AbortSignal.timeout(300_000))) {
|
|
67
|
+
signal?.throwIfAborted();
|
|
68
|
+
const parsed = schema.safeParse(row);
|
|
69
|
+
if (!parsed.success)
|
|
70
|
+
throw new DatabaseValidationError(parsed.error);
|
|
71
|
+
batch.push(parsed.data);
|
|
72
|
+
if (batch.length === job.batchSize)
|
|
73
|
+
await flush();
|
|
74
|
+
}
|
|
75
|
+
if (batch.length)
|
|
76
|
+
await flush();
|
|
77
|
+
return { cursor, committed, jobId: job.jobId };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { mariaDbConfig } from "./mariadb.schema.js";
|
|
2
|
+
export { createMariaDbDialect } from "./mariadb.connection.js";
|
|
3
|
+
export { MariaDbCompatibilityPlugin } from "./mariadb.compatibility.js";
|
|
4
|
+
export { createMariaDbBackup, verifyMariaDbBackup, rehearseMariaDbRestore, } from "./mariadb.backup.js";
|
|
5
|
+
export { ensureMariaDbDatabase } from "./mariadb.connection.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { mariaDbConfig } from "./mariadb.schema.js";
|
|
2
|
+
export { createMariaDbDialect } from "./mariadb.connection.js";
|
|
3
|
+
export { MariaDbCompatibilityPlugin } from "./mariadb.compatibility.js";
|
|
4
|
+
export { createMariaDbBackup, verifyMariaDbBackup, rehearseMariaDbRestore, } from "./mariadb.backup.js";
|
|
5
|
+
export { ensureMariaDbDatabase } from "./mariadb.connection.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function createMariaDbBackup(environment: NodeJS.ProcessEnv, destination: string): Promise<void>;
|
|
2
|
+
export declare function verifyMariaDbBackup(path: string): Promise<void>;
|
|
3
|
+
export declare function rehearseMariaDbRestore(environment: NodeJS.ProcessEnv, path: string): Promise<void>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { access, mkdir, mkdtemp, writeFile, readFile, link, rm, stat, } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import mysql from "mysql2/promise";
|
|
7
|
+
import { mariaDbConfig } from "./mariadb.schema.js";
|
|
8
|
+
function option(value) {
|
|
9
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n").replaceAll("\r", "\\r")}"`;
|
|
10
|
+
}
|
|
11
|
+
async function withCredentials(environment, action) {
|
|
12
|
+
const config = mariaDbConfig(environment);
|
|
13
|
+
const base = resolve(environment.DB_BACKUP_DIR ?? "storage/private/data/backup");
|
|
14
|
+
await mkdir(base, { recursive: true });
|
|
15
|
+
const directory = await mkdtemp(join(base, ".mariadb-"));
|
|
16
|
+
const path = join(directory, "client.cnf");
|
|
17
|
+
try {
|
|
18
|
+
await writeFile(path, `[client]\nhost=${option(config.host)}\nport=${config.port}\nuser=${option(config.user)}\npassword=${option(config.password)}\n`, { mode: 0o600, flag: "wx" });
|
|
19
|
+
return await action(path);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
await rm(directory, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function run(command, args, input) {
|
|
26
|
+
await new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(command, args, {
|
|
28
|
+
stdio: [input ? "pipe" : "ignore", "ignore", "ignore"],
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
});
|
|
31
|
+
child.once("error", () => reject(new Error("MariaDB client executable unavailable; configure DB_DUMP_BIN and DB_CLIENT_BIN.")));
|
|
32
|
+
child.once("exit", (code) => code === 0
|
|
33
|
+
? resolve()
|
|
34
|
+
: reject(new Error(`MariaDB client command failed (${code}).`)));
|
|
35
|
+
if (input) {
|
|
36
|
+
const stream = createReadStream(input);
|
|
37
|
+
stream.once("error", reject);
|
|
38
|
+
child.stdin.once("error", reject);
|
|
39
|
+
stream.pipe(child.stdin);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export async function createMariaDbBackup(environment, destination) {
|
|
44
|
+
const config = mariaDbConfig(environment);
|
|
45
|
+
const target = resolve(destination);
|
|
46
|
+
await mkdir(dirname(target), { recursive: true });
|
|
47
|
+
try {
|
|
48
|
+
await access(target);
|
|
49
|
+
throw new Error("Backup destination already exists.");
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== "ENOENT")
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
const directory = await mkdtemp(join(dirname(target), ".mariadb-snapshot-"));
|
|
56
|
+
const snapshot = join(directory, "snapshot.sql");
|
|
57
|
+
try {
|
|
58
|
+
await withCredentials(environment, (path) => run(environment.DB_DUMP_BIN || "mariadb-dump", [
|
|
59
|
+
`--defaults-extra-file=${path}`,
|
|
60
|
+
"--single-transaction",
|
|
61
|
+
"--quick",
|
|
62
|
+
"--hex-blob",
|
|
63
|
+
"--routines",
|
|
64
|
+
"--triggers",
|
|
65
|
+
`--result-file=${snapshot}`,
|
|
66
|
+
config.database,
|
|
67
|
+
]));
|
|
68
|
+
if (!(await stat(snapshot)).size)
|
|
69
|
+
throw new Error("MariaDB dump was empty.");
|
|
70
|
+
const metadata = {
|
|
71
|
+
database: config.database,
|
|
72
|
+
createdAt: new Date().toISOString(),
|
|
73
|
+
sha256: await checksum(snapshot),
|
|
74
|
+
};
|
|
75
|
+
await link(snapshot, target);
|
|
76
|
+
await writeFile(`${target}.json`, JSON.stringify(metadata, null, 2) + "\n", {
|
|
77
|
+
flag: "wx",
|
|
78
|
+
mode: 0o600,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await rm(directory, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function verifyMariaDbBackup(path) {
|
|
86
|
+
const metadata = JSON.parse(await readFile(`${path}.json`, "utf8"));
|
|
87
|
+
if (!(await stat(path)).size || (await checksum(path)) !== metadata.sha256)
|
|
88
|
+
throw new Error("MariaDB backup checksum failed.");
|
|
89
|
+
}
|
|
90
|
+
export async function rehearseMariaDbRestore(environment, path) {
|
|
91
|
+
await verifyMariaDbBackup(path);
|
|
92
|
+
const config = mariaDbConfig(environment);
|
|
93
|
+
const { database: master, ...connection } = config;
|
|
94
|
+
const target = `${master.slice(0, 32)}_restore_${randomBytes(6).toString("hex")}`;
|
|
95
|
+
if (target.length > 64)
|
|
96
|
+
throw new Error("Master name too long for an isolated restore database.");
|
|
97
|
+
const db = await mysql.createConnection(connection);
|
|
98
|
+
let created = false;
|
|
99
|
+
let userCreated = false;
|
|
100
|
+
const user = `restore_${randomBytes(8).toString("hex")}`;
|
|
101
|
+
const password = randomBytes(32).toString("base64url");
|
|
102
|
+
try {
|
|
103
|
+
await db.query(`CREATE DATABASE ${mysql.escapeId(target)} CHARACTER SET utf8mb4 COLLATE utf8mb4_bin`);
|
|
104
|
+
created = true;
|
|
105
|
+
await db.query("CREATE USER ?@'%' IDENTIFIED BY ?", [user, password]);
|
|
106
|
+
userCreated = true;
|
|
107
|
+
await db.query(`GRANT ALL PRIVILEGES ON ${mysql.escapeId(target)}.* TO ?@'%'`, [user]);
|
|
108
|
+
await withCredentials({ ...environment, DB_USER: user, DB_PASSWORD: password }, (credential) => run(environment.DB_CLIENT_BIN || "mariadb", [`--defaults-extra-file=${credential}`, "--batch", target], path));
|
|
109
|
+
const [tables] = await db.query("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=?", [target]);
|
|
110
|
+
if (!tables.length)
|
|
111
|
+
throw new Error("Restored database has no tables.");
|
|
112
|
+
for (const row of tables) {
|
|
113
|
+
const table = String(row.TABLE_NAME);
|
|
114
|
+
const [checks] = await db.query(`CHECK TABLE ${mysql.escapeId(target)}.${mysql.escapeId(table)}`);
|
|
115
|
+
if (!checks.some((check) => check.Msg_type === "status" && check.Msg_text === "OK"))
|
|
116
|
+
throw new Error(`Restore table check failed: ${table}`);
|
|
117
|
+
}
|
|
118
|
+
console.info(`Isolated restore passed: ${tables.length} tables checked.`);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
try {
|
|
122
|
+
if (created)
|
|
123
|
+
await db.query(`DROP DATABASE ${mysql.escapeId(target)}`);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
try {
|
|
127
|
+
if (userCreated)
|
|
128
|
+
await db.query("DROP USER ?@'%'", [user]);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await db.end();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function checksum(path) {
|
|
137
|
+
const hash = createHash("sha256");
|
|
138
|
+
for await (const chunk of createReadStream(path))
|
|
139
|
+
hash.update(chunk);
|
|
140
|
+
return hash.digest("hex");
|
|
141
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type KyselyPlugin, type PluginTransformQueryArgs, type PluginTransformResultArgs } from "kysely";
|
|
2
|
+
export declare class MariaDbCompatibilityPlugin implements KyselyPlugin {
|
|
3
|
+
private readonly transformer;
|
|
4
|
+
transformQuery(args: PluginTransformQueryArgs): import("kysely").RootOperationNode;
|
|
5
|
+
transformResult(args: PluginTransformResultArgs): Promise<import("kysely").QueryResult<import("kysely").UnknownRow>>;
|
|
6
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { OperationNodeTransformer, ColumnUpdateNode, ReferenceNode, DataTypeNode, } from "kysely";
|
|
2
|
+
// Translate portable SQLite SQL at the database boundary.
|
|
3
|
+
// Values stay bound parameters; no business behavior or private package imports live here.
|
|
4
|
+
class MariaDbTransformer extends OperationNodeTransformer {
|
|
5
|
+
transformColumnDefinition(node) {
|
|
6
|
+
const transformed = super.transformColumnDefinition(node);
|
|
7
|
+
if (transformed.column.column.name === "timestamp") {
|
|
8
|
+
return { ...transformed, dataType: DataTypeNode.create("datetime(3)") };
|
|
9
|
+
}
|
|
10
|
+
if (DataTypeNode.is(transformed.dataType) &&
|
|
11
|
+
transformed.dataType.dataType === "text") {
|
|
12
|
+
const name = transformed.column.column.name;
|
|
13
|
+
const indexed = name === "id" ||
|
|
14
|
+
name === "key" ||
|
|
15
|
+
name === "email" ||
|
|
16
|
+
name === "name" ||
|
|
17
|
+
name === "token_hash" ||
|
|
18
|
+
name.endsWith("_id") ||
|
|
19
|
+
name.endsWith("_at");
|
|
20
|
+
return indexed
|
|
21
|
+
? { ...transformed, dataType: DataTypeNode.create("varchar(255)") }
|
|
22
|
+
: transformed;
|
|
23
|
+
}
|
|
24
|
+
return transformed;
|
|
25
|
+
}
|
|
26
|
+
transformInsertQuery(node) {
|
|
27
|
+
let transformed = super.transformInsertQuery(node);
|
|
28
|
+
if (transformed.into?.table.identifier.name === "migrations" &&
|
|
29
|
+
transformed.values) {
|
|
30
|
+
transformed = {
|
|
31
|
+
...transformed,
|
|
32
|
+
values: new MigrationTimestampTransformer().transformNode(transformed.values),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const conflict = transformed.onConflict;
|
|
36
|
+
if (!conflict)
|
|
37
|
+
return transformed;
|
|
38
|
+
if (conflict.indexWhere ||
|
|
39
|
+
conflict.updateWhere ||
|
|
40
|
+
conflict.constraint ||
|
|
41
|
+
conflict.indexExpression) {
|
|
42
|
+
throw new Error("Unsupported MariaDB conflict target.");
|
|
43
|
+
}
|
|
44
|
+
const column = conflict.columns?.[0];
|
|
45
|
+
if (!conflict.updates && !column)
|
|
46
|
+
throw new Error("Missing MariaDB conflict column.");
|
|
47
|
+
return {
|
|
48
|
+
...transformed,
|
|
49
|
+
onConflict: undefined,
|
|
50
|
+
onDuplicateKey: {
|
|
51
|
+
kind: "OnDuplicateKeyNode",
|
|
52
|
+
updates: conflict.updates ?? [
|
|
53
|
+
ColumnUpdateNode.create(column, ReferenceNode.create(column)),
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
transformRaw(node) {
|
|
59
|
+
const transformed = super.transformRaw(node);
|
|
60
|
+
return {
|
|
61
|
+
...transformed,
|
|
62
|
+
sqlFragments: transformed.sqlFragments.map((fragment) => fragment.replace(/json_group_array\(\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\)/g, "coalesce(json_arrayagg($1),json_array())")),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function migrationTimestamp(value) {
|
|
67
|
+
return typeof value === "string" &&
|
|
68
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
|
|
69
|
+
? value.replace("T", " ").replace("Z", "")
|
|
70
|
+
: value;
|
|
71
|
+
}
|
|
72
|
+
class MigrationTimestampTransformer extends OperationNodeTransformer {
|
|
73
|
+
transformValue(node) {
|
|
74
|
+
return { ...node, value: migrationTimestamp(node.value) };
|
|
75
|
+
}
|
|
76
|
+
transformPrimitiveValueList(node) {
|
|
77
|
+
return { ...node, values: node.values.map(migrationTimestamp) };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export class MariaDbCompatibilityPlugin {
|
|
81
|
+
transformer = new MariaDbTransformer();
|
|
82
|
+
transformQuery(args) {
|
|
83
|
+
return this.transformer.transformNode(args.node);
|
|
84
|
+
}
|
|
85
|
+
async transformResult(args) {
|
|
86
|
+
return args.result;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import mysql from "mysql2/promise";
|
|
2
|
+
import { createConnection, createPool } from "mysql2";
|
|
3
|
+
import { MysqlDialect } from "kysely";
|
|
4
|
+
import { mariaDbConfig } from "./mariadb.schema.js";
|
|
5
|
+
export function createMariaDbDialect(environment) {
|
|
6
|
+
const config = mariaDbConfig(environment);
|
|
7
|
+
return new MysqlDialect({
|
|
8
|
+
pool: async () => createPool(config),
|
|
9
|
+
controlConnection: createConnection,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export async function ensureMariaDbDatabase(environment, name) {
|
|
13
|
+
const config = mariaDbConfig({ ...environment, DB_MASTER_NAME: name });
|
|
14
|
+
const { database, ...options } = config;
|
|
15
|
+
const connection = await mysql.createConnection(options);
|
|
16
|
+
try {
|
|
17
|
+
await connection.query(`CREATE DATABASE IF NOT EXISTS ${mysql.escapeId(database)} CHARACTER SET utf8mb4 COLLATE utf8mb4_bin`);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
await connection.end();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function mariaDbConfig(environment: NodeJS.ProcessEnv): {
|
|
2
|
+
host: string;
|
|
3
|
+
port: number;
|
|
4
|
+
user: string;
|
|
5
|
+
password: string;
|
|
6
|
+
database: string;
|
|
7
|
+
connectionLimit: number;
|
|
8
|
+
connectTimeout: number;
|
|
9
|
+
charset: string;
|
|
10
|
+
supportBigNumbers: boolean;
|
|
11
|
+
bigNumberStrings: boolean;
|
|
12
|
+
multipleStatements: boolean;
|
|
13
|
+
timezone: string;
|
|
14
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const connectionSchema = z.object({
|
|
3
|
+
DB_HOST: z.string().trim().min(1),
|
|
4
|
+
DB_PORT: z.coerce.number().int().min(1).max(65535),
|
|
5
|
+
DB_USER: z.string().trim().min(1),
|
|
6
|
+
DB_PASSWORD: z.string().min(1),
|
|
7
|
+
DB_MASTER_NAME: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/),
|
|
8
|
+
});
|
|
9
|
+
export function mariaDbConfig(environment) {
|
|
10
|
+
const result = connectionSchema.safeParse(environment);
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
throw new Error(`Invalid MariaDB configuration: ${result.error.issues.map((i) => i.path.join(".")).join(", ")}`);
|
|
13
|
+
}
|
|
14
|
+
const value = result.data;
|
|
15
|
+
return {
|
|
16
|
+
host: value.DB_HOST,
|
|
17
|
+
port: value.DB_PORT,
|
|
18
|
+
user: value.DB_USER,
|
|
19
|
+
password: value.DB_PASSWORD,
|
|
20
|
+
database: value.DB_MASTER_NAME,
|
|
21
|
+
connectionLimit: 10,
|
|
22
|
+
connectTimeout: 10000,
|
|
23
|
+
charset: "utf8mb4_bin",
|
|
24
|
+
supportBigNumbers: true,
|
|
25
|
+
bigNumberStrings: true,
|
|
26
|
+
multipleStatements: false,
|
|
27
|
+
timezone: "Z",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, link, rm, access } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { DatabaseSync, backup } from "node:sqlite";
|
|
4
|
+
export async function createDatabaseBackup(sourcePath, destinationPath) {
|
|
5
|
+
if (!sourcePath.trim() ||
|
|
6
|
+
!destinationPath.trim() ||
|
|
7
|
+
sourcePath === ":memory:") {
|
|
8
|
+
throw new Error("Backup requires a source file and a new destination file.");
|
|
9
|
+
}
|
|
10
|
+
const source = resolve(sourcePath);
|
|
11
|
+
const destination = resolve(destinationPath);
|
|
12
|
+
if (source === destination)
|
|
13
|
+
throw new Error("Backup destination must differ from its source.");
|
|
14
|
+
await access(source);
|
|
15
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
16
|
+
const directory = await mkdtemp(join(dirname(destination), ".sqlite-backup-"));
|
|
17
|
+
const snapshot = join(directory, "snapshot.sqlite");
|
|
18
|
+
let database;
|
|
19
|
+
try {
|
|
20
|
+
database = new DatabaseSync(source, { readOnly: true });
|
|
21
|
+
await backup(database, snapshot);
|
|
22
|
+
verifyDatabaseBackup(snapshot);
|
|
23
|
+
// Exclusive linking preserves an existing destination and exposes only a complete snapshot.
|
|
24
|
+
await link(snapshot, destination);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
database?.close();
|
|
28
|
+
await rm(directory, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function verifyDatabaseBackup(path) {
|
|
32
|
+
const database = new DatabaseSync(resolve(path), { readOnly: true });
|
|
33
|
+
try {
|
|
34
|
+
const checks = database.prepare("PRAGMA integrity_check").all();
|
|
35
|
+
if (checks.length !== 1 || checks[0].integrity_check !== "ok") {
|
|
36
|
+
throw new Error("SQLite backup integrity check failed.");
|
|
37
|
+
}
|
|
38
|
+
if (database.prepare("PRAGMA foreign_key_check").all().length) {
|
|
39
|
+
throw new Error("SQLite backup contains foreign key violations.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
database.close();
|
|
44
|
+
}
|
|
45
|
+
}
|