@better-auth/memory-adapter
Advanced tools
+109
-27
@@ -33,5 +33,81 @@ import { createAdapterFactory } from "@better-auth/core/db/adapter"; | ||
| //#region src/memory-adapter.ts | ||
| /** | ||
| * Index a table's rows by their `id` for row-level reconciliation. Every | ||
| * better-auth row carries an `id` (the adapter's join logic already keys on | ||
| * `record.id`), so the id is a stable identity for the three-way merge. | ||
| */ | ||
| function indexById(rows) { | ||
| const byId = /* @__PURE__ */ new Map(); | ||
| for (const row of rows) byId.set(row.id, row); | ||
| return byId; | ||
| } | ||
| /** | ||
| * Commit a transaction onto the live database with a three-way merge so a | ||
| * concurrent write that interleaved at an `await` point survives. | ||
| * | ||
| * `base` is the snapshot taken when the transaction started; `clone` is that | ||
| * snapshot after the transaction mutated it; `target` is the live database as | ||
| * it stands now (possibly carrying concurrent writes). Replaying only the | ||
| * `base -> clone` delta onto `target` applies the transaction's own creates, | ||
| * updates, and deletes without disturbing rows or tables the transaction never | ||
| * touched. A row the transaction did not change keeps the live version, so a | ||
| * concurrent edit to a different row is preserved. A row the transaction did | ||
| * change wins last-writer-wins over a concurrent edit to the same row, which is | ||
| * acceptable for an in-memory development adapter; isolation is guaranteed only | ||
| * at row/table granularity. | ||
| * | ||
| * `target` is mutated in place so any reference held elsewhere (for example the | ||
| * `db` object the caller passed to `memoryAdapter`) stays valid. | ||
| */ | ||
| function mergeTransactionInto(target, base, clone) { | ||
| const models = new Set([...Object.keys(base), ...Object.keys(clone)]); | ||
| for (const model of models) { | ||
| if (!(model in clone)) { | ||
| delete target[model]; | ||
| continue; | ||
| } | ||
| const baseById = indexById(base[model] ?? []); | ||
| const cloneRows = clone[model] ?? []; | ||
| const cloneById = indexById(cloneRows); | ||
| const liveRows = target[model] ?? []; | ||
| const merged = []; | ||
| const placed = /* @__PURE__ */ new Set(); | ||
| for (const liveRow of liveRows) { | ||
| const id = liveRow.id; | ||
| const baseRow = baseById.get(id); | ||
| const cloneRow = cloneById.get(id); | ||
| if (baseRow !== void 0 && cloneRow === void 0) continue; | ||
| if (cloneRow !== void 0 && rowChanged(baseRow, cloneRow)) merged.push(cloneRow); | ||
| else merged.push(liveRow); | ||
| placed.add(id); | ||
| } | ||
| for (const cloneRow of cloneRows) if (!baseById.has(cloneRow.id) && !placed.has(cloneRow.id)) merged.push(cloneRow); | ||
| target[model] = merged; | ||
| } | ||
| } | ||
| /** | ||
| * Whether the transaction mutated a row, comparing its pre-transaction | ||
| * snapshot to its post-transaction state. Rows hold scalar columns and | ||
| * adapter-serializable values, so JSON equality reliably tells a | ||
| * transaction-made edit apart from an untouched row. | ||
| */ | ||
| function rowChanged(baseRow, cloneRow) { | ||
| if (baseRow === void 0) return true; | ||
| return JSON.stringify(baseRow) !== JSON.stringify(cloneRow); | ||
| } | ||
| const memoryAdapter = (db, config) => { | ||
| let lazyOptions = null; | ||
| const adapterCreator = createAdapterFactory({ | ||
| /** | ||
| * Build an adapter factory whose operations read and write `activeDb`. | ||
| * The non-transactional adapter targets the live `db`. A transaction | ||
| * targets an isolated clone so its uncommitted writes are invisible to | ||
| * concurrent operations against the live `db`. A failed transaction leaves | ||
| * the live `db` untouched, and a committed one replays only its own | ||
| * row/table changes, so a concurrent write that interleaved at an `await` | ||
| * point survives either outcome. Isolation is at row/table granularity: | ||
| * the in-memory adapter does not serialize writes, so two operations that | ||
| * edit the same row resolve last-writer-wins. It is built for development | ||
| * and tests, not production concurrency control. | ||
| */ | ||
| const buildAdapterFactory = (activeDb) => createAdapterFactory({ | ||
| config: { | ||
@@ -44,15 +120,11 @@ adapterId: "memory", | ||
| customTransformInput(props) { | ||
| if (props.options.advanced?.database?.generateId === "serial" && props.field === "id" && props.action === "create") return db[props.model].length + 1; | ||
| if (props.options.advanced?.database?.generateId === "serial" && props.field === "id" && props.action === "create") return activeDb[props.model].length + 1; | ||
| return props.data; | ||
| }, | ||
| transaction: async (cb) => { | ||
| const clone = structuredClone(db); | ||
| try { | ||
| return await cb(adapterCreator(lazyOptions)); | ||
| } catch (error) { | ||
| Object.keys(db).forEach((key) => { | ||
| db[key] = clone[key]; | ||
| }); | ||
| throw error; | ||
| } | ||
| const base = structuredClone(activeDb); | ||
| const clone = structuredClone(activeDb); | ||
| const result = await cb(buildAdapterFactory(clone)(lazyOptions)); | ||
| mergeTransactionInto(activeDb, base, clone); | ||
| return result; | ||
| } | ||
@@ -84,5 +156,5 @@ }, | ||
| const baseRecords = (() => { | ||
| const table = db[model]; | ||
| const table = activeDb[model]; | ||
| if (!table) { | ||
| logger.error(`[MemoryAdapter] Model ${model} not found in the DB`, Object.keys(db)); | ||
| logger.error(`[MemoryAdapter] Model ${model} not found in the DB`, Object.keys(activeDb)); | ||
| throw new Error(`Model ${model} not found`); | ||
@@ -158,5 +230,5 @@ } | ||
| const joinModelName = getModelName(joinModel); | ||
| const joinTable = db[joinModelName]; | ||
| const joinTable = activeDb[joinModelName]; | ||
| if (!joinTable) { | ||
| logger.error(`[MemoryAdapter] JoinOption model ${joinModelName} not found in the DB`, Object.keys(db)); | ||
| logger.error(`[MemoryAdapter] JoinOption model ${joinModelName} not found in the DB`, Object.keys(activeDb)); | ||
| throw new Error(`JoinOption model ${joinModelName} not found`); | ||
@@ -185,5 +257,5 @@ } | ||
| create: async ({ model, data }) => { | ||
| if (options.advanced?.database?.generateId === "serial") data.id = db[getModelName(model)].length + 1; | ||
| if (!db[model]) db[model] = []; | ||
| db[model].push(data); | ||
| if (options.advanced?.database?.generateId === "serial") data.id = activeDb[getModelName(model)].length + 1; | ||
| if (!activeDb[model]) activeDb[model] = []; | ||
| activeDb[model].push(data); | ||
| return data; | ||
@@ -218,5 +290,6 @@ }, | ||
| if (where) return convertWhereClause(where, model).length; | ||
| return db[model].length; | ||
| return activeDb[model].length; | ||
| }, | ||
| update: async ({ model, where, update }) => { | ||
| if (where.length === 0) return null; | ||
| const res = convertWhereClause(where, model); | ||
@@ -229,11 +302,12 @@ res.forEach((record) => { | ||
| delete: async ({ model, where }) => { | ||
| const table = db[model]; | ||
| if (where.length === 0) return; | ||
| const table = activeDb[model]; | ||
| const res = convertWhereClause(where, model); | ||
| db[model] = table.filter((record) => !res.includes(record)); | ||
| activeDb[model] = table.filter((record) => !res.includes(record)); | ||
| }, | ||
| deleteMany: async ({ model, where }) => { | ||
| const table = db[model]; | ||
| const table = activeDb[model]; | ||
| const res = convertWhereClause(where, model); | ||
| let count = 0; | ||
| db[model] = table.filter((record) => { | ||
| activeDb[model] = table.filter((record) => { | ||
| if (res.includes(record)) { | ||
@@ -248,9 +322,16 @@ count++; | ||
| consumeOne: async ({ model, where }) => { | ||
| const table = db[model]; | ||
| const table = activeDb[model]; | ||
| const target = convertWhereClause(where, model)[0]; | ||
| if (!target) return null; | ||
| db[model] = table.filter((record) => record !== target); | ||
| activeDb[model] = table.filter((record) => record !== target); | ||
| return target; | ||
| }, | ||
| updateMany({ model, where, update }) { | ||
| incrementOne: async ({ model, where, increment, set }) => { | ||
| const target = convertWhereClause(where, model)[0]; | ||
| if (!target) return null; | ||
| for (const [field, delta] of Object.entries(increment)) target[field] = (typeof target[field] === "number" ? target[field] : 0) + delta; | ||
| if (set) Object.assign(target, set); | ||
| return target; | ||
| }, | ||
| updateMany: async ({ model, where, update }) => { | ||
| const res = convertWhereClause(where, model); | ||
@@ -260,3 +341,3 @@ res.forEach((record) => { | ||
| }); | ||
| return res[0] || null; | ||
| return res.length; | ||
| } | ||
@@ -266,2 +347,3 @@ }; | ||
| }); | ||
| const adapterCreator = buildAdapterFactory(db); | ||
| return (options) => { | ||
@@ -268,0 +350,0 @@ lazyOptions = options; |
+3
-3
| { | ||
| "name": "@better-auth/memory-adapter", | ||
| "version": "1.6.16", | ||
| "version": "1.6.17", | ||
| "description": "Memory adapter for Better Auth", | ||
@@ -39,3 +39,3 @@ "type": "module", | ||
| "@better-auth/utils": "0.4.1", | ||
| "@better-auth/core": "^1.6.16" | ||
| "@better-auth/core": "^1.6.17" | ||
| }, | ||
@@ -46,3 +46,3 @@ "devDependencies": { | ||
| "typescript": "^5.9.3", | ||
| "@better-auth/core": "1.6.16" | ||
| "@better-auth/core": "1.6.17" | ||
| }, | ||
@@ -49,0 +49,0 @@ "scripts": { |
17928
29.39%345
31.18%