-
Notifications
You must be signed in to change notification settings - Fork 0
Replace drizzle-orm with native ObjectQL implementation using better-auth naming conventions #582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Replace drizzle-orm with native ObjectQL implementation using better-auth naming conventions #582
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
|
@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>
bc0f249
into
copilot/start-better-auth-implementation
There was a problem hiding this comment.
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 wiredAuthManagerto use it when adataEngineis 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
| File | Description |
|---|---|
| pnpm-lock.yaml | Removes drizzle-orm from plugin-auth importer deps; lockfile still shows better-auth resolved with drizzle-orm peer. |
| packages/plugins/plugin-auth/src/objects/index.ts | New barrel export for auth Object Protocol definitions. |
| packages/plugins/plugin-auth/src/objects/auth-user.object.ts | Adds user object schema matching better-auth naming. |
| packages/plugins/plugin-auth/src/objects/auth-session.object.ts | Adds session object schema matching better-auth naming. |
| packages/plugins/plugin-auth/src/objects/auth-account.object.ts | Adds account object schema matching better-auth naming. |
| packages/plugins/plugin-auth/src/objects/auth-verification.object.ts | Adds verification object schema matching better-auth naming. |
| packages/plugins/plugin-auth/src/objectql-adapter.ts | Implements better-auth adapter on top of IDataEngine (where translation + CRUD). |
| packages/plugins/plugin-auth/src/index.ts | Exports adapter + object definitions from plugin entrypoint. |
| packages/plugins/plugin-auth/src/auth-plugin.ts | Injects dataEngine from kernel data service into AuthManager. |
| packages/plugins/plugin-auth/src/auth-manager.ts | Uses ObjectQL adapter when dataEngine is provided; warns/falls back otherwise. |
| packages/plugins/plugin-auth/package.json | Removes drizzle-orm peer dependency. |
| packages/plugins/plugin-auth/README.md | Updates 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 }; |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| filter[fieldName] = { $regex: condition.value }; | |
| filter[fieldName] = { $contains: condition.value }; |
| const results = await dataEngine.find(objectName, { | ||
| filter, | ||
| limit: limit || 100, | ||
| skip: offset, | ||
| sort, | ||
| }); |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| 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)) |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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).
| // 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, | ||
| }); |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| 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; | ||
| } |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| // Initialize auth manager | ||
| this.authManager = new AuthManager(this.options); | ||
| // Get data engine service for database operations | ||
| const dataEngine = ctx.getService<any>('data'); |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| const dataEngine = ctx.getService<any>('data'); | |
| let dataEngine: any | undefined; | |
| try { | |
| dataEngine = ctx.getService<any>('data'); | |
| } catch { | |
| ctx.logger.warn('No data engine service found - auth will use in-memory storage'); | |
| } |
|
|
||
| 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 }; | ||
| } | ||
| } | ||
|
|
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| 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 }; | |
| } | |
| } | |
| for (const condition of where) { | |
| // Use field names as-is (no conversion needed) | |
| const fieldName = condition.field; | |
| // Map better-auth operators to ObjectQL/Mongo-style operator keys | |
| let operatorKey: 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; | |
| } | |
| const existing = 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 && typeof existing === '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. | |
| const merged: Record<string, any> = {}; | |
| merged.$eq = existing; | |
| merged[operatorKey] = condition.value; | |
| filter[fieldName] = merged; | |
| } | |
| } |
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,verificationdefined usingObjectSchema.create()auth_prefix)emailVerified,createdAt,userId, etc.ObjectQL Adapter (
src/objectql-adapter.ts)Integration
dataEngineparameter, automatically injected by kernel'sdataservicedatabaseUrlparameter (obsolete with ObjectQL)peerDependenciesExample
Benefits
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.