Skip to content

Commit 4b6cf91

Browse files
authored
Merge pull request #745 from objectstack-ai/copilot/adapt-better-auth-sys-prefix
2 parents 709b617 + 0cca954 commit 4b6cf91

9 files changed

Lines changed: 185 additions & 35 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
162162

163163
**Migration (v3.x → v4.0):**
164164
- v3.x: The `SystemObjectName` constants now emit `sys_`-prefixed names. Implementations using `StorageNameMapping.resolveTableName()` can set `tableName` to preserve legacy physical table names during the transition.
165+
- v3.x: The `@objectstack/plugin-auth` ObjectQL adapter now includes `AUTH_MODEL_TO_PROTOCOL` mapping to translate better-auth's hardcoded model names (`user`, `session`, `account`, `verification`) to protocol names (`sys_user`, `sys_session`, `sys_account`, `sys_verification`). Custom adapters must adopt the same mapping.
165166
- v4.0: Legacy un-prefixed aliases will be fully removed.
166167

167168
---
@@ -364,7 +365,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
364365
- [ ] **Phase A: Core Driver** (v3.1) — `IDataDriver` + `ISchemaDriver` implementation, QueryAST→SQL compiler, plugin wrapper
365366
- [ ] **Phase B: Edge & Sync** (v3.2) — Embedded replica sync, WASM build for Cloudflare/Deno, offline write queue
366367
- [ ] **Phase C: Multi-Tenancy** (v3.3) — Database-per-tenant router, Turso Platform API integration
367-
- [ ] **Phase D: Advanced** (v4.0) — Vector search + `IAIService`, FTS5 + `ISearchService`, better-auth adapter
368+
- [ ] **Phase D: Advanced** (v4.0) — Vector search + `IAIService`, FTS5 + `ISearchService`, ~~better-auth adapter~~ (✅ done in plugin-auth)
368369
- [ ] Driver benchmark suite comparing performance across all drivers
369370

370371
### 6.2 Multi-Tenancy

content/docs/guides/authentication.mdx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,15 @@ That's it! Your authentication endpoints are now available at `/api/v1/auth/*`.
111111

112112
The plugin automatically uses ObjectQL for data persistence. No additional database configuration is required - it works with your existing ObjectQL setup.
113113

