@daveyplate/better-auth-instantdb
Version:
Better Auth InstantDB Adapter
416 lines (336 loc) • 15.9 kB
text/typescript
import type { InstantAdminDatabase, InstantSchemaDef, TransactionChunk } from "@instantdb/admin"
import { id } from "@instantdb/react"
import type { Where } from "better-auth"
import { createAdapter } from "better-auth/adapters"
type Direction = "asc" | "desc"
type Order = { [key: string]: Direction }
export interface InstantDBAdapterConfig {
/**
* Helps you debug issues with the adapter.
*/
debugLogs?: boolean
/**
* If the table names in the schema are plural.
*/
usePlural?: boolean
/**
* Hooks to add additional transactions to the create and update methods.
*/
transactionHooks?: {
create?: ({
model,
data
}: {
model: string
// biome-ignore lint/suspicious/noExplicitAny:
data: Record<string, any>
// biome-ignore lint/suspicious/noExplicitAny:
}) => Promise<TransactionChunk<any, any>[] | undefined> | Promise<void>
update?: ({
model,
update,
where
}: {
model: string
// biome-ignore lint/suspicious/noExplicitAny:
update: Record<string, any>
where: Where[]
// biome-ignore lint/suspicious/noExplicitAny:
}) => Promise<TransactionChunk<any, any>[] | undefined> | Promise<void>
}
// biome-ignore lint/suspicious/noExplicitAny:
db: InstantAdminDatabase<InstantSchemaDef<any, any, any>>
}
export function parseWhere(where?: Where[]) {
const whereQuery = {} as Record<string, unknown>
where?.map((item) => {
switch (item.operator) {
case "eq":
whereQuery[item.field] = item.value
break
case "in":
whereQuery[item.field] = { $in: item.value }
break
case "contains":
whereQuery[item.field] = { $like: `%${item.value}%` }
break
case "starts_with":
whereQuery[item.field] = { $like: `${item.value}%` }
break
case "ends_with":
whereQuery[item.field] = { $like: `%${item.value}` }
break
case "ne":
whereQuery[item.field] = { $not: item.value }
break
case "gt":
whereQuery[item.field] = { $gt: item.value }
break
case "gte":
whereQuery[item.field] = { $gte: item.value }
break
case "lt":
whereQuery[item.field] = { $lt: item.value }
break
case "lte":
whereQuery[item.field] = { $lte: item.value }
break
}
})
return whereQuery
}
export const instantDBAdapter = ({
usePlural = true,
debugLogs = false,
transactionHooks,
db
}: InstantDBAdapterConfig) =>
createAdapter({
config: {
adapterId: "instantdb-adapter", // A unique identifier for the adapter.
adapterName: "InstantDB Adapter", // The name of the adapter.
usePlural, // Whether the table names in the schema are plural.
debugLogs, // Whether to enable debug logs.
supportsJSON: false, // Whether the database supports JSON. (Default: true)
supportsDates: false, // Whether the database supports dates. (Default: true)
supportsBooleans: true, // Whether the database supports booleans. (Default: true)
disableIdGeneration: true, // Whether to disable automatic ID generation. (Default: false)
supportsNumericIds: false // Whether the database supports numeric IDs. (Default: true)
},
adapter: ({ options, getFieldName, getDefaultModelName }) => {
return {
async create({ data, model }) {
// @ts-ignore
data.id = options.advanced?.database?.generateId
? options.advanced.database.generateId({ model })
: id()
const transactions = []
// Create the $users entity along with the user entity
if (getDefaultModelName(model) === "user") {
transactions.push(db.tx.$users[data.id].update({ email: data.email }))
}
// Create the InstantDB token and override session.token
if (getDefaultModelName(model) === "session") {
// Get the $users entity for this session's userId with the user link
const queryData = await db.query({
$users: { $: { where: { id: data.userId } }, user: {} }
})
const $users = queryData.$users
if ($users.length === 0) {
throw new Error(`$users entity not found: ${data.userId}`)
}
// Get the user link from the $users entity
const $user = $users[0] as unknown as {
email: string
user?: { email: string }
}
const user = $user.user
if (!user) {
throw new Error(`user link not found: ${data.userId}`)
}
// Create the InstantDB token and override session.token
if (debugLogs) {
console.log("[InstantDB] Create token for:", $user.email)
}
const token = await db.auth.createToken($user.email)
const tokenField = getFieldName({ model, field: "token" })
// @ts-ignore
data[tokenField] = token
// Update $users entity email to match the user email
if (user.email !== $user.email) {
transactions.push(
db.tx.$users[data.userId].update({ email: user.email })
)
}
}
transactions.push(db.tx[model][data.id].update(data))
// Link user to $users
if (getDefaultModelName(model) === "user") {
transactions.push(db.tx[model][data.id].link({ $user: data.id }))
}
// Link other models to user
try {
const userIdField = getFieldName({ model, field: "userId" })
if (data[userIdField]) {
transactions.push(
db.tx[model][data.id].link({ user: data[userIdField] })
)
}
} catch (error) {}
if (transactionHooks?.create) {
const hookTransactions = await transactionHooks.create({
data,
model
})
if (hookTransactions) transactions.push(...hookTransactions)
}
await db.transact(transactions)
return data
},
async count({ model, where }) {
const query = { [model]: { $: { where: parseWhere(where) } } }
if (debugLogs) {
console.log("[InstantDB] Query:", JSON.stringify(query))
}
const result = await db.query(query)
if (debugLogs) {
console.log("[InstantDB] Result:", JSON.stringify(result))
}
const entities = result[model]
return entities.length
},
async delete({ model, where }) {
const query = { [model]: { $: { where: parseWhere(where) } } }
if (debugLogs) {
console.log("[InstantDB] Query:", JSON.stringify(query))
}
const result = await db.query(query)
if (debugLogs) {
console.log("[InstantDB] Result:", JSON.stringify(result))
}
const entities = result[model]
// If a session is deleted, we need to sign out the token
if (getDefaultModelName(model) === "session") {
entities.map(async (entity) => {
try {
const tokenField = getFieldName({ model, field: "token" })
await db.auth.signOut({
refresh_token: entity[tokenField] as string
})
} catch (error) {}
})
}
const transactions = entities.map((entity) => db.tx[model][entity.id].delete())
await db.transact(transactions)
},
async deleteMany({ model, where }) {
const query = { [model]: { $: { where: parseWhere(where) } } }
if (debugLogs) {
console.log("[InstantDB] Query:", JSON.stringify(query))
}
const result = await db.query(query)
if (debugLogs) {
console.log("[InstantDB] Result:", JSON.stringify(result))
}
const entities = result[model]
// If a sessions are deleted, we need to sign out the tokens
if (getDefaultModelName(model) === "session") {
entities.map(async (entity) => {
try {
const tokenField = getFieldName({ model, field: "token" })
await db.auth.signOut({
refresh_token: entity[tokenField] as string
})
} catch (error) {}
})
}
const transactions = entities.map((entity) => db.tx[model][entity.id].delete())
await db.transact(transactions)
return entities.length
},
async findMany({ model, where, limit, sortBy, offset }) {
let order: Order | undefined
if (sortBy) {
order = {
[sortBy.field]: sortBy.direction
}
}
const query: Parameters<typeof db.query>[0] = {
[model]: { $: { where: parseWhere(where), limit, offset, order } }
}
if (debugLogs) {
console.log("[InstantDB] Query:", JSON.stringify(query))
}
const result = await db.query(query)
if (debugLogs) {
console.log("[InstantDB] Result:", JSON.stringify(result))
}
const entities = result[model]
// biome-ignore lint/suspicious/noExplicitAny:
return entities as any[]
},
async findOne({ model, where }) {
const query = { [model]: { $: { where: parseWhere(where) } } }
if (debugLogs) {
console.log("[InstantDB] Query:", JSON.stringify(query))
}
const result = await db.query(query)
if (debugLogs) {
console.log("[InstantDB] Result:", JSON.stringify(result))
}
const entities = result[model]
// biome-ignore lint/suspicious/noExplicitAny:
if (entities.length > 0) return entities[0] as any
return null
},
async update({ model, update, where }) {
const query = { [model]: { $: { where: parseWhere(where) } } }
if (debugLogs) {
console.log("[InstantDB] Query:", JSON.stringify(query))
}
const result = await db.query(query)
if (debugLogs) {
console.log("[InstantDB] Result:", JSON.stringify(result))
}
const entities = result[model]
const transactions = entities.map((entity) =>
db.tx[model][entity.id].update(update as Record<string, unknown>)
)
// If a user email is updated, we need to update the $users entity email
if (getDefaultModelName(model) === "user") {
const emailField = getFieldName({ model, field: "email" })
// @ts-ignore
const email = update[emailField]
if (email) {
transactions.push(
...entities.map((entity) =>
db.tx.$users[entity.id].update({ email })
)
)
}
}
if (transactionHooks?.update) {
const hookTransactions = await transactionHooks.update({
update: update as Record<string, unknown>,
model,
where
})
if (hookTransactions) transactions.push(...hookTransactions)
}
await db.transact(transactions)
// Return the updated entity
if (entities.length > 0) {
return { ...entities[0], ...update }
}
return null
},
async updateMany({ model, update, where }) {
const query = { [model]: { $: { where: parseWhere(where) } } }
if (debugLogs) {
console.log("[InstantDB] Query:", JSON.stringify(query))
}
const result = await db.query(query)
if (debugLogs) {
console.log("[InstantDB] Result:", JSON.stringify(result))
}
const entities = result[model]
const transactions = entities.map((entity) =>
db.tx[model][entity.id].update(update)
)
if (transactionHooks?.update) {
const hookTransactions = await transactionHooks.update({
update: update as Record<string, unknown>,
model,
where
})
if (hookTransactions) {
transactions.push(...hookTransactions)
}
}
await db.transact(transactions)
return entities.length
},
options: { usePlural, debugLogs }
}
}
})