fix(billing-stripe): webhook idempotency resolves the app's discovered entity (mikro-orm 7.1.x crash) - #247
Conversation
…d entity Every Stripe webhook 500'd on mikro-orm 7.1.x: handleWebhookEvent queried the package's internal StripeWebhookEvent entity object, which the consuming app's ORM never discovers, and mikro-orm 7.1.11 crashes on undiscovered entities (meta.relations undefined in EntityLoader.lookupEagerLoadedRelationships). - idempotency lookup/write resolve by entity NAME by default, against the app's discovered 'StripeWebhookEvent' (blueprint apps ship one); apps can instead inject their own entity mapper-style via a new optional constructor parameter - row write uses em.create + flush instead of native em.insert, which bypassed onCreate hooks and failed NOT NULL on sqlBaseProperties- style generated ids - the internal entity set is now importable from a new ./persistence subpath for apps that want to register the package's definitions - acceptance test: an ORM discovering ONLY the app's own entity handles an event (no undiscovered-entity crash), writes exactly one idempotency row, replays as a no-op, and supports entity injection - restore the never-committed __test__/test-utils.ts setup file in both billing implementations (their suites were unrunnable), and complete the plan schema-equality sample that was always missing its required billingProvider Published as @forklaunch/[email protected]. Co-Authored-By: Claude Fable 5 <[email protected]>
📝 WalkthroughWalkthroughThe Stripe package now uses the consuming application's registered webhook entity for MikroORM queries and persistence. The package exports the entity entry point and updates version alignment. Tests cover idempotency, entity injection, encryption setup, and the Stripe billing provider fixture. ChangesStripe webhook persistence
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant StripeWebhookService
participant StripeWebhookEvent
participant MikroORM
StripeWebhookService->>StripeWebhookEvent: use injected entity reference
StripeWebhookService->>MikroORM: check webhook idempotency key
StripeWebhookService->>MikroORM: create and flush webhook record
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@blueprint/implementations/billing/stripe/__test__/webhookIdempotency.test.ts`:
- Around line 10-14: Reorder the imports so the external dependencies from
`@mikro-orm/sqlite`, stripe, and uuid come first, followed by the Forklaunch
`@forklaunch/core/persistence` import, while preserving the existing relative
service import.
- Around line 91-125: Isolate the tests by clearing the StripeWebhookEvent table
in beforeEach. In the “replaying the same idempotency key is a no-op” test, call
makeService().handleWebhookEvent(event) twice and retain the single-row
assertion; update the injected-entity test to expect one row rather than relying
on data from earlier tests.
In `@blueprint/implementations/billing/stripe/package.json`:
- Around line 58-60: Update the package exports entry for "./persistence/*" so
it is not left with an incorrect types-only mapping: either remove the wildcard
export and retain "./persistence", or map it to the actual lib/persistence
outputs with valid types, import, require, and default targets.
In `@blueprint/implementations/billing/stripe/services/webhook.service.ts`:
- Around line 185-187: Update the webhook deduplication lookups in the service’s
webhook event handling flow to query by the persisted Stripe event identifier
using stripeId: event.id instead of event.request?.idempotency_key. Apply the
same change to the additional lookup noted by the review, while retaining
idempotencyKey only as stored metadata.
- Around line 185-187: Update the webhook flow around the lookup at em.findOne
and the record creation near the switch handlers so processing is claimed
atomically before any handler side effects run. Add a database uniqueness
constraint for the webhook identity, attempt the claim through an atomic insert
or equivalent, and immediately return when the claim already exists; retain the
existing processing path only for the delivery that successfully claims it.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ad1efdd5-93cc-4551-9c8e-f6406a8363c9
⛔ Files ignored due to path filters (1)
blueprint/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (8)
blueprint/implementations/billing/base/__test__/test-utils.tsblueprint/implementations/billing/stripe/__test__/schemaEquality.test.tsblueprint/implementations/billing/stripe/__test__/test-utils.tsblueprint/implementations/billing/stripe/__test__/webhookIdempotency.test.tsblueprint/implementations/billing/stripe/package.jsonblueprint/implementations/billing/stripe/services/webhook.service.tsblueprint/implementations/billing/stripe/tsconfig.build.jsoncli/src/core/package_json/package_json_constants.rs
| import { defineComplianceEntity, fp } from '@forklaunch/core/persistence'; | ||
| import { MikroORM } from '@mikro-orm/sqlite'; | ||
| import Stripe from 'stripe'; | ||
| import { v4 } from 'uuid'; | ||
| import { StripeWebhookService } from '../services/webhook.service'; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Move the Forklaunch import after external imports.
Place @forklaunch/core/persistence after @mikro-orm/sqlite, stripe, and uuid.
As per coding guidelines: Organize imports in 7 layers: Node built-ins, external dependencies, Forklaunch framework packages, cross-module imports, local persistence, local domain, and same directory.
Proposed fix
-import { defineComplianceEntity, fp } from '`@forklaunch/core/persistence`';
import { MikroORM } from '`@mikro-orm/sqlite`';
import Stripe from 'stripe';
import { v4 } from 'uuid';
+import { defineComplianceEntity, fp } from '`@forklaunch/core/persistence`';📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { defineComplianceEntity, fp } from '@forklaunch/core/persistence'; | |
| import { MikroORM } from '@mikro-orm/sqlite'; | |
| import Stripe from 'stripe'; | |
| import { v4 } from 'uuid'; | |
| import { StripeWebhookService } from '../services/webhook.service'; | |
| import { MikroORM } from '`@mikro-orm/sqlite`'; | |
| import Stripe from 'stripe'; | |
| import { v4 } from 'uuid'; | |
| import { defineComplianceEntity, fp } from '`@forklaunch/core/persistence`'; | |
| import { StripeWebhookService } from '../services/webhook.service'; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@blueprint/implementations/billing/stripe/__test__/webhookIdempotency.test.ts`
around lines 10 - 14, Reorder the imports so the external dependencies from
`@mikro-orm/sqlite`, stripe, and uuid come first, followed by the Forklaunch
`@forklaunch/core/persistence` import, while preserving the existing relative
service import.
Source: Coding guidelines
| test('replaying the same idempotency key is a no-op', async () => { | ||
| await makeService().handleWebhookEvent(event); | ||
|
|
||
| const rows = await orm.em | ||
| .fork() | ||
| .find('StripeWebhookEvent' as never, {} as never); | ||
| expect(rows).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('an app can inject its own discovered entity object (mapper-style)', async () => { | ||
| const service = new StripeWebhookService( | ||
| null as unknown as Stripe, | ||
| orm.em.fork(), | ||
| null as never, | ||
| noopOtel as never, | ||
| null as never, | ||
| null as never, | ||
| null as never, | ||
| null as never, | ||
| null as never, | ||
| { USER: 'user' } as never, | ||
| StripeWebhookEvent as never | ||
| ); | ||
| const injectedEvent = { | ||
| id: 'evt_test_2', | ||
| type: 'some.unhandled.event', | ||
| request: { idempotency_key: 'ik_test_2' }, | ||
| data: { object: {} } | ||
| } as unknown as Stripe.Event; | ||
| await service.handleWebhookEvent(injectedEvent); | ||
|
|
||
| const rows = await orm.em | ||
| .fork() | ||
| .find('StripeWebhookEvent' as never, {} as never); | ||
| expect(rows).toHaveLength(2); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Isolate each idempotency test.
The first test leaves one row in the database. The replay test calls the service once, so it passes without testing a replay. The injected-entity test also expects the row from an earlier test.
Clear the table in beforeEach. Call handleWebhookEvent twice in the replay test. Update the injected-entity assertion to expect one row.
Proposed fix
+ beforeEach(async () => {
+ await orm.em
+ .fork()
+ .nativeDelete('StripeWebhookEvent' as never, {} as never);
+ });
+
test('replaying the same idempotency key is a no-op', async () => {
- await makeService().handleWebhookEvent(event);
+ const service = makeService();
+ await service.handleWebhookEvent(event);
+ await service.handleWebhookEvent(event);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@blueprint/implementations/billing/stripe/__test__/webhookIdempotency.test.ts`
around lines 91 - 125, Isolate the tests by clearing the StripeWebhookEvent
table in beforeEach. In the “replaying the same idempotency key is a no-op”
test, call makeService().handleWebhookEvent(event) twice and retain the
single-row assertion; update the injected-entity test to expect one row rather
than relying on data from earlier tests.
| await this.em.findOne<StripeWebhookEventEntity>(this.webhookEventEntity, { | ||
| idempotencyKey: event.request?.idempotency_key | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use the Stripe event ID for webhook deduplication.
event.request?.idempotency_key can be absent. The current lookup then does not identify one webhook event. Events without that value can be treated as duplicates, or the ORM can receive an undefined criterion.
Query by stripeId: event.id. Retain idempotencyKey only as metadata.
Proposed fix
- await this.em.findOne<StripeWebhookEventEntity>(this.webhookEventEntity, {
- idempotencyKey: event.request?.idempotency_key
- })
+ await this.em.findOne<StripeWebhookEventEntity>(this.webhookEventEntity, {
+ stripeId: event.id
+ })Also applies to: 506-510
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@blueprint/implementations/billing/stripe/services/webhook.service.ts` around
lines 185 - 187, Update the webhook deduplication lookups in the service’s
webhook event handling flow to query by the persisted Stripe event identifier
using stripeId: event.id instead of event.request?.idempotency_key. Apply the
same change to the additional lookup noted by the review, while retaining
idempotencyKey only as stored metadata.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Claim the webhook atomically before processing side effects.
Two concurrent deliveries can both find no record at Line 185. Both deliveries then run the switch handlers before either creates a record at Line 506.
Add a database uniqueness constraint for the webhook identity. Create an atomic processing claim before the handlers run. Return when the claim already exists.
Also applies to: 503-512
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@blueprint/implementations/billing/stripe/services/webhook.service.ts` around
lines 185 - 187, Update the webhook flow around the lookup at em.findOne and the
record creation near the switch handlers so processing is claimed atomically
before any handler side effects run. Add a database uniqueness constraint for
the webhook identity, attempt the claim through an atomic insert or equivalent,
and immediately return when the claim already exists; retain the existing
processing path only for the delivery that successfully claims it.
…mapper pattern Per review: the injected webhook entity is now a generic type parameter on StripeWebhookService (WebhookEventEntity extends StripeWebhookEventShape), inferred from the injected schema exactly like the mapper entity generics — no EntityName<any>, no casts at injection sites. Internally the idempotency flow queries through the structural shape with the same internal-cast idiom the mapper services use. Name resolution remains the zero-config default for apps that do not inject. Blueprint app registrations inject the app's discovered StripeWebhookEvent entity (the registrations template is a symlink, so scaffolds inherit the entity-first pattern automatically). Published as @forklaunch/[email protected]. Co-Authored-By: Claude Fable 5 <[email protected]>
|
Caution CodeRabbit couldn't update its existing comment. The review summary may be out of date. Error details |
… entity Per review: the sealed package receives the application's discovered entity itself — a required constructor parameter typed by its ~entity member (the mapper constraint idiom), generic-inferred for full typing. The name-resolution default is gone: injection is the contract, and this is the outer surface where the app always wires it. Internal queries go through the structural StripeWebhookEventShape with the same internal-cast idiom the mapper services use. Published as @forklaunch/[email protected] (required parameter is an API change). Blueprint app + symlinked scaffold template already inject the entity. Co-Authored-By: Claude Fable 5 <[email protected]>
|
Caution CodeRabbit couldn't update its existing comment. The review summary may be out of date. Error details |
… files) A git add sweep included the untracked cli/.docker-cargo registry cache, which lit up CodeQL with third-party crate alerts. Remove it from tracking and gitignore both docker build cache dirs. Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@blueprint/implementations/billing/stripe/services/webhook.service.ts`:
- Around line 128-135: The webhookEventEntity documentation contains an
obsolete, contradictory description. In the service options/type declaration,
remove the preceding default-resolution wording and retain one concise
description stating that webhookEventEntity is required and must be the
ORM-discovered entity.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ab33afa7-4b18-4c34-a8f1-d1dd2545d8d1
📒 Files selected for processing (6)
blueprint/billing-stripe/registrations.tsblueprint/implementations/billing/stripe/__test__/webhookIdempotency.test.tsblueprint/implementations/billing/stripe/package.jsonblueprint/implementations/billing/stripe/services/webhook.service.tscli/.gitignorecli/src/core/package_json/package_json_constants.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- cli/src/core/package_json/package_json_constants.rs
| /** | ||
| * The application's discovered webhook idempotency entity — this sealed | ||
| * package must operate on the entity the app's ORM actually discovered | ||
| * (its own definition, or this package's via the ./persistence subpath). | ||
| * Querying an undiscovered entity object crashes mikro-orm 7.1.x deep in | ||
| * EntityLoader (meta.relations undefined). | ||
| */ | ||
| webhookEventEntity: { '~entity': WebhookEventEntity } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove the obsolete parameter documentation.
webhookEventEntity is required at Line 135. The preceding block states that it has a default name-resolution path. Line 128 starts a second, contradictory parameter block. Keep one description that states the entity is required.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@blueprint/implementations/billing/stripe/services/webhook.service.ts` around
lines 128 - 135, The webhookEventEntity documentation contains an obsolete,
contradictory description. In the service options/type declaration, remove the
preceding default-resolution wording and retain one concise description stating
that webhookEventEntity is required and must be the ORM-discovered entity.
Summary
Fixes the reported high-severity crash: every Stripe webhook 500'd with
Cannot read properties of undefined (reading 'filter')becausehandleWebhookEventpassed the package's internalStripeWebhookEvententity object toem.findOne/em.insert— an entity the consuming app's ORM never discovered, which mikro-orm 7.1.x rejects (meta.relationsundefined for undiscovered metadata).Fix (report's Alt A + Option 3 + Option 1, layered)
StripeWebhookEvent(the blueprint app and scaffolds ship one). Undiscovered name now fails with mikro-orm's clear discovery error instead of the cryptic crash. Typed viaInferEntitygeneric; mikro-orm 7 removedstringfromEntityName's type only, runtime name resolution is unchanged.webhookEventEntitylets an app inject its own entity object, consistent with how Plan/Subscription mappers already work../persistencesubpath exports the package's entity definitions for apps that prefer to discover those directly.em.insertis a native insert that bypassesonCreatehooks — withsqlBaseProperties-style generated ids the row write failed NOT NULL even after the discovery fix. Nowem.create+flush.Acceptance (per report)
New
__test__/webhookIdempotency.test.ts— a consuming app whose ORM discovers only its own entity: event handled without the crash, exactly one idempotency row written (generated id), replay of the sameidempotency_keyis a no-op, and entity injection works.Also restored the never-committed
__test__/test-utils.tsvitest setup in both billing implementations — their suites were unrunnable since inception (the long-standing "missing test-utils" gap) — and completed the plan schema-equality sample that always lacked its requiredbillingProvider. Full suites now: stripe 8 passed, base 5 passed. Blueprint workspace builds green.Published as
@forklaunch/[email protected](1.1.31 carried the name-resolution fix only; 1.1.32 adds injection +em.create).Upstream note: the report's defensive
(meta.relations ?? []).filter(...)belongs in mikro-orm itself; worth filing there.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests
Chores