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
8 changes: 8 additions & 0 deletions .changeset/calm-auth-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"kitcn": patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Release the narrowed public context type as a minor

RunMutationCtx is publicly exported from kitcn/server, and this commit deliberately makes its third transactionLimits argument a type error, so existing consumers using that signature stop compiling. Classify this as a minor breaking change rather than a patch. .agents/rules/changeset.mdcL15-L19

Useful? React with πŸ‘Β / πŸ‘Ž.

---

## Patches

- Fix unbounded auth queries hanging after 200 rows.
- Prevent action contexts from exposing mutation-only transaction options.
73 changes: 72 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

545 changes: 545 additions & 0 deletions docs/plans/2026-07-30-fix-auth-adapter-runtime-sync.md

Large diffs are not rendered by default.

364 changes: 364 additions & 0 deletions docs/plans/2026-07-30-sync-convex-auth.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: Convex auth pagination needs an unbounded budget and forward progress
date: 2026-07-30
category: integration-issues
module: auth-adapter
problem_type: integration_issue
component: authentication
symptoms:
- unbounded auth count and findMany calls can loop after 200 rows
- shared runMutation contexts expose options unavailable from actions
root_cause: wrong_api
resolution_type: code_fix
severity: high
tags: [auth, convex, better-auth, pagination, action]
---

# Convex auth pagination needs an unbounded budget and forward progress

## Problem

The shared auth pagination helper treated a missing caller limit as a 200-row
total limit. After collecting the first page, it requested zero rows while
retaining the cursor and could loop forever.

The shared mutation runner type also used the mutation-context signature even
though its runtime union includes action contexts. Newer Convex versions allow
transaction-limit options only for nested calls inside mutations.

## Symptoms

- `count()` and unbounded `findMany()` do not terminate above 200 matching rows.
- A stalled backend page can repeat forever when it is not done, returns
nothing, and leaves the cursor unchanged.
- TypeScript permits a transaction-options argument on a context that may be an
action, even though action runners accept only the function reference and
arguments.

## Solution

Keep pagination unbounded only when the caller omits `limit`, while preserving
the 200-row per-page cap. Abort when a non-final page neither advances the
cursor nor produces rows or a count.

Type the shared `runMutation` property from `GenericActionCtx`. Mutation
contexts remain assignable because their runner supports the action-safe call
shape plus mutation-only options.

## Why This Works

The page cap and total result limit are different constraints. An unbounded
query still fetches at most 200 rows per request, but every subsequent request
retains a positive budget until Convex reports completion.

The action signature is the common callable surface across both runtime
contexts. Consumers can call mutations safely without receiving options that
only one branch of the union can honor.

## Prevention

1. Test unbounded pagination with more rows than the per-page cap.
2. Require every non-final pagination step to advance its cursor or produce
output.
3. Type union-context methods from the narrowest runtime that must support the
call.
4. Run the type regression against a pinned Convex version that exposes
mutation-only transaction options, while keeping the package baseline at its
minimum supported version.
5. Re-audit copied Convex Better Auth helpers whenever upstream changes their
termination or context contracts.

## Related

- `docs/solutions/integration-issues/convex-better-auth-upstream-sync-runtime-fixes-20260416.md`
- `docs/solutions/integration-issues/better-auth-1-6-support-needs-structural-convex-auth-wrappers-20260416.md`
2 changes: 1 addition & 1 deletion fixtures/next-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.27.0",
"lucide-react": "^1.28.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.27.0",
"lucide-react": "^1.28.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/start-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.27.0",
"lucide-react": "^1.28.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shadcn": "latest",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/start/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.27.0",
"lucide-react": "^1.28.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shadcn": "latest",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/vite-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.27.0",
"lucide-react": "^1.28.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shadcn": "latest",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.27.0",
"lucide-react": "^1.28.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shadcn": "latest",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"bun-types": "^1.3.9",
"concurrently": "^9.2.1",
"convex-test": "^0.0.41",
"convex-type-test": "npm:convex@1.42.3",
"eslint": "10.0.2",
"eslint-plugin-react-hooks": "7.0.1",
"fast-check": "^4.5.3",
Expand Down
42 changes: 42 additions & 0 deletions packages/kitcn/src/auth/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,48 @@ describe('handlePagination', () => {
expect(state.docs).toEqual([]);
expect(state.isDone).toBe(true);
});

