Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,6 +22,7 @@ import type {
declare const fullApi: ApiFromModules<{
auth: typeof auth;
http: typeof http;
maintenance: typeof maintenance;
sync: typeof sync;
}>;

Expand Down
66 changes: 66 additions & 0 deletions convex/maintenance.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {};

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

batchSize bounds this mutation by document count, but collections and backups each embed a whole snapshot tree, so the batch is bounded by bytes in practice. packages/model's own validateTree allows 2,000 nodes / 600 KB per snapshot, and Convex caps a transaction at 16 MiB read — about 168 KB per document at 100 docs, so any deployment whose snapshots average ~600 bookmarks makes this call throw Read too much data in a single function execution.

Two consequences beyond the throw: collections/devices/operations are already gone by then, so the reset stops half-finished, and the same first 100 documents fail on every rerun, which makes the documented "run the same command again to finish" recovery in docs/auth-setup.md:73 unreachable. Bound the batch by bytes instead (details in the review body).

for (const doc of docs) await ctx.db.delete(doc._id);
return docs.length;
},
});
10 changes: 10 additions & 0 deletions docs/auth-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
133 changes: 133 additions & 0 deletions tests/maintenance.test.ts
Original file line number Diff line number Diff line change
@@ -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])));
});
Loading