diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e0359fd..786fd68 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,9 +25,26 @@ jobs: strategy: matrix: os: [ubuntu-24.04] - node: [20.x, 22.x, 24.x, 25.x] + node: [22.x, 24.x, 25.x] runs-on: ${{matrix.os}} + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: adapter-test-only + POSTGRES_DB: adapter_consume_regression + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + ADAPTER_TEST_POSTGRES_URL: postgresql://postgres:adapter-test-only@127.0.0.1:5432/adapter_consume_regression + steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index c901020..eb7879e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # better-auth-mikro-orm +## 0.5.12 + +### Patch Changes + +- [#10](https://github.com/forklaunch/better-auth-mikro-orm/pull/10) [`abde984`](https://github.com/forklaunch/better-auth-mikro-orm/commit/abde98426542e87e583f5fd7fc0d0c56965acbcc) Thanks [@RohinBhargava](https://github.com/RohinBhargava)! - Prevent consumed verification values from being reinserted by a later EntityManager flush. Read the consumption snapshot without registering it in the identity map, and preserve the guarded delete so concurrent consumers still have exactly one winner. + ## 0.5.11 ### Patch Changes diff --git a/package.json b/package.json index 570c47a..6171d4c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": false, "type": "module", "name": "@forklaunch/better-auth-mikro-orm-fork", - "version": "0.5.11", + "version": "0.5.12", "description": "Mikro ORM Adapter for Better Auth", "keywords": [ "auth", @@ -55,6 +55,7 @@ "@faker-js/faker": "10.4.0", "@mikro-orm/core": "7.1.8", "@mikro-orm/decorators": "7.1.8", + "@mikro-orm/postgresql": "7.1.8", "@mikro-orm/sqlite": "7.1.8", "@types/node": "25.6.0", "@vitest/coverage-v8": "4.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2375fa0..7375da1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,7 +14,7 @@ importers: devDependencies: '@better-auth/test-utils': specifier: 1.7.2 - version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.3.6))(jose@6.2.10)(kysely@0.29.4)(nanostores@1.5.2))(better-auth@1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(vitest@4.1.5))(vitest@4.1.5) + version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.3.6))(jose@6.2.10)(kysely@0.29.4)(nanostores@1.5.2))(better-auth@1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(pg@8.22.0)(vitest@4.1.5))(vitest@4.1.5) '@biomejs/biome': specifier: 2.4.13 version: 2.4.13 @@ -42,6 +42,9 @@ importers: '@mikro-orm/decorators': specifier: 7.1.8 version: 7.1.8(@mikro-orm/core@7.1.8) + '@mikro-orm/postgresql': + specifier: 7.1.8 + version: 7.1.8(@mikro-orm/core@7.1.8) '@mikro-orm/sqlite': specifier: 7.1.8 version: 7.1.8(@mikro-orm/core@7.1.8) @@ -56,7 +59,7 @@ importers: version: 4.1.5(vitest@4.1.5) better-auth: specifier: 1.7.2 - version: 1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(vitest@4.1.5) + version: 1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(pg@8.22.0)(vitest@4.1.5) del-cli: specifier: 7.0.0 version: 7.0.0 @@ -611,6 +614,12 @@ packages: reflect-metadata: optional: true + '@mikro-orm/postgresql@7.1.8': + resolution: {integrity: sha512-jmYfVDErMFDDKQalWdzpiNnfre3HMquxxNLghxUWLjJ0EA1O2ggcoqEgjKFOISUp86Q2F5FTZDxM2qXEqT+XWg==} + engines: {node: '>= 22.17.0'} + peerDependencies: + '@mikro-orm/core': 7.1.8 + '@mikro-orm/sql@7.1.8': resolution: {integrity: sha512-hR4pbzlSdryniafc072ifs8PhIaksdSq2CZHABikEJadi40e+cEu3aXg32aHuXRje4epg5mCDmGwxDt4twxryA==} engines: {node: '>= 22.17.0'} @@ -1633,6 +1642,45 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} + + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} + + pg-cursor@2.21.0: + resolution: {integrity: sha512-IYvk/j+Suhtbo/C3uOf4JLsLK/gWxOTUOmYbDsbKnLaVJDq+KwhwK6ngpRfiCk8eDMS3AmGQABZCv0cREEzHQw==} + peerDependencies: + pg: ^8 + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1652,6 +1700,34 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres-interval@4.1.0: + resolution: {integrity: sha512-nJLACcOHi2iyRuMJeW6CuIA0Dc0i1YEpRr26XDyHAfxPjYUJOFxwZjmOc5oFFctTTh/OlRAMlnlXEJvr170fJg==} + engines: {node: '>=12'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -1780,6 +1856,10 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -1997,6 +2077,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2084,10 +2168,10 @@ snapshots: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - '@better-auth/test-utils@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.3.6))(jose@6.2.10)(kysely@0.29.4)(nanostores@1.5.2))(better-auth@1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(vitest@4.1.5))(vitest@4.1.5)': + '@better-auth/test-utils@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.3.6))(jose@6.2.10)(kysely@0.29.4)(nanostores@1.5.2))(better-auth@1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(pg@8.22.0)(vitest@4.1.5))(vitest@4.1.5)': dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.3.6))(jose@6.2.10)(kysely@0.29.4)(nanostores@1.5.2) - better-auth: 1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(vitest@4.1.5) + better-auth: 1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(pg@8.22.0)(vitest@4.1.5) vitest: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)) '@better-auth/utils@0.4.2': @@ -2549,6 +2633,19 @@ snapshots: dependencies: '@mikro-orm/core': 7.1.8 + '@mikro-orm/postgresql@7.1.8(@mikro-orm/core@7.1.8)': + dependencies: + '@mikro-orm/core': 7.1.8 + '@mikro-orm/sql': 7.1.8(@mikro-orm/core@7.1.8) + kysely: 0.29.4 + pg: 8.22.0 + pg-cursor: 2.21.0(pg@8.22.0) + postgres-array: 3.0.4 + postgres-date: 2.1.0 + postgres-interval: 4.1.0 + transitivePeerDependencies: + - pg-native + '@mikro-orm/sql@7.1.8(@mikro-orm/core@7.1.8)': dependencies: '@mikro-orm/core': 7.1.8 @@ -2785,7 +2882,7 @@ snapshots: base64-js@1.5.1: {} - better-auth@1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(vitest@4.1.5): + better-auth@1.7.2(better-sqlite3@12.11.1)(mongodb@7.1.0)(pg@8.22.0)(vitest@4.1.5): dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.3.6))(jose@6.2.10)(kysely@0.29.4)(nanostores@1.5.2) '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.3.6))(jose@6.2.10)(kysely@0.29.4)(nanostores@1.5.2))(@better-auth/utils@0.4.2) @@ -2807,6 +2904,7 @@ snapshots: optionalDependencies: better-sqlite3: 12.11.1 mongodb: 7.1.0 + pg: 8.22.0 vitest: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -3395,6 +3493,45 @@ snapshots: pathe@2.0.3: {} + pg-cloudflare@1.4.1: + optional: true + + pg-connection-string@2.14.1: {} + + pg-cursor@2.21.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.16.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3409,6 +3546,22 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-date@2.1.0: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres-interval@4.1.0: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -3545,6 +3698,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + split2@4.2.0: {} + sprintf-js@1.0.3: {} stackback@0.0.2: {} @@ -3709,6 +3864,8 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yargs-parser@21.1.1: {} diff --git a/src/adapter.ts b/src/adapter.ts index e7a7ee5..2bcc5e8 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -227,7 +227,12 @@ const adapter = const metadata = getEntityMetadata(model) const filter = normalizeWhereClauses(metadata, where) - const entity = await orm.em.findOne(metadata.class, filter) + // This is a snapshot, not an entity to persist. In particular, + // unsetting the identity of a transaction's managed entity can make + // a parent context reinsert the consumed credential on a later flush. + const entity = await orm.em.findOne(metadata.class, filter, { + disableIdentityMap: true + }) if (!entity) { return null @@ -247,11 +252,6 @@ const adapter = return null } - // nativeDelete bypasses the identity map, so the deleted row would - // otherwise linger there and be handed back by a later findOne on the - // same EntityManager. - orm.em.getUnitOfWork().unsetIdentity(entity) - return snapshot }, diff --git a/tests/node/consumeOne.postgres.test.ts b/tests/node/consumeOne.postgres.test.ts new file mode 100644 index 0000000..30189b0 --- /dev/null +++ b/tests/node/consumeOne.postgres.test.ts @@ -0,0 +1,156 @@ +import {randomUUID} from "node:crypto" +import {RequestContext} from "@mikro-orm/core" +import {defineEntity, MikroORM, p} from "@mikro-orm/postgresql" +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, + vi +} from "vitest" + +import {mikroOrmAdapter} from "../../src/adapter.ts" + +const Verification = defineEntity({ + name: "Verification", + properties: { + id: p.string().primary(), + identifier: p.string(), + value: p.string(), + expiresAt: p.datetime(), + createdAt: p.datetime(), + updatedAt: p.datetime() + } +}) + +// Use a dedicated database: this suite replaces its verification table. +const clientUrl = process.env.ADAPTER_TEST_POSTGRES_URL + +describe.skipIf(!clientUrl)("consumeOne with PostgreSQL", () => { + let orm: Awaited> + let adapter: ReturnType> + + beforeAll(async () => { + orm = await MikroORM.init({clientUrl, entities: [Verification]}) + await orm.schema.refresh() + adapter = mikroOrmAdapter(orm)({}) + }) + + beforeEach(async () => { + await orm.em.fork().nativeDelete(Verification, {}) + }) + + afterAll(async () => { + await orm?.close() + }) + + const input = (identifier: string) => ({ + id: randomUUID(), + identifier, + value: "single-use-test-value", + expiresAt: new Date(Date.now() + 600_000), + createdAt: new Date(), + updatedAt: new Date() + }) + const consume = (identifier: string) => + adapter.consumeOne({ + model: "verification", + where: [{field: "identifier", value: identifier}] + }) + + test("a later adapter write cannot resurrect a consumed credential", async () => { + await RequestContext.create(orm.em, async () => { + await adapter.create({model: "verification", data: input("original")}) + expect(await consume("original")).toMatchObject({identifier: "original"}) + expect( + await orm.em.fork().count(Verification, {identifier: "original"}) + ).toBe(0) + + // Same request/EntityManager: create flushes after nativeDelete. + await adapter.create({model: "verification", data: input("later-write")}) + expect( + await orm.em.fork().count(Verification, {identifier: "original"}) + ).toBe(0) + expect(await consume("original")).toBeNull() + }) + }) + + test("consumption inside a transaction survives a later parent-context flush", async () => { + const seed = orm.em.fork() + seed.create(Verification, input("transactional")) + await seed.flush() + await RequestContext.create(orm.em, async () => { + await adapter.findOne({ + model: "verification", + where: [{field: "identifier", value: "transactional"}] + }) + const consumed = await adapter.transaction(async tx => { + const result = await tx.consumeOne({ + model: "verification", + where: [{field: "identifier", value: "transactional"}] + }) + await tx.create({ + model: "verification", + data: input("inside-transaction") + }) + return result + }) + expect(consumed).toBeTruthy() + await adapter.create({ + model: "verification", + data: input("after-transaction") + }) + expect( + await orm.em.fork().count(Verification, {identifier: "transactional"}) + ).toBe(0) + expect(await consume("transactional")).toBeNull() + }) + }) + + test("two consumers that both read the row still have exactly one winner", async () => { + const seed = orm.em.fork() + seed.create(Verification, input("racing")) + await seed.flush() + let readers = 0 + let release!: () => void + const bothRead = new Promise(resolve => { + release = resolve + }) + + const run = (index: number) => + RequestContext.create(orm.em, async () => { + const em = orm.em.getContext() + const findOne = em.findOne.bind(em) + const spy = vi + .spyOn(em, "findOne") + .mockImplementation(async (...args) => { + const row = await findOne(...args) + if (++readers === 2) release() + await bothRead + return row + }) + try { + const result = await consume("racing") + await adapter.create({ + model: "verification", + data: input(`later-${index}`) + }) + return result + } finally { + spy.mockRestore() + } + }) + + const results = await Promise.all([run(1), run(2)]) + expect(results.filter(Boolean)).toHaveLength(1) + expect(results.filter(value => value === null)).toHaveLength(1) + expect( + await orm.em.fork().count(Verification, {identifier: "racing"}) + ).toBe(0) + await RequestContext.create(orm.em, async () => { + expect(await consume("racing")).toBeNull() + }) + }) +})