Merge branch 'fix/joinspace-tests-and-dev-db-drift'

Two small items from the S2S DM unification backlog.

#28 — 4 stale JoinSpace test assertions (test-only fix)
  Assertions from the old JoinServer component drifted when the file was
  renamed (fc06e25) without being updated: placeholder text, submit
  button now disables-when-empty (so the 'Invite code is required' error
  path is unreachable from the rendered form), and joinByCode signature
  gained a second `origin` argument. Updated each assertion to match
  current behavior. No code change. 131/131 web tests now pass.

#29 — Local dev DB missing federation_peers.remote_max_upload_size
  Root cause: baselineExistingInstall trusts that any pre-existing
  install's schema matches 0000_initial. That breaks for dev DBs from
  the pre-drizzle manual migrate.ts system that didn't ran every
  idempotent ALTER — 0000 gets marked applied without its columns
  actually existing, and a later migration that recreates the table
  (0004_cooing_black_knight) crashes. New healInitialSchemaDrift()
  walks 0000_snapshot.json, and for each existing table ADDs any
  declared-but-missing columns before drizzle's migrate() runs.
  Idempotent; production instances are no-op.

Remaining dev-DB drift flagged for a follow-up: federation_outbox and
federation_mutation_log retain pre-rename column names (message_id /
dm_channel_id) that the old manual system renamed to entity_id /
context_id. Both tables are empty on affected DBs, but the clean fix
requires DROP + RECREATE with index reinstatement, beyond #29's
scope. Outbox worker emits SQLITE_ERROR ticks post-boot on unfixed
dev DBs; separate item.