114-
The plugin creates the following auth objects:
115-
- `user` - User accounts
116-
- `session` - Active sessions
117-
- `account` - OAuth provider accounts
118-
- `verification` - Email/phone verification tokens
114+
The plugin creates the following auth objects (using ObjectStack `sys_` protocol names):
115+
- `sys_user` - User accounts (mapped from better-auth's `user` model)
116+
- `sys_session` - Active sessions (mapped from better-auth's `session` model)
117+
- `sys_account` - OAuth provider accounts (mapped from better-auth's `account` model)
118+
- `sys_verification` - Email/phone verification tokens (mapped from better-auth's `verification` model)
119+
120+
> **Note:** better-auth uses hardcoded model names (`user`, `session`, etc.). The ObjectQL adapter
121+
> automatically maps these to `sys_`-prefixed protocol names via `AUTH_MODEL_TO_PROTOCOL`.
122+
> Client-side API routes (`/api/v1/auth/*`) are **not affected** — they do not expose object names.
119123
120124
---
121125

@@ -569,12 +573,16 @@ try {
569573

570574
### ObjectStack Field Naming
571575

572-
The plugin uses ObjectStack's snake_case naming convention for field names, which is required by the ObjectStack protocol:
576+
The plugin uses ObjectStack's `sys_` prefix convention for protocol object names and snake_case for field names, which is required by the ObjectStack protocol:
573577

574-
- Table names: `user`, `session`, `account`, `verification` (compatible with better-auth)
578+
- Object names: `sys_user`, `sys_session`, `sys_account`, `sys_verification` (protocol names)
575579
- Field names: `email_verified`, `created_at`, `user_id` (snake_case)
576580

577-
The ObjectQL adapter automatically handles field name transformation between better-auth's expectations and ObjectStack's snake_case convention, providing seamless integration while maintaining protocol compliance.
581+
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.
582+
583+
> **Upgrade note:** If you have custom adapters or plugins that reference auth objects by name,
584+
> update them to use `sys_user`, `sys_session`, `sys_account`, `sys_verification`
585+
> (or import from `SystemObjectName` constants).
578586
579587
---
580588

packages/plugins/plugin-auth/README.md

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ Authentication & Identity Plugin for ObjectStack.
3434
-**No Third-Party ORM** - No dependency on drizzle-orm or other ORMs
3535
-**Better-Auth Native Schema** - Uses better-auth's naming conventions for seamless migration
3636
-**Object Definitions** - Auth objects defined using ObjectStack's Object Protocol
37-
- `user` - User accounts (better-auth native table name)
38-
- `session` - Active sessions (better-auth native table name)
39-
- `account` - OAuth provider accounts (better-auth native table name)
40-
- `verification` - Email/phone verification tokens (better-auth native table name)
37+
- `sys_user` - User accounts (protocol name, mapped from better-auth's `user`)
38+
- `sys_session` - Active sessions (protocol name, mapped from better-auth's `session`)
39+
- `sys_account` - OAuth provider accounts (protocol name, mapped from better-auth's `account`)
40+
- `sys_verification` - Email/phone verification tokens (protocol name, mapped from better-auth's `verification`)
4141
-**ObjectQL Adapter** - Custom adapter bridges better-auth to ObjectQL
4242

4343
The plugin uses [better-auth](https://www.better-auth.com/) for robust, production-ready authentication functionality. All requests are forwarded directly to better-auth's universal handler, ensuring full compatibility with all better-auth features. Data persistence is handled by ObjectQL using **ObjectStack's snake_case naming conventions** for field names to maintain consistency across the platform.
@@ -187,7 +187,7 @@ The plugin uses **ObjectQL** for data persistence instead of third-party ORMs:
187187
```typescript
188188
// Object definitions use ObjectStack's snake_case naming conventions
189189
export const AuthUser = ObjectSchema.create({
190-
name: 'user', // better-auth compatible table name
190+
name: 'sys_user', // ObjectStack protocol name (better-auth model 'user' is mapped automatically)
191191
fields: {
192192
id: Field.text({ label: 'User ID', required: true }),
193193
email: Field.email({ label: 'Email', required: true }),
@@ -213,19 +213,25 @@ export const AuthUser = ObjectSchema.create({
213213
-**Compatible Schema** - Uses better-auth compatible table structure with ObjectStack's snake_case field naming
214214

215215
**Database Objects:**
216-
Uses better-auth compatible table names with ObjectStack's snake_case field naming:
217-
- `user` - User accounts (id, email, name, email_verified, created_at, etc.)
218-
- `session` - Active sessions (id, token, user_id, expires_at, ip_address, etc.)
219-
- `account` - OAuth provider accounts (id, provider_id, account_id, user_id, tokens, etc.)
220-
- `verification` - Verification tokens (id, value, identifier, expires_at, etc.)
216+
Uses ObjectStack `sys_` prefixed protocol names with snake_case field naming.
217+
The adapter automatically maps better-auth model names to protocol names:
218+
- `sys_user` (← better-auth `user`) - User accounts (id, email, name, email_verified, created_at, etc.)
219+
- `sys_session` (← better-auth `session`) - Active sessions (id, token, user_id, expires_at, ip_address, etc.)
220+
- `sys_account` (← better-auth `account`) - OAuth provider accounts (id, provider_id, account_id, user_id, tokens, etc.)
221+
- `sys_verification` (← better-auth `verification`) - Verification tokens (id, value, identifier, expires_at, etc.)
221222

222223
**Adapter:**
223-
The `createObjectQLAdapter()` function bridges better-auth's database interface to ObjectQL's IDataEngine with field name transformation:
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`):
224225

225226
```typescript
226-
// Better-auth → ObjectQL Adapter (handles snake_case transformation)
227+
// Better-auth → ObjectQL Adapter (handles model name mapping + field transformation)
228+
import { createObjectQLAdapter, AUTH_MODEL_TO_PROTOCOL } from '@objectstack/plugin-auth';
229+
227230
const adapter = createObjectQLAdapter(dataEngine);
228231

232+
// Mapping: { user: 'sys_user', session: 'sys_session', account: 'sys_account', verification: 'sys_verification' }
233+
console.log(AUTH_MODEL_TO_PROTOCOL);
234+
229235
// Better-auth uses this adapter for all database operations
230236
const auth = betterAuth({
231237
database: adapter,
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import {
5+
createObjectQLAdapter,
6+
AUTH_MODEL_TO_PROTOCOL,
7+
resolveProtocolName,
8+
} from './objectql-adapter';
9+
import { SystemObjectName } from '@objectstack/spec/system';
10+
import type { IDataEngine } from '@objectstack/core';
11+
12+
describe('AUTH_MODEL_TO_PROTOCOL mapping', () => {
13+
it('should map all four core better-auth models to sys_ protocol names', () => {
14+
expect(AUTH_MODEL_TO_PROTOCOL.user).toBe('sys_user');
15+
expect(AUTH_MODEL_TO_PROTOCOL.session).toBe('sys_session');
16+
expect(AUTH_MODEL_TO_PROTOCOL.account).toBe('sys_account');
17+
expect(AUTH_MODEL_TO_PROTOCOL.verification).toBe('sys_verification');
18+
});
19+
20+
it('should align with SystemObjectName constants', () => {
21+
expect(AUTH_MODEL_TO_PROTOCOL.user).toBe(SystemObjectName.USER);
22+
expect(AUTH_MODEL_TO_PROTOCOL.session).toBe(SystemObjectName.SESSION);
23+
expect(AUTH_MODEL_TO_PROTOCOL.account).toBe(SystemObjectName.ACCOUNT);
24+
expect(AUTH_MODEL_TO_PROTOCOL.verification).toBe(SystemObjectName.VERIFICATION);
25+
});
26+
});
27+
28+
describe('resolveProtocolName', () => {
29+
it('should resolve core models to sys_ prefixed names', () => {
30+
expect(resolveProtocolName('user')).toBe('sys_user');
31+
expect(resolveProtocolName('session')).toBe('sys_session');
32+
expect(resolveProtocolName('account')).toBe('sys_account');
33+
expect(resolveProtocolName('verification')).toBe('sys_verification');
34+
});
35+
36+
it('should fall back to original name for unknown models', () => {
37+
expect(resolveProtocolName('organization')).toBe('organization');
38+
expect(resolveProtocolName('custom_model')).toBe('custom_model');
39+
});
40+
});
41+
42+
describe('createObjectQLAdapter – model name mapping', () => {
43+
let mockEngine: IDataEngine;
44+
45+
beforeEach(() => {
46+
mockEngine = {
47+
insert: vi.fn().mockResolvedValue({ id: '1' }),
48+
findOne: vi.fn().mockResolvedValue({ id: '1' }),
49+
find: vi.fn().mockResolvedValue([]),
50+
count: vi.fn().mockResolvedValue(0),
51+
update: vi.fn().mockResolvedValue({ id: '1' }),
52+
delete: vi.fn().mockResolvedValue(undefined),
53+
} as unknown as IDataEngine;
54+
});
55+
56+
it('create: should call dataEngine.insert with sys_ protocol name', async () => {
57+
const adapter = createObjectQLAdapter(mockEngine);
58+
await adapter.create({ model: 'user', data: { email: 'a@b.com' } });
59+
expect(mockEngine.insert).toHaveBeenCalledWith('sys_user', { email: 'a@b.com' });
60+
});
61+
62+
it('findOne: should call dataEngine.findOne with sys_ protocol name', async () => {
63+
const adapter = createObjectQLAdapter(mockEngine);
64+
await adapter.findOne({
65+
model: 'session',
66+
where: [{ field: 'token', value: 'abc', operator: 'eq', connector: 'AND' }],
67+
});
68+
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_session', expect.objectContaining({
69+
filter: { token: 'abc' },
70+
}));
71+
});
72+
73+
it('findMany: should call dataEngine.find with sys_ protocol name', async () => {
74+
const adapter = createObjectQLAdapter(mockEngine);
75+
await adapter.findMany({ model: 'account', limit: 10 });
76+
expect(mockEngine.find).toHaveBeenCalledWith('sys_account', expect.objectContaining({
77+
limit: 10,
78+
}));
79+
});
80+
81+
it('count: should call dataEngine.count with sys_ protocol name', async () => {
82+
const adapter = createObjectQLAdapter(mockEngine);
83+
await adapter.count({ model: 'verification' });
84+
expect(mockEngine.count).toHaveBeenCalledWith('sys_verification', expect.anything());
85+
});
86+
87+
it('update: should call dataEngine with sys_ protocol name', async () => {
88+
const adapter = createObjectQLAdapter(mockEngine);
89+
await adapter.update({
90+
model: 'user',
91+
where: [{ field: 'id', value: '1', operator: 'eq', connector: 'AND' }],
92+
update: { name: 'New' },
93+
});
94+
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_user', expect.anything());
95+
expect(mockEngine.update).toHaveBeenCalledWith('sys_user', expect.objectContaining({ name: 'New', id: '1' }));
96+
});
97+
98+
it('delete: should call dataEngine with sys_ protocol name', async () => {
99+
const adapter = createObjectQLAdapter(mockEngine);
100+
await adapter.delete({
101+
model: 'session',
102+
where: [{ field: 'id', value: '1', operator: 'eq', connector: 'AND' }],
103+
});
104+
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_session', expect.anything());
105+
expect(mockEngine.delete).toHaveBeenCalledWith('sys_session', expect.anything());
106+
});
107+
108+
it('should pass through unknown model names unchanged', async () => {
109+
const adapter = createObjectQLAdapter(mockEngine);
110+
await adapter.create({ model: 'organization', data: { name: 'Acme' } });
111+
expect(mockEngine.insert).toHaveBeenCalledWith('organization', { name: 'Acme' });
112+
});
113+
});

packages/plugins/plugin-auth/src/objectql-adapter.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@
22

33
import type { IDataEngine } from '@objectstack/core';
44
import type { CleanedWhere } from 'better-auth/adapters';
5+
import { SystemObjectName } from '@objectstack/spec/system';
6+
7+
/**
8+
* Mapping from better-auth model names to ObjectStack protocol object names.
9+
*
10+
* better-auth uses hardcoded model names ('user', 'session', 'account', 'verification')
11+
* while ObjectStack's protocol layer uses `sys_` prefixed names. This map bridges the two.
12+
*/
13+
export const AUTH_MODEL_TO_PROTOCOL: Record<string, string> = {
14+
user: SystemObjectName.USER,
15+
session: SystemObjectName.SESSION,
16+
account: SystemObjectName.ACCOUNT,
17+
verification: SystemObjectName.VERIFICATION,
18+
};
19+
20+
/**
21+
* Resolve a better-auth model name to the ObjectStack protocol object name.
22+
* Falls back to the original model name for custom / non-core models.
23+
*/
24+
export function resolveProtocolName(model: string): string {
25+
return AUTH_MODEL_TO_PROTOCOL[model] ?? model;
26+
}
527

628
/**
729
* ObjectQL Adapter for better-auth
@@ -10,7 +32,8 @@ import type { CleanedWhere } from 'better-auth/adapters';
1032
* This allows better-auth to use ObjectQL for data persistence instead of
1133
* third-party ORMs like drizzle-orm.
1234
*
13-
* Uses better-auth's native naming conventions (camelCase) for seamless migration.
35+
* Model names from better-auth (e.g. 'user') are automatically mapped to
36+
* ObjectStack protocol names (e.g. 'sys_user') via {@link AUTH_MODEL_TO_PROTOCOL}.
1437
*
1538
* @param dataEngine - ObjectQL data engine instance
1639
* @returns better-auth CustomAdapter
@@ -50,8 +73,7 @@ export function createObjectQLAdapter(dataEngine: IDataEngine) {
5073

5174
return {
5275
create: async <T extends Record<string, any>>({ model, data, select: _select }: { model: string; data: T; select?: string[] }): Promise<T> => {
53-
// Use model name as-is (no conversion needed)
54-
const objectName = model;
76+
const objectName = resolveProtocolName(model);
5577

5678
// Note: select parameter is currently not supported by ObjectQL's insert operation
5779
// The full record is always returned after insertion
@@ -60,7 +82,7 @@ export function createObjectQLAdapter(dataEngine: IDataEngine) {
6082
},
6183

6284
findOne: async <T>({ model, where, select, join: _join }: { model: string; where: CleanedWhere[]; select?: string[]; join?: any }): Promise<T | null> => {
63-
const objectName = model;
85+
const objectName = resolveProtocolName(model);
6486
const filter = convertWhere(where);
6587

6688
// Note: join parameter is not currently supported by ObjectQL's findOne operation
@@ -76,7 +98,7 @@ export function createObjectQLAdapter(dataEngine: IDataEngine) {
7698
},
7799

78100
findMany: async <T>({ model, where, limit, offset, sortBy, join: _join }: { model: string; where?: CleanedWhere[]; limit: number; offset?: number; sortBy?: { field: string; direction: 'asc' | 'desc' }; join?: any }): Promise<T[]> => {
79-
const objectName = model;
101+
const objectName = resolveProtocolName(model);
80102
const filter = where ? convertWhere(where) : {};
81103

82104
// Note: join parameter is not currently supported by ObjectQL's find operation
@@ -98,14 +120,14 @@ export function createObjectQLAdapter(dataEngine: IDataEngine) {
98120
},
99121

100122
count: async ({ model, where }: { model: string; where?: CleanedWhere[] }): Promise<number> => {
101-
const objectName = model;
123+
const objectName = resolveProtocolName(model);
102124
const filter = where ? convertWhere(where) : {};
103125

104126
return await dataEngine.count(objectName, { filter });
105127
},
106128

107129
update: async <T>({ model, where, update }: { model: string; where: CleanedWhere[]; update: Record<string, any> }): Promise<T | null> => {
108-
const objectName = model;
130+
const objectName = resolveProtocolName(model);
109131
const filter = convertWhere(where);
110132

111133
// Find the record first to get its ID
@@ -123,7 +145,7 @@ export function createObjectQLAdapter(dataEngine: IDataEngine) {
123145
},
124146

125147
updateMany: async ({ model, where, update }: { model: string; where: CleanedWhere[]; update: Record<string, any> }): Promise<number> => {
126-
const objectName = model;
148+
const objectName = resolveProtocolName(model);
127149
const filter = convertWhere(where);
128150

129151
// Note: Sequential updates are used here because ObjectQL's IDataEngine interface
@@ -145,7 +167,7 @@ export function createObjectQLAdapter(dataEngine: IDataEngine) {
145167
},
146168

147169
delete: async ({ model, where }: { model: string; where: CleanedWhere[] }): Promise<void> => {
148-
const objectName = model;
170+
const objectName = resolveProtocolName(model);
149171
const filter = convertWhere(where);
150172

151173
// Note: We need to find the record first to get its ID because ObjectQL's
@@ -160,7 +182,7 @@ export function createObjectQLAdapter(dataEngine: IDataEngine) {
160182
},
161183

162184
deleteMany: async ({ model, where }: { model: string; where: CleanedWhere[] }): Promise<number> => {
163-
const objectName = model;
185+
const objectName = resolveProtocolName(model);
164186
const filter = convertWhere(where);
165187

166188
// Note: Sequential deletes are used here because ObjectQL's delete operation

packages/plugins/plugin-auth/src/objects/auth-account.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
2121
* - password: string | null (for email/password provider)
2222
*/
2323
export const AuthAccount = ObjectSchema.create({
24-
name: 'account',
24+
name: 'sys_account',
2525
label: 'Account',
2626
pluralLabel: 'Accounts',
2727
icon: 'link',

packages/plugins/plugin-auth/src/objects/auth-session.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
1616
* - user_agent: string | null
1717
*/
1818
export const AuthSession = ObjectSchema.create({
19-
name: 'session',
19+
name: 'sys_session',
2020
label: 'Session',
2121
pluralLabel: 'Sessions',
2222
icon: 'key',

packages/plugins/plugin-auth/src/objects/auth-user.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
1515
* - image: string | null
1616
*/
1717
export const AuthUser = ObjectSchema.create({
18-
name: 'user',
18+
name: 'sys_user',
1919
label: 'User',
2020
pluralLabel: 'Users',
2121
icon: 'user',

packages/plugins/plugin-auth/src/objects/auth-verification.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
1414
* - identifier: string (email or phone number)
1515
*/
1616
export const AuthVerification = ObjectSchema.create({
17-
name: 'verification',
17+
name: 'sys_verification',
1818
label: 'Verification',
1919
pluralLabel: 'Verifications',
2020
icon: 'shield-check',

0 commit comments

Comments
 (0)