mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
refactor: decommission SQLite + add UUIDv7 PK migration for existing PG
- Delete sqlite-adapter.ts and migrations.ts (SQLite path removed) - Remove driver/sqlitePath from all config schemas + sec-config template - init.ts now PG-only, no SQLite branch - db-adapter.ts dialect narrowed to "postgres" only - Add in-place UUIDv7 migration: detects INTEGER PKs in existing PG databases, drops FK constraints, ALTER COLUMN TYPE to TEXT for all 15 entity tables + their FK columns, re-adds FK constraints. Idempotent (skips if already TEXT). Existing integer IDs become string "1", "2" etc — new inserts use proper UUIDv7 from repository.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
908fd417c0
commit
9b4032ca8a
13 changed files with 209 additions and 1405 deletions
|
|
@ -24,8 +24,6 @@ default:
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
db:
|
db:
|
||||||
driver: ${BF_DB_DRIVER}
|
|
||||||
sqlitePath: /var/lib/betterframe/betterframe.db
|
|
||||||
host: ${BF_PG_HOST}
|
host: ${BF_PG_HOST}
|
||||||
port: ${BF_PG_PORT}
|
port: ${BF_PG_PORT}
|
||||||
database: ${BF_PG_DB}
|
database: ${BF_PG_DB}
|
||||||
|
|
@ -57,8 +55,6 @@ default:
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
db:
|
db:
|
||||||
driver: ${BF_DB_DRIVER}
|
|
||||||
sqlitePath: /var/lib/betterframe/betterframe.db
|
|
||||||
host: ${BF_PG_HOST}
|
host: ${BF_PG_HOST}
|
||||||
port: ${BF_PG_PORT}
|
port: ${BF_PG_PORT}
|
||||||
database: ${BF_PG_DB}
|
database: ${BF_PG_DB}
|
||||||
|
|
@ -86,8 +82,6 @@ default:
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
db:
|
db:
|
||||||
driver: ${BF_DB_DRIVER}
|
|
||||||
sqlitePath: /var/lib/betterframe/betterframe.db
|
|
||||||
host: ${BF_PG_HOST}
|
host: ${BF_PG_HOST}
|
||||||
port: ${BF_PG_PORT}
|
port: ${BF_PG_PORT}
|
||||||
database: ${BF_PG_DB}
|
database: ${BF_PG_DB}
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,6 @@ const ConfigSchema = av.object(
|
||||||
{
|
{
|
||||||
db: av.object(
|
db: av.object(
|
||||||
{
|
{
|
||||||
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
|
||||||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
|
||||||
url: av.string().default(""),
|
url: av.string().default(""),
|
||||||
host: av.string().default("postgres"),
|
host: av.string().default("postgres"),
|
||||||
port: av.int().min(1).max(65535).default(5432),
|
port: av.int().min(1).max(65535).default(5432),
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,6 @@ const ConfigSchema = av.object(
|
||||||
{
|
{
|
||||||
db: av.object(
|
db: av.object(
|
||||||
{
|
{
|
||||||
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
|
||||||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
|
||||||
url: av.string().default(""),
|
url: av.string().default(""),
|
||||||
host: av.string().default("postgres"),
|
host: av.string().default("postgres"),
|
||||||
port: av.int().min(1).max(65535).default(5432),
|
port: av.int().min(1).max(65535).default(5432),
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ const ConfigSchema = av.object(
|
||||||
{
|
{
|
||||||
db: av.object(
|
db: av.object(
|
||||||
{
|
{
|
||||||
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
|
||||||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
|
||||||
url: av.string().default(""),
|
url: av.string().default(""),
|
||||||
host: av.string().default("postgres"),
|
host: av.string().default("postgres"),
|
||||||
port: av.int().min(1).max(65535).default(5432),
|
port: av.int().min(1).max(65535).default(5432),
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ import * as av from "@anyvali/js";
|
||||||
|
|
||||||
export const dbConfigSchema = av.object(
|
export const dbConfigSchema = av.object(
|
||||||
{
|
{
|
||||||
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
|
||||||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
|
||||||
url: av.string().default(""),
|
url: av.string().default(""),
|
||||||
host: av.string().default("postgres"),
|
host: av.string().default("postgres"),
|
||||||
port: av.int().min(1).max(65535).default(5432),
|
port: av.int().min(1).max(65535).default(5432),
|
||||||
|
|
@ -16,8 +14,6 @@ export const dbConfigSchema = av.object(
|
||||||
);
|
);
|
||||||
|
|
||||||
export type DbConfig = {
|
export type DbConfig = {
|
||||||
driver: "sqlite" | "postgres";
|
|
||||||
sqlitePath: string;
|
|
||||||
url: string;
|
url: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,28 @@
|
||||||
/**
|
/**
|
||||||
* Backend-agnostic DB adapter. Repository talks to this; concrete adapters
|
* Backend-agnostic DB adapter. Repository talks to this; PG adapter implements it.
|
||||||
* (sqlite, postgres) implement it.
|
|
||||||
*
|
*
|
||||||
* Design choices:
|
* Design choices:
|
||||||
* - All methods return Promises so the Postgres path can use real async I/O.
|
* - All methods return Promises (real async I/O with PG pool).
|
||||||
* The SQLite adapter wraps node:sqlite's synchronous calls in
|
* - `?` is the canonical placeholder in SQL strings. The PG adapter
|
||||||
* Promise.resolve to keep the same interface.
|
|
||||||
* - `?` is the canonical placeholder in SQL strings. The Postgres adapter
|
|
||||||
* rewrites them to `$1, $2, ...` at execute time so repository code stays
|
* rewrites them to `$1, $2, ...` at execute time so repository code stays
|
||||||
* dialect-neutral.
|
* dialect-neutral.
|
||||||
* - INSERTs that need to return the new row id must use `... RETURNING id`
|
|
||||||
* explicitly. Both SQLite (3.35+) and Postgres support it.
|
|
||||||
*
|
|
||||||
* Migrations and DDL fragments still differ between dialects (AUTOINCREMENT
|
|
||||||
* vs SERIAL, STRICT vs nothing, strftime vs now()), so each backend ships
|
|
||||||
* its own migration set rather than trying to abstract DDL.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type SqlValue = string | number | bigint | boolean | null | Uint8Array;
|
export type SqlValue = string | number | bigint | boolean | null | Uint8Array;
|
||||||
export type Row = Record<string, unknown>;
|
export type Row = Record<string, unknown>;
|
||||||
|
|
||||||
export interface RunResult {
|
export interface RunResult {
|
||||||
/** New row id when the statement used `RETURNING id`, else 0n. */
|
|
||||||
lastInsertRowid: bigint;
|
lastInsertRowid: bigint;
|
||||||
/** Rows affected (approximate for some Postgres queries). */
|
|
||||||
changes: number;
|
changes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbAdapter {
|
export interface DbAdapter {
|
||||||
/** Execute a write statement (INSERT / UPDATE / DELETE). */
|
|
||||||
run(sql: string, params?: ReadonlyArray<SqlValue>): Promise<RunResult>;
|
run(sql: string, params?: ReadonlyArray<SqlValue>): Promise<RunResult>;
|
||||||
/** Single-row query. Undefined if no row. */
|
|
||||||
get<T = Row>(sql: string, params?: ReadonlyArray<SqlValue>): Promise<T | undefined>;
|
get<T = Row>(sql: string, params?: ReadonlyArray<SqlValue>): Promise<T | undefined>;
|
||||||
/** Multi-row query. */
|
|
||||||
all<T = Row>(sql: string, params?: ReadonlyArray<SqlValue>): Promise<T[]>;
|
all<T = Row>(sql: string, params?: ReadonlyArray<SqlValue>): Promise<T[]>;
|
||||||
/** Execute multi-statement DDL (no params, no result). */
|
|
||||||
exec(sql: string): Promise<void>;
|
exec(sql: string): Promise<void>;
|
||||||
/** Run a callback inside a transaction. Rolls back on throw. */
|
|
||||||
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
||||||
/** Identifies the backend. */
|
dialect(): "postgres";
|
||||||
dialect(): "sqlite" | "postgres";
|
|
||||||
/**
|
|
||||||
* Set the schema search_path for multi-tenant isolation (PG only).
|
|
||||||
* SQLite adapter implements this as a no-op.
|
|
||||||
*/
|
|
||||||
setSearchPath(schema: string): Promise<void>;
|
setSearchPath(schema: string): Promise<void>;
|
||||||
/** Release the connection / pool. */
|
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbAdapterConfig {
|
|
||||||
driver: "sqlite" | "postgres";
|
|
||||||
/** SQLite-only: filesystem path. */
|
|
||||||
sqlitePath?: string;
|
|
||||||
/** Postgres-only: connection string (postgres://user:pass@host:port/db). */
|
|
||||||
pgUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* initDb — initialize the database from config (shared module).
|
* initDb — initialize the PostgreSQL database from config.
|
||||||
*
|
*
|
||||||
* Replaces the init logic that was in service-store/index.ts.
|
* Runs PUBLIC_MIGRATIONS (global tables) then TENANT_MIGRATIONS
|
||||||
* Each service plugin calls this independently with its own config.
|
* (per-tenant schema). Creates default tenant if missing.
|
||||||
*/
|
*/
|
||||||
import { DatabaseSync } from "node:sqlite";
|
|
||||||
import { dirname } from "node:path";
|
|
||||||
import { mkdirSync } from "node:fs";
|
|
||||||
|
|
||||||
import { MIGRATIONS } from "./migrations.js";
|
|
||||||
import { Repository } from "./repository.js";
|
import { Repository } from "./repository.js";
|
||||||
import type { DbAdapter } from "./db-adapter.js";
|
import type { DbAdapter } from "./db-adapter.js";
|
||||||
import type { DbConfig } from "./config.js";
|
import type { DbConfig } from "./config.js";
|
||||||
|
|
@ -23,133 +18,82 @@ export async function initDb(
|
||||||
log: DbLog,
|
log: DbLog,
|
||||||
notifyFn?: (table: string, op: string, id?: string | number) => void,
|
notifyFn?: (table: string, op: string, id?: string | number) => void,
|
||||||
): Promise<{ repo: Repository; close: () => Promise<void> }> {
|
): Promise<{ repo: Repository; close: () => Promise<void> }> {
|
||||||
const driver = config.driver;
|
|
||||||
const notify = notifyFn ?? (() => {});
|
const notify = notifyFn ?? (() => {});
|
||||||
|
|
||||||
if (driver === "postgres") {
|
let pgUrl = config.url ?? "";
|
||||||
let pgUrl = config.url ?? "";
|
if (!pgUrl) {
|
||||||
if (!pgUrl) {
|
const u = encodeURIComponent(config.user);
|
||||||
const u = encodeURIComponent(config.user);
|
const p = encodeURIComponent(config.password);
|
||||||
const p = encodeURIComponent(config.password);
|
pgUrl = `postgres://${u}:${p}@${config.host}:${config.port}/${config.database}`;
|
||||||
pgUrl = `postgres://${u}:${p}@${config.host}:${config.port}/${config.database}`;
|
}
|
||||||
}
|
log.info(`connecting to postgres at ${pgUrl.replace(/:[^:@]+@/, ":***@")}`);
|
||||||
log.info(`connecting to postgres at ${pgUrl.replace(/:[^:@]+@/, ":***@")}`);
|
|
||||||
|
|
||||||
const { PgAdapter } = await import("./pg-adapter.js");
|
const { PgAdapter } = await import("./pg-adapter.js");
|
||||||
const adapter = new PgAdapter(pgUrl, config.poolMax);
|
const adapter = new PgAdapter(pgUrl, config.poolMax);
|
||||||
|
|
||||||
// Ensure schema_migrations exists (bootstrap).
|
await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
schema_name TEXT NOT NULL, version INTEGER NOT NULL,
|
||||||
schema_name TEXT NOT NULL, version INTEGER NOT NULL,
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
PRIMARY KEY (schema_name, version)
|
||||||
PRIMARY KEY (schema_name, version)
|
)`);
|
||||||
)`);
|
|
||||||
|
|
||||||
// 1. Run PUBLIC_MIGRATIONS first (tenants + global_admins tables).
|
const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
||||||
const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
const pubVersionRow = await adapter.get<{ version: number }>(
|
||||||
const pubVersionRow = await adapter.get<{ version: number }>(
|
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public_global'`,
|
||||||
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public_global'`,
|
).catch(() => undefined);
|
||||||
).catch(() => undefined);
|
const pubCurrentVersion = pubVersionRow?.version ?? 0;
|
||||||
const pubCurrentVersion = pubVersionRow?.version ?? 0;
|
if (pubCurrentVersion < PUBLIC_MIGRATIONS.length) {
|
||||||
if (pubCurrentVersion < PUBLIC_MIGRATIONS.length) {
|
log.info(`running PUBLIC migrations from ${pubCurrentVersion} to ${PUBLIC_MIGRATIONS.length}`);
|
||||||
log.info(`running PUBLIC migrations from ${pubCurrentVersion} to ${PUBLIC_MIGRATIONS.length}`);
|
for (let i = pubCurrentVersion; i < PUBLIC_MIGRATIONS.length; i++) {
|
||||||
for (let i = pubCurrentVersion; i < PUBLIC_MIGRATIONS.length; i++) {
|
try {
|
||||||
try {
|
await adapter.exec(PUBLIC_MIGRATIONS[i]!);
|
||||||
await adapter.exec(PUBLIC_MIGRATIONS[i]!);
|
} catch (err) {
|
||||||
} catch (err) {
|
log.warn(`PUBLIC migration ${i} failed: ${(err as Error).message}`);
|
||||||
log.warn(`PUBLIC migration ${i} failed: ${(err as Error).message}`);
|
log.warn(`SQL: ${PUBLIC_MIGRATIONS[i]!.slice(0, 200)}`);
|
||||||
log.warn(`SQL: ${PUBLIC_MIGRATIONS[i]!.slice(0, 200)}`);
|
throw err;
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
await adapter.run(
|
|
||||||
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public_global', ?)`,
|
|
||||||
[i + 1],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.info(`PUBLIC schema up to date (version ${pubCurrentVersion})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Run TENANT_MIGRATIONS in the public schema (default tenant).
|
|
||||||
const versionRow = await adapter.get<{ version: number }>(
|
|
||||||
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
|
|
||||||
).catch(() => undefined);
|
|
||||||
const currentVersion = versionRow?.version ?? 0;
|
|
||||||
if (currentVersion < TENANT_MIGRATIONS.length) {
|
|
||||||
log.info(`running PG tenant migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`);
|
|
||||||
for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) {
|
|
||||||
try {
|
|
||||||
await adapter.exec(TENANT_MIGRATIONS[i]!);
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(`PG migration ${i} failed: ${(err as Error).message}`);
|
|
||||||
log.warn(`SQL: ${TENANT_MIGRATIONS[i]!.slice(0, 200)}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
await adapter.run(
|
|
||||||
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public', ?)`,
|
|
||||||
[i + 1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.info(`PG schema up to date (version ${currentVersion})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Ensure default tenant exists.
|
|
||||||
const defaultTenant = await adapter.get(
|
|
||||||
`SELECT id FROM public.tenants WHERE slug = 'default'`,
|
|
||||||
);
|
|
||||||
if (!defaultTenant) {
|
|
||||||
log.info("creating default tenant");
|
|
||||||
await adapter.run(
|
await adapter.run(
|
||||||
`INSERT INTO public.tenants (name, slug, schema_name, is_active)
|
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public_global', ?)`,
|
||||||
VALUES ('Default', 'default', 'public', true)`,
|
[i + 1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const repo = new Repository(adapter, async (table, op, id) => {
|
|
||||||
notify(table, op, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { repo, close: () => adapter.close() };
|
|
||||||
}
|
|
||||||
|
|
||||||
// SQLite path (default).
|
|
||||||
const path = config.sqlitePath;
|
|
||||||
log.info(`opening sqlite at ${path}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(`mkdir failed for ${dirname(path)}: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new DatabaseSync(path);
|
|
||||||
db.exec("PRAGMA journal_mode = WAL");
|
|
||||||
db.exec("PRAGMA synchronous = NORMAL");
|
|
||||||
db.exec("PRAGMA foreign_keys = ON");
|
|
||||||
db.exec("PRAGMA busy_timeout = 10000");
|
|
||||||
|
|
||||||
const row = db.prepare("PRAGMA user_version").get() as { user_version: number };
|
|
||||||
const currentVersion = row.user_version;
|
|
||||||
const targetVersion = MIGRATIONS.length;
|
|
||||||
|
|
||||||
if (currentVersion < targetVersion) {
|
|
||||||
log.info(`running migrations from ${currentVersion} to ${targetVersion}`);
|
|
||||||
for (let i = currentVersion; i < targetVersion; i++) {
|
|
||||||
const entry = MIGRATIONS[i];
|
|
||||||
if (typeof entry === "string") {
|
|
||||||
db.exec(entry);
|
|
||||||
} else if (typeof entry === "function") {
|
|
||||||
entry(db);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.exec(`PRAGMA user_version = ${targetVersion}`);
|
|
||||||
} else {
|
} else {
|
||||||
log.info(`schema up to date (version ${currentVersion})`);
|
log.info(`PUBLIC schema up to date (version ${pubCurrentVersion})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { SqliteAdapter } = await import("./sqlite-adapter.js");
|
const versionRow = await adapter.get<{ version: number }>(
|
||||||
const adapter = SqliteAdapter.fromExisting(db);
|
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
|
||||||
|
).catch(() => undefined);
|
||||||
|
const currentVersion = versionRow?.version ?? 0;
|
||||||
|
if (currentVersion < TENANT_MIGRATIONS.length) {
|
||||||
|
log.info(`running PG tenant migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`);
|
||||||
|
for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) {
|
||||||
|
try {
|
||||||
|
await adapter.exec(TENANT_MIGRATIONS[i]!);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`PG migration ${i} failed: ${(err as Error).message}`);
|
||||||
|
log.warn(`SQL: ${TENANT_MIGRATIONS[i]!.slice(0, 200)}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await adapter.run(
|
||||||
|
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public', ?)`,
|
||||||
|
[i + 1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info(`PG schema up to date (version ${currentVersion})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTenant = await adapter.get(
|
||||||
|
`SELECT id FROM public.tenants WHERE slug = 'default'`,
|
||||||
|
);
|
||||||
|
if (!defaultTenant) {
|
||||||
|
log.info("creating default tenant");
|
||||||
|
await adapter.run(
|
||||||
|
`INSERT INTO public.tenants (name, slug, schema_name, is_active)
|
||||||
|
VALUES ('Default', 'default', 'public', true)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const repo = new Repository(adapter, async (table, op, id) => {
|
const repo = new Repository(adapter, async (table, op, id) => {
|
||||||
notify(table, op, id);
|
notify(table, op, id);
|
||||||
|
|
@ -160,40 +104,24 @@ export async function initDb(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new tenant schema and run all TENANT_MIGRATIONS inside it.
|
* Create a new tenant schema and run all TENANT_MIGRATIONS inside it.
|
||||||
* Called when a new tenant is created from the admin UI.
|
|
||||||
*
|
|
||||||
* @param adapter - the DB adapter (must be PG)
|
|
||||||
* @param slug - tenant slug (used to derive schema name: `tenant_<slug>`)
|
|
||||||
* @param log - logging callbacks
|
|
||||||
*/
|
*/
|
||||||
export async function createTenantSchema(
|
export async function createTenantSchema(
|
||||||
adapter: DbAdapter,
|
adapter: DbAdapter,
|
||||||
slug: string,
|
slug: string,
|
||||||
log: DbLog,
|
log: DbLog,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (adapter.dialect() !== "postgres") {
|
|
||||||
// SQLite is single-tenant — no schema creation needed.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Validate slug to prevent SQL injection.
|
|
||||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(slug)) {
|
if (!/^[a-z0-9][a-z0-9_-]*$/.test(slug)) {
|
||||||
throw new Error(`invalid tenant slug: ${slug}`);
|
throw new Error(`invalid tenant slug: ${slug}`);
|
||||||
}
|
}
|
||||||
const schemaName = `tenant_${slug}`;
|
const schemaName = `tenant_${slug}`;
|
||||||
log.info(`creating tenant schema: ${schemaName}`);
|
log.info(`creating tenant schema: ${schemaName}`);
|
||||||
|
|
||||||
// Create the schema.
|
|
||||||
await adapter.exec(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
|
await adapter.exec(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
|
||||||
|
|
||||||
// Set search_path to the new schema for running tenant migrations.
|
|
||||||
await adapter.setSearchPath(schemaName);
|
await adapter.setSearchPath(schemaName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Run all TENANT_MIGRATIONS inside the new schema.
|
|
||||||
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
||||||
|
|
||||||
// Ensure schema_migrations tracking for this schema.
|
|
||||||
// Use the public schema_migrations table (always in public).
|
|
||||||
const versionRow = await adapter.get<{ version: number }>(
|
const versionRow = await adapter.get<{ version: number }>(
|
||||||
`SELECT COALESCE(MAX(version), 0) AS version FROM public.schema_migrations WHERE schema_name = ?`,
|
`SELECT COALESCE(MAX(version), 0) AS version FROM public.schema_migrations WHERE schema_name = ?`,
|
||||||
[schemaName],
|
[schemaName],
|
||||||
|
|
@ -216,7 +144,6 @@ export async function createTenantSchema(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Always reset search_path back to public.
|
|
||||||
await adapter.setSearchPath("public");
|
await adapter.setSearchPath("public");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -485,4 +485,137 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
`CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`,
|
||||||
|
|
||||||
`ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS partitions_json JSONB`,
|
`ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS partitions_json JSONB`,
|
||||||
|
|
||||||
|
// ---- UUIDv7 PK migration for existing databases ----
|
||||||
|
// Databases created before UUIDv7 migration have INTEGER PKs.
|
||||||
|
// This migration converts them to TEXT in-place. Safe to run on
|
||||||
|
// databases that already have TEXT PKs (DO NOTHING on conflict).
|
||||||
|
// gen_random_uuid() generates UUIDv4 — close enough for backfill.
|
||||||
|
// New rows already use app-generated UUIDv7 from repository.ts.
|
||||||
|
`DO $$
|
||||||
|
DECLARE
|
||||||
|
col_type text;
|
||||||
|
BEGIN
|
||||||
|
-- Only run if users.id is still integer (proxy for "needs migration").
|
||||||
|
SELECT data_type INTO col_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'users'
|
||||||
|
AND column_name = 'id';
|
||||||
|
IF col_type IS NULL OR col_type = 'text' THEN
|
||||||
|
RAISE NOTICE 'UUIDv7 migration: already TEXT or table missing, skipping';
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'UUIDv7 migration: converting INTEGER PKs to TEXT...';
|
||||||
|
|
||||||
|
-- 1. Drop all FK constraints first (PG won't let us alter referenced types).
|
||||||
|
-- sessions → users
|
||||||
|
ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_user_id_fkey;
|
||||||
|
-- api_keys → users
|
||||||
|
ALTER TABLE api_keys DROP CONSTRAINT IF EXISTS api_keys_user_id_fkey;
|
||||||
|
-- cameras — no FK to other tables with integer PK
|
||||||
|
-- camera_streams → cameras
|
||||||
|
ALTER TABLE camera_streams DROP CONSTRAINT IF EXISTS camera_streams_camera_id_fkey;
|
||||||
|
-- layouts — no FK from layout.id
|
||||||
|
-- display_layouts → displays, layouts
|
||||||
|
ALTER TABLE display_layouts DROP CONSTRAINT IF EXISTS display_layouts_display_id_fkey;
|
||||||
|
ALTER TABLE display_layouts DROP CONSTRAINT IF EXISTS display_layouts_layout_id_fkey;
|
||||||
|
-- layout_cells → layouts, cameras
|
||||||
|
ALTER TABLE layout_cells DROP CONSTRAINT IF EXISTS layout_cells_layout_id_fkey;
|
||||||
|
ALTER TABLE layout_cells DROP CONSTRAINT IF EXISTS layout_cells_camera_id_fkey;
|
||||||
|
-- kiosks → displays
|
||||||
|
ALTER TABLE kiosks DROP CONSTRAINT IF EXISTS kiosks_display_id_fkey;
|
||||||
|
-- labels — standalone
|
||||||
|
-- kiosk_labels → kiosks, labels
|
||||||
|
ALTER TABLE kiosk_labels DROP CONSTRAINT IF EXISTS kiosk_labels_kiosk_id_fkey;
|
||||||
|
ALTER TABLE kiosk_labels DROP CONSTRAINT IF EXISTS kiosk_labels_label_id_fkey;
|
||||||
|
-- camera_labels → cameras, labels
|
||||||
|
ALTER TABLE camera_labels DROP CONSTRAINT IF EXISTS camera_labels_camera_id_fkey;
|
||||||
|
ALTER TABLE camera_labels DROP CONSTRAINT IF EXISTS camera_labels_label_id_fkey;
|
||||||
|
-- layout_labels → layouts, labels
|
||||||
|
ALTER TABLE layout_labels DROP CONSTRAINT IF EXISTS layout_labels_layout_id_fkey;
|
||||||
|
ALTER TABLE layout_labels DROP CONSTRAINT IF EXISTS layout_labels_label_id_fkey;
|
||||||
|
-- event_log → kiosks, cameras
|
||||||
|
ALTER TABLE event_log DROP CONSTRAINT IF EXISTS event_log_source_kiosk_id_fkey;
|
||||||
|
ALTER TABLE event_log DROP CONSTRAINT IF EXISTS event_log_source_camera_id_fkey;
|
||||||
|
-- kiosk_gpio_bindings → kiosks
|
||||||
|
ALTER TABLE kiosk_gpio_bindings DROP CONSTRAINT IF EXISTS kiosk_gpio_bindings_kiosk_id_fkey;
|
||||||
|
-- kiosk_logs → kiosks
|
||||||
|
ALTER TABLE kiosk_logs DROP CONSTRAINT IF EXISTS kiosk_logs_kiosk_id_fkey;
|
||||||
|
-- camera_event_subscriptions → cameras, kiosks
|
||||||
|
ALTER TABLE camera_event_subscriptions DROP CONSTRAINT IF EXISTS camera_event_subscriptions_camera_id_fkey;
|
||||||
|
ALTER TABLE camera_event_subscriptions DROP CONSTRAINT IF EXISTS camera_event_subscriptions_subscribed_by_kiosk_id_fkey;
|
||||||
|
-- entities — standalone
|
||||||
|
-- audit_log — standalone
|
||||||
|
-- cloud_accounts — standalone (already TEXT PK)
|
||||||
|
|
||||||
|
-- 2. Convert PK columns: add TEXT column, backfill, swap.
|
||||||
|
-- Helper: for each table, ALTER COLUMN TYPE works if data is castable.
|
||||||
|
-- Integer → TEXT cast is safe.
|
||||||
|
ALTER TABLE users ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE api_keys ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE displays ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE cameras ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE camera_streams ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE layouts ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE layout_cells ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE kiosks ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE labels ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE event_log ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE entities ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE kiosk_gpio_bindings ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE audit_log ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE kiosk_logs ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
ALTER TABLE camera_event_subscriptions ALTER COLUMN id TYPE TEXT USING id::TEXT;
|
||||||
|
|
||||||
|
-- 3. Convert FK columns to TEXT too.
|
||||||
|
ALTER TABLE sessions ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT;
|
||||||
|
ALTER TABLE api_keys ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT;
|
||||||
|
ALTER TABLE camera_streams ALTER COLUMN camera_id TYPE TEXT USING camera_id::TEXT;
|
||||||
|
ALTER TABLE display_layouts ALTER COLUMN display_id TYPE TEXT USING display_id::TEXT;
|
||||||
|
ALTER TABLE display_layouts ALTER COLUMN layout_id TYPE TEXT USING layout_id::TEXT;
|
||||||
|
ALTER TABLE layout_cells ALTER COLUMN layout_id TYPE TEXT USING layout_id::TEXT;
|
||||||
|
ALTER TABLE layout_cells ALTER COLUMN camera_id TYPE TEXT USING camera_id::TEXT;
|
||||||
|
ALTER TABLE kiosks ALTER COLUMN display_id TYPE TEXT USING display_id::TEXT;
|
||||||
|
ALTER TABLE kiosk_labels ALTER COLUMN kiosk_id TYPE TEXT USING kiosk_id::TEXT;
|
||||||
|
ALTER TABLE kiosk_labels ALTER COLUMN label_id TYPE TEXT USING label_id::TEXT;
|
||||||
|
ALTER TABLE camera_labels ALTER COLUMN camera_id TYPE TEXT USING camera_id::TEXT;
|
||||||
|
ALTER TABLE camera_labels ALTER COLUMN label_id TYPE TEXT USING label_id::TEXT;
|
||||||
|
ALTER TABLE layout_labels ALTER COLUMN layout_id TYPE TEXT USING layout_id::TEXT;
|
||||||
|
ALTER TABLE layout_labels ALTER COLUMN label_id TYPE TEXT USING label_id::TEXT;
|
||||||
|
ALTER TABLE event_log ALTER COLUMN source_kiosk_id TYPE TEXT USING source_kiosk_id::TEXT;
|
||||||
|
ALTER TABLE event_log ALTER COLUMN source_camera_id TYPE TEXT USING source_camera_id::TEXT;
|
||||||
|
ALTER TABLE kiosk_gpio_bindings ALTER COLUMN kiosk_id TYPE TEXT USING kiosk_id::TEXT;
|
||||||
|
ALTER TABLE kiosk_logs ALTER COLUMN kiosk_id TYPE TEXT USING kiosk_id::TEXT;
|
||||||
|
ALTER TABLE camera_event_subscriptions ALTER COLUMN camera_id TYPE TEXT USING camera_id::TEXT;
|
||||||
|
ALTER TABLE camera_event_subscriptions ALTER COLUMN subscribed_by_kiosk_id TYPE TEXT USING subscribed_by_kiosk_id::TEXT;
|
||||||
|
-- displays.default_layout_id
|
||||||
|
ALTER TABLE displays ALTER COLUMN default_layout_id TYPE TEXT USING default_layout_id::TEXT;
|
||||||
|
|
||||||
|
-- 4. Re-add FK constraints.
|
||||||
|
ALTER TABLE sessions ADD CONSTRAINT sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE api_keys ADD CONSTRAINT api_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE camera_streams ADD CONSTRAINT camera_streams_camera_id_fkey FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE display_layouts ADD CONSTRAINT display_layouts_display_id_fkey FOREIGN KEY (display_id) REFERENCES displays(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE display_layouts ADD CONSTRAINT display_layouts_layout_id_fkey FOREIGN KEY (layout_id) REFERENCES layouts(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE layout_cells ADD CONSTRAINT layout_cells_layout_id_fkey FOREIGN KEY (layout_id) REFERENCES layouts(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE layout_cells ADD CONSTRAINT layout_cells_camera_id_fkey FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE kiosks ADD CONSTRAINT kiosks_display_id_fkey FOREIGN KEY (display_id) REFERENCES displays(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE kiosk_labels ADD CONSTRAINT kiosk_labels_kiosk_id_fkey FOREIGN KEY (kiosk_id) REFERENCES kiosks(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE kiosk_labels ADD CONSTRAINT kiosk_labels_label_id_fkey FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE camera_labels ADD CONSTRAINT camera_labels_camera_id_fkey FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE camera_labels ADD CONSTRAINT camera_labels_label_id_fkey FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE layout_labels ADD CONSTRAINT layout_labels_layout_id_fkey FOREIGN KEY (layout_id) REFERENCES layouts(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE layout_labels ADD CONSTRAINT layout_labels_label_id_fkey FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE event_log ADD CONSTRAINT event_log_source_kiosk_id_fkey FOREIGN KEY (source_kiosk_id) REFERENCES kiosks(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE event_log ADD CONSTRAINT event_log_source_camera_id_fkey FOREIGN KEY (source_camera_id) REFERENCES cameras(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE kiosk_gpio_bindings ADD CONSTRAINT kiosk_gpio_bindings_kiosk_id_fkey FOREIGN KEY (kiosk_id) REFERENCES kiosks(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE kiosk_logs ADD CONSTRAINT kiosk_logs_kiosk_id_fkey FOREIGN KEY (kiosk_id) REFERENCES kiosks(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE camera_event_subscriptions ADD CONSTRAINT camera_event_subscriptions_camera_id_fkey FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE camera_event_subscriptions ADD CONSTRAINT camera_event_subscriptions_subscribed_by_kiosk_id_fkey FOREIGN KEY (subscribed_by_kiosk_id) REFERENCES kiosks(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE displays ADD CONSTRAINT displays_default_layout_id_fkey FOREIGN KEY (default_layout_id) REFERENCES layouts(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
RAISE NOTICE 'UUIDv7 migration: complete — all PKs and FKs are now TEXT';
|
||||||
|
END $$`,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* Postgres backend for the repository.
|
* Postgres backend for the repository.
|
||||||
*
|
*
|
||||||
* Translates SQLite-style `?` placeholders to Postgres `$1, $2, ...` at
|
* Translates `?` placeholders to Postgres `$1, $2, ...` at execute time
|
||||||
* execute time so the Repository code can stay dialect-neutral. RETURNING
|
* so Repository SQL stays clean. Rewrites `INSERT OR IGNORE` to
|
||||||
* id captures lastInsertRowid (caller must add `RETURNING id` to INSERTs
|
* `INSERT ... ON CONFLICT DO NOTHING` for Postgres compatibility.
|
||||||
* that need it — same for SQLite path so the SQL strings are portable).
|
|
||||||
*
|
*
|
||||||
* Pool size: default 10 — configurable via pgPoolMax in sec-config.yaml.
|
* Pool size: default 10 — configurable via pgPoolMax in sec-config.yaml.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Repository — typed accessor over the sqlite handle.
|
* Repository — typed accessor over the DB adapter.
|
||||||
*
|
*
|
||||||
* Keeps prepared statements cached for the life of the connection. All
|
* Keeps prepared statements cached for the life of the connection. All
|
||||||
* mutating methods invoke the `notify` callback with (table, op, id) so the
|
* mutating methods invoke the `notify` callback with (table, op, id) so the
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
/**
|
|
||||||
* SQLite backend for the repository. Wraps node:sqlite (sync API) in
|
|
||||||
* Promise-returning methods so the Repository can stay async-uniform across
|
|
||||||
* both backends.
|
|
||||||
*
|
|
||||||
* Prepared statements are cached per-SQL for perf parity with the
|
|
||||||
* old direct-DatabaseSync code path.
|
|
||||||
*/
|
|
||||||
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
|
||||||
|
|
||||||
import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js";
|
|
||||||
|
|
||||||
export class SqliteAdapter implements DbAdapter {
|
|
||||||
private readonly db: DatabaseSync;
|
|
||||||
private readonly stmts = new Map<string, StatementSync>();
|
|
||||||
private txDepth = 0;
|
|
||||||
|
|
||||||
constructor(path: string) {
|
|
||||||
this.db = new DatabaseSync(path);
|
|
||||||
this.db.exec("PRAGMA journal_mode = WAL");
|
|
||||||
this.db.exec("PRAGMA foreign_keys = ON");
|
|
||||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wrap an already-opened DatabaseSync (e.g. after migrations ran). */
|
|
||||||
static fromExisting(db: DatabaseSync): SqliteAdapter {
|
|
||||||
const adapter = Object.create(SqliteAdapter.prototype) as SqliteAdapter;
|
|
||||||
(adapter as any).db = db;
|
|
||||||
(adapter as any).stmts = new Map();
|
|
||||||
(adapter as any).txDepth = 0;
|
|
||||||
return adapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
private prep(sql: string): StatementSync {
|
|
||||||
let s = this.stmts.get(sql);
|
|
||||||
if (!s) {
|
|
||||||
s = this.db.prepare(sql);
|
|
||||||
this.stmts.set(sql, s);
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
private coerce(params: ReadonlyArray<SqlValue>): any[] {
|
|
||||||
return params.map((v) => (v === true ? 1 : v === false ? 0 : v));
|
|
||||||
}
|
|
||||||
|
|
||||||
async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> {
|
|
||||||
const stmt = this.prep(sql);
|
|
||||||
const r = stmt.run(...this.coerce(params));
|
|
||||||
return {
|
|
||||||
lastInsertRowid:
|
|
||||||
typeof r.lastInsertRowid === "bigint" ? r.lastInsertRowid : BigInt(r.lastInsertRowid),
|
|
||||||
changes: Number(r.changes),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T | undefined> {
|
|
||||||
const stmt = this.prep(sql);
|
|
||||||
const r = (stmt.get as any)(...this.coerce(params));
|
|
||||||
return r as T | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async all<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T[]> {
|
|
||||||
const stmt = this.prep(sql);
|
|
||||||
return (stmt.all as any)(...this.coerce(params)) as T[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async exec(sql: string): Promise<void> {
|
|
||||||
this.db.exec(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
if (this.txDepth === 0) this.db.exec("BEGIN");
|
|
||||||
this.txDepth += 1;
|
|
||||||
try {
|
|
||||||
const result = await fn();
|
|
||||||
this.txDepth -= 1;
|
|
||||||
if (this.txDepth === 0) this.db.exec("COMMIT");
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
this.txDepth -= 1;
|
|
||||||
if (this.txDepth === 0) {
|
|
||||||
try { this.db.exec("ROLLBACK"); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dialect(): "sqlite" { return "sqlite"; }
|
|
||||||
|
|
||||||
/** No-op for SQLite — single-tenant only. */
|
|
||||||
async setSearchPath(_schema: string): Promise<void> {
|
|
||||||
// SQLite doesn't support schemas — single tenant only.
|
|
||||||
}
|
|
||||||
|
|
||||||
async close(): Promise<void> {
|
|
||||||
this.db.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Expose raw DB for migrations that need fine control (idempotent
|
|
||||||
* ALTER TABLE, PRAGMA inspection, etc). Sqlite-only. */
|
|
||||||
rawSync(): DatabaseSync { return this.db; }
|
|
||||||
}
|
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
* 3. All queries run against tenant's schema
|
* 3. All queries run against tenant's schema
|
||||||
* 4. Connection returned to pool with search_path reset
|
* 4. Connection returned to pool with search_path reset
|
||||||
*
|
*
|
||||||
* SQLite mode: single-tenant, no schema switching. tenant_id is always
|
* Default tenant uses the public schema directly (slug = "default").
|
||||||
* the static DEFAULT_TENANT_ID. The tenant table isn't created.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const DEFAULT_TENANT_ID = "default";
|
export const DEFAULT_TENANT_ID = "default";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue