-- Production-readiness hardening migration: -- * Session: switch tokenHash from bcrypt-hashed to sha256-hashed (deterministic -- lookup), add previousTokenHash for refresh-token reuse detection, add -- unique constraint on tokenHash. -- * AppStatus, AppClick, Notification, AuditLog: composite indexes that match -- the actual query shapes (entity + time range). -- -- Existing Session rows store bcrypt hashes that are no longer compatible with -- the new sha256 lookup. We invalidate ALL sessions on upgrade — users will be -- prompted to log in once. This is the safer option than keeping incompatible -- rows that would silently fail validation. PRAGMA foreign_keys=OFF; -- ---- Session: rebuild with new shape and clear contents --------------------- CREATE TABLE "new_Session" ( "id" TEXT NOT NULL PRIMARY KEY, "userId" TEXT NOT NULL, "tokenHash" TEXT NOT NULL, "previousTokenHash" TEXT, "label" TEXT, "userAgent" TEXT, "ipAddress" TEXT, "rememberMe" BOOLEAN NOT NULL DEFAULT false, "lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "expiresAt" DATETIME NOT NULL, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); -- intentionally do NOT copy old rows — bcrypt hashes won't validate against -- sha256 lookups and users would silently fail to refresh. DROP TABLE "Session"; ALTER TABLE "new_Session" RENAME TO "Session"; CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash"); CREATE INDEX "Session_userId_idx" ON "Session"("userId"); CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt"); -- ---- Invite: same bcrypt -> sha256 migration for the same reason ------------ -- Invites are short-lived (default 7 days). Existing unused invite rows would -- be unreachable after the switch; clear them so admins re-issue if needed. CREATE TABLE "new_Invite" ( "id" TEXT NOT NULL PRIMARY KEY, "tokenHash" TEXT NOT NULL, "email" TEXT, "role" TEXT NOT NULL DEFAULT 'user', "expiresAt" DATETIME NOT NULL, "usedAt" DATETIME, "usedByUserId" TEXT, "createdById" TEXT, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); DROP TABLE "Invite"; ALTER TABLE "new_Invite" RENAME TO "Invite"; CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash"); CREATE INDEX "Invite_createdById_idx" ON "Invite"("createdById"); -- ---- ApiToken: same bcrypt -> sha256 migration ------------------------------ -- Existing API tokens stop working at upgrade; users must regenerate. This is -- preferable to keeping broken-but-present rows. CREATE TABLE "new_ApiToken" ( "id" TEXT NOT NULL PRIMARY KEY, "userId" TEXT NOT NULL, "name" TEXT NOT NULL, "tokenHash" TEXT NOT NULL, "scope" TEXT NOT NULL, "lastUsedAt" DATETIME, "expiresAt" DATETIME, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); DROP TABLE "ApiToken"; ALTER TABLE "new_ApiToken" RENAME TO "ApiToken"; CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash"); CREATE INDEX "ApiToken_userId_idx" ON "ApiToken"("userId"); CREATE INDEX "ApiToken_tokenHash_idx" ON "ApiToken"("tokenHash"); PRAGMA foreign_keys=ON; -- ---- Composite indexes for hot query paths ---------------------------------- DROP INDEX IF EXISTS "AppStatus_appId_idx"; CREATE INDEX "AppStatus_appId_checkedAt_idx" ON "AppStatus"("appId", "checkedAt"); DROP INDEX IF EXISTS "AppClick_userId_idx"; CREATE INDEX "AppClick_userId_clickedAt_idx" ON "AppClick"("userId", "clickedAt"); DROP INDEX IF EXISTS "Notification_userId_idx"; DROP INDEX IF EXISTS "Notification_sentAt_idx"; CREATE INDEX "Notification_userId_sentAt_idx" ON "Notification"("userId", "sentAt"); DROP INDEX IF EXISTS "AuditLog_userId_idx"; DROP INDEX IF EXISTS "AuditLog_entityType_entityId_idx"; CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt"); CREATE INDEX "AuditLog_entityType_entityId_createdAt_idx" ON "AuditLog"("entityType", "entityId", "createdAt");