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:
@@ -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 });
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user