Skip to content

Replace drizzle-orm with native ObjectQL implementation using better-auth naming conventions - #582

Merged
hotlong merged 6 commits into
copilot/start-better-auth-implementationfrom
copilot/implement-objectql-database-objects
Feb 10, 2026
Merged

Replace drizzle-orm with native ObjectQL implementation using better-auth naming conventions#582
hotlong merged 6 commits into
copilot/start-better-auth-implementationfrom
copilot/implement-objectql-database-objects

Conversation

CopilotAI commented Feb 10, 2026

Copy link
Copy Markdown
Contributor

PR #580 integrated better-auth with drizzle-orm as database adapter. This PR replaces that with native ObjectQL implementation, eliminating the third-party ORM dependency while maintaining better-auth's native naming conventions for seamless migration.

Changes

Auth Object Definitions (src/objects/*.object.ts)

  • user, session, account, verification defined using ObjectSchema.create()
  • Uses better-auth's native table names (no auth_ prefix)
  • Uses better-auth's native field naming (camelCase): emailVerified, createdAt, userId, etc.
  • Maps directly to better-auth's schema requirements for seamless migration

ObjectQL Adapter (src/objectql-adapter.ts)

  • Implements better-auth's CustomAdapter interface using ObjectQL's IDataEngine
  • No name translation needed - uses better-auth's native naming conventions directly
  • Converts better-auth where clauses to ObjectQL filter format
  • Sequential updates/deletes due to IDataEngine requiring IDs (noted for future optimization)

Integration

  • AuthManager accepts dataEngine parameter, automatically injected by kernel's data service
  • Removed databaseUrl parameter (obsolete with ObjectQL)
  • Removed drizzle-orm from peerDependencies

Example

// Object definitions use better-auth's native namingexportconstAuthUser=ObjectSchema.create({name: 'user',// better-auth native table namefields: {email: Field.email({label: 'Email',required: true}),emailVerified: Field.boolean({label: 'Email Verified'}),// camelCasecreatedAt: Field.datetime({label: 'Created At'}),// camelCase// ...},indexes: [{fields: ['email'],unique: true}]});// Adapter bridges better-auth to ObjectQL (no name conversion)constadapter=createObjectQLAdapter(dataEngine);constauth=betterAuth({database: adapter});

Benefits

  • Seamless Migration: Existing better-auth databases work without schema changes
  • Driver-agnostic: Works with any ObjectQL driver (memory, SQL, NoSQL) without ORM coupling
  • Simplified Adapter: No name conversion logic needed
  • Better-auth Compatibility: Full compatibility with better-auth ecosystem
Original prompt

拉取请求: https://github.com/objectstack-ai/spec/pull/580/files
数据库应该基于objectql自己创建对象实现。不要使用第三方orm


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel

vercelBot commented Feb 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
objectstack-studioReadyReadyPreview, CommentFeb 10, 2026 6:30am
specReadyReadyPreview, CommentFeb 10, 2026 6:30am

Request Review

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
CopilotAI changed the title [WIP] Implement database using ObjectQL objectsReplace drizzle-orm with native ObjectQL implementation for auth persistenceFeb 10, 2026
CopilotAI requested a review from hotlongFebruary 10, 2026 06:09
@hotlong

Copy link
Copy Markdown
Contributor

@copilot 所有的数据库表名和字段名使用better-auth现有的规范,确保现有的客户能无缝迁移

- Changed table names: auth_user → user, auth_session → session, etc.
- Changed field names: email_verified → emailVerified, created_at → createdAt, etc.
- Removed name conversion logic from adapter (no longer needed)
- Updated documentation to reflect better-auth native schema usage
- All tests passing (11/11)
This ensures existing better-auth databases can migrate without schema changes.
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>

CopilotAI commented Feb 10, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot 所有的数据库表名和字段名使用better-auth现有的规范,确保现有的客户能无缝迁移

已完成。现在使用 better-auth 的原生命名规范:

表名: user, session, account, verification (不再使用 auth_ 前缀)
字段名: emailVerified, createdAt, userId 等 (使用 camelCase,不再使用 snake_case)

适配器已简化,不再需要名称转换逻辑。现有 better-auth 数据库可以无缝迁移到 ObjectQL。

Commit: d746197

CopilotAI changed the title Replace drizzle-orm with native ObjectQL implementation for auth persistenceReplace drizzle-orm with native ObjectQL implementation using better-auth naming conventionsFeb 10, 2026
@hotlong
hotlong marked this pull request as ready for review February 10, 2026 06:32
CopilotAI review requested due to automatic review settings February 10, 2026 06:32
@hotlong
hotlong merged commit bc0f249 into copilot/start-better-auth-implementationFeb 10, 2026
3 checks passed

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates @objectstack/plugin-auth to persist better-auth data via a native ObjectQL IDataEngine adapter, removing the plugin’s direct drizzle-orm dependency and introducing explicit auth object definitions that follow better-auth’s native naming.

Changes:

  • Added ObjectQL-backed better-auth adapter (createObjectQLAdapter) and wired AuthManager to use it when a dataEngine is provided.
  • Added Object Protocol object definitions for better-auth tables (user, session, account, verification) and exported them from the plugin.
  • Updated plugin init/docs/deps to reflect ObjectQL-based persistence and removed drizzle-orm peer dependency.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 7 comments.

Show a summary per file
FileDescription
pnpm-lock.yamlRemoves drizzle-orm from plugin-auth importer deps; lockfile still shows better-auth resolved with drizzle-orm peer.
packages/plugins/plugin-auth/src/objects/index.tsNew barrel export for auth Object Protocol definitions.
packages/plugins/plugin-auth/src/objects/auth-user.object.tsAdds user object schema matching better-auth naming.
packages/plugins/plugin-auth/src/objects/auth-session.object.tsAdds session object schema matching better-auth naming.
packages/plugins/plugin-auth/src/objects/auth-account.object.tsAdds account object schema matching better-auth naming.
packages/plugins/plugin-auth/src/objects/auth-verification.object.tsAdds verification object schema matching better-auth naming.
packages/plugins/plugin-auth/src/objectql-adapter.tsImplements better-auth adapter on top of IDataEngine (where translation + CRUD).
packages/plugins/plugin-auth/src/index.tsExports adapter + object definitions from plugin entrypoint.
packages/plugins/plugin-auth/src/auth-plugin.tsInjects dataEngine from kernel data service into AuthManager.
packages/plugins/plugin-auth/src/auth-manager.tsUses ObjectQL adapter when dataEngine is provided; warns/falls back otherwise.
packages/plugins/plugin-auth/package.jsonRemoves drizzle-orm peer dependency.
packages/plugins/plugin-auth/README.mdUpdates docs to describe ObjectQL persistence and new architecture.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

} else if (condition.operator === 'lte') {
filter[fieldName] = { $lte: condition.value };
} else if (condition.operator === 'contains') {
filter[fieldName] = { $regex: condition.value };

CopilotAIFeb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ObjectQL filter protocol uses string operators like $contains/$startsWith/$endsWith (see packages/spec/src/data/filter.zod.ts), but contains is currently mapped to $regex. Map contains to $contains to stay compatible with ObjectQL drivers that validate against the protocol schema.

Suggested change
filter[fieldName]={$regex: condition.value};
filter[fieldName]={$contains: condition.value};

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +95
const results = await dataEngine.find(objectName, {
filter,
limit: limit || 100,
skip: offset,
sort,
});

CopilotAIFeb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit: limit || 100 changes semantics for valid falsy values and can introduce an unexpected cap if limit is omitted. Prefer passing limit through as-is (or use nullish coalescing) so caller intent is preserved and it stays consistent with DataEngineQueryOptions.limit being optional.

Copilot uses AI. Check for mistakes.
Comment threadpnpm-lock.yaml
Comment on lines 609 to 611
better-auth:
specifier: ^1.4.18
version: 1.4.18(drizzle-orm@0.41.0(kysely@0.28.11))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@types/node@25.2.2)(happy-dom@20.5.3)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(tsx@4.21.0))

CopilotAIFeb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lockfile still resolves better-auth with a drizzle-orm@0.41.0 peer (it appears in the resolved version string). If the goal is to remove the ORM dependency, regenerate pnpm-lock.yaml after removing drizzle-orm so the resolved better-auth snapshot no longer includes it (or ensure the peer isn't auto-installed).

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +80
// Get data engine service for database operations
const dataEngine = ctx.getService<any>('data');
if (!dataEngine) {
ctx.logger.warn('No data engine service found - auth will use in-memory storage');
}

// Initialize auth manager with data engine
this.authManager = new AuthManager({
...this.options,
dataEngine,
});

CopilotAIFeb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change introduces new behavior (injecting dataEngine and supporting a missing data service) but there are no assertions covering it in the existing plugin test suite. Add tests that verify: (1) init does not throw when data is absent, and (2) when data is present it is passed into AuthManager.

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +49
export function createObjectQLAdapter(dataEngine: IDataEngine) {
/**
* Convert better-auth where clause to ObjectQL query format
*/
function convertWhere(where: CleanedWhere[]): Record<string, any> {
const filter: Record<string, any> = {};

for (const condition of where) {
// Use field names as-is (no conversion needed)
const fieldName = condition.field;

if (condition.operator === 'eq') {
filter[fieldName] = condition.value;
} else if (condition.operator === 'ne') {
filter[fieldName] = { $ne: condition.value };
} else if (condition.operator === 'in') {
filter[fieldName] = { $in: condition.value };
} else if (condition.operator === 'gt') {
filter[fieldName] = { $gt: condition.value };
} else if (condition.operator === 'gte') {
filter[fieldName] = { $gte: condition.value };
} else if (condition.operator === 'lt') {
filter[fieldName] = { $lt: condition.value };
} else if (condition.operator === 'lte') {
filter[fieldName] = { $lte: condition.value };
} else if (condition.operator === 'contains') {
filter[fieldName] = { $regex: condition.value };
}
}

return filter;
}

CopilotAIFeb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adapter’s where-clause translation is core to correctness (operator mapping, multi-condition merging, limit/offset/sort), but there are no unit tests covering these cases. Add focused tests for convertWhere() and for each adapter method’s call into IDataEngine to prevent silent query regressions.

Copilot uses AI. Check for mistakes.
// Initialize auth manager
this.authManager = new AuthManager(this.options);
// Get data engine service for database operations
const dataEngine = ctx.getService<any>('data');

CopilotAIFeb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ctx.getService('data') throws when the service is not registered (KernelBase.getService throws on missing services), so this init path will crash instead of falling back to in-memory as the log message suggests. Wrap the lookup in try/catch (or use a non-throwing service existence check) and only warn when the service is absent.

Suggested change
constdataEngine=ctx.getService<any>('data');
letdataEngine: any|undefined;
try{
dataEngine=ctx.getService<any>('data');
}catch{
ctx.logger.warn('No data engine service found - auth will use in-memory storage');
}

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +47

for (const condition of where) {
// Use field names as-is (no conversion needed)
const fieldName = condition.field;

if (condition.operator === 'eq') {
filter[fieldName] = condition.value;
} else if (condition.operator === 'ne') {
filter[fieldName] = { $ne: condition.value };
} else if (condition.operator === 'in') {
filter[fieldName] = { $in: condition.value };
} else if (condition.operator === 'gt') {
filter[fieldName] = { $gt: condition.value };
} else if (condition.operator === 'gte') {
filter[fieldName] = { $gte: condition.value };
} else if (condition.operator === 'lt') {
filter[fieldName] = { $lt: condition.value };
} else if (condition.operator === 'lte') {
filter[fieldName] = { $lte: condition.value };
} else if (condition.operator === 'contains') {
filter[fieldName] = { $regex: condition.value };
}
}

CopilotAIFeb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When multiple where conditions target the same field (e.g. gte + lte), this loop overwrites filter[fieldName] and drops earlier operators. Accumulate operators into a single object per field (or build a top-level $and) so all conditions are preserved.

Suggested change
for(constconditionofwhere){
// Use field names as-is (no conversion needed)
constfieldName=condition.field;
if(condition.operator==='eq'){
filter[fieldName]=condition.value;
}elseif(condition.operator==='ne'){
filter[fieldName]={$ne: condition.value};
}elseif(condition.operator==='in'){
filter[fieldName]={$in: condition.value};
}elseif(condition.operator==='gt'){
filter[fieldName]={$gt: condition.value};
}elseif(condition.operator==='gte'){
filter[fieldName]={$gte: condition.value};
}elseif(condition.operator==='lt'){
filter[fieldName]={$lt: condition.value};
}elseif(condition.operator==='lte'){
filter[fieldName]={$lte: condition.value};
}elseif(condition.operator==='contains'){
filter[fieldName]={$regex: condition.value};
}
}
for(constconditionofwhere){
// Use field names as-is (no conversion needed)
constfieldName=condition.field;
// Map better-auth operators to ObjectQL/Mongo-style operator keys
letoperatorKey: string|null=null;
switch(condition.operator){
case'eq':
operatorKey='$eq';
break;
case'ne':
operatorKey='$ne';
break;
case 'in':
operatorKey='$in';
break;
case'gt':
operatorKey='$gt';
break;
case'gte':
operatorKey='$gte';
break;
case'lt':
operatorKey='$lt';
break;
case'lte':
operatorKey='$lte';
break;
case'contains':
operatorKey='$regex';
break;
default:
operatorKey=null;
}
if(!operatorKey){
continue;
}
constexisting=filter[fieldName];
// If there is no existing filter for this field, keep behavior for first eq,
// otherwise start an operator object.
if(existing===undefined){
if(operatorKey==='$eq'){
// Preserve simple equality as a primitive value when it's the only condition
filter[fieldName]=condition.value;
}else{
filter[fieldName]={[operatorKey]: condition.value};
}
continue;
}
// There is already a filter for this field: merge the new operator.
if(existing!==null&&typeofexisting==='object'){
// Existing is already an operator object; just add/overwrite this operator.
existing[operatorKey]=condition.value;
filter[fieldName]=existing;
}else{
// Existing is a primitive (likely from an earlier eq); wrap it and merge.
constmerged: Record<string,any>={};
merged.$eq=existing;
merged[operatorKey]=condition.value;
filter[fieldName]=merged;
}
}

Copilot uses AI. Check for mistakes.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@hotlong