feat: add sqlite drizzle adapter for better-auth and clerk codemods

- split better-auth drizzle adapters into separate postgres/sqlite variants with a dedicated sqlite schema template
- simplify auth-client templates to use `createAuthClient()` without an explicit baseURL
- export `Node` type and `SyntaxKind` enum from the codemods package
- add codemod for the auth-clerk module
This commit is contained in:
jake committed 2026-05-20 12:06:48 -04:00
1 parent b74ffb9d1a
commit a50ababb49
11 files changed
+256 -77

No files matched your search

+4 -3
View File
@@ -1,8 +1,9 @@
export type { CodemodContext, Codemod, CodemodResult } from "./types.ts";
// Re-export the ts-morph Project type so consumers don't need a direct
// ts-morph dep just to type a parameter.
export type { Project, SourceFile } from "ts-morph";
// Re-export common ts-morph types + the SyntaxKind enum so module codemods
// don't need a direct ts-morph dep.
export type { Project, SourceFile, Node } from "ts-morph";
export { SyntaxKind } from "ts-morph";
export { openProject } from "./project.ts";
+4
View File
@@ -176,6 +176,10 @@ importers:
'@stanza/registry':
specifier: workspace:*
version: link:../../../packages/registry
devDependencies:
'@types/node':
specifier: ^25.9.1
version: 25.9.1
registry/modules/db-postgres:
dependencies:
+77 -67
View File
@@ -1,57 +1,73 @@
import { defineModule } from "@stanza/registry";
// Shared env declaration — every adapter wants the same two vars.
const env = [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
{
name: "BETTER_AUTH_URL",
example: "http://localhost:3000",
required: true,
description: "Public URL of the app (used server-side for callbacks and cookies).",
},
];
const deps = { "better-auth": "^1.6.11" };
export default defineModule({
id: "better-auth",
slot: "auth",
label: "Better Auth",
description: "Framework-agnostic, headless TypeScript auth library.",
version: "0.1.0",
peers: { orm: ["drizzle", "prisma"], framework: ["next", "tanstack-start"] },
peers: {
orm: ["drizzle", "prisma"],
framework: ["next", "tanstack-start"],
db: ["postgres", "sqlite"],
},
homepage: "https://better-auth.com",
adapters: [
{
key: "next+drizzle",
match: { framework: "next", orm: "drizzle" },
dependencies: { "better-auth": "^1.6.11" },
env: [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
{
name: "BETTER_AUTH_URL",
example: "http://localhost:3000",
required: true,
description: "Public URL of the app.",
},
],
key: "next+drizzle+postgres",
match: { framework: "next", orm: "drizzle", db: "postgres" },
dependencies: deps,
env,
templates: [
{ src: "next/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "next/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
{ src: "next/route.ts", dest: "app/api/auth/[...all]/route.ts", scope: "app" },
{ src: "shared/auth-schema.drizzle.ts", dest: "src/db/auth-schema.ts", scope: "app" },
{
src: "shared/auth-schema.drizzle-postgres.ts",
dest: "src/db/auth-schema.ts",
scope: "app",
},
],
},
{
key: "next+drizzle+sqlite",
match: { framework: "next", orm: "drizzle", db: "sqlite" },
dependencies: deps,
env,
templates: [
{ src: "next/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "next/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
{ src: "next/route.ts", dest: "app/api/auth/[...all]/route.ts", scope: "app" },
{
src: "shared/auth-schema.drizzle-sqlite.ts",
dest: "src/db/auth-schema.ts",
scope: "app",
},
],
},
{
key: "next+prisma",
match: { framework: "next", orm: "prisma" },
dependencies: { "better-auth": "^1.6.11" },
env: [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
{
name: "BETTER_AUTH_URL",
example: "http://localhost:3000",
required: true,
description: "Public URL of the app.",
},
],
dependencies: deps,
env,
templates: [
{ src: "next/auth.prisma.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "next/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
@@ -59,48 +75,42 @@ export default defineModule({
],
},
{
key: "tanstack-start+drizzle",
match: { framework: "tanstack-start", orm: "drizzle" },
dependencies: { "better-auth": "^1.6.11" },
env: [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
{
name: "BETTER_AUTH_URL",
example: "http://localhost:3000",
required: true,
description: "Public URL of the app.",
},
],
key: "tanstack-start+drizzle+postgres",
match: { framework: "tanstack-start", orm: "drizzle", db: "postgres" },
dependencies: deps,
env,
templates: [
{ src: "tanstack/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "tanstack/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
{ src: "tanstack/api.ts", dest: "src/routes/api/auth/$.ts", scope: "app" },
{ src: "shared/auth-schema.drizzle.ts", dest: "src/db/auth-schema.ts", scope: "app" },
{
src: "shared/auth-schema.drizzle-postgres.ts",
dest: "src/db/auth-schema.ts",
scope: "app",
},
],
},
{
key: "tanstack-start+drizzle+sqlite",
match: { framework: "tanstack-start", orm: "drizzle", db: "sqlite" },
dependencies: deps,
env,
templates: [
{ src: "tanstack/auth.drizzle.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "tanstack/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
{ src: "tanstack/api.ts", dest: "src/routes/api/auth/$.ts", scope: "app" },
{
src: "shared/auth-schema.drizzle-sqlite.ts",
dest: "src/db/auth-schema.ts",
scope: "app",
},
],
},
{
key: "tanstack-start+prisma",
match: { framework: "tanstack-start", orm: "prisma" },
dependencies: { "better-auth": "^1.6.11" },
env: [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
{
name: "BETTER_AUTH_URL",
example: "http://localhost:3000",
required: true,
description: "Public URL of the app.",
},
],
dependencies: deps,
env,
templates: [
{ src: "tanstack/auth.prisma.ts", dest: "src/lib/auth.ts", scope: "app" },
{ src: "tanstack/auth-client.ts", dest: "src/lib/auth-client.ts", scope: "app" },
@@ -1,5 +1,6 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL,
});
// Defaults to the current origin. For cross-origin auth (e.g. a separate
// API host), pass `baseURL: "https://your-domain.com"` here.
// https://better-auth.com/docs/concepts/client
export const authClient = createAuthClient();
@@ -0,0 +1,73 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
// SQLite has no native boolean or timestamp types. We use:
// - integer(...) with mode "boolean" for booleans (0/1)
// - integer(...) with mode "timestamp" for timestamps (epoch seconds)
// Defaults are applied in JS via .$defaultFn() since SQLite's CURRENT_TIMESTAMP
// would store as ISO text, which doesn't round-trip cleanly with the mode.
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
token: text("token").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }),
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
@@ -1,5 +1,6 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_BETTER_AUTH_URL,
});
// Defaults to the current origin. For cross-origin auth (e.g. a separate
// API host), pass `baseURL: "https://your-domain.com"` here.
// https://better-auth.com/docs/concepts/client
export const authClient = createAuthClient();
@@ -0,0 +1,82 @@
import path from "node:path";
import { type Codemod, SyntaxKind, addNamedImport } from "@stanza/codemods";
const LAYOUT_REL_FROM_APP = "app/layout.tsx";
const PROVIDER_NAME = "ClerkRootProvider";
const PROVIDER_IMPORT = "./_clerk-provider";
const wrapRootLayout: Codemod = {
id: "wrap-root-layout",
description: "Wrap {children} in app/layout.tsx with <ClerkRootProvider>.",
apply(ctx) {
const layoutAbs = path.join(ctx.appRoot, LAYOUT_REL_FROM_APP);
const layoutRel = path.relative(ctx.projectRoot, layoutAbs);
const sf = ctx.project().addSourceFileAtPath(layoutAbs);
// Idempotency guard: if the provider is already wired up, do nothing.
if (sf.getText().includes(PROVIDER_NAME)) {
return { touchedFiles: [] };
}
addNamedImport(sf, PROVIDER_IMPORT, PROVIDER_NAME);
// Find the bare `{children}` JsxExpression and wrap it. We match exactly
// — anything that's already been edited (e.g. wrapped by another module)
// won't match this literal, which is the desired safety property.
const wrapped = `<${PROVIDER_NAME}>{children}</${PROVIDER_NAME}>`;
let wrappedAny = false;
for (const node of sf.getDescendantsOfKind(SyntaxKind.JsxExpression)) {
if (node.getText().replace(/\s+/g, "") === "{children}") {
node.replaceWithText(wrapped);
wrappedAny = true;
break;
}
}
if (!wrappedAny) {
// The layout has been customized; we can't safely auto-wrap. Surface
// the situation rather than silently shipping a broken auth setup.
throw new Error(
`Could not find \`{children}\` in ${layoutRel} to wrap with <${PROVIDER_NAME}>. ` +
`If you've customized the layout, wrap children with <${PROVIDER_NAME}> manually.`,
);
}
ctx.claimRegion(layoutRel, "imports.clerk-provider");
ctx.claimRegion(layoutRel, "providers.clerk");
return { touchedFiles: [layoutRel] };
},
revert(ctx) {
const layoutAbs = path.join(ctx.appRoot, LAYOUT_REL_FROM_APP);
const layoutRel = path.relative(ctx.projectRoot, layoutAbs);
const sf = ctx.project().addSourceFileAtPath(layoutAbs);
// Remove the import.
const importDecl = sf.getImportDeclaration(
(d) => d.getModuleSpecifierValue() === PROVIDER_IMPORT,
);
importDecl?.remove();
// Unwrap the JSX back to bare `{children}`. We match the exact text we
// would have written, so user edits to the wrapped block won't be touched.
const wrapped = `<${PROVIDER_NAME}>{children}</${PROVIDER_NAME}>`;
for (const node of sf.getDescendantsOfKind(SyntaxKind.JsxElement)) {
if (node.getText().replace(/\s+/g, "") === wrapped) {
node.replaceWithText("{children}");
break;
}
}
ctx.releaseRegion(layoutRel, "imports.clerk-provider");
ctx.releaseRegion(layoutRel, "providers.clerk");
return { touchedFiles: [layoutRel] };
},
};
export default {
"wrap-root-layout": wrapRootLayout,
};
+1
View File
@@ -37,6 +37,7 @@ export default defineModule({
{ src: "middleware.ts", dest: "middleware.ts", scope: "app" },
{ src: "layout-wrapper.tsx", dest: "app/_clerk-provider.tsx", scope: "app" },
],
codemods: ["wrap-root-layout"],
},
],
});
+3
View File
@@ -8,5 +8,8 @@
"dependencies": {
"@stanza/codemods": "workspace:*",
"@stanza/registry": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.9.1"
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["module.ts"],
"compilerOptions": {
"types": ["node"]
},
"include": ["module.ts", "codemods/**/*.ts"],
"exclude": ["templates"]
}