From eb7e77d4f0a3d07c8f0349e056bec52c590ccec6 Mon Sep 17 00:00:00 2001 From: Ethan Braunstein Date: Sun, 27 Sep 2026 12:27:11 -0400 Subject: [PATCH] feat(convex): add operator-only data reset --- convex/_generated/api.d.ts | 2 + convex/maintenance.ts | 66 ++++++++++++++++++ docs/auth-setup.md | 10 +++ tests/maintenance.test.ts | 133 +++++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 convex/maintenance.ts create mode 100644 tests/maintenance.test.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 6ca55bc..ca8a99a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as auth from "../auth.js"; import type * as http from "../http.js"; +import type * as maintenance from "../maintenance.js"; import type * as sync from "../sync.js"; import type { @@ -21,6 +22,7 @@ import type { declare const fullApi: ApiFromModules<{ auth: typeof auth; http: typeof http; + maintenance: typeof maintenance; sync: typeof sync; }>; diff --git a/convex/maintenance.ts b/convex/maintenance.ts new file mode 100644 index 0000000..5e8668a --- /dev/null +++ b/convex/maintenance.ts @@ -0,0 +1,66 @@ +import { v } from 'convex/values'; +import type { TableNames } from './_generated/dataModel'; +import { components, internal } from './_generated/api'; +import { internalAction, internalMutation } from './_generated/server'; +import schema from './schema'; + +const batchSize = 100; + +// Delete dependent records before users. Keep this list in sync with the installed +// Better Auth component schema; its tables cannot be queried through the app db. +const authTables = [ + 'session', + 'account', + 'verification', + 'twoFactor', + 'oauthAccessToken', + 'oauthConsent', + 'oauthApplication', + 'rateLimit', + 'user', + 'jwks', +] as const; + +/** Operator-only: run with `npx convex run maintenance:clearAll '{"confirm":"DELETE_ALL_DATA"}'`. */ +export const clearAll = internalAction({ + args: { confirm: v.literal('DELETE_ALL_DATA') }, + handler: async (ctx) => { + const deleted: Record = {}; + + for (const table of Object.keys(schema.tables)) { + let count = 0; + let batch: number; + do { + batch = await ctx.runMutation(internal.maintenance.clearAppBatch, { table }); + count += batch; + } while (batch > 0); + deleted[table] = count; + } + + for (const model of authTables) { + let count = 0; + let batch: number; + do { + const result = await ctx.runMutation(components.betterAuth.adapter.deleteMany, { + input: { model }, + paginationOpts: { numItems: batchSize, cursor: null }, + }); + batch = result.count; + count += batch; + } while (batch > 0); + deleted[`betterAuth.${model}`] = count; + } + + return deleted; + }, +}); + +export const clearAppBatch = internalMutation({ + args: { table: v.string() }, + handler: async (ctx, { table }) => { + if (!(table in schema.tables)) throw new Error(`Unknown app table: ${table}`); + const docs = await ctx.db.query(table as TableNames).take(batchSize); + for (const doc of docs) await ctx.db.delete(doc._id); + return docs.length; + }, +}); diff --git a/docs/auth-setup.md b/docs/auth-setup.md index 564ea32..cecb0c8 100644 --- a/docs/auth-setup.md +++ b/docs/auth-setup.md @@ -62,6 +62,16 @@ unset PASSWORD Add `--prod` (or `--preview-name …`) to target another deployment. This works for both password-less and existing accounts, keeps the account's collection, and ends every existing session, so each browser asks for the new password on its next sync. The function is internal; it cannot be called from the extension or the public HTTP routes. +## Clearing a deployment + +To delete all app records and every table in the installed Better Auth component, run this operator-only action against the selected deployment: + +```sh +npx convex run maintenance:clearAll '{"confirm":"DELETE_ALL_DATA"}' +``` + +Add `--prod` or `--preview-name …` to select another deployment. This permanently removes collections, devices, operations, backups, accounts, sessions, signing keys and rate-limit data. All browsers must sign up again. The action deletes in batches and returns a count for each table; if it is interrupted, run the same command again to finish. Stop clients from writing to the deployment while clearing it. + ## Rate limiting Better Auth's limiter is enabled explicitly (it otherwise depends on `NODE_ENV`, which Convex does not set) and stores counters in the component's `rateLimit` table. Per client IP, `/sign-in/email` allows 10 attempts per 5 minutes and `/sign-up/email` 5 per hour; other auth routes use Better Auth's defaults. If Better Auth cannot resolve a client IP it logs a warning and falls back to one bucket per route for everyone; check the deployment logs for that warning after deploying. diff --git a/tests/maintenance.test.ts b/tests/maintenance.test.ts new file mode 100644 index 0000000..b9c900b --- /dev/null +++ b/tests/maintenance.test.ts @@ -0,0 +1,133 @@ +import { expect, it } from 'vitest'; +import betterAuth from '@convex-dev/better-auth/test'; +import { components, internal } from '../convex/_generated/api'; +import schema from '../convex/schema'; +import { authenticatedBackend } from './fixtures/auth'; + +it('clears every app and Better Auth table in batches and can be rerun', async () => { + const { unauthenticated, account } = await authenticatedBackend(); + const now = Date.now(); + const collectionId = await unauthenticated.run((ctx) => + ctx.db.insert('collections', { + nodes: [], + revision: 0, + sourceDevice: 'test', + createdAt: now, + }), + ); + const deviceId = await unauthenticated.run((ctx) => + ctx.db.insert('devices', { + collectionId, + name: 'Test', + browser: 'Chrome', + lastSeen: now, + cursor: 0, + sequence: 0, + revoked: false, + }), + ); + await unauthenticated.run(async (ctx) => { + await ctx.db.insert('operations', { + collectionId, + deviceId, + operationId: 'op', + sequence: 1, + revision: 1, + nodeId: 'node', + title: 'Node', + kind: 'create', + device: 'Test', + at: now, + conflict: false, + }); + for (let i = 0; i < 101; i++) { + await ctx.db.insert('backups', { + collectionId, + deviceId, + nodes: [], + at: now, + reason: `test-${i}`, + }); + } + }); + + const authRows = [ + { + model: 'verification', + data: { identifier: 'test', value: 'code', expiresAt: now, createdAt: now, updatedAt: now }, + }, + { model: 'twoFactor', data: { secret: 'secret', backupCodes: 'codes', userId: 'test' } }, + { model: 'oauthApplication', data: { clientId: 'test' } }, + { model: 'oauthAccessToken', data: { accessToken: 'test' } }, + { model: 'oauthConsent', data: { clientId: 'test' } }, + { model: 'jwks', data: { publicKey: 'public', privateKey: 'private', createdAt: now } }, + ] as const; + for (const row of authRows) { + await unauthenticated.run((ctx) => + ctx.runMutation(components.betterAuth.adapter.create, { input: row }), + ); + } + for (let i = 0; i < 101; i++) { + await unauthenticated.run((ctx) => + ctx.runMutation(components.betterAuth.adapter.create, { + input: { model: 'rateLimit', data: { key: `test-${i}`, count: 1, lastRequest: now } }, + }), + ); + } + // The fixture account contributes user and session rows. + await account('another@example.com'); + await unauthenticated.run((ctx) => + ctx.runMutation(components.betterAuth.adapter.create, { + input: { + model: 'account', + data: { + accountId: 'test', + providerId: 'credential', + userId: 'test', + createdAt: now, + updatedAt: now, + }, + }, + }), + ); + + await expect( + unauthenticated.action(internal.maintenance.clearAll, { confirm: 'no' as 'DELETE_ALL_DATA' }), + ).rejects.toThrow(); + const deleted = await unauthenticated.action(internal.maintenance.clearAll, { + confirm: 'DELETE_ALL_DATA', + }); + expect(deleted).toMatchObject({ + collections: 1, + devices: 1, + operations: 1, + backups: 101, + 'betterAuth.user': 2, + 'betterAuth.session': 2, + 'betterAuth.account': 1, + 'betterAuth.rateLimit': 101, + }); + expect(Object.keys(deleted).sort()).toEqual( + [ + ...Object.keys(schema.tables), + ...Object.keys(betterAuth.schema.tables).map((table) => `betterAuth.${table}`), + ].sort(), + ); + await unauthenticated.run(async (ctx) => { + for (const table of Object.keys(schema.tables)) { + expect(await ctx.db.query(table as keyof typeof schema.tables).first()).toBeNull(); + } + for (const model of Object.keys(betterAuth.schema.tables)) { + const result = await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: model as 'user', + paginationOpts: { numItems: 1, cursor: null }, + }); + expect(result.page).toEqual([]); + } + }); + expect( + await unauthenticated.action(internal.maintenance.clearAll, { + confirm: 'DELETE_ALL_DATA', + }), + ).toEqual(Object.fromEntries(Object.keys(deleted).map((table) => [table, 0]))); +});