Skip to content

Commit a323eb4

Browse files
authored
Merge pull request #236 from udecode/codex/stable-query-options-args
2 parents 34f7790 + 508f6df commit a323eb4

15 files changed

Lines changed: 465 additions & 22 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"kitcn": patch
3+
---
4+
5+
## Patches
6+
7+
- Fix auth Stripe subscription writes so `createdAt` and `updatedAt` are only written when the target table defines them.

bun.lock

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
title: Auth plugin timestamp writes must respect table schema
3+
date: 2026-04-22
4+
category: integration-issues
5+
module: auth-adapter
6+
problem_type: integration_issue
7+
component: authentication
8+
symptoms:
9+
- Better Auth Stripe subscription upgrade fails with Convex schema validation
10+
- Convex reports extra `createdAt` or `updatedAt` on the `subscription` table
11+
- `@better-auth/stripe` writes `updatedAt` even though its subscription schema omits it
12+
root_cause: wrong_api
13+
resolution_type: code_fix
14+
severity: high
15+
tags: [auth, better-auth, stripe, subscriptions, timestamps, schema]
16+
---
17+
18+
# Auth plugin timestamp writes must respect table schema
19+
20+
## Problem
21+
22+
Stripe subscription flows can write timestamp fields that the Stripe plugin's
23+
own Better Auth schema does not define. Convex rejects those writes when the
24+
generated `subscription` table omits `createdAt` and `updatedAt`.
25+
26+
## Symptoms
27+
28+
- `/api/auth/subscription/upgrade` fails during Better Auth Stripe upgrade.
29+
- Convex throws an extra-field validator error for `createdAt` or `updatedAt`.
30+
- The failure happens before subscription state can be patched.
31+
32+
## What Didn't Work
33+
34+
- Treating `createdAt` and `updatedAt` as universal auth fields. Core Better
35+
Auth tables use them, but plugin tables do not always opt in.
36+
- Looking only at core Better Auth tables. `@better-auth/stripe` lives in a
37+
separate package and its `subscription` schema has no timestamp fields.
38+
39+
## Solution
40+
41+
Resolve the concrete write field set for the target model before auth writes.
42+
Use the Convex table validator when available, then fall back to the Better Auth
43+
schema. Only synthesize or preserve auth timestamp fields when that target table
44+
defines them.
45+
46+
```ts
47+
const stripUnsupportedAuthTimestamps = (
48+
data: Record<string, unknown>,
49+
schema: Schema,
50+
betterAuthSchema: any,
51+
model: string
52+
) => {
53+
const writeFields = resolveWriteFields(schema, betterAuthSchema, model);
54+
if (!writeFields) {
55+
return data;
56+
}
57+
58+
let result: Record<string, unknown> | undefined;
59+
for (const field of ["createdAt", "updatedAt"] as const) {
60+
if (field in data && !writeFields.has(field)) {
61+
result ??= { ...data };
62+
delete result[field];
63+
}
64+
}
65+
66+
return result ?? data;
67+
};
68+
```
69+
70+
Cover both paths:
71+
72+
- creates should not inject timestamps into plugin tables without those fields
73+
- updates should strip unsupported `updatedAt` before `ctx.db.patch()` or ORM
74+
`set()`
75+
76+
## Why This Works
77+
78+
The generated auth runtime is the last boundary before Convex validates the
79+
document. Filtering there fixes db and ORM writes without hiding arbitrary schema
80+
mistakes: only the known auth timestamp fields get special treatment.
81+
82+
## Prevention
83+
84+
- When adding auth plugin support, inspect the plugin package's schema, not only
85+
Better Auth core tables.
86+
- Add regression tests for plugin tables that intentionally omit core auth
87+
fields.
88+
- Do not assume `createdAt` and `updatedAt` are universal across auth plugin
89+
tables.
90+
91+
## Related Issues
92+
93+
- [Better Auth 1.6 support needs structural Convex auth wrappers](./better-auth-1-6-support-needs-structural-convex-auth-wrappers-20260416.md)
94+
- [Convex Better Auth upstream sync must filter runtime fixes from repo churn](./convex-better-auth-upstream-sync-runtime-fixes-20260416.md)
95+
- [Root auth schema sync should merge missing fragments into local tables](./root-auth-schema-sync-should-merge-missing-fragments-into-local-tables-20260328.md)

