Skip to content

Commit abe5a1c

Browse files
Copilothotlong
andcommitted
feat(plugin-auth): use better-auth modelName/fields mapping for snake_case compatibility
- Add auth-schema-config.ts with model/field mapping constants for all 4 core auth models - Refactor objectql-adapter.ts to use createAdapterFactory from better-auth/adapters - Update auth-manager.ts to pass user/session/account/verification config with modelName and fields - Add tests for schema config, factory adapter, and config verification - Update README and authentication guide documentation - Keep legacy createObjectQLAdapter for backward compatibility Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 4290079 commit abe5a1c

8 files changed

Lines changed: 559 additions & 171 deletions

File tree

content/docs/guides/authentication.mdx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,20 @@ The plugin uses ObjectStack's `sys_` prefix convention for protocol object names
583583
- Object names: `sys_user`, `sys_session`, `sys_account`, `sys_verification` (protocol names)
584584
- Field names: `email_verified`, `created_at`, `user_id` (snake_case)
585585

586-
better-auth internally uses model names like `user` and `session`. The ObjectQL adapter (`AUTH_MODEL_TO_PROTOCOL` mapping) automatically translates these to `sys_`-prefixed protocol names, providing seamless integration.
586+
better-auth internally uses camelCase model and field names (`user`, `emailVerified`, `userId`).
587+
The plugin bridges this gap using better-auth's official **`modelName` / `fields` schema customisation API**:
588+
589+
```typescript
590+
// Declared in the betterAuth() config via AUTH_*_CONFIG constants:
591+
user: { modelName: 'sys_user', fields: { emailVerified: 'email_verified', … } },
592+
session: { modelName: 'sys_session', fields: { userId: 'user_id', expiresAt: 'expires_at', … } },
593+
account: { modelName: 'sys_account', fields: { providerId: 'provider_id', accountId: 'account_id', … } },
594+
verification: { modelName: 'sys_verification', fields: { expiresAt: 'expires_at', … } },
595+
```
596+
597+
The ObjectQL adapter factory (`createObjectQLAdapterFactory`) then uses better-auth's `createAdapterFactory`
598+
which automatically transforms all data and where-clauses using these mappings — no manual
599+
camelCase ↔ snake_case conversion is needed in the adapter.
587600

588601
> **Upgrade note:** If you have custom adapters or plugins that reference auth objects by name,
589602
> update them to use `sys_user`, `sys_session`, `sys_account`, `sys_verification`

packages/plugins/plugin-auth/README.md

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -220,33 +220,53 @@ The adapter automatically maps better-auth model names to protocol names:
220220
- `sys_account` (← better-auth `account`) - OAuth provider accounts (id, provider_id, account_id, user_id, tokens, etc.)
221221
- `sys_verification` (← better-auth `verification`) - Verification tokens (id, value, identifier, expires_at, etc.)
222222

223-
**Adapter:**
224-
The `createObjectQLAdapter()` function bridges better-auth's database interface to ObjectQL's IDataEngine. It includes a model→protocol name mapping (`AUTH_MODEL_TO_PROTOCOL`) that translates better-auth's hardcoded model names (e.g. `user`) to ObjectStack protocol names (e.g. `sys_user`):
223+
**Schema Mapping (modelName + fields):**
225224

