feat: v0.1.0 — sqlite-migrate package extraction from authd #1
157
tests/cli.test.ts
Normal file
157
tests/cli.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { runMigrateCli } from '../src/cli.ts';
|
||||||
|
|
||||||
|
function captureStreams() {
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
const stderr = new PassThrough();
|
||||||
|
const stdoutBuf: Buffer[] = [];
|
||||||
|
const stderrBuf: Buffer[] = [];
|
||||||
|
stdout.on('data', (c) => stdoutBuf.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
|
||||||
|
stderr.on('data', (c) => stderrBuf.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
|
||||||
|
return {
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
get stdoutText() {
|
||||||
|
return Buffer.concat(stdoutBuf).toString('utf8');
|
||||||
|
},
|
||||||
|
get stderrText() {
|
||||||
|
return Buffer.concat(stderrBuf).toString('utf8');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runMigrateCli', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'sqlite-migrate-cli-test-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("command='migrate' calls applyMigrations and writes expected lines", () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);');
|
||||||
|
const caps = captureStreams();
|
||||||
|
|
||||||
|
const code = runMigrateCli({
|
||||||
|
openDb: () => new Database(':memory:'),
|
||||||
|
migrationsDir: tmpDir,
|
||||||
|
command: 'migrate',
|
||||||
|
stdout: caps.stdout,
|
||||||
|
stderr: caps.stderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(caps.stdoutText).toContain('applied:1');
|
||||||
|
expect(caps.stdoutText).toContain('migrations: 1 applied, 0 pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("command='status' emits pending then applied then pending again", () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);');
|
||||||
|
|
||||||
|
const sharedDb = new Database(':memory:');
|
||||||
|
const openDb = () => {
|
||||||
|
const proxy = new Proxy(sharedDb, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === 'close') return () => {};
|
||||||
|
const v = (target as any)[prop];
|
||||||
|
return typeof v === 'function' ? v.bind(target) : v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return proxy as unknown as Database.Database;
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrateCaps = captureStreams();
|
||||||
|
const migrateCode = runMigrateCli({
|
||||||
|
openDb,
|
||||||
|
migrationsDir: tmpDir,
|
||||||
|
command: 'migrate',
|
||||||
|
stdout: migrateCaps.stdout,
|
||||||
|
stderr: migrateCaps.stderr,
|
||||||
|
});
|
||||||
|
expect(migrateCode).toBe(0);
|
||||||
|
|
||||||
|
const statusCaps = captureStreams();
|
||||||
|
const statusCode = runMigrateCli({
|
||||||
|
openDb,
|
||||||
|
migrationsDir: tmpDir,
|
||||||
|
command: 'status',
|
||||||
|
stdout: statusCaps.stdout,
|
||||||
|
stderr: statusCaps.stderr,
|
||||||
|
});
|
||||||
|
expect(statusCode).toBe(0);
|
||||||
|
expect(statusCaps.stdoutText).toContain('applied:0001');
|
||||||
|
|
||||||
|
writeFileSync(join(tmpDir, '0002_more.sql'), 'CREATE TABLE y (b INTEGER);');
|
||||||
|
const statusCaps2 = captureStreams();
|
||||||
|
const statusCode2 = runMigrateCli({
|
||||||
|
openDb,
|
||||||
|
migrationsDir: tmpDir,
|
||||||
|
command: 'status',
|
||||||
|
stdout: statusCaps2.stdout,
|
||||||
|
stderr: statusCaps2.stderr,
|
||||||
|
});
|
||||||
|
expect(statusCode2).toBe(0);
|
||||||
|
expect(statusCaps2.stdoutText).toContain('pending:0002');
|
||||||
|
|
||||||
|
sharedDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("command='stamp' marks applied without running body", () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(tmpDir, '0001_init.sql'),
|
||||||
|
'CREATE TABLE only_if_applied (x INTEGER);',
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharedDb = new Database(':memory:');
|
||||||
|
const openDb = () => {
|
||||||
|
const proxy = new Proxy(sharedDb, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === 'close') return () => {};
|
||||||
|
const v = (target as any)[prop];
|
||||||
|
return typeof v === 'function' ? v.bind(target) : v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return proxy as unknown as Database.Database;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stampCaps = captureStreams();
|
||||||
|
const code = runMigrateCli({
|
||||||
|
openDb,
|
||||||
|
migrationsDir: tmpDir,
|
||||||
|
command: 'stamp',
|
||||||
|
version: '0001',
|
||||||
|
stdout: stampCaps.stdout,
|
||||||
|
stderr: stampCaps.stderr,
|
||||||
|
});
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(stampCaps.stdoutText).toContain('stamped:0001');
|
||||||
|
|
||||||
|
const tableMissing = sharedDb
|
||||||
|
.prepare(
|
||||||
|
`SELECT name FROM sqlite_master WHERE type='table' AND name='only_if_applied'`,
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
expect(tableMissing).toBeUndefined();
|
||||||
|
|
||||||
|
const noVersionCaps = captureStreams();
|
||||||
|
const noVersionCode = runMigrateCli({
|
||||||
|
openDb,
|
||||||
|
migrationsDir: tmpDir,
|
||||||
|
command: 'stamp',
|
||||||
|
stdout: noVersionCaps.stdout,
|
||||||
|
stderr: noVersionCaps.stderr,
|
||||||
|
});
|
||||||
|
expect(noVersionCode).toBe(1);
|
||||||
|
expect(noVersionCaps.stderrText).toContain('error:');
|
||||||
|
|
||||||
|
sharedDb.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
331
tests/migrate.test.ts
Normal file
331
tests/migrate.test.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import {
|
||||||
|
applyMigrations,
|
||||||
|
listMigrations,
|
||||||
|
readAppliedRows,
|
||||||
|
stampMigration,
|
||||||
|
} from '../src/migrate.ts';
|
||||||
|
|
||||||
|
const LEGACY_BOOTSTRAP_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_failed_at INTEGER,
|
||||||
|
locked_until INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_attempts_locked ON login_attempts(locked_until);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||||
|
client_id TEXT PRIMARY KEY,
|
||||||
|
client_secret_hash TEXT NOT NULL,
|
||||||
|
client_name TEXT NOT NULL,
|
||||||
|
redirect_uris TEXT NOT NULL,
|
||||||
|
allowed_grants TEXT NOT NULL,
|
||||||
|
allowed_scopes TEXT NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
last_used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
revoked_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_clients_owner ON oauth_clients(owner_user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
|
||||||
|
code_hash TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
code_challenge TEXT NOT NULL,
|
||||||
|
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
redeemed_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_authorization_codes(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_authorization_codes(client_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_access_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
revoked_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_at_expires ON oauth_access_tokens(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_at_owner ON oauth_access_tokens(client_id, user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
revoked_at INTEGER,
|
||||||
|
replaced_by_token_hash TEXT,
|
||||||
|
family_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_rt_expires ON oauth_refresh_tokens(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_rt_family ON oauth_refresh_tokens(family_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mfa_profiles (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
secret_encrypted TEXT NOT NULL,
|
||||||
|
enrolled_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mfa_recovery_codes (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, code_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfa_recovery_user
|
||||||
|
ON mfa_recovery_codes(user_id) WHERE used_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mfa_attempts (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_failed_at INTEGER,
|
||||||
|
locked_until INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfa_attempts_locked ON mfa_attempts(locked_until);
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface SchemaObject {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
sql: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSql(sql: string | null): string | null {
|
||||||
|
if (sql === null) return null;
|
||||||
|
return sql.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotSchema(db: Database.Database): SchemaObject[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT type, name, sql FROM sqlite_master
|
||||||
|
WHERE type IN ('table','index')
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
AND name != 'schema_migrations'
|
||||||
|
ORDER BY type, name`,
|
||||||
|
)
|
||||||
|
.all() as SchemaObject[];
|
||||||
|
return rows.map((r) => ({ ...r, sql: normalizeSql(r.sql) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('migrate', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let db: Database.Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'sqlite-migrate-test-'));
|
||||||
|
db = new Database(':memory:');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fresh apply produces byte-equivalent schema to legacy bootstrap', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
|
||||||
|
|
||||||
|
const legacyDb = new Database(':memory:');
|
||||||
|
legacyDb.pragma('foreign_keys = ON');
|
||||||
|
legacyDb.exec(LEGACY_BOOTSTRAP_SQL);
|
||||||
|
const legacySnapshot = snapshotSchema(legacyDb);
|
||||||
|
legacyDb.close();
|
||||||
|
|
||||||
|
const summary = applyMigrations(db, tmpDir);
|
||||||
|
expect(summary.applied).toBe(1);
|
||||||
|
expect(summary.alreadyApplied).toBe(0);
|
||||||
|
expect(summary.pending).toBe(0);
|
||||||
|
expect(summary.stamped).toEqual([]);
|
||||||
|
|
||||||
|
const newSnapshot = snapshotSchema(db);
|
||||||
|
expect(newSnapshot).toEqual(legacySnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent on re-apply', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
|
||||||
|
applyMigrations(db, tmpDir);
|
||||||
|
const summary = applyMigrations(db, tmpDir);
|
||||||
|
expect(summary.applied).toBe(0);
|
||||||
|
expect(summary.alreadyApplied).toBe(1);
|
||||||
|
expect(summary.pending).toBe(0);
|
||||||
|
expect(summary.stamped).toEqual([]);
|
||||||
|
|
||||||
|
const rows = readAppliedRows(db);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].version).toBe('0001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts on checksum mismatch', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE original (x INTEGER);');
|
||||||
|
applyMigrations(db, tmpDir);
|
||||||
|
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE tampered (x INTEGER);');
|
||||||
|
|
||||||
|
expect(() => applyMigrations(db, tmpDir)).toThrow(/checksum mismatch.*0001_init/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps genesis on pre-existing DB with users table', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
|
||||||
|
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);`);
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const summary = applyMigrations(db, tmpDir, {
|
||||||
|
logger: (msg) => warnings.push(msg),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.stamped).toEqual(['0001']);
|
||||||
|
expect(summary.applied).toBe(0);
|
||||||
|
expect(summary.alreadyApplied).toBe(1);
|
||||||
|
expect(warnings.some((w) => w.includes('genesis-stamp'))).toBe(true);
|
||||||
|
|
||||||
|
const rows = readAppliedRows(db);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].version).toBe('0001');
|
||||||
|
|
||||||
|
const files = listMigrations(tmpDir);
|
||||||
|
expect(rows[0].checksum).toBe(files[0].checksum);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back when a migration fails partway', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_ok.sql'), 'CREATE TABLE a (x INTEGER);');
|
||||||
|
writeFileSync(
|
||||||
|
join(tmpDir, '0002_bad.sql'),
|
||||||
|
'CREATE TABLE b (x INTEGER); CREATE TABLE INVALID_SYNTAX_HERE',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => applyMigrations(db, tmpDir)).toThrow();
|
||||||
|
|
||||||
|
const rows = readAppliedRows(db);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].version).toBe('0001');
|
||||||
|
|
||||||
|
const aExists = db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='a'`)
|
||||||
|
.get();
|
||||||
|
expect(aExists).toBeDefined();
|
||||||
|
|
||||||
|
const bExists = db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='b'`)
|
||||||
|
.get();
|
||||||
|
expect(bExists).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stampMigration marks version applied without running body', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE only_if_applied (x INTEGER);');
|
||||||
|
const file = stampMigration(db, tmpDir, '0001');
|
||||||
|
expect(file.version).toBe('0001');
|
||||||
|
const rows = readAppliedRows(db);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
const tableMissing = db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='only_if_applied'`)
|
||||||
|
.get();
|
||||||
|
expect(tableMissing).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stampMigration refuses double-stamp', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);');
|
||||||
|
stampMigration(db, tmpDir, '0001');
|
||||||
|
expect(() => stampMigration(db, tmpDir, '0001')).toThrow(/already applied/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid migration filenames', () => {
|
||||||
|
writeFileSync(join(tmpDir, 'notamigration.sql'), 'SELECT 1;');
|
||||||
|
expect(() => applyMigrations(db, tmpDir)).toThrow(/invalid migration filename/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors stampGenesis option even without users table', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
|
||||||
|
const summary = applyMigrations(db, tmpDir, { stampGenesis: true });
|
||||||
|
expect(summary.stamped).toEqual(['0001']);
|
||||||
|
expect(summary.applied).toBe(0);
|
||||||
|
|
||||||
|
const usersExists = db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`)
|
||||||
|
.get();
|
||||||
|
expect(usersExists).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// New probe-table specs
|
||||||
|
|
||||||
|
it('probe-table-not-present + stampGenesis=false runs first migration normally', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE users (id TEXT PRIMARY KEY);');
|
||||||
|
const summary = applyMigrations(db, tmpDir, { stampGenesis: false });
|
||||||
|
expect(summary.stamped).toEqual([]);
|
||||||
|
expect(summary.applied).toBe(1);
|
||||||
|
expect(summary.alreadyApplied).toBe(0);
|
||||||
|
const usersExists = db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`)
|
||||||
|
.get();
|
||||||
|
expect(usersExists).toBeDefined();
|
||||||
|
const rows = readAppliedRows(db);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].version).toBe('0001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('genesisProbeTable=undefined defaults to users (authd back-compat)', () => {
|
||||||
|
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
|
||||||
|
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const summary = applyMigrations(db, tmpDir, {
|
||||||
|
logger: (msg) => warnings.push(msg),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.stamped).toEqual(['0001']);
|
||||||
|
expect(summary.applied).toBe(0);
|
||||||
|
expect(summary.alreadyApplied).toBe(1);
|
||||||
|
expect(
|
||||||
|
warnings.some((w) => w.includes('genesis-stamp') && w.includes("'users'")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("genesisProbeTable='sessions' stamps a buchinese-shaped DB", () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(tmpDir, '0001_init.sql'),
|
||||||
|
`CREATE TABLE users (id TEXT PRIMARY KEY); CREATE TABLE sessions (id TEXT PRIMARY KEY);`,
|
||||||
|
);
|
||||||
|
db.exec(`CREATE TABLE sessions (id TEXT PRIMARY KEY);`);
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const summary = applyMigrations(db, tmpDir, {
|
||||||
|
genesisProbeTable: 'sessions',
|
||||||
|
logger: (msg) => warnings.push(msg),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.stamped).toEqual(['0001']);
|
||||||
|
expect(summary.applied).toBe(0);
|
||||||
|
|
||||||
|
const usersExists = db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`)
|
||||||
|
.get();
|
||||||
|
expect(usersExists).toBeUndefined();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
warnings.some((w) => w.includes('genesis-stamp') && w.includes("'sessions'")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user