test('continues unbounded pagination beyond the first 200 rows', async () => {
let index = 0;
const state = await handlePagination(async ({ paginationOpts }) => {
if (paginationOpts.numItems === 0) {
throw new Error('pagination requested zero rows');
}

index++;
return {
continueCursor: `cursor-${index}`,
isDone: index === 2,
page:
index === 1
? Array.from({ length: 200 }, (_, id) => ({ id }))
: [{ id: 200 }],
pageStatus: 'Done' as const,
};
});

expect(state.docs).toHaveLength(201);
});

test('aborts a page that cannot make forward progress', async () => {
let calls = 0;

await expect(
handlePagination(async () => {
calls++;
if (calls > 1) {
throw new Error('test query cap reached');
}

return {
continueCursor: null,
isDone: false,
page: [],
pageStatus: 'Done' as const,
};
})
).rejects.toThrow('Pagination made no forward progress');
});
});

describe('adapterConfig', () => {
Expand Down
11 changes: 10 additions & 1 deletion packages/kitcn/src/auth/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,26 @@ export const handlePagination = async (
};

do {
const cursorBeforePage = state.cursor;
const result = await next({
paginationOpts: {
cursor: state.cursor,
numItems: Math.min(
numItems ?? 200,
(limit ?? 200) - state.docs.length,
limit === undefined
? Number.POSITIVE_INFINITY
: limit - state.docs.length,
200
),
},
});
onResult(result);

const advanced = state.cursor !== cursorBeforePage;
const produced = (result.page?.length ?? 0) > 0 || (result.count ?? 0) > 0;
if (!(state.isDone || advanced || produced)) {
throw new Error('Pagination made no forward progress');
}
} while (!state.isDone);

return state;
Expand Down
47 changes: 47 additions & 0 deletions packages/kitcn/src/server/context-utils.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type {
FunctionReference,
GenericDataModel,
GenericMutationCtx,
} from 'convex/server';
import { expectTypeOf, test } from 'vitest';
import type { RunMutationCtx } from './context-utils';

type TestMutation = FunctionReference<
'mutation',
'internal',
{ value: string },
null
>;

const checkCommonRunMutationCalls = (
ctx: RunMutationCtx<GenericDataModel>,
mutation: TestMutation
) => {
void ctx.runMutation(mutation, { value: 'accepted' });

void ctx.runMutation(
mutation,
{ value: 'rejected' },
// @ts-expect-error transaction limits are unavailable from action contexts
{ transactionLimits: { documentsRead: 1 } }
);
};

const checkMutationRunMutationOptions = (
ctx: GenericMutationCtx<GenericDataModel>,
mutation: TestMutation
) => {
void ctx.runMutation(
mutation,
{ value: 'accepted' },
{ transactionLimits: { documentsRead: 1 } }
);
};

test('runMutation uses the call shape shared by mutation and action contexts', () => {
expectTypeOf<
RunMutationCtx<GenericDataModel>['runMutation']
>().toBeFunction();
expectTypeOf(checkCommonRunMutationCalls).toBeFunction();
expectTypeOf(checkMutationRunMutationOptions).toBeFunction();
});
2 changes: 1 addition & 1 deletion packages/kitcn/src/server/context-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type RunMutationCtx<DataModel extends GenericDataModel> = (
| GenericMutationCtx<DataModel>
| GenericActionCtx<DataModel>
) & {
runMutation: GenericMutationCtx<DataModel>['runMutation'];
runMutation: GenericActionCtx<DataModel>['runMutation'];
};

export type SchedulerCtx<TCtx> = TCtx extends {
Expand Down
1 change: 1 addition & 0 deletions packages/kitcn/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"include": ["src", "../../tooling/global.d.ts"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test-d.ts",
"src/**/*.test.tsx",
"src/**/*.vitest.ts",
"src/**/*.vitest.tsx"
Expand Down
14 changes: 14 additions & 0 deletions packages/kitcn/tsconfig.type-tests.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"baseUrl": "../..",
"noEmit": true,
"paths": {
"convex/server": [
"node_modules/convex-type-test/dist/esm-types/server/index.d.ts"
]
}
},
"exclude": [],
"include": ["src/**/*.test-d.ts", "../../tooling/global.d.ts"]
}
5 changes: 5 additions & 0 deletions vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export default defineConfig({
'packages/**/*.vitest.ts',
'packages/**/*.vitest.tsx',
],
typecheck: {
enabled: true,
include: ['packages/**/*.test-d.ts'],
tsconfig: 'packages/kitcn/tsconfig.type-tests.json',
},
exclude: ['**/node_modules/**', '**/tmp/**', '**/src/solid/**'],
},
},
Expand Down
Loading