Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zenstack-v3",
"version": "3.0.0-beta.23",
"version": "3.0.0-beta.24",
"description": "ZenStack",
"packageManager": "[email protected]",
"scripts": {
Expand Down
9 changes: 9 additions & 0 deletions packages/auth-adapters/better-auth/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import config from '@zenstackhq/eslint-config/base.js';
import tseslint from 'typescript-eslint';

/** @type {import("eslint").Linter.Config} */
export default tseslint.config(config, {
rules: {
'@typescript-eslint/no-unused-expressions': 'off',
},
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
{
"name": "@zenstackhq/kysely-sql-js",
"version": "3.0.0-beta.23",
"description": "Kysely dialect for sql.js",
"name": "@zenstackhq/better-auth",
"version": "3.0.0-beta.24",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"type": "module",
"scripts": {
"build": "tsc --noEmit && tsup-node",
"watch": "tsup-node --watch",
"lint": "eslint src --ext ts",
"pack": "pnpm pack"
},
"keywords": [],
"keywords": [
"better-auth",
"auth"
],
"author": "ZenStack Team",
"license": "MIT",
"files": [
Expand All @@ -31,16 +34,21 @@
"require": "./package.json"
}
},
"dependencies": {
"@zenstackhq/orm": "workspace:*",
"@zenstackhq/language": "workspace:*",
"@zenstackhq/common-helpers": "workspace:*",
"ts-pattern": "catalog:"
},
"peerDependencies": {
"@better-auth/core": "^1.3.0",
"better-auth": "^1.3.0"
},
"devDependencies": {
"@types/sql.js": "^1.4.9",
"@better-auth/core": "^1.3.0",
"better-auth": "^1.3.0",
"@zenstackhq/eslint-config": "workspace:*",
"@zenstackhq/typescript-config": "workspace:*",
"@zenstackhq/vitest-config": "workspace:*",
"sql.js": "^1.13.0",
"kysely": "catalog:"
},
"peerDependencies": {
"sql.js": "^1.13.0",
"kysely": "catalog:"
"@zenstackhq/vitest-config": "workspace:*"
}
}
240 changes: 240 additions & 0 deletions packages/auth-adapters/better-auth/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import type { BetterAuthOptions } from '@better-auth/core';
import type { DBAdapter, DBAdapterDebugLogOption, Where } from '@better-auth/core/db/adapter';
import { BetterAuthError } from '@better-auth/core/error';
import type { ClientContract, ModelOperations, UpdateInput } from '@zenstackhq/orm';
import type { GetModels, SchemaDef } from '@zenstackhq/orm/schema';
import {
createAdapterFactory,
type AdapterFactoryCustomizeAdapterCreator,
type AdapterFactoryOptions,
} from 'better-auth/adapters';
import { generateSchema } from './schema-generator';

/**
* Options for the ZenStack adapter factory.
*/
export interface AdapterConfig {
/**
* Database provider
*/
provider: 'sqlite' | 'postgresql';

/**
* Enable debug logs for the adapter
*
* @default false
*/
debugLogs?: DBAdapterDebugLogOption | undefined;

/**
* Use plural table names
*
* @default false
*/
usePlural?: boolean | undefined;
}

/**
* Create a Better-Auth adapter for ZenStack ORM.
* @param db ZenStack ORM client instance
* @param config adapter configuration options
*/
export const zenstackAdapter = <Schema extends SchemaDef>(db: ClientContract<Schema>, config: AdapterConfig) => {
let lazyOptions: BetterAuthOptions | null = null;
const createCustomAdapter =
(db: ClientContract<Schema>): AdapterFactoryCustomizeAdapterCreator =>
({ getFieldName, options }) => {
const convertSelect = (select?: string[], model?: string) => {
if (!select || !model) return undefined;
return select.reduce((prev, cur) => {
return {
...prev,
[getFieldName({ model, field: cur })]: true,
};
}, {});
};
function operatorToORMOperator(operator: string) {
switch (operator) {
case 'starts_with':
return 'startsWith';
case 'ends_with':
return 'endsWith';
case 'ne':
return 'not';
case 'not_in':
return 'notIn';
default:
return operator;
}
}
const convertWhereClause = (model: string, where?: Where[]): any => {
if (!where || !where.length) return {};
if (where.length === 1) {
const w = where[0]!;
if (!w) {
throw new BetterAuthError('Invalid where clause');
}
return {
[getFieldName({ model, field: w.field })]:
w.operator === 'eq' || !w.operator
? w.value
: {
[operatorToORMOperator(w.operator)]: w.value,
},
};
}
const and = where.filter((w) => w.connector === 'AND' || !w.connector);
const or = where.filter((w) => w.connector === 'OR');
const andClause = and.map((w) => {
return {
[getFieldName({ model, field: w.field })]:
w.operator === 'eq' || !w.operator
? w.value
: {
[operatorToORMOperator(w.operator)]: w.value,
},
};
});
const orClause = or.map((w) => {
return {
[getFieldName({ model, field: w.field })]:
w.operator === 'eq' || !w.operator
? w.value
: {
[operatorToORMOperator(w.operator)]: w.value,
},
};
});

return {
...(andClause.length ? { AND: andClause } : {}),
...(orClause.length ? { OR: orClause } : {}),
};
};

function requireModelDb(db: ClientContract<Schema>, model: string) {
const modelDb = db[model as keyof typeof db];
if (!modelDb) {
throw new BetterAuthError(
`Model ${model} does not exist in the database. If you haven't generated the ZenStack schema, you need to run 'npx zen generate'`,
);
}
return modelDb as unknown as ModelOperations<SchemaDef, GetModels<SchemaDef>>;
}

return {
async create({ model, data: values, select }): Promise<any> {
const modelDb = requireModelDb(db, model);
return await modelDb.create({
data: values,
select: convertSelect(select, model),
});
},

async findOne({ model, where, select }): Promise<any> {
const modelDb = requireModelDb(db, model);
const whereClause = convertWhereClause(model, where);
return await modelDb.findFirst({
where: whereClause,
select: convertSelect(select, model),
});
},

async findMany({ model, where, limit, offset, sortBy }): Promise<any[]> {
const modelDb = requireModelDb(db, model);
const whereClause = convertWhereClause(model, where);
return await modelDb.findMany({
where: whereClause,
take: limit || 100,
skip: offset || 0,
...(sortBy?.field
? {
orderBy: {
[getFieldName({ model, field: sortBy.field })]:
sortBy.direction === 'desc' ? 'desc' : 'asc',
} as any,
}
: {}),
});
},

async count({ model, where }) {
const modelDb = requireModelDb(db, model);
const whereClause = convertWhereClause(model, where);
return await modelDb.count({
where: whereClause,
});
},

async update({ model, where, update }): Promise<any> {
const modelDb = requireModelDb(db, model);
const whereClause = convertWhereClause(model, where);
return await modelDb.update({
where: whereClause,
data: update as UpdateInput<SchemaDef, GetModels<SchemaDef>>,
});
},

async updateMany({ model, where, update }) {
const modelDb = requireModelDb(db, model);
const whereClause = convertWhereClause(model, where);
const result = await modelDb.updateMany({
where: whereClause,
data: update,
});
return result ? (result.count as number) : 0;
},

async delete({ model, where }): Promise<any> {
const modelDb = requireModelDb(db, model);
const whereClause = convertWhereClause(model, where);
try {
await modelDb.delete({
where: whereClause,
});
} catch {
// If the record doesn't exist, we don't want to throw an error
}
},

async deleteMany({ model, where }) {
const modelDb = requireModelDb(db, model);
const whereClause = convertWhereClause(model, where);
const result = await modelDb.deleteMany({
where: whereClause,
});
return result ? (result.count as number) : 0;
},

options: config,

createSchema: async ({ file, tables }) => {
return generateSchema(file, tables, config, options);
},
};
};

const adapterOptions: AdapterFactoryOptions = {
config: {
adapterId: 'zenstack',
adapterName: 'ZenStack Adapter',
usePlural: config.usePlural ?? false,
debugLogs: config.debugLogs ?? false,
transaction: (cb) =>
db.$transaction((tx) => {
const adapter = createAdapterFactory({
config: adapterOptions!.config,
adapter: createCustomAdapter(tx as ClientContract<Schema>),
})(lazyOptions!);
return cb(adapter);
}),
},
adapter: createCustomAdapter(db),
};

const adapter = createAdapterFactory(adapterOptions);
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
lazyOptions = options;
return adapter(options);
};
};
1 change: 1 addition & 0 deletions packages/auth-adapters/better-auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { zenstackAdapter, type AdapterConfig } from './adapter';
Loading
Loading