example/convex/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ RESEND_API_KEY=
1414
POLAR_SERVER=sandbox
1515
POLAR_ACCESS_TOKEN=
1616
POLAR_WEBHOOK_SECRET=
17-
POLAR_PRODUCT_PREMIUM=
17+
POLAR_PRODUCT_PREMIUM=

example/convex/functions/_generated/dataModel.d.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,53 @@ export type DataModel = {
923923
searchIndexes: {};
924924
vectorIndexes: {};
925925
};
926+
subscription: {
927+
document: {
928+
billingInterval?: null | string;
929+
cancelAt?: null | number;
930+
cancelAtPeriodEnd?: null | boolean;
931+
canceledAt?: null | number;
932+
endedAt?: null | number;
933+
periodEnd?: null | number;
934+
periodStart?: null | number;
935+
plan: string;
936+
referenceId: string;
937+
seats?: null | number;
938+
status?: null | string;
939+
stripeCustomerId?: null | string;
940+
stripeScheduleId?: null | string;
941+
stripeSubscriptionId?: null | string;
942+
trialEnd?: null | number;
943+
trialStart?: null | number;
944+
_id: Id<"subscription">;
945+
_creationTime: number;
946+
};
947+
fieldPaths:
948+
| "_creationTime"
949+
| "_id"
950+
| "billingInterval"
951+
| "cancelAt"
952+
| "cancelAtPeriodEnd"
953+
| "canceledAt"
954+
| "endedAt"
955+
| "periodEnd"
956+
| "periodStart"
957+
| "plan"
958+
| "referenceId"
959+
| "seats"
960+
| "status"
961+
| "stripeCustomerId"
962+
| "stripeScheduleId"
963+
| "stripeSubscriptionId"
964+
| "trialEnd"
965+
| "trialStart";
966+
indexes: {
967+
by_id: ["_id"];
968+
by_creation_time: ["_creationTime"];
969+
};
970+
searchIndexes: {};
971+
vectorIndexes: {};
972+
};
926973
subscriptions: {
927974
document: {
928975
amount?: null | number;
@@ -1250,6 +1297,7 @@ export type DataModel = {
12501297
name: string;
12511298
personalOrganizationId?: null | string;
12521299
role?: null | string;
1300+
stripeCustomerId?: null | string;
12531301
updatedAt: number;
12541302
userId?: null | string;
12551303
username?: null | string;
@@ -1282,6 +1330,7 @@ export type DataModel = {
12821330
| "name"
12831331
| "personalOrganizationId"
12841332
| "role"
1333+
| "stripeCustomerId"
12851334
| "updatedAt"
12861335
| "userId"
12871336
| "username"

example/convex/functions/auth.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { stripe } from '@better-auth/stripe';
12
import { admin, anonymous, organization, username } from 'better-auth/plugins';
23
import { convex } from 'kitcn/auth';
34
import { requireSchedulerCtx } from 'kitcn/server';
5+
import Stripe from 'stripe';
46
import { getEnv } from '../lib/get-env';
57
import {
68
AUTH_DEMO_ANON_EMAIL_DOMAIN,
@@ -11,6 +13,10 @@ import { internal } from './_generated/api';
1113
import authConfig from './auth.config';
1214
import { defineAuth } from './generated/auth';
1315

16+
const STRIPE_EXAMPLE_PRICE_ID = 'price_kitcn_example';
17+
const STRIPE_EXAMPLE_SECRET_KEY = 'sk_test_kitcn_example';
18+
const STRIPE_EXAMPLE_WEBHOOK_SECRET = 'whsec_kitcn_example';
19+
1420
export default defineAuth((ctx) => {
1521
const env = getEnv();
1622

@@ -90,6 +96,19 @@ export default defineAuth((ctx) => {
9096
);
9197
},
9298
}),
99+
stripe({
100+
stripeClient: new Stripe(STRIPE_EXAMPLE_SECRET_KEY),
101+
stripeWebhookSecret: STRIPE_EXAMPLE_WEBHOOK_SECRET,
102+
subscription: {
103+
enabled: true,
104+
plans: [
105+
{
106+
name: 'premium',
107+
priceId: STRIPE_EXAMPLE_PRICE_ID,
108+
},
109+
],
110+
},
111+
}),
93112
convex({
94113
authConfig,
95114
jwks: env.JWKS,

example/convex/functions/plugins.lock.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
"session": {
3030
"owner": "local"
3131
},
32+
"subscription": {
33+
"checksum": "bc65c00ae14d",
34+
"owner": "managed"
35+
},
3236
"user": {
3337
"owner": "local"
3438
},

example/convex/functions/schema.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export const userTable = convexTable(
213213
}),
214214
displayUsername: text(),
215215
userId: text(),
216+
stripeCustomerId: text(),
216217
},
217218
(t) => [
218219
uniqueIndex('email').on(t.email),
@@ -603,6 +604,25 @@ export const triggerDemoRunTable = convexTable(
603604
(t) => [index('ownerId').on(t.ownerId)]
604605
);
605606

607+
export const subscriptionTable = convexTable('subscription', {
608+
plan: text().notNull(),
609+
referenceId: text().notNull(),
610+
stripeCustomerId: text(),
611+
stripeSubscriptionId: text(),
612+
status: text(),
613+
periodStart: timestamp(),
614+
periodEnd: timestamp(),
615+
trialStart: timestamp(),
616+
trialEnd: timestamp(),
617+
cancelAtPeriodEnd: boolean(),
618+
cancelAt: timestamp(),
619+
canceledAt: timestamp(),
620+
endedAt: timestamp(),
621+
seats: integer(),
622+
billingInterval: text(),
623+
stripeScheduleId: text(),
624+
});
625+
606626
export const tables = {
607627
session: sessionTable,
608628
account: accountTable,
@@ -626,6 +646,7 @@ export const tables = {
626646
triggerDemoAudit: triggerDemoAuditTable,
627647
triggerDemoStats: triggerDemoStatsTable,
628648
triggerDemoRun: triggerDemoRunTable,
649+
subscription: subscriptionTable,
629650
};
630651

631652
const schema = defineSchema(tables, {

example/package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@
3131
"typecheck:watch": "tsc --noEmit --watch"
3232
},
3333
"dependencies": {
34-
"@kitcn/resend": "workspace:*",
34+
"@better-auth/stripe": "1.6.5",
3535
"@convex-dev/react-query": "0.1.0",
3636
"@hookform/resolvers": "5.2.2",
37+
"@kitcn/resend": "workspace:*",
38+
"@opentelemetry/api": "1.9.0",
3739
"@react-email/components": "1.0.8",
3840
"@react-email/render": "2.0.4",
3941
"@t3-oss/env-nextjs": "0.13.10",
42+
"@tanstack/react-query": "5.95.2",
4043
"better-auth": "1.6.5",
41-
"kitcn": "workspace:*",
4244
"class-variance-authority": "0.7.1",
4345
"clsx": "2.1.1",
4446
"cmdk": "1.1.1",
@@ -49,6 +51,7 @@
4951
"hono": "4.12.9",
5052
"input-otp": "1.4.2",
5153
"jotai-x": "2.3.3",
54+
"kitcn": "workspace:*",
5255
"lucide-react": "0.575.0",
5356
"next-themes": "0.4.6",
5457
"nuqs": "2.8.8",
@@ -61,14 +64,14 @@
6164
"react-resizable-panels": "3.0.6",
6265
"recharts": "2.15.4",
6366
"sonner": "2.0.7",
67+
"stripe": "^22.0.1",
6468
"superjson": "2.2.6",
6569
"tailwind-merge": "3.5.0",
6670
"ts-essentials": "10.1.1",
6771
"tsx": "4.21.0",
6872
"type-fest": "5.4.4",
6973
"use-debounce": "10.1.0",
70-
"vaul": "1.1.2",
71-
"@tanstack/react-query": "5.95.2"
74+
"vaul": "1.1.2"
7275
},
7376
"devDependencies": {
7477
"@tailwindcss/postcss": "4.2.1",

example/src/lib/convex/auth-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { stripeClient } from '@better-auth/stripe/client';
12
import { type Auth, ac, roles } from '@convex/auth-shared';
23
import {
34
adminClient,
@@ -24,6 +25,7 @@ export const authClient = createAuthClient({
2425
ac,
2526
roles,
2627
}),
28+
stripeClient({ subscription: true }),
2729
convexClient(),
2830
],
2931
});

0 commit comments

Comments
 (0)