226-
```typescript
227-
// Better-auth → ObjectQL Adapter (handles model name mapping + field transformation)
228-
import { createObjectQLAdapter, AUTH_MODEL_TO_PROTOCOL } from '@objectstack/plugin-auth';
229-
230-
const adapter = createObjectQLAdapter(dataEngine);
225+
better-auth uses camelCase field names internally (`emailVerified`, `userId`, `createdAt`, etc.)
226+
while ObjectStack's protocol layer uses snake_case (`email_verified`, `user_id`, `created_at`).
231227

232-
// Mapping: { user: 'sys_user', session: 'sys_session', account: 'sys_account', verification: 'sys_verification' }
233-
console.log(AUTH_MODEL_TO_PROTOCOL);
228+
The plugin leverages better-auth's official `modelName` / `fields` schema customisation API
229+
to declare the mapping at configuration time. The `createAdapterFactory` wrapper then
230+
transforms data and where-clauses automatically — no runtime camelCase ↔ snake_case
231+
conversion is needed in the adapter itself.
234232

235-
// better-auth requires a DBAdapterInstance (factory function), not a raw adapter object.
236-
// Passing a plain object falls through to the Kysely adapter path and fails silently.
237-
// Wrap the adapter in a factory function:
233+
```typescript
234+
// Schema mapping constants (auth-schema-config.ts)
235+
import {
236+
AUTH_USER_CONFIG,
237+
AUTH_SESSION_CONFIG,
238+
AUTH_ACCOUNT_CONFIG,
239+
AUTH_VERIFICATION_CONFIG,
240+
} from '@objectstack/plugin-auth';
241+
242+
// Applied to the betterAuth() config:
238243
const auth = betterAuth({
239-
database: (options) => ({
240-
id: 'objectql',
241-
...adapter,
242-
transaction: async (cb) => cb(adapter),
243-
}),
244-
// ... other config
244+
database: createObjectQLAdapterFactory(dataEngine),
245+
user: { ...AUTH_USER_CONFIG },
246+
session: { ...AUTH_SESSION_CONFIG, expiresIn: 604800 },
247+
account: { ...AUTH_ACCOUNT_CONFIG },
248+
verification: { ...AUTH_VERIFICATION_CONFIG },
249+
// ...
245250
});
246251
```
247252

248-
> **Note:** `AuthManager` handles this wrapping automatically when you provide a `dataEngine`.
249-
> You only need the factory pattern above when using `createObjectQLAdapter()` directly.
253+
**Adapter Factory:**
254+
The `createObjectQLAdapterFactory()` function uses better-auth's `createAdapterFactory` to
255+
bridge ObjectQL's IDataEngine with better-auth. Model-name and field-name transformations
256+
are applied by the factory wrapper so the adapter code stays simple:
257+
258+
```typescript
259+
import { createObjectQLAdapterFactory } from '@objectstack/plugin-auth';
260+
261+
const adapterFactory = createObjectQLAdapterFactory(dataEngine);
262+
// adapterFactory is (options: BetterAuthOptions) => DBAdapter
263+
```
264+
265+
> **Note:** `AuthManager` handles all of this automatically when you provide a `dataEngine`.
266+
> You only need the factory/config above when using the adapter directly.
267+
268+
A legacy `createObjectQLAdapter()` function (with manual model-name mapping via
269+
`AUTH_MODEL_TO_PROTOCOL`) is still exported for backward compatibility.
250270

251271
## Development
252272

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ describe('AuthManager', () => {
107107
});
108108

109109
describe('createDatabaseConfig – adapter wrapping', () => {
110-
it('should pass a function (DBAdapterInstance) to betterAuth when dataEngine is provided', () => {
110+
it('should pass a function (AdapterFactory) to betterAuth when dataEngine is provided', () => {
111111
const mockDataEngine = {
112112
insert: vi.fn(),
113113
findOne: vi.fn(),
@@ -128,7 +128,7 @@ describe('AuthManager', () => {
128128
// We need to trigger the lazy init first
129129
});
130130

131-
it('should provide a factory function as database config that returns adapter with id and transaction', () => {
131+
it('should provide a factory function as database config', () => {
132132
const mockDataEngine = {
133133
insert: vi.fn().mockResolvedValue({ id: '1' }),
134134
findOne: vi.fn().mockResolvedValue({ id: '1' }),
@@ -153,21 +153,75 @@ describe('AuthManager', () => {
153153
// Trigger lazy initialisation
154154
manager.getAuthInstance();
155155

156-
// The database config should be a function (DBAdapterInstance)
156+
// The database config should be a function (AdapterFactory)
157157
expect(typeof capturedConfig.database).toBe('function');
158+
});
159+
160+
it('should include modelName and fields mapping for user, session, account, verification', () => {
161+
const mockDataEngine = {
162+
insert: vi.fn().mockResolvedValue({ id: '1' }),
163+
findOne: vi.fn().mockResolvedValue({ id: '1' }),
164+
find: vi.fn().mockResolvedValue([]),
165+
count: vi.fn().mockResolvedValue(0),
166+
update: vi.fn().mockResolvedValue({ id: '1' }),
167+
delete: vi.fn().mockResolvedValue(undefined),
168+
};
169+
170+
let capturedConfig: any;
171+
(betterAuth as any).mockImplementation((config: any) => {
172+
capturedConfig = config;
173+
return { handler: vi.fn(), api: {} };
174+
});
175+
176+
const manager = new AuthManager({
177+
secret: 'test-secret-at-least-32-chars-long',
178+
baseUrl: 'http://localhost:3000',
179+
dataEngine: mockDataEngine as any,
180+
});
181+
182+
manager.getAuthInstance();
158183

159-
// Calling the factory should return an adapter object
160-
const adapterResult = capturedConfig.database({});
161-
expect(adapterResult).toHaveProperty('id', 'objectql');
162-
expect(typeof adapterResult.create).toBe('function');
163-
expect(typeof adapterResult.findOne).toBe('function');
164-
expect(typeof adapterResult.findMany).toBe('function');
165-
expect(typeof adapterResult.count).toBe('function');
166-
expect(typeof adapterResult.update).toBe('function');
167-
expect(typeof adapterResult.delete).toBe('function');
168-
expect(typeof adapterResult.deleteMany).toBe('function');
169-
expect(typeof adapterResult.updateMany).toBe('function');
170-
expect(typeof adapterResult.transaction).toBe('function');
184+
// Verify user model config
185+
expect(capturedConfig.user).toBeDefined();
186+
expect(capturedConfig.user.modelName).toBe('sys_user');
187+
expect(capturedConfig.user.fields).toEqual(expect.objectContaining({
188+
emailVerified: 'email_verified',
189+
createdAt: 'created_at',
190+
updatedAt: 'updated_at',
191+
}));
192+
193+
// Verify session model config (merged with session timing config)
194+
expect(capturedConfig.session).toBeDefined();
195+
expect(capturedConfig.session.modelName).toBe('sys_session');
196+
expect(capturedConfig.session.fields).toEqual(expect.objectContaining({
197+
userId: 'user_id',
198+
expiresAt: 'expires_at',
199+
ipAddress: 'ip_address',
200+
userAgent: 'user_agent',
201+
}));
202+
203+
// Verify account model config
204+
expect(capturedConfig.account).toBeDefined();
205+
expect(capturedConfig.account.modelName).toBe('sys_account');
206+
expect(capturedConfig.account.fields).toEqual(expect.objectContaining({
207+
userId: 'user_id',
208+
providerId: 'provider_id',
209+
accountId: 'account_id',
210+
accessToken: 'access_token',
211+
refreshToken: 'refresh_token',
212+
idToken: 'id_token',
213+
accessTokenExpiresAt: 'access_token_expires_at',
214+
refreshTokenExpiresAt: 'refresh_token_expires_at',
215+
}));
216+
217+
// Verify verification model config
218+
expect(capturedConfig.verification).toBeDefined();
219+
expect(capturedConfig.verification.modelName).toBe('sys_verification');
220+
expect(capturedConfig.verification.fields).toEqual(expect.objectContaining({
221+
expiresAt: 'expires_at',
222+
createdAt: 'created_at',
223+
updatedAt: 'updated_at',
224+
}));
171225
});
172226

173227
it('should return undefined (in-memory fallback) when no dataEngine is provided', () => {

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { betterAuth } from 'better-auth';
44
import type { Auth, BetterAuthOptions } from 'better-auth';
55
import type { AuthConfig } from '@objectstack/spec/system';
66
import type { IDataEngine } from '@objectstack/core';
7-
import { createObjectQLAdapter } from './objectql-adapter.js';
7+
import { createObjectQLAdapterFactory } from './objectql-adapter.js';
8+
import {
9+
AUTH_USER_CONFIG,
10+
AUTH_SESSION_CONFIG,
11+
AUTH_ACCOUNT_CONFIG,
12+
AUTH_VERIFICATION_CONFIG,
13+
} from './auth-schema-config.js';
814

915
/**
1016
* Extended options for AuthManager
@@ -71,17 +77,30 @@ export class AuthManager {
7177
basePath: '/', // ← 关键修复!告诉 better-auth 路径已被剥离
7278

7379
// Database adapter configuration
74-
// For now, we configure a basic setup that will be enhanced
75-
// when database URL is provided and drizzle-orm is available
7680
database: this.createDatabaseConfig(),
7781

82+
// Model/field mapping: camelCase (better-auth) → snake_case (ObjectStack)
83+
// These declarations tell better-auth the actual table/column names used
84+
// by ObjectStack's protocol layer, enabling automatic transformation via
85+
// createAdapterFactory.
86+
user: {
87+
...AUTH_USER_CONFIG,
88+
},
89+
account: {
90+
...AUTH_ACCOUNT_CONFIG,
91+
},
92+
verification: {
93+
...AUTH_VERIFICATION_CONFIG,
94+
},
95+
7896
// Email configuration
7997
emailAndPassword: {
8098
enabled: true,
8199
},
82100

83101
// Session configuration
84102
session: {
103+
...AUTH_SESSION_CONFIG,
85104
expiresIn: this.config.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days default
86105
updateAge: this.config.session?.updateAge || 60 * 60 * 24, // 1 day default
87106
},
@@ -103,19 +122,14 @@ export class AuthManager {
103122
* so it is correctly recognised as a `DBAdapterInstance`.
104123
*/
105124
private createDatabaseConfig(): any {
106-
// Use ObjectQL adapter if dataEngine is provided
125+
// Use ObjectQL adapter factory if dataEngine is provided
107126
if (this.config.dataEngine) {
108-
const adapter = createObjectQLAdapter(this.config.dataEngine);
109-
// Return a DBAdapterInstance factory function
110-
return (_options: any) => ({
111-
id: 'objectql',
112-
...adapter,
113-
// ObjectQL does not yet expose a separate transaction context,
114-
// so we pass the adapter itself. better-auth patches this
115-
// automatically when missing, but providing it avoids a
116-
// runtime warning from getBaseAdapter().
117-
transaction: async <R>(cb: (trx: any) => Promise<R>): Promise<R> => cb(adapter),
118-
});
127+
// createObjectQLAdapterFactory returns an AdapterFactory
128+
// (options => DBAdapter) which better-auth invokes via getBaseAdapter().
129+
// The factory is created by better-auth's createAdapterFactory and
130+
// automatically applies modelName/fields transformations declared in
131+
// the betterAuth config above.
132+
return createObjectQLAdapterFactory(this.config.dataEngine);
119133
}
120134

121135
// Fallback warning if no dataEngine is provided

0 commit comments

Comments
 (0)