No migration files changed. Deployed instances are unaffected by
either commit (#28 test-only, #29 heals only DBs missing columns —
production DBs aren't missing any). Skip redeploy.
This commit is contained in:
Jannis Braun
2026-04-23 03:00:49 +02:00
3 changed files with 96 additions and 12 deletions
+7 -1
View File
@@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { config } from '../config.js'; import { config } from '../config.js';
import * as schema from './schema.js'; import * as schema from './schema.js';
import { baselineExistingInstall, ensureDefaults } from './migrate.js'; import { baselineExistingInstall, healInitialSchemaDrift, ensureDefaults } from './migrate.js';
import { setWorkerId } from '../utils/snowflake.js'; import { setWorkerId } from '../utils/snowflake.js';
import { mkdirSync } from 'fs'; import { mkdirSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
@@ -29,6 +29,12 @@ export function initDatabase() {
// CREATE TABLE on a database that already has tables. // CREATE TABLE on a database that already has tables.
baselineExistingInstall(sqlite); baselineExistingInstall(sqlite);
// Reconcile any columns present in the 0000 baseline but missing
// from the physical schema. Fixes drift between the pre-drizzle
// manual migration system and drizzle-kit's assumed baseline —
// a no-op on fresh installs and correctly-migrated DBs.
healInitialSchemaDrift(sqlite);
// Apply any pending Drizzle migrations // Apply any pending Drizzle migrations
const migrationsFolder = resolve(__dirname, '../../drizzle'); const migrationsFolder = resolve(__dirname, '../../drizzle');
const db = drizzle(sqlite, { schema }); const db = drizzle(sqlite, { schema });
+76
View File
@@ -64,6 +64,82 @@ export function baselineExistingInstall(db: Database.Database): void {
console.log(`[migrate] Baselined initial migration: ${initialEntry.tag} (hash: ${hash.slice(0, 12)}...)`); console.log(`[migrate] Baselined initial migration: ${initialEntry.tag} (hash: ${hash.slice(0, 12)}...)`);
} }
/**
* Heal column drift between an existing install and the 0000_initial
* schema baseline.
*
* The baseline in `baselineExistingInstall` assumes that any existing
* install's schema already matches 0000_initial.sql. That assumption is
* usually fine for installs created through the old pre-drizzle manual
* migration system — but a DB can fall behind if it skipped one of those
* manual ALTERs (e.g. a dev-env that was paused before the
* `remote_max_upload_size` migration in 1e4e71a landed). On such DBs,
* baseline marks 0000 as applied without the columns actually being
* there, and a later migration that recreates the table (e.g.
* 0004_cooing_black_knight) crashes trying to SELECT them.
*
* Walks every table defined in 0000_snapshot.json; for each that already
* exists in the DB, ADDs any columns the snapshot declares but the table
* is missing. Idempotent: on a correctly-migrated DB every column is
* already present and the loop is a no-op. Columns that SQLite's
* ALTER TABLE ADD COLUMN cannot express (PRIMARY KEY; NOT NULL without a
* default) are skipped with a warning rather than corrupting data.
*
* Only reconciles against the 0000 baseline — later migrations add their
* own columns through normal migration SQL and are handled by
* Drizzle's migrator.
*/
export function healInitialSchemaDrift(db: Database.Database): void {
const migrationsFolder = path.resolve(__dirname, '../../drizzle');
const snapshotPath = path.join(migrationsFolder, 'meta', '0000_snapshot.json');
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
tables: Record<string, {
name: string;
columns: Record<string, {
name: string;
type: string;
primaryKey: boolean;
notNull: boolean;
autoincrement: boolean;
default?: string | number;
}>;
}>;
};
for (const table of Object.values(snapshot.tables)) {
const exists = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?"
).get(table.name);
if (!exists) continue;
const existingCols = new Set(
(db.prepare(`PRAGMA table_info("${table.name}")`).all() as { name: string }[])
.map(c => c.name)
);
for (const col of Object.values(table.columns)) {
if (existingCols.has(col.name)) continue;
if (col.primaryKey) {
console.warn(`[migrate] Skipping drift heal for ${table.name}.${col.name}: PRIMARY KEY cannot be added via ALTER TABLE`);
continue;
}
if (col.notNull && col.default === undefined) {
console.warn(`[migrate] Skipping drift heal for ${table.name}.${col.name}: NOT NULL with no default (would violate existing rows)`);
continue;
}
const parts = [`"${col.name}"`, col.type];
if (col.notNull) parts.push('NOT NULL');
if (col.default !== undefined) parts.push(`DEFAULT ${col.default}`);
const sql = `ALTER TABLE "${table.name}" ADD COLUMN ${parts.join(' ')}`;
console.log(`[migrate] Healing schema drift: ${sql}`);
db.exec(sql);
}
}
}
/** /**
* Ensure data invariants after schema migration. Idempotent — safe to run * Ensure data invariants after schema migration. Idempotent — safe to run
* on every boot. Uses raw better-sqlite3 handle (not Drizzle ORM). * on every boot. Uses raw better-sqlite3 handle (not Drizzle ORM).
@@ -55,19 +55,20 @@ describe('JoinSpaceModal', () => {
useUIStore.setState({ activeModal: 'joinSpace' }); useUIStore.setState({ activeModal: 'joinSpace' });
renderModal(); renderModal();
expect(screen.getByText('Join a Space')).toBeInTheDocument(); expect(screen.getByText('Join a Space')).toBeInTheDocument();
expect(screen.getByPlaceholderText('e.g. abc123')).toBeInTheDocument(); expect(
screen.getByPlaceholderText('e.g. abc123 or https://instance.com/join/abc123')
).toBeInTheDocument();
expect(screen.getByText('Join Space')).toBeInTheDocument(); expect(screen.getByText('Join Space')).toBeInTheDocument();
}); });
it('shows validation error when submitting empty code', async () => { it('disables the Join Space button while the input is empty', () => {
const user = userEvent.setup();
useUIStore.setState({ activeModal: 'joinSpace' }); useUIStore.setState({ activeModal: 'joinSpace' });
renderModal(); renderModal();
const submitButton = screen.getByText('Join Space'); // The submit button is the validation gate in this UI — there is no
await user.click(submitButton); // click-to-show-error path. parseInviteInput's 'Invite code is required'
// branch is defensive only and unreachable from the rendered form.
expect(screen.getByText('Invite code is required')).toBeInTheDocument(); expect(screen.getByText('Join Space')).toBeDisabled();
}); });
it('calls joinByCode with the entered invite code and navigates on success', async () => { it('calls joinByCode with the entered invite code and navigates on success', async () => {
@@ -79,16 +80,17 @@ describe('JoinSpaceModal', () => {
renderModal(); renderModal();
// Type invite code // Type invite code
const input = screen.getByPlaceholderText('e.g. abc123'); const input = screen.getByPlaceholderText('e.g. abc123 or https://instance.com/join/abc123');
await user.type(input, 'my-invite-code'); await user.type(input, 'my-invite-code');
// Click join // Click join
const submitButton = screen.getByText('Join Space'); const submitButton = screen.getByText('Join Space');
await user.click(submitButton); await user.click(submitButton);
// joinByCode should be called with the code // joinByCode(code, origin) — bare code has no origin, so second arg is
// undefined (parseInviteInput returns { code, origin: undefined }).
await waitFor(() => { await waitFor(() => {
expect(mockJoinByCode).toHaveBeenCalledWith('my-invite-code'); expect(mockJoinByCode).toHaveBeenCalledWith('my-invite-code', undefined);
}); });
// Should navigate to the new space // Should navigate to the new space
@@ -108,7 +110,7 @@ describe('JoinSpaceModal', () => {
renderModal(); renderModal();
const input = screen.getByPlaceholderText('e.g. abc123'); const input = screen.getByPlaceholderText('e.g. abc123 or https://instance.com/join/abc123');
await user.type(input, 'bad-code'); await user.type(input, 'bad-code');
const submitButton = screen.getByText('Join Space'); const submitButton = screen.getByText('Join Space');