Files
gerbeur/api/db/migrate.ts
khannurien 73b80af08e
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
v3: the chat stops dropping things and holds up to long messages — a message sent while the socket was down vanished with the draft already cleared, and a rejected one fared no better since the client ignored any error that wasn't about a vote or a like. Sends now carry a nonce and sit dimmed in the list until their echo retires them; the server's refusal, an offline socket or ten seconds of silence turns one into a failed row with retry, edit and discard, and a failed row that turns up in the history after a reconnect is retired as having landed after all. Failed edits and deletes show in a bar above the composer. The live stream only goes to signed-in sockets, matching the history behind auth; sends, edits and deletes share a per-user token bucket (typing and read markers get their own, and are just dropped past it); and moderation re-reads the role from the database on every check rather than trusting the one a week-old socket captured. Unread now lives on the server as users.chat_read_at (migration 0014, existing users start caught up), so the count survives a reload and every tab and device clears together; opening onto unread history lands on a "new" divider rather than the bottom, and marks it read only once you've scrolled down to it. After a reconnect the chat re-reads everything from its oldest loaded message and takes that as the truth, edits and deletions included, or reloads the latest page if too much happened. History pages on (created_at, id) so a shared millisecond can't hide a message at a boundary. In the modal: a run of messages from one author shares a header with the time in the gutter on hover, mentions and replies to you get an accent edge, a pill counts what arrived while you were scrolled up, the draft survives closing and reloading, relative times tick, reply previews are flattened from markdown, owners can delete their own messages, ↑ in an empty composer edits your last one, and there's a proper loading and retry state instead of a flash of "No messages yet". Long messages: the composer and edit box grow only so far and then scroll, instead of squeezing the list away; the counter shows only near the limit and sits above the box, since .comment-reply-textarea's padding shorthand had quietly cancelled the room kept for it inside; TextEditor sizes itself before paint, so opening an edit animates to the right height instead of dipping and snapping; and the list follows the bottom through any change in its content's height while pinned there, so ending an edit returns you to the end. A mention notification opens the chat over the page you're on rather than the home page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 20:36:49 +00:00

73 lines
3.1 KiB
TypeScript

import type { DatabaseSync } from "node:sqlite";
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts";
import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts";
import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
import { up as up0006Categories } from "./migrations/0006_categories.ts";
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
interface Migration {
name: string;
up: (db: DatabaseSync) => void;
}
// Append-only — never edit or reorder a migration once it has shipped.
// Each `up` must be idempotent: it also runs against fresh databases created
// straight from schema.sql, where the change it makes already exists.
const MIGRATIONS: Migration[] = [
{ name: "0001_comment_likes", up: up0001CommentLikes },
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
{ name: "0003_dump_backlinks", up: up0003DumpBacklinks },
{ name: "0004_user_roles", up: up0004UserRoles },
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin },
{ name: "0006_categories", up: up0006Categories },
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
{ name: "0008_chat_messages", up: up0008ChatMessages },
{ name: "0009_chat_reply", up: up0009ChatReply },
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
{ name: "0011_split_favicon_thumbnail", up: up0011SplitFaviconThumbnail },
{
name: "0012_fix_favicon_reclassification",
up: up0012FixFaviconReclassification,
},
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
{ name: "0014_chat_read_state", up: up0014ChatReadState },
];
export function runMigrations(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);`);
const applied = new Set(
(db.prepare(`SELECT name FROM schema_migrations;`).all() as {
name: string;
}[]).map((r) => r.name),
);
for (const { name, up } of MIGRATIONS) {
if (applied.has(name)) continue;
db.exec("BEGIN;");
try {
up(db);
db.prepare(
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?);`,
).run(name, new Date().toISOString());
db.exec("COMMIT;");
console.log(`[migrate] applied ${name}`);
} catch (err) {
db.exec("ROLLBACK;");
throw err;
}
}
}