chore: merge with dev
This commit is contained in:
145
.context/migration-interface/CATALOG.md
Normal file
145
.context/migration-interface/CATALOG.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Catalog: Migration & Import Patterns
|
||||
|
||||
Source: 4-agent parallel discovery on 2026-04-29 covering `scripts/src/common/migrations/`, `scripts-v2/runs/`, `scripts/mintlify-import.sh` chain, `scripts/firecrawl-import-from-csv.sh` chain, plus a deep read of billing v2 action machinery.
|
||||
|
||||
---
|
||||
|
||||
## A. Migration archetypes (what scripts actually do)
|
||||
|
||||
### A1. Version bump via `billingActions.migrate()`
|
||||
- `scripts/src/common/migrations/perform-migration.ts`, `lingo.ts`, `lingo-prepaid-to-payperuse.ts`
|
||||
- `scripts-v2/runs/browser-use/migrate-free/` (uses `billingActions.migrate` with `noBillingChanges: true`)
|
||||
- Trivially fits `{ customerId, plans: [{ expire: cusProductId, insert: { productId, version } }] }`.
|
||||
- Note: scripts loop multiple matching cusProducts per customer and reload state mid-loop because `migrate()` mutates state.
|
||||
|
||||
### A2. Multi-cusProduct subscription update — **the canonical complex case**
|
||||
- `scripts-v2/runs/mintlify/migrate-credits/multi-update/` — replaces legacy AI-credits prepaid with V2 prepaid+consumable across N cusProducts sharing one Stripe sub.
|
||||
- Operates on `CusProductGroup = { subscriptionId, cusProducts[] }`.
|
||||
- Per group: `setupStripeBillingContext` once → per-product `computeCustomPlanNewCustomerProduct` → aggregate into `AutumnBillingPlan` → `evaluateStripeBillingPlan` → `executeBillingPlan`.
|
||||
- This is structurally ~95% of the proposed interface, coupled to AI-credits-specific helpers (`resolveAiCreditsQuantity`, `setupUpdateFullProductContext`).
|
||||
|
||||
### A3. Pricing-shape swap, cusProduct identity preserved
|
||||
- `scripts/src/common/migrations/firecrawl-credit-pack-to-usage-price.ts`
|
||||
- Keeps the same `customer_product_id`, replaces its child cus_ents/cus_prices with custom rows. Preserves balance/reset/expiry.
|
||||
- **Does NOT fit `{expire, insert}`** — cusProduct stays.
|
||||
|
||||
### A4. 1→N cusProduct split
|
||||
- `scripts/src/common/migrations/split-one-cus-product-into-two.ts` (Email → Domain + Inbox).
|
||||
- Expires one cusProduct, inserts N new ones referencing the SAME `subscription_ids`.
|
||||
- Fits `{ expire, insert: [...] }` only if interface explicitly supports shared subscription_ids and balance/option carryover per inserted plan.
|
||||
|
||||
### A5. Flag-flip / balance-clamp on existing rows
|
||||
- `cont-use-to-single-use.ts`, `fix-creator-connections-interval.ts`, `browser-use-reset-spam-users.ts`, `additive-entitlement-migration.ts` (gifting branch), `retroactively-add-plan-item-to-customers.ts` "update" mode.
|
||||
- Pure UPDATE on `entitlements` / `customer_entitlements`. No cusProduct churn, no Stripe.
|
||||
- **Does not fit `{expire, insert}` at all** — separate primitive.
|
||||
|
||||
### A6. Raw-DB bulk replace (no Stripe, no billing v2)
|
||||
- `scripts-v2/runs/sebipaps/sandbox-product-sync/migrate-plan.ts` — chunked `delete cus_ent + delete cus_price + update cusProduct.internal_product_id`.
|
||||
- Free-tier or 1:1 product remap. Bypasses billing v2 entirely.
|
||||
- Should be subsumed by a "DB-only bulk swap" mode of the unified interface.
|
||||
|
||||
---
|
||||
|
||||
## B. Catalog-prep archetypes (what runs BEFORE migrations)
|
||||
|
||||
These are *preparation* patterns — extending the product catalog so a migration can land.
|
||||
|
||||
### B1. Add entitlement (+optional price) to all versions of a product
|
||||
- `retroactively-add-plan-item-to-versions.ts` — uses latest version that already has the ent as a TEMPLATE, clones onto older versions.
|
||||
- `FINAL-add-entitlement-to-plans.ts` — adds feature to all versions matching id-prefix.
|
||||
- `add-creator-connections-entitlement.ts`, `add-boolean-customer-entitlement.ts`, `add-feature-to-customers.ts`, `add-new-boolean-feature.ts`.
|
||||
- Pure catalog (`entitlements`, `prices`) mutations. No customer-side touch.
|
||||
|
||||
### B2. Backfill cus_ents for existing customers on extended products
|
||||
- `retroactively-add-plan-item-to-customers.ts` — sibling of B1; per active cusProduct on listed products, insert missing `customer_entitlements` (and raw `customer_prices` if `requirePrice`).
|
||||
- `add-creator-connections-cus-entitlement.ts`, `add-boolean-customer-entitlement.ts` (also does B1).
|
||||
- Uses `initCusEntitlement` + direct `CusEntService.insert`. **Zero scripts use** `createFullCusProduct`.
|
||||
|
||||
### B3. Catalog clone (sandbox → live, or version bump)
|
||||
- `scripts-v2/runs/sebipaps/sandbox-product-sync/copy-products.ts` — `handleCopyFeatures`, `handleVersionProductV2`, `createProduct`.
|
||||
- `scripts-v2/runs/mintlify/migrate-credits/steps/init-plan-resources/` — deterministic-id init for V2 prepaid/consumable price+entitlement; sibling entrypoint `setup-credits-catalog.ts`.
|
||||
- Pattern: prep entrypoint shares folder + `data/` with the migration entrypoint.
|
||||
|
||||
### B4. Stripe-side prep (clone archived Stripe prices)
|
||||
- `scripts-v2/runs/mintlify/fix-archived-price/` — clones archived Stripe prices, updates `Price.config.stripe_price_id`, then patches subscription items.
|
||||
- Doesn't fit `{expire, insert}` — it's a price-id swap on the same cusProduct.
|
||||
|
||||
---
|
||||
|
||||
## C. Import patterns (Stripe → Autumn)
|
||||
|
||||
### C1. Mintlify import (`scripts/src/mintlify/import/`)
|
||||
- Source: Mongo orgs/deployments/period_usage. Stripe customers/subs.
|
||||
- Steps: create customers → create entities (per deployment) → attach base plans via `createFullCusProduct` → derive addons from Stripe subs → "loose" custom entitlements via direct Drizzle insert → sync period usage into balances → cleanup.
|
||||
- Plan resolver: Mongo `plan` string field.
|
||||
- Custom allowances: signature-deduped per-customer entitlement rows.
|
||||
|
||||
### C2. Firecrawl import (`scripts/src/firecrawl/import/`)
|
||||
- Source: BigQuery `autumn_shared` (teams, orgs, balances, coupons). Stripe subs/prices/customers.
|
||||
- Steps: select Stripe subs → resolve plan from `lookup_key` → resolve/create customer → ensure team entity → build target product state with per-customer overrides (custom allowance, custom topup price, signature dedup) → flush via `customerProductService.insertIfNotExists` → repair Stripe sub link → schedule end-of-cycle plan switches via `billingActions.attach` with `no_billing_changes: true` → sync team credit state → import coupon balances directly to `customer_entitlements.balance` with deterministic ids.
|
||||
- Plan resolver: Stripe `price.lookup_key`.
|
||||
- Custom allowances: per-customer freshly-created `entitlements` + `prices` rows with signature dedup cache.
|
||||
|
||||
### Cross-cutting import friction (top 5)
|
||||
1. Stripe price → Autumn plan resolver is org-specific (Mongo field vs lookup_key vs price.metadata).
|
||||
2. Per-customer catalog overrides require signature-deduped per-customer `entitlements`+`prices` rows.
|
||||
3. Linking to existing Stripe sub (idempotent), not creating it. `initOptions.subscriptionId` + repair.
|
||||
4. Balance carryover is a separate concern: deterministic `balanceId`, direct write to `customer_entitlements.balance`, bypass `createFullCusProduct`.
|
||||
5. Entity scoping + duplicate cleanup (same Stripe sub re-attached to wrong entity, stale entities, duplicate plans across entities).
|
||||
|
||||
---
|
||||
|
||||
## D. Billing v2 architecture (the substrate)
|
||||
|
||||
### D1. The 5-stage pipeline (every action follows this)
|
||||
```
|
||||
setup<Action>BillingContext({ ctx, params }) -> <Action>BillingContext
|
||||
handle<Action>Errors({ ctx, billingContext }) -> throws RecaseError
|
||||
compute<Action>Plan({ ctx, billingContext }) -> AutumnBillingPlan
|
||||
evaluateStripeBillingPlan({ ctx, billingContext, autumnBillingPlan }) -> StripeBillingPlan
|
||||
executeBillingPlan({ ctx, billingContext, billingPlan }) -> BillingResult
|
||||
```
|
||||
|
||||
### D2. AutumnBillingPlan already expresses every migration shape
|
||||
- `insertCustomerProducts: FullCusProduct[]`
|
||||
- `updateCustomerProduct(s)?: CustomerProductUpdate[]` — patch options/status/canceled/anchor/sub_ids
|
||||
- `deleteCustomerProduct(s)?: FullCusProduct[]` — for scheduled product replacement
|
||||
- `customPrices?: Price[]`, `customEntitlements?: Entitlement[]`, `customFreeTrial?`
|
||||
- `updateByStripeScheduleId?` — for schedule swaps
|
||||
- `insertCustomerEntitlements?`, `updateCustomerEntitlements?`
|
||||
- `lineItems?`, `customLineItems?`
|
||||
- `autoTopupRebalance`, `upsertSubscription`, `upsertInvoice`, `refundPlan`
|
||||
|
||||
### D3. evaluateStripeBillingPlan capabilities
|
||||
- ✅ Multi-product, mixed prepaid/metered, schedules, cancel+replace, multi-entity, refunds, manual invoices.
|
||||
- ✅ DB-only mode: `billingContext.skipBillingChanges = true` returns `{}` and execute no-ops Stripe.
|
||||
- ⚠️ Single-customer per evaluation. No batch mode.
|
||||
|
||||
### D4. Existing `migrate` action
|
||||
- `actions/migrate/migrate.ts` is already in the registry. Need to read what it actually does — likely the simple version-bump path; we may extend or replace.
|
||||
|
||||
### D5. Critical gap: shared custom rows
|
||||
- `setupCustomFullProduct` always mints fresh per-customer `Price`/`Entitlement` rows when `hasCustomItems` is true.
|
||||
- For bulk migration where 10k customers should share the same prepared price/ent rows, we need either:
|
||||
- A `prepared` mode that reuses existing catalog rows, OR
|
||||
- A separate one-shot prep phase that creates the shared rows once, with the migration action only inserting `customer_products` + `customer_prices`/`customer_entitlements` referencing those existing IDs.
|
||||
|
||||
### D6. customizePlanV1 is PUT-style
|
||||
- `items?: ApiPlanItemV1[]` — when provided, replaces full list.
|
||||
- For migrations we want PATCH-style (incremental add/remove/update of plan items).
|
||||
|
||||
---
|
||||
|
||||
## E. Verdict on `{ customerId, plans: [{ expire, insert }] }`
|
||||
|
||||
Fits cleanly: **A1, A2 (with subscription-group dimension), A4 (with shared sub_ids), A6 (with DB-only mode).**
|
||||
|
||||
Does NOT fit, needs separate primitive(s):
|
||||
- **A3** — pricing-shape swap on same cusProduct identity.
|
||||
- **A5** — flag-flip / balance-clamp on existing rows.
|
||||
- **B1, B2** — catalog prep & cus_ent backfill (different layer entirely).
|
||||
- **B3** — catalog clone (different layer).
|
||||
- **B4** — Stripe price-id swap (different layer).
|
||||
- **C1, C2 imports** — need 5 additional dimensions on top of migration shape.
|
||||
|
||||
**Implication: the unified "migration interface" is really a layered system, not a single action.**
|
||||
29
.context/migration-interface/DECISIONS.md
Normal file
29
.context/migration-interface/DECISIONS.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Decisions
|
||||
|
||||
Append-only log of architectural and design decisions.
|
||||
|
||||
## 2026-04-29 — Project lives in `autumn/.context/`, not the outer worktree
|
||||
The work targets first-class billing actions in `autumn/server/src/internal/billing/v2/actions/` and ships with tests in `autumn/`. Context is colocated with the code it describes. The outer `.context/migration-interface/` holds only a redirect README.
|
||||
|
||||
## 2026-04-29 — Phase 1 is cataloging, not designing
|
||||
The user explicitly asked to survey existing migration & import scripts before committing to interface shape. Risk of premature lock-in is high — there are at least three classes of patterns (DB-only mutations, DB+Stripe mutations, imports with balance carryover) and each could break the proposed shape differently. Output of Phase 1 is a verdict-per-script catalog.
|
||||
|
||||
## 2026-04-29 — Build as core product feature, not internal tooling
|
||||
Will eventually surface to users on the dashboard. Implications: lives in `autumn/server/src/internal/operations/` (peer to `billing/v2/`), has integration tests, no `as any`, follows full billing-action conventions for the heavyweight executors (`setupBillingContext` → compute Autumn plan → `evaluateStripeBillingPlan` → execute).
|
||||
|
||||
## 2026-04-29 — Planner + Executor model, not single billing action
|
||||
Migrations and imports differ in HOW they produce a plan but converge in HOW the plan executes. A single `migrate` action would conflate heterogeneous mutation surfaces (catalog, customer product graph, balance, Stripe repair) into one sprawling compute function. Splitting produces:
|
||||
- Durable, inspectable, persistable `OperationPlan` artifact
|
||||
- Dry-run identical to live (only `apply()` differs)
|
||||
- Pluggable planners (MigrationDefinition, ImportDefinition) emit same operation graph
|
||||
- Specialized executors per mutation surface, each with own idempotency/guards
|
||||
- Reuses billing v2 pipeline INSIDE the CustomerProductGraphOperation executor — no replacement, just orchestration above
|
||||
|
||||
## 2026-04-29 — Operations typed by mutation surface, not lifecycle intent
|
||||
Initial design used `PlanOp = expire | insert | swap | patch` (lifecycle-flavored). Better: `Operation = CatalogOperation | CustomerProductGraphOperation | CustomerEntitlementOperation | BalanceOperation | StripeRepairOperation | CacheInvalidationOperation`. Each surface has different idempotency, transaction boundaries, and concurrency rules — surface-typed ops let executors specialize cleanly.
|
||||
|
||||
## 2026-04-29 — `is_custom` decoupled from "uses shared catalog rows"
|
||||
Today `setupCustomFullProduct` always mints fresh per-customer Price/Entitlement rows when customize is provided. For 10k-customer migrations this creates 10k Stripe prices unnecessarily. New model: `ProductItemPatch.add_existing { entitlement_id, price_id }` references shared catalog rows by id; `is_custom` becomes purely an ownership flag (one-off vs catalog), not "this row is per-customer." Existing custom rows untouched; new code uses new model.
|
||||
|
||||
## 2026-04-29 — Ownership is first-class
|
||||
Today repair logic is bespoke per-script because we can't distinguish imported from user-created customer_products. New model: `Ownership { origin: "imported"|"user_created"|"migrated", source?, imported_at? }` on every TargetAssignment. Likely stored as new column on `customer_products`. Unblocks safe re-runnable imports and convergence/repair semantics.
|
||||
71
.context/migration-interface/PLAN.md
Normal file
71
.context/migration-interface/PLAN.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# migration-interface Plan
|
||||
|
||||
## Phase 1: Catalog & Stress-Test (IN PROGRESS)
|
||||
Goal: enumerate every migration/import pattern we've shipped, then check the proposed interface against each one. Output: a catalog doc with per-script summaries and "interface covers? / what breaks?" verdicts.
|
||||
|
||||
- Survey `scripts/src/common/migrations/` (retroactively-add-plan-item-to-customers, -to-versions, -plan-items, plus siblings)
|
||||
- Survey `scripts-v2/runs/` migration scripts (mintlify migrate-credits is the canonical example)
|
||||
- Survey `scripts/mintlify-import.sh` and `scripts/firecrawl-import.sh` import flows
|
||||
- Categorize each script along axes:
|
||||
- DB-only vs DB + Stripe
|
||||
- Single-customer vs bulk
|
||||
- Plan transformation shape (add item / swap plan / change pricing / cancel-and-replace / schedule)
|
||||
- Custom per-customer overrides (rollover, lifetime balance, balance carryover)
|
||||
- Preparation step required (new prices/products created before migration)
|
||||
- Verdict per script: covered by proposed interface? What's missing?
|
||||
|
||||
## Phase 2: Interface Design
|
||||
- Lock the migration action signature(s) — likely one new `migrate` action alongside `multiAttach` and `createSchedule`
|
||||
- Solve the "insert + customize" friction:
|
||||
- Move customize from PUT-style (full item list) to PATCH-style (incremental add/remove/update)
|
||||
- Decide: do migrations create new custom price/entitlement rows, or reuse a single per-plan row? (Stripe price/product creation is the binding constraint)
|
||||
- Decide whether "preparation" (creating shared prices/entitlements ahead of migration) is a separate action or a step in the same one
|
||||
- Validate design against catalog from Phase 1
|
||||
|
||||
## Phase 3: Implementation
|
||||
- Build the action(s) under `autumn/server/src/internal/billing/v2/actions/`
|
||||
- Tests: integration tests covering catalog scenarios from Phase 1
|
||||
- Reuse `evaluateStripeBillingPlan`, `setupBillingContext` patterns from existing actions
|
||||
|
||||
## Phase 4: Migrate Existing Scripts
|
||||
- Port mintlify migrate-credits to use the new action
|
||||
- Port retroactively-add-plan-item-to-customers
|
||||
- Each port validates the interface; refine as needed
|
||||
|
||||
## Phase 5 (stretch): Imports
|
||||
- Extend or sibling action for Stripe→Autumn linkage
|
||||
- Handle the hard cases: balance carryover for metered features, custom configurations (rollovers, lifetime balances), many-plans Stripe accounts
|
||||
- Goal: collapse the bespoke mintlify-import.sh / firecrawl-import.sh logic
|
||||
|
||||
---
|
||||
|
||||
## Initial Design Sketch (from kickoff conversation)
|
||||
|
||||
**Scope of one call:** one customer, one Stripe subscription's worth of customer_products.
|
||||
|
||||
**Proposed shape:**
|
||||
```ts
|
||||
{
|
||||
customerId: string,
|
||||
subscriptionId?: string, // inferable from plans
|
||||
plans: [
|
||||
{
|
||||
expire?: { customer_product_id?: string; plan_id?: string; entity_id?: string },
|
||||
insert?: { plan_id: string; customize?: ... },
|
||||
}
|
||||
],
|
||||
schedules?: [...] // future, optional
|
||||
}
|
||||
```
|
||||
|
||||
**Open questions:**
|
||||
1. Customize: PATCH vs PUT — strongly leaning PATCH (incremental add/remove/update of plan items)
|
||||
2. Custom prices: per-customer rows vs shared per-plan row reused across migrations (Stripe-side cost is the constraint)
|
||||
3. Preparation step (creating shared prices/entitlements + backfilling onto prior plan versions and customer_products): part of this interface or a separate "prepare migration" action?
|
||||
4. Mapping public `apiPlanItemV1` ↔ DB `price + entitlement` pair — how does the interface accept input?
|
||||
|
||||
## Architecture Notes
|
||||
- Build on `evaluateStripeBillingPlan` — the Autumn-plan → Stripe-state mapper already handles schedules, multi-entity, mixed prepaid/metered. Don't reinvent.
|
||||
- `customer.processor?.id` is Stripe customer ID (per autumn-operations-constraints rule). Watch the ID confusion.
|
||||
- Stripe writes must respect: amount conversion via `atmnToStripeAmount`, idempotent price creation via `createStripePriceIFNotExist`, `proration_behavior: "none"` for surgical item changes.
|
||||
- This is product code, not script code — full TypeScript discipline, no `as any`, full test coverage.
|
||||
306
.context/migration-interface/PROPOSAL.md
Normal file
306
.context/migration-interface/PROPOSAL.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Proposal: Operation Plan + Pluggable Planners
|
||||
|
||||
## Mental model
|
||||
|
||||
> **Planning produces a typed `OperationPlan`. Execution consumes it.**
|
||||
> Migrations and imports differ in how they *produce* the plan; everything downstream — guards, dry-run, persistence, execution, reconciliation — is shared.
|
||||
|
||||
This is a strict departure from "build one big billing action." The migration interface is not a single action — it is a **planner / executor pair**, where the plan is a durable, inspectable artifact and execution is a typed reducer over operations.
|
||||
|
||||
The reason: scripts today informally do exactly this lifecycle (scope → load → resolve → prepare → plan → guard → execute → verify), but the contracts between stages are bespoke. The win is canonizing the stage contracts, not unifying the verbs.
|
||||
|
||||
---
|
||||
|
||||
## Why planner + executors (vs. single billing action)
|
||||
|
||||
1. **Operations are heterogeneous.** Catalog mutations, customer-product graph changes, balance syncs, Stripe repair, cache invalidation — each has different idempotency, different transaction boundaries, different blast radius. One "migrate" action that handles all of them gets a sprawling `compute*Plan` function. Separate executors keep each surface clean.
|
||||
2. **Plans are first-class artifacts.** Persistable. Diffable. Re-runnable. Approval-able. Eventually user-facing on the dashboard ("preview migration → approve → apply").
|
||||
3. **Dry-run = real plan.** Same code path produces the plan; only `apply()` differs. No drift.
|
||||
4. **Imports vs migrations diverge cleanly.** They're different *planners* emitting the same operation graph. Shared execution; specialized planning.
|
||||
5. **Concrete reuse path.** `CustomerProductGraphOperation.replace` executor wraps the existing 5-stage billing v2 pipeline. We're not replacing billing v2 — we're putting an orchestration layer above it.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Module placement
|
||||
|
||||
```
|
||||
autumn/server/src/internal/operations/
|
||||
types/
|
||||
operation.ts -- Operation discriminated union
|
||||
operationPlan.ts -- OperationPlan, CustomerOperationPlan, RunContext
|
||||
operationResult.ts -- OperationResult, GuardResult, MigrationDiff
|
||||
productItemPatch.ts -- ProductItemPatch union (PATCH-style customize)
|
||||
executors/
|
||||
catalog/ -- CatalogOperation executor
|
||||
customerProductGraph/ -- CustomerProductGraphOperation executor (wraps billing v2)
|
||||
customerEntitlement/ -- CustomerEntitlementOperation executor
|
||||
balance/ -- BalanceOperation executor
|
||||
stripeRepair/ -- StripeRepairOperation executor
|
||||
cache/ -- CacheInvalidationOperation executor
|
||||
planners/
|
||||
migration/ -- MigrationDefinition runner
|
||||
import/ -- ImportDefinition runner
|
||||
guards/ -- composable, named guard sets
|
||||
reporting/ -- diff, audit, CSV export
|
||||
persistence/ -- migration_runs / migration_plans schema + service
|
||||
execute.ts -- top-level executePlan(plan, { dryRun })
|
||||
```
|
||||
|
||||
This sits **as a peer** to `billing/v2/`, not under it. `billing/v2/` is the substrate; `operations/` is the orchestration layer.
|
||||
|
||||
### Type model
|
||||
|
||||
```ts
|
||||
// The durable artifact
|
||||
type OperationPlan = {
|
||||
run: RunContext; // org_id, env, dry_run, source description, created_at
|
||||
catalog: Operation[]; // run-level (executes once, before per-customer)
|
||||
customers: CustomerOperationPlan[];// per-customer
|
||||
warnings: GuardResult[]; // non-blocking
|
||||
blockers: GuardResult[]; // execution refuses if any blockers
|
||||
};
|
||||
|
||||
type CustomerOperationPlan = {
|
||||
customer_id: string;
|
||||
// Operations declared in execution order. Within a customer, ops execute serially.
|
||||
// Operations targeting the same Stripe subscription are batched by the executor
|
||||
// into a single subscriptions.update — this is the existing multi-update atomicity
|
||||
// boundary.
|
||||
operations: Operation[];
|
||||
guards: GuardResult[];
|
||||
preview: CustomerPreview; // human-readable diff of intended changes
|
||||
};
|
||||
|
||||
type Operation =
|
||||
| CatalogOperation
|
||||
| CustomerProductGraphOperation
|
||||
| CustomerEntitlementOperation
|
||||
| BalanceOperation
|
||||
| StripeRepairOperation
|
||||
| CacheInvalidationOperation;
|
||||
|
||||
// Mutation surface 1: catalog
|
||||
type CatalogOperation =
|
||||
| { type: "ensure_feature"; spec: FeatureSpec }
|
||||
| { type: "ensure_plan_item"; product_selector: ProductSelector; item: ProductItemSpec }
|
||||
| { type: "ensure_shared_price"; signature: string; spec: PriceSpec }
|
||||
| { type: "ensure_shared_entitlement"; signature: string; spec: EntitlementSpec }
|
||||
| { type: "link_stripe_resource"; price_id: string; stripe_price_id?: string; stripe_product_id?: string }
|
||||
| { type: "swap_archived_stripe_price"; price_id: string };
|
||||
|
||||
// Mutation surface 2: customer product graph (the big one)
|
||||
type CustomerProductGraphOperation =
|
||||
| { type: "attach"; assignment: TargetAssignment; ownership: Ownership }
|
||||
| { type: "replace"; subscription_id?: string;
|
||||
from: CusProductSelector[]; // expire these
|
||||
to: TargetAssignment[]; // insert these
|
||||
carry?: { balance: boolean; subscription_ids: boolean; anchors: boolean } }
|
||||
| { type: "expire"; targets: CusProductSelector[]; reason: string }
|
||||
| { type: "schedule"; phases: ScheduledPhase[] };
|
||||
|
||||
// Mutation surface 3: customer entitlement (B-layer in old proposal)
|
||||
type CustomerEntitlementOperation =
|
||||
| { type: "backfill_for_customers"; product_selector; feature_id; require_price?: boolean }
|
||||
| { type: "patch_flag"; selector; flags: Partial<Pick<Entitlement, "usage_allowed" | "carry_from_previous">> }
|
||||
| { type: "clamp_balance"; selector; ceiling: number };
|
||||
|
||||
// Mutation surface 4: balance (imports especially)
|
||||
type BalanceOperation =
|
||||
| { type: "seed_balance"; balance_id: string; cus_ent_selector; amount: number; entity_id?: string }
|
||||
| { type: "set_balance"; cus_ent_id: string; balance: number }
|
||||
| { type: "delete_stale_balance"; cus_ent_id: string; reason: string };
|
||||
|
||||
// Mutation surface 5: stripe repair (imports especially)
|
||||
type StripeRepairOperation =
|
||||
| { type: "link_subscription"; cus_product_id: string; stripe_subscription_id: string }
|
||||
| { type: "expire_orphaned_imported_cus_product"; cus_product_id: string };
|
||||
|
||||
type CacheInvalidationOperation =
|
||||
| { type: "invalidate_customer_cache"; customer_ids: string[] }
|
||||
| { type: "invalidate_products_cache" };
|
||||
```
|
||||
|
||||
### TargetAssignment + ProductItemPatch
|
||||
|
||||
The two key composite types.
|
||||
|
||||
```ts
|
||||
type TargetAssignment = {
|
||||
product: { id: string; version?: number };
|
||||
entity_id?: string | null;
|
||||
subscription_id?: string | null; // for imports linking to existing Stripe sub
|
||||
feature_quantities?: FeatureQuantity[];
|
||||
item_patches?: ProductItemPatch[]; // PATCH-style customize
|
||||
billing_dates?: { trial_ends_at?: number; reset_cycle_anchor?: number; billing_cycle_anchor?: number };
|
||||
ownership?: Ownership; // imported / user-created / migrated
|
||||
};
|
||||
|
||||
// PATCH-style. Compiles into FullProduct + customPrices + customEnts
|
||||
// that existing billing v2 compute already understands.
|
||||
type ProductItemPatch =
|
||||
| { op: "add_existing"; entitlement_id: string; price_id?: string } // reference shared catalog rows
|
||||
| { op: "replace_feature"; feature_id: string; entitlement_id: string; price_id?: string }
|
||||
| { op: "remove_feature"; feature_id: string }
|
||||
| { op: "override_allowance"; feature_id: string; allowance: number; unlimited?: boolean }
|
||||
| { op: "override_price"; feature_id?: string; price: PricePatch } // mints a custom Price row
|
||||
| { op: "set_options"; feature_id: string; options: { quantity?: number } };
|
||||
|
||||
type Ownership = {
|
||||
origin: "imported" | "user_created" | "migrated";
|
||||
source?: string; // e.g. "stripe_subscription:sub_abc"
|
||||
imported_at?: number;
|
||||
};
|
||||
```
|
||||
|
||||
`add_existing` is the load-bearing fix: it lets a 10k-customer migration reference one shared catalog row instead of minting 10k custom rows. **`is_custom` becomes an ownership flag (one-off vs catalog) decoupled from "uses custom prepared rows."**
|
||||
|
||||
### Execution model
|
||||
|
||||
```ts
|
||||
// Top-level entry point
|
||||
async function executePlan(plan: OperationPlan, opts: { dryRun: boolean }): Promise<PlanResult>
|
||||
|
||||
// Strict execution rules:
|
||||
// 1. If plan.blockers is non-empty AND !dryRun → throw, refuse.
|
||||
// 2. Catalog ops execute first, in declared order, before any customer ops.
|
||||
// 3. Customer plans execute in parallel (configurable concurrency); same-customer
|
||||
// operations execute serially in declared order.
|
||||
// 4. Within one customer, operations targeting the same Stripe subscription are
|
||||
// GROUPED by the CustomerProductGraphOperation executor and emitted as ONE
|
||||
// subscriptions.update via the existing evaluateStripeBillingPlan + executeBillingPlan
|
||||
// path. This preserves multi-update atomicity.
|
||||
// 5. Each operation returns OperationResult { status, diff?, error?, artifacts? }.
|
||||
// 6. Dry-run produces the same operations list; executors emit `would_apply` results
|
||||
// with diffs but mutate nothing.
|
||||
```
|
||||
|
||||
**The CustomerProductGraphOperation executor for `replace` is essentially today's `multi-update/`** generalized: build the per-product context list, call `computeCustomPlanNewCustomerProduct` per item, aggregate into `AutumnBillingPlan`, run through `evaluateStripeBillingPlan` + `executeBillingPlan`. Everything downstream of the Operation type is reused.
|
||||
|
||||
### Persistence
|
||||
|
||||
Plans persist to DB for non-trivial runs. Schema:
|
||||
|
||||
```
|
||||
migration_runs -- one per orchestrated run (org, env, source, status)
|
||||
migration_plans -- the OperationPlan JSON, snapshot at planning time
|
||||
migration_ops -- one row per operation, with status + diff + result
|
||||
migration_artifacts -- CSV exports, snapshots, audit rows
|
||||
```
|
||||
|
||||
Trivial cases (single-customer one-off) can skip persistence and go straight through `executePlan`. Bulk runs persist.
|
||||
|
||||
States: `draft → planned → applying → completed | failed | partial`. A `partial` failed run is resumable: the executor skips ops with `status: applied` and re-runs `pending` / `failed` ops.
|
||||
|
||||
### Planners
|
||||
|
||||
```ts
|
||||
type MigrationDefinition = {
|
||||
id: string;
|
||||
selectCandidates(ctx): Promise<CustomerScope[]>;
|
||||
buildTargetState(ctx, customer): Promise<TargetState>;
|
||||
plan(ctx, target): OperationPlan["customers"][number];
|
||||
};
|
||||
|
||||
type ImportDefinition = {
|
||||
id: string;
|
||||
loadExternalSource(ctx): Promise<SourceSnapshot>;
|
||||
resolveIdentities(ctx, source): Promise<IdentityMap>;
|
||||
mapTargets(ctx, source, identities): Promise<TargetState[]>;
|
||||
plan(ctx, target): OperationPlan["customers"][number];
|
||||
};
|
||||
```
|
||||
|
||||
Mintlify import becomes `ImportDefinition`. Mintlify migrate-credits becomes `MigrationDefinition`. Both emit the same `OperationPlan` shape.
|
||||
|
||||
---
|
||||
|
||||
## Phased build order
|
||||
|
||||
### v0 — Migration Core (3-4 weeks)
|
||||
**Concrete deliverable: port mintlify migrate-credits' multi-update path to use this system.** That single port validates 80% of the abstraction.
|
||||
|
||||
1. `Operation` type + `ProductItemPatch` compiler (`patches → FullProduct + customPrices + customEnts`).
|
||||
2. `CustomerProductGraphOperation.replace` executor wrapping the existing multi-update pipeline.
|
||||
3. `OperationResult` + `CustomerPreview` shapes (Autumn diff + Stripe sub-update preview).
|
||||
4. Guard set: `subscription-replace` (mismatched-sub-in-group, missing payment, anchor drift, collection_method preservation).
|
||||
5. `executePlan` for in-memory plans (no DB persistence yet).
|
||||
6. Integration tests: port the mintlify migrate-credits scenarios.
|
||||
7. Migration runner harness for scripts-v2 (one-line: `runPlan(planner, opts)`).
|
||||
|
||||
### v0.5 — Catalog Prep (1-2 weeks)
|
||||
8. `CatalogOperation` executor: `ensure_feature`, `ensure_plan_item`, `ensure_shared_price`, `ensure_shared_entitlement`, `link_stripe_resource`.
|
||||
9. Run-level catalog ops execute before per-customer ops.
|
||||
10. Port `retroactively-add-plan-item-to-versions` + `retroactively-add-plan-item-to-customers` to the new system (validates catalog → customer-entitlement composition).
|
||||
|
||||
### v0.75 — Persistence + Resume (1 week)
|
||||
11. `migration_runs` / `migration_plans` / `migration_ops` schema.
|
||||
12. `executePlan` writes operation results; partial-failure resume.
|
||||
13. Read-only view of past runs (no UI yet, just service methods).
|
||||
|
||||
### v1 — Auxiliary surfaces (2 weeks)
|
||||
14. `BalanceOperation` executor (Mintlify period_usage, Firecrawl coupon balances).
|
||||
15. `CustomerEntitlementOperation` executor (flag-flips, balance clamps, backfills).
|
||||
16. `StripeRepairOperation` executor.
|
||||
17. Port one full Firecrawl import slice as a smoke test.
|
||||
|
||||
### v1.5 — Imports as planner (2-3 weeks)
|
||||
18. `ImportDefinition` runner with identity resolution + source normalization stages.
|
||||
19. Port mintlify-import to use this. Firecrawl-import follows.
|
||||
|
||||
### v2 (deferred) — Dashboard + API
|
||||
20. Dashboard view: list runs, inspect plan, dry-run preview, approve & apply.
|
||||
21. Public API for self-serve migrations.
|
||||
|
||||
---
|
||||
|
||||
## Synthesis: what changed from v1 of this proposal
|
||||
|
||||
| v1 (PROPOSAL.md before) | v2 (now) | Why |
|
||||
|---|---|---|
|
||||
| Single `migrate` action with `PlanOp` discriminated union | Planner + executors with typed operations by mutation surface | Heterogeneous mutation surfaces don't belong in one action |
|
||||
| 3 layers (catalog / backfill / transition) | Flat operation graph with execution ordering rules | Layers were artificial; ops at the same level with declarative dependencies is cleaner |
|
||||
| `use_shared_rows` flag bolt-on | `ProductItemPatch.add_existing { entitlement_id, price_id }` first-class | Semantics are clearer when they're a discrete patch op |
|
||||
| `is_custom` overloaded for "one-off" + "uses custom rows" | Decouple: `is_custom` = ownership flag; shared rows = first-class catalog rows | Resolves a long-standing concept conflation |
|
||||
| Imports = migration + 5 extensions | Imports = different planner emitting same operation graph | Cleaner separation of concerns; identity/source/mapping is genuinely different from migration |
|
||||
| Action-level `preview` flag | First-class plan artifact; dry-run = identical plan path | Plan becomes inspectable, persistable, approvable |
|
||||
| No persistence story | `migration_runs/_plans/_ops` schema; resumable partial runs | User asked for "core feature, not internal tool" — first-class needs persistence |
|
||||
| No ownership concept | `Ownership` first-class on every TargetAssignment | Repair logic is currently bespoke per-script because we can't tell imported from user-created |
|
||||
|
||||
---
|
||||
|
||||
## Open design questions (the load-bearing ones)
|
||||
|
||||
1. **Plan persistence: opt-in or default-on?**
|
||||
Lean: **default-on for `bulk` planners (>1 customer); opt-out for one-shot operations.** Persistence enables resume, audit, dashboard surfacing — worth the schema cost.
|
||||
|
||||
2. **Operation ordering within a customer plan: declared or topologically derived?**
|
||||
Lean: **declared.** Planners produce ops in execution order. Simpler, and the planner already knows what depends on what (e.g. `attach` before `seed_balance`). No DAG solver needed.
|
||||
|
||||
3. **Subscription-group atomicity: explicit or implicit?**
|
||||
Lean: **implicit, in the executor.** The executor groups operations by Stripe sub before executing. This keeps the operation list flat and forgiving — caller doesn't have to think about Stripe's batching boundary, the executor handles it.
|
||||
|
||||
4. **Ownership flag: stored where?**
|
||||
Options: (a) new column on `customer_products`, (b) jsonb metadata, (c) separate `customer_product_ownership` table.
|
||||
Lean: **new column** — it's load-bearing for repair queries; jsonb is too soft.
|
||||
|
||||
5. **Existing `billingActions.migrate`: keep, deprecate, or absorb?**
|
||||
Need to read it. If it's a thin version-bump helper, keep as a shorthand that produces a one-op plan. If it's heavier, deprecate in favor of `MigrationDefinition`.
|
||||
|
||||
6. **Where does identity resolution live for imports?**
|
||||
In the `ImportDefinition.resolveIdentities` stage. The output is an `IdentityMap` that subsequent stages consume. Don't bake identity logic into Operations — keep operations identity-agnostic (they take `customer_id`, not "find or create customer with this email").
|
||||
|
||||
7. **Public-facing types: when?**
|
||||
Defer until v2 (dashboard). Internal types can move freely; public types lock when we expose this to users. Avoid premature stability commitments.
|
||||
|
||||
8. **`is_custom` decoupling: backwards compat?**
|
||||
Existing custom rows have `is_custom: true`. New "shared catalog rows referenced by patch" don't set this flag. We need a cleanup or co-existence story for current per-customer custom rows. Lean: leave existing rows alone; new code uses the new model; eventually a `null is_custom` migration cleans up.
|
||||
|
||||
---
|
||||
|
||||
## Open question deferred from v1
|
||||
|
||||
- **PATCH vs PUT customize:** resolved by `ProductItemPatch` discriminated union (PATCH-style, with `add_existing` for shared rows).
|
||||
- **Custom prices: per-customer rows vs reused per-plan rows:** resolved by `add_existing` / `ensure_shared_*` catalog ops.
|
||||
- **Preparation step placement:** resolved — it's `CatalogOperation` ops inside the same plan.
|
||||
35
.context/migration-interface/STATUS.md
Normal file
35
.context/migration-interface/STATUS.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# migration-interface
|
||||
|
||||
## Goal
|
||||
Design a shared billing-action interface for customer migrations and imports so scripts stop hand-rolling Stripe + Autumn DB orchestration. Eventually exposed to users on the dashboard, so it must be a first-class product feature with tests — not an internal tool.
|
||||
|
||||
## Current Phase
|
||||
Phase 1: Discovery complete (catalog written). PROPOSAL.md v2 drafted — planner + executor model with typed operations. Awaiting user review of architecture and 8 open design questions.
|
||||
|
||||
## Branch
|
||||
john/migration-interface
|
||||
|
||||
## Active Tasks
|
||||
- (none yet — will create parallel cataloging tasks if Phase 1 surface gets large)
|
||||
|
||||
## What's Done
|
||||
- Phase 1 discovery: cataloged `scripts/src/common/migrations/` (23 files), `scripts-v2/runs/` migration scripts, mintlify+firecrawl import chains, and billing v2 action machinery (`multiAttach`, `createSchedule`, `updateSubscription`, `evaluateStripeBillingPlan`, `AutumnBillingPlan`, `customizePlanV1`, `setupCustomFullProduct`).
|
||||
- See `CATALOG.md` for full pattern inventory and `PROPOSAL.md` for the layered-interface design.
|
||||
|
||||
## What's Next
|
||||
- Walk through `PROPOSAL.md` with user — confirm 3-layer model and discuss the 7 open design questions
|
||||
- Read existing `actions/migrate/migrate.ts` to decide extend-vs-replace
|
||||
- Lock Layer 3 scope (include `patch` op? or split into `migrate` + extended `updateSubscription`?)
|
||||
- Begin Phase 2: design `MigrateParams`, `MigrateBillingContext`, `computeMigratePlan` signatures concretely
|
||||
|
||||
## Active Blockers
|
||||
- None
|
||||
|
||||
## Key Files
|
||||
- `server/src/internal/billing/v2/actions/index.ts` — where new action(s) will register
|
||||
- `server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts` — closest existing analog
|
||||
- `server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts` — schedule pattern reference
|
||||
- `server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts` — single-subscription mutation reference
|
||||
- `server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts` — Autumn plan → Stripe state mapper
|
||||
- `shared/api/billing/common/customizePlan/customizePlanV1.ts` — public customize schema
|
||||
- `shared/api/products/items/apiPlanItemV1.ts` — public plan item shape
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -145,5 +145,11 @@ TAKEHOME.md
|
||||
AGENTS.md
|
||||
|
||||
server/.turbo
|
||||
|
||||
.trigger
|
||||
.context
|
||||
.opencode/
|
||||
|
||||
|
||||
./AGENTS.md
|
||||
./CLAUDE.md
|
||||
@@ -40,12 +40,7 @@
|
||||
"url": "https://mcp.plain.com/mcp",
|
||||
"oauth": {}
|
||||
},
|
||||
"slack": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.slack.com/mcp",
|
||||
"oauth": {}
|
||||
},
|
||||
"autumn_prod_internal": {
|
||||
"autumn-internal": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"sh",
|
||||
@@ -53,20 +48,7 @@
|
||||
"cd \"/Users/johnyeocx/Autumn/autumn-cloud\" && exec infisical run --env=prod --recursive -- bun run \"/Users/johnyeocx/Autumn/autumn-cloud/ai/src/mcp/index.ts\""
|
||||
],
|
||||
"env": {
|
||||
"AUTUMN_CLOUD_ROOT": "/Users/johnyeocx/Autumn/autumn-cloud",
|
||||
"AUTUMN_MCP_ENV": "prod"
|
||||
}
|
||||
},
|
||||
"autumn_dev_internal": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"sh",
|
||||
"-c",
|
||||
"cd \"/Users/johnyeocx/Autumn/autumn-cloud\" && exec infisical run --env=dev --recursive -- bun run \"/Users/johnyeocx/Autumn/autumn-cloud/ai/src/mcp/index.ts\""
|
||||
],
|
||||
"env": {
|
||||
"AUTUMN_CLOUD_ROOT": "/Users/johnyeocx/Autumn/autumn-cloud",
|
||||
"AUTUMN_MCP_ENV": "dev"
|
||||
"AUTUMN_CLOUD_ROOT": "/Users/johnyeocx/Autumn/autumn-cloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
153
AGENTS.md
153
AGENTS.md
@@ -1,153 +0,0 @@
|
||||
<!-- Generated by ai-sync. Edit ai/rules/ instead. -->
|
||||
|
||||
# Concise Comments
|
||||
|
||||
Cap any single comment block at **two lines**. Most should be one.
|
||||
|
||||
- Default to writing no comment. Only add one when the WHY is non-obvious — a hidden constraint, a subtle invariant, a workaround for a specific bug, behavior that would surprise a reader.
|
||||
- If you're tempted to write a third line, the comment is wrong: either delete it, or replace it with a clearer name / smaller function so the code explains itself.
|
||||
- Don't restate WHAT the code does — well-named identifiers already do that.
|
||||
- Don't reference the current task, fix, or callers ("used by X", "added for the Y flow", "handles the case from issue #123") — those belong in the PR description and rot as the codebase evolves.
|
||||
- This applies to inline `//` comments AND JSDoc/docstring blocks. A JSDoc whose summary line + one detail line covers the WHY is fine; a four-line paragraph is not.
|
||||
|
||||
If something genuinely needs more explanation, link to a doc or a memory file rather than inlining a wall of text in source.
|
||||
|
||||
# Autumn Shared Utils
|
||||
|
||||
Before writing inline `.filter()`, `.find()`, `.some()`, boolean predicate, or `<src>To<dst>` transform logic over Autumn objects (`Price`, `Entitlement`, `FullCusProduct`, `FullCustomer`, `Feature`, etc.), **check `autumn/shared/utils/` for an existing helper.** Reaching for `array.filter(... === id)` directly is almost always a sign the utility was missed.
|
||||
|
||||
The package is organized resource-first, pattern-second. Within each `<resource>Utils/` folder:
|
||||
|
||||
- `classify*/` — `is*` boolean predicates (`isPrepaidPrice`, `isCustomerProductPaidRecurring`)
|
||||
- `convert*/` — `<src>To<dst>` transforms (`cusProductToPrices`, `entToPrice`)
|
||||
- `find*/` — `Array.find` lookups (`findFeatureById`, `findPriceByFeatureId`)
|
||||
- `filter*/` — `Array.filter` collections (`filterCustomerProductsByFeatureId`)
|
||||
- `enrich*` files — augment with joined data (`enrichEntitlementWithFeature`)
|
||||
|
||||
**If the helper you need doesn't exist, ALWAYS ask the user before adding one.** Naming and folder placement are cross-cutting and non-trivial — wrong placement clutters `@autumn/shared` for every consumer.
|
||||
|
||||
Full convention (folder tree, naming nuances, anti-patterns): see the `shared-utils` skill.
|
||||
|
||||
# Installing External Skills
|
||||
|
||||
Third-party skills installed via `bunx skills add <pkg>` land under each agent's local skill dir (`.claude/skills/`, `.cursor/skills/`, etc.). Those locations are NOT a source of truth — `bun ai sync` only reads from `ai/config/skills/**` and prunes anything else it manages, so a raw `bunx skills add` will not propagate to the other consumer repos (autumn, cloud).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Install via the CLI as usual:
|
||||
```sh
|
||||
bunx skills add <owner>/<repo>
|
||||
```
|
||||
2. Move the installed skill folder(s) into `ai/config/skills/external/<skill-name>/`. `external/` is core, so both `autumn` and `cloud` consume it. Use `cloud/external/` only if the skill references cloud-only code.
|
||||
3. Delete the leftover copies from `.claude/skills/`, `.cursor/skills/`, `.agents/skills/`, `.opencode/skills/` — `bun ai sync` will recreate them as symlinks.
|
||||
4. Run `bun ai sync` to symlink the skill into every agent dir across every repo that pulls the `ai/` submodule.
|
||||
|
||||
## Notes
|
||||
|
||||
- Skill folder names must be globally unique across `ai/config/skills/**` (sync flattens them).
|
||||
- Keep upstream `SKILL.md` frontmatter intact — `name` and `description` drive when the agent loads it. Only edit if the description is not specific enough about WHEN to use the skill.
|
||||
- If the skill ships a `references/` or `scripts/` subfolder, copy the whole directory tree, not just `SKILL.md`.
|
||||
- Re-running `bunx skills add` upstream-updates: install fresh, diff against `ai/config/skills/external/<name>/`, then promote the changes.
|
||||
|
||||
# Scope Cache Refresh Changes Safely
|
||||
|
||||
When changing cache-refresh behavior for API routes:
|
||||
|
||||
- Prefer editing `server/src/honoMiddlewares/refreshCacheConfigs.ts` to add or remove route entries.
|
||||
- Do **not** remove or bypass logic in `server/src/honoMiddlewares/refreshCacheMiddleware.ts` unless the user explicitly asks to change middleware behavior globally.
|
||||
- If the request is ambiguous, ask whether they want a route-level config change or a global middleware behavior change.
|
||||
|
||||
# Cache Version Increment Policy
|
||||
|
||||
Treat `cache_version` as a DB-side stale-sync guard for `syncItemV4`, not a general cache mutation counter.
|
||||
|
||||
## Increment cache_version only when needed
|
||||
|
||||
Increment only for DB updates that must not be overwritten by stale sync payloads read from cache (for example lifecycle or billing transitions).
|
||||
|
||||
## Do not increment on cache-side/runtime patch paths
|
||||
|
||||
For runtime balance/reset/cache patch flows, do not bump `cache_version` in cache writers or Lua update scripts.
|
||||
|
||||
Examples:
|
||||
|
||||
- FullSubject cache patch helpers (`updateSubjectBalanceCache`, reset/deduction cache patch sync helpers)
|
||||
- FullSubject Lua cache update scripts (`updateSubjectBalances`)
|
||||
- Update-balance runtime paths that patch Redis and then sync
|
||||
|
||||
## Call-site rule for CusEntService.update
|
||||
|
||||
When using `CusEntService.update(...)` in runtime FullSubject cache patch flows, set `incrementCacheVersion` explicitly.
|
||||
|
||||
- Use `incrementCacheVersion: false` for routine balance/reset/adjustment updates that are mirrored to Redis.
|
||||
- Use `incrementCacheVersion: true` only for intentional DB-side stale-write protection transitions.
|
||||
|
||||
## Why
|
||||
|
||||
Incorrect version bumps create `CACHE_VERSION_MISMATCH` conflicts in `syncItemV4`, causing repeated invalidation and stale/lost update behavior.
|
||||
|
||||
## Legacy exception (review-required)
|
||||
|
||||
There is a legacy-compatibility exception in the adjust-balance flow:
|
||||
|
||||
- `adjustBalanceDbAndCache` currently uses `CusEntService.increment/decrement`, which increments `cache_version`.
|
||||
- Treat this as a reviewable legacy action, not a pattern to copy into new runtime/cache patch paths.
|
||||
- Any new or refactored runtime balance/reset/cache patch code should continue following this policy and avoid adding new cache-version bumps by default.
|
||||
|
||||
## Project Context System
|
||||
|
||||
Projects maintain state in `.context/<project>/` folders across sessions. Tasks are optional parallel workstreams within a project.
|
||||
|
||||
### Default: NOT interacting with a project
|
||||
**Unless the user explicitly mentions a project or task by name, assume the current conversation is NOT associated with any project.** Session-start hooks may surface a list of active projects as reference material — that alone is NOT a signal that the current work belongs to any of them.
|
||||
|
||||
Do not:
|
||||
- Read `.context/**` files proactively
|
||||
- Write, update, or append to any project's STATUS.md / DECISIONS.md / sessions
|
||||
- Assume a script, audit, or change is part of a project just because it touches files related to one (e.g. a user-of-framework script is not part of the framework's project)
|
||||
|
||||
Only engage with `.context/<project>/` when the user explicitly references the project, opens a task in it, or asks for project-tracking actions.
|
||||
|
||||
### Reading context (when a project IS referenced)
|
||||
When the user mentions a project or task name and `.context/<name>/` exists:
|
||||
1. Read project STATUS.md first (20-30 line "resume card")
|
||||
2. If the project has `tasks/`, list active tasks
|
||||
3. If the user mentions a specific task, read `tasks/<task>/STATUS.md`
|
||||
4. Read the most recent session summary if more detail is needed
|
||||
5. Do NOT read everything upfront. Use progressive disclosure.
|
||||
|
||||
### Updating context (at breakpoints, NOT continuously)
|
||||
Only update when the current work IS part of a project (see default-off rule above). When it is, update at these moments ONLY:
|
||||
- Phase or milestone completed
|
||||
- Architectural decision made (append to DECISIONS.md)
|
||||
- User says they're done or switching tasks
|
||||
- Blocker discovered or resolved
|
||||
- Task created, completed, or handed off
|
||||
|
||||
Do NOT update context during normal coding work. Work first, compact at breakpoints.
|
||||
|
||||
A STATUS.md entry should record changes to the project itself -- not one-off work that merely uses the project (e.g. writing a consumer script of a framework is not a framework-project update).
|
||||
|
||||
### Compaction quality
|
||||
STATUS.md must be:
|
||||
- Correct (reflects actual current state, not stale)
|
||||
- Complete (no critical information missing)
|
||||
- Concise (project < 30 lines, task < 20 lines)
|
||||
|
||||
Always REWRITE STATUS.md completely rather than append.
|
||||
|
||||
### File structure
|
||||
```
|
||||
.context/<project>/
|
||||
STATUS.md -- project resume card (includes Active Tasks section)
|
||||
PLAN.md -- phases and architecture
|
||||
DECISIONS.md -- append-only decision log
|
||||
sessions/ -- dated session summaries
|
||||
tasks/ -- optional parallel workstreams
|
||||
<task>/
|
||||
STATUS.md -- task resume card
|
||||
DECISIONS.md
|
||||
```
|
||||
|
||||
### Task handoff
|
||||
When a task is being handed to another agent, ensure the task's STATUS.md is up to date -- it's the handoff artifact.
|
||||
137
CLAUDE.md
137
CLAUDE.md
@@ -1,137 +0,0 @@
|
||||
# Basic rules
|
||||
- Never run a "dev" or "build" command, chances are I'm already running it in the background. Just ask me to check for updates or whatever you need
|
||||
- Never ever ever write a "TO DO" comment. If you've been told to do something, DO IT. Don't stop halfway. Never give up and just leave a "to do" comment and say - "haha heres working code :)" - that is unacceptible. Always finish your task, no matter how many iterations you need to perform.
|
||||
- DO NOT alter .gitignore
|
||||
- JS Doc comments should be SHORT and SWEET. Don't need examples unless ABSOLUTELY necessary
|
||||
- When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas.
|
||||
- **Spell out variable names in full form** - avoid abbreviations in variable/function names. Use `customerProduct` not `cusProduct`, `customerEntitlements` not `cusEnts`, `organization` not `org` (in new code). Clarity over brevity.
|
||||
|
||||
# Testing
|
||||
- When writing tests, ALWAYS read:
|
||||
1. `server/tests/_guides/general-test-guide.md` - Common patterns, client initialization, public keys
|
||||
2. Case-specific guide (e.g., `server/tests/_guides/check-endpoint-tests.md` for `/check` tests)
|
||||
- When running tests, ALL server-side console logs go to the server's logs which you do not have access to. You must ask the user to paste you in the logs, instead of expecting the server logs to magically appear
|
||||
in the test logs. Use your common sense
|
||||
|
||||
# Linting and Codebase rules
|
||||
- You can access the biome linter by running `bunx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write <folder or file path>`
|
||||
|
||||
- Note, biome does not perform typechecking. For typechecks, always `cd` into the relevant workspace and run `bun ts`. Do not run `bunx tsgo --build --noEmit` directly, and do not use `tsc` for workspace typechecks.
|
||||
|
||||
- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead.
|
||||
|
||||
- This codebase uses Bun as its preferred package manager and Node runtime.
|
||||
|
||||
- **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";`
|
||||
|
||||
- **ALWAYS use named import for Decimal.js**: `import { Decimal } from "decimal.js";` NOT `import Decimal from "decimal.js";`
|
||||
|
||||
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
|
||||
|
||||
- When creating "hooks" folders, don't nest them under "components"
|
||||
|
||||
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
|
||||
|
||||
- For regular functions, use inline object types in the function signature rather than creating separate type definitions. Only create named types when they're reused across multiple functions or exported.
|
||||
```typescript
|
||||
// ❌ BAD - Unnecessary type definition for single-use params
|
||||
type DoSomethingParams = {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
};
|
||||
const doSomething = async ({ ctx, customerId }: DoSomethingParams) => { ... }
|
||||
|
||||
// ✅ GOOD - Inline object type
|
||||
const doSomething = async ({ ctx, customerId }: { ctx: AutumnContext; customerId: string }) => { ... }
|
||||
```
|
||||
|
||||
- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why <package name>` which will tell you why a certain package was installed and by who.
|
||||
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
|
||||
|
||||
- For single-line if statements (especially guard clauses), omit curly braces to keep code neat: `if (!isValid) throw error;` instead of wrapping in braces.
|
||||
|
||||
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
|
||||
|
||||
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`
|
||||
|
||||
- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`
|
||||
|
||||
- When referring to a `customer_entitlement` object (or plural `customer_entitlements`), always use the full name. Do not abbreviate to "entitlement" or "entitlements" as this will be confused with the separate `entitlement` object.
|
||||
|
||||
## Error Handling in API Routes
|
||||
- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes
|
||||
- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc.
|
||||
- For internal/unexpected errors (like missing configuration, database errors, etc.), throw `InternalError` from `@autumn/shared`
|
||||
- The onError middleware automatically converts these errors to appropriate HTTP responses
|
||||
- Examples:
|
||||
```typescript
|
||||
// ❌ BAD - Don't do this
|
||||
if (!org) {
|
||||
return c.json({ message: "Org not found", code: "not_found" }, 404);
|
||||
}
|
||||
|
||||
// ✅ GOOD - Validation/expected errors use RecaseError
|
||||
if (!org) {
|
||||
throw new RecaseError({
|
||||
message: "Org not found",
|
||||
code: ErrCode.NotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ GOOD - Internal/unexpected errors use InternalError
|
||||
if (!upstash) {
|
||||
throw new InternalError({
|
||||
message: "Upstash not configured",
|
||||
code: "upstash_not_configured",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Bad example
|
||||
/ root
|
||||
-> components
|
||||
|-> hooks
|
||||
## Good example
|
||||
/ root
|
||||
-> components
|
||||
-> hooks
|
||||
|
||||
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
|
||||
|
||||
- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why <package name>` which will tell you why a certain package was installed and by who.
|
||||
|
||||
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
|
||||
|
||||
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
|
||||
|
||||
# Figma MCP guidance
|
||||
- When you are using the Figma MCP server, you **must** follow our design system. Below is an example implementation of CVA with out design system
|
||||
|
||||
## File Naming
|
||||
DON'T name files one word (like index.ts, model.ts, etc.). Give proper indication in the filename to which resource it's targeting. For example, a utility file for organizations should be named orgUtils.ts. This is because it's easier to search for files like this. That being said, the filename shouldn't be overly long (less than three words is ideal)
|
||||
|
||||
## File Moving/Renaming
|
||||
When restructuring, moving, or renaming files, **ALWAYS use terminal commands** (`mv`, `mkdir`) instead of rewriting files. This preserves git history and ensures no lines/logic are accidentally lost or changed
|
||||
|
||||
## Deleting Files
|
||||
- **NEVER use `rm` commands unless the file is confirmed to be unused**
|
||||
- **ALWAYS ask for user approval before running any `rm` or `rm -rf` commands**
|
||||
- Before deleting, verify the file has no imports/references in the codebase
|
||||
|
||||
# Vite
|
||||
## Components
|
||||
- Always use v2 components from `@/components/v2/` (buttons, inputs, dialogs, sheets, selects, etc.) for new features. Old components in `@/components/ui/` are deprecated.
|
||||
|
||||
## Sheets
|
||||
- Use `Sheet.tsx` for overlay sheets (modal-style with backdrop). Use `SheetHeader`, `SheetFooter`, `SheetSection` from `SharedSheetComponents.tsx` for consistent styling.
|
||||
- `InlineSheet.tsx` provides `SheetContainer` for inline sheets (embedded in page layout). It re-exports shared components for backwards compatibility.
|
||||
- Both sheet types support the same header/footer/section components, ensuring consistent UI patterns across overlay and inline implementations.
|
||||
|
||||
## Styling
|
||||
- DO NOT hardcode styles when possible. Always try to reuse existing Tailwind classes or component patterns from similar components in the codebase.
|
||||
- When adding interactive elements (hover, focus, active states), look for existing patterns in similar components and reuse those class combinations.
|
||||
- Consistency is key - if a pattern exists, use it rather than creating a new one.
|
||||
|
||||
## Form Elements
|
||||
- When creating form input elements (inputs, selects, textareas, etc.) in the vite folder, ALWAYS read `vite/FORM_DESIGN_GUIDELINES.md` first to understand the atomic CSS class system.
|
||||
38
README.md
38
README.md
@@ -25,48 +25,22 @@ All this without having to handle webhooks, upgrades/downgrades, cancellations o
|
||||
**Self Hosted**: If you'd like to self-host Autumn:
|
||||
|
||||
1. Make sure you have `bun` installed
|
||||
2. Install the project dependencies
|
||||
2. Install the project dependencies:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
3. Run our set up script:
|
||||
3. Run Autumn:
|
||||
```bash
|
||||
bun setup
|
||||
```
|
||||
|
||||
4. Generate the relevant tables in your postgres DB
|
||||
```bash
|
||||
bun db:generate && bun db:migrate
|
||||
```
|
||||
|
||||
5. Run Autumn:
|
||||
|
||||
For Windows
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
For mac/linux:
|
||||
```bash
|
||||
docker compose -f docker-compose.unix.yml up
|
||||
bun dev
|
||||
```
|
||||
|
||||
That's it! You should be able to see the Autumn dashboard on `http://localhost:3000`.
|
||||
|
||||
> ℹ️ Autumn depends on a bunch of services. If you'd like help with self-hosting or running a local instance, contact the team on [Discord](https://discord.gg/53emPtY9tA).
|
||||
|
||||
> ⚠️ To log in, enter an email at the sign in page, and an OTP should appear in your console / terminal. Normally, we use Resend to email an OTP or Google OAuth -- these can be set up by providing your credentials in `server/.env`
|
||||
|
||||
> ℹ️ Our set up script initializes the required env vars and (optionally) a Supabase instance. If you'd like to use your own Postgres instance, you can do so -- just paste the connection string in the `DATABASE_URL` env variable at `server/.env`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter a `SyntaxError: Unexpected end of JSON input` error when running `bun setup` again after previously running it, you may need to clear your database tables first. This is a [known issue](https://github.com/drizzle-team/drizzle-orm/issues/4529) that can occur when running database migrations multiple times.
|
||||
|
||||
To resolve this:
|
||||
|
||||
1. Connect to your database
|
||||
2. Drop all existing tables
|
||||
3. Run the setup script again:
|
||||
|
||||
> ℹ️ If you'd like to use your own Postgres instance, paste the connection string in the `DATABASE_URL` env variable at `server/.env`
|
||||
|
||||
## Why Autumn
|
||||
|
||||
|
||||
@@ -13,4 +13,10 @@ NODE_ENV = "test"
|
||||
|
||||
[install]
|
||||
# Only install package versions published at least 3 days ago
|
||||
minimumReleaseAge = 259200 # seconds
|
||||
minimumReleaseAge = 259200 # seconds
|
||||
minimumReleaseAgeExcludes = [
|
||||
"@trigger.dev/build",
|
||||
"@trigger.dev/core",
|
||||
"@trigger.dev/sdk",
|
||||
"trigger.dev",
|
||||
]
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
services:
|
||||
|
||||
valkey:
|
||||
image: docker.io/bitnami/valkey:8.0
|
||||
environment:
|
||||
- ALLOW_EMPTY_PASSWORD=yes
|
||||
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
|
||||
volumes:
|
||||
- valkey-data:/bitnami/valkey/data
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "-h", "localhost", "-p", "6379", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
|
||||
shared:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: shared
|
||||
volumes:
|
||||
- ./shared:/app/shared
|
||||
- shared-dist:/app/shared/dist
|
||||
- shared-node-modules:/app/shared/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
restart: unless-stopped
|
||||
|
||||
# Vite frontend
|
||||
vite:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: vite
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./vite:/app/vite
|
||||
- shared-dist:/app/shared/dist
|
||||
- vite-node-modules:/app/vite/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
# Main Express server
|
||||
server:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: server
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
- shared-dist:/app/shared/dist
|
||||
- server-node-modules:/app/server/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- REDIS_URL=redis://valkey:6379
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- ENCRYPTION_IV=${ENCRYPTION_IV}
|
||||
- ENCRYPTION_PASSWORD=${ENCRYPTION_PASSWORD}
|
||||
- TESTS_ORG=${TESTS_ORG}
|
||||
- TESTS_ORG_ID=${TESTS_ORG_ID}
|
||||
- LOCALTUNNEL_RESERVED_KEY=${LOCALTUNNEL_RESERVED_KEY}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- BETTER_AUTH_URL=http://localhost:8080
|
||||
- STRIPE_WEBHOOK_URL=${STRIPE_WEBHOOK_URL}
|
||||
- HYPERBROWSER_API_KEY=${HYPERBROWSER_API_KEY}
|
||||
depends_on:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
# BullMQ Workers
|
||||
workers:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: workers
|
||||
volumes:
|
||||
# Mount server source for hot reload (workers use server code)
|
||||
- ./server:/app/server
|
||||
- shared-dist:/app/shared/dist
|
||||
- server-node-modules:/app/server/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- REDIS_URL=redis://valkey:6379
|
||||
depends_on:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
# Run localtunnel
|
||||
localtunnel:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: localtunnel
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
environment:
|
||||
- LOCALTUNNEL_RESERVED_KEY=${LOCALTUNNEL_RESERVED_KEY}
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# Shared package dist output
|
||||
shared-dist:
|
||||
valkey-data:
|
||||
|
||||
# Node modules volumes to avoid host/container conflicts
|
||||
shared-node-modules:
|
||||
server-node-modules:
|
||||
vite-node-modules:
|
||||
root-node-modules:
|
||||
@@ -1,119 +0,0 @@
|
||||
services:
|
||||
|
||||
valkey:
|
||||
image: docker.io/valkey/valkey:8.0
|
||||
environment:
|
||||
- ALLOW_EMPTY_PASSWORD=yes
|
||||
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
|
||||
volumes:
|
||||
- valkey-data:/bitnami/valkey/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', '-h', 'localhost', '-p', '6379', 'ping']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
|
||||
shared:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: shared
|
||||
volumes:
|
||||
- ./shared:/app/shared
|
||||
- shared-dist:/app/shared/dist
|
||||
- shared-node-modules:/app/shared/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
restart: unless-stopped
|
||||
|
||||
# Vite frontend
|
||||
vite:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: vite
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./vite:/app/vite
|
||||
- shared-dist:/app/shared/dist
|
||||
- vite-node-modules:/app/vite/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- WATCHPACK_POLLING=true
|
||||
depends_on:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
# Main Express server
|
||||
server:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: server
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
- shared-dist:/app/shared/dist
|
||||
- root-node-modules:/app/node_modules
|
||||
- server-node-modules:/app/server/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- REDIS_URL=redis://valkey:6379
|
||||
depends_on:
|
||||
- shared
|
||||
- valkey
|
||||
restart: unless-stopped
|
||||
|
||||
# BullMQ Workers
|
||||
workers:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: workers
|
||||
volumes:
|
||||
# Mount server source for hot reload (workers use server code)
|
||||
- ./server:/app/server
|
||||
- shared-dist:/app/shared/dist
|
||||
- root-node-modules:/app/node_modules
|
||||
- server-node-modules:/app/server/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- REDIS_URL=redis://valkey:6379
|
||||
depends_on:
|
||||
- shared
|
||||
- valkey
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
# Run localtunnel
|
||||
localtunnel:
|
||||
image: oven/bun:latest
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: localtunnel
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
volumes:
|
||||
# Shared package dist output
|
||||
shared-dist:
|
||||
valkey-data:
|
||||
|
||||
# Node modules volumes to avoid host/container conflicts
|
||||
shared-node-modules:
|
||||
server-node-modules:
|
||||
vite-node-modules:
|
||||
root-node-modules:
|
||||
@@ -1,111 +0,0 @@
|
||||
services:
|
||||
valkey:
|
||||
image: docker.io/valkey/valkey:8.0
|
||||
environment:
|
||||
- ALLOW_EMPTY_PASSWORD=yes
|
||||
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
|
||||
volumes:
|
||||
- valkey-data:/valkey/valkey/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-h", "localhost", "-p", "6379", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
|
||||
shared:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: shared
|
||||
volumes:
|
||||
- ./shared:/app/shared
|
||||
- shared-dist:/app/shared/dist
|
||||
- shared-node-modules:/app/shared/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
restart: unless-stopped
|
||||
|
||||
# Vite frontend
|
||||
vite:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: vite
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./vite:/app/vite
|
||||
- shared-dist:/app/shared/dist
|
||||
- vite-node-modules:/app/vite/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
# Main Express server
|
||||
server:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: server
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
- shared-dist:/app/shared/dist
|
||||
- server-node-modules:/app/server/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- REDIS_URL=redis://valkey:6379
|
||||
depends_on:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
# BullMQ Workers
|
||||
workers:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: workers
|
||||
volumes:
|
||||
# Mount server source for hot reload (workers use server code)
|
||||
- ./server:/app/server
|
||||
- shared-dist:/app/shared/dist
|
||||
- server-node-modules:/app/server/node_modules
|
||||
- root-node-modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- REDIS_URL=redis://valkey:6379
|
||||
depends_on:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
# Run localtunnel
|
||||
localtunnel:
|
||||
build:
|
||||
dockerfile: docker/dev.dockerfile
|
||||
context: .
|
||||
target: localtunnel
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# Shared package dist output
|
||||
shared-dist:
|
||||
valkey-data:
|
||||
|
||||
# Node modules volumes to avoid host/container conflicts
|
||||
shared-node-modules:
|
||||
server-node-modules:
|
||||
vite-node-modules:
|
||||
root-node-modules:
|
||||
@@ -20,7 +20,7 @@
|
||||
],
|
||||
"workspaces": {
|
||||
".": {
|
||||
"entry": ["apps/scope-picker/**/*"]
|
||||
"entry": ["apps/scope-picker/**/*", "trigger.config.ts"]
|
||||
},
|
||||
"server": {
|
||||
"entry": [
|
||||
|
||||
@@ -92,6 +92,10 @@
|
||||
"migrate-functions:prod": "infisical run --env=prod --recursive -- bun scripts/migrations/migrate-functions.ts",
|
||||
"validate-schema": "infisical run --env=prod --recursive -- bun scripts/migrations/validate-schema.ts",
|
||||
"validate-shebangs": "infisical run --env=prod --recursive -- bun scripts/migrations/validate-fullsubject-shebangs.ts",
|
||||
"tinybird:info": "cd server && infisical run --env=dev --recursive -- bunx tinybird info",
|
||||
"tinybird:deploy:dev:check": "cd server && infisical run --env=dev --recursive -- bunx tinybird deploy --check",
|
||||
"tinybird:deploy:dev": "cd server && infisical run --env=dev --recursive -- bunx tinybird deploy",
|
||||
"trigger:deploy": "bunx trigger.dev deploy",
|
||||
"setupci": "node scripts/setup/setupci.js",
|
||||
"replicate": "bun scripts/db/replicate.ts",
|
||||
"db:pull": "bun scripts/db/pull.ts",
|
||||
@@ -128,6 +132,7 @@
|
||||
"@aws-sdk/client-sqs": "^3.985.0",
|
||||
"@better-auth/core": "catalog:",
|
||||
"@better-auth/oauth-provider": "catalog:",
|
||||
"@trigger.dev/sdk": "4.4.6",
|
||||
"@wooorm/starry-night": "^3.8.0",
|
||||
"ag-charts-react": "^12.3.0",
|
||||
"better-auth": "catalog:",
|
||||
@@ -139,12 +144,14 @@
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.4.21",
|
||||
"@biomejs/biome": "^2.2.7",
|
||||
"@trigger.dev/build": "4.4.6",
|
||||
"@types/node": "^24.9.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"husky": "^9.1.7",
|
||||
"inquirer": "^12.10.0",
|
||||
"knip": "^6.7.0",
|
||||
"trigger.dev": "4.4.6",
|
||||
"ts-to-zod": "^5.1.0",
|
||||
"turbo": "^2.9.6"
|
||||
}
|
||||
|
||||
@@ -49,6 +49,30 @@ function getEnvVariable(filePath: string, key: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPackageDependencyVersion({
|
||||
projectRoot,
|
||||
packageName,
|
||||
}: {
|
||||
projectRoot: string;
|
||||
packageName: string;
|
||||
}): string {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(projectRoot, "package.json"), "utf-8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const version =
|
||||
packageJson.dependencies?.[packageName] ??
|
||||
packageJson.devDependencies?.[packageName];
|
||||
|
||||
if (!version) {
|
||||
throw new Error(`Missing ${packageName} in package.json`);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
function killPorts({ ports }: { ports: number[] }) {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
@@ -136,6 +160,11 @@ async function startDev() {
|
||||
|
||||
// Use cmd on Windows, sh on Unix
|
||||
const isWindows = process.platform === "win32";
|
||||
const triggerDevVersion = getPackageDependencyVersion({
|
||||
projectRoot,
|
||||
packageName: "trigger.dev",
|
||||
});
|
||||
const bunInstallBin = dirname(process.execPath);
|
||||
|
||||
let shellArgs: string[];
|
||||
if (serverOnly) {
|
||||
@@ -174,6 +203,14 @@ async function startDev() {
|
||||
? `"cd server && bun ${workersScript}"`
|
||||
: `"cd server && bun ${workersScript}"`,
|
||||
);
|
||||
|
||||
names.push("trigger");
|
||||
colors.push("cyan");
|
||||
cmds.push(
|
||||
isWindows
|
||||
? `"bunx trigger.dev@${triggerDevVersion} dev"`
|
||||
: `"bunx trigger.dev@${triggerDevVersion} dev"`,
|
||||
);
|
||||
}
|
||||
|
||||
names.push("vite", "checkout");
|
||||
@@ -196,12 +233,13 @@ async function startDev() {
|
||||
|
||||
const concurrentlyProc = Bun.spawn(shellArgs, {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_PORT: VITE_PORT.toString(),
|
||||
SERVER_PORT: SERVER_PORT.toString(),
|
||||
CHECKOUT_PORT: CHECKOUT_PORT.toString(),
|
||||
VITE_APP_ENV: viteAppEnv,
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_PORT: VITE_PORT.toString(),
|
||||
SERVER_PORT: SERVER_PORT.toString(),
|
||||
CHECKOUT_PORT: CHECKOUT_PORT.toString(),
|
||||
VITE_APP_ENV: viteAppEnv,
|
||||
BUN_INSTALL_BIN: process.env.BUN_INSTALL_BIN ?? bunInstallBin,
|
||||
...(worktreeNum > 1 && {
|
||||
CLIENT_URL: `http://localhost:${VITE_PORT}`,
|
||||
BETTER_AUTH_URL: `http://localhost:${SERVER_PORT}`,
|
||||
|
||||
170
server/experiments/explainCustomerFilter.ts
Normal file
170
server/experiments/explainCustomerFilter.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import {
|
||||
buildCustomerCount,
|
||||
buildCustomerSelect,
|
||||
} from "@/internal/migrations/v2/filters/customers/buildCustomerSelect.js";
|
||||
import type { CustomerFilter } from "@autumn/shared/api/migrations/filters/customerFilter.js";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
// Import initDrizzle directly — avoid `experimentEnv` because its
|
||||
// `loadLocalEnv()` reads `server/.env` and clobbers env vars injected by
|
||||
// `infisical run --env=staging` (e.g. DATABASE_URL).
|
||||
import { initDrizzle } from "../src/db/initDrizzle";
|
||||
import { FeatureService } from "../src/internal/features/FeatureService.js";
|
||||
|
||||
const prodTestOrgId = (() => {
|
||||
const v = process.env.PROD_TEST_ORG_ID;
|
||||
if (!v) throw new Error("PROD_TEST_ORG_ID env var is required");
|
||||
return v;
|
||||
})();
|
||||
|
||||
// Mask password but show host so we can verify which DB we're hitting.
|
||||
const dbUrl = process.env.DATABASE_URL ?? "";
|
||||
console.log(
|
||||
"DATABASE URL host:",
|
||||
dbUrl.replace(/:\/\/[^@]+@/, "://***:***@") || "(empty)",
|
||||
);
|
||||
|
||||
// To run against a remote env (e.g. staging) via infisical:
|
||||
// infisical run --env=staging --recursive -- bun run server/experiments/explainCustomerFilter.ts
|
||||
|
||||
// ─── Configuration ──────────────────────────────────────────────────
|
||||
const ORG_ID = prodTestOrgId;
|
||||
const ENV = AppEnv.Live;
|
||||
const SAMPLE_LIMIT = 1_000;
|
||||
const TRUNCATE_EXPLAIN = true;
|
||||
const EXPLAIN_MAX_LINES = 20;
|
||||
|
||||
|
||||
const FILTER: CustomerFilter = {
|
||||
// plan: { plan_id: "free" },
|
||||
// plan: { item: { feature_id: "CREDITS", price: { $ne: null } } }
|
||||
plan: { plan_id: "free", recurring: true }
|
||||
};
|
||||
|
||||
const MIGRATION_PLAN = {
|
||||
filter: {
|
||||
customers: {
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
const truncateExplainText = (text: string, maxLines: number): string => {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length <= maxLines) return text;
|
||||
const omitted = lines.length - maxLines;
|
||||
return [...lines.slice(0, maxLines), `... (${omitted} more lines truncated)`].join(
|
||||
"\n",
|
||||
);
|
||||
};
|
||||
|
||||
const printExplainPlan = async ({
|
||||
db,
|
||||
query,
|
||||
label,
|
||||
}: {
|
||||
db: ReturnType<typeof initDrizzle>["db"];
|
||||
query: SQL;
|
||||
label: string;
|
||||
}) => {
|
||||
console.log(`\n--- EXPLAIN ANALYZE: ${label} ---`);
|
||||
const explainResult = await db.execute(
|
||||
sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`,
|
||||
);
|
||||
const lines: string[] = [];
|
||||
for (const row of explainResult)
|
||||
lines.push(String((row as Record<string, unknown>)["QUERY PLAN"]));
|
||||
const joined = lines.join("\n");
|
||||
console.log(
|
||||
TRUNCATE_EXPLAIN ? truncateExplainText(joined, EXPLAIN_MAX_LINES) : joined,
|
||||
);
|
||||
};
|
||||
|
||||
const dialect = new PgDialect();
|
||||
|
||||
const inlineParams = (text: string, params: readonly unknown[]): string =>
|
||||
text.replace(/\$(\d+)/g, (_, n) => {
|
||||
const v = params[Number(n) - 1];
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
});
|
||||
|
||||
const printSqlQuery = ({ query, label }: { query: SQL; label: string }) => {
|
||||
const { sql: text, params } = dialect.sqlToQuery(query);
|
||||
console.log(`\n--- SQL: ${label} ---`);
|
||||
console.log(inlineParams(text, params));
|
||||
};
|
||||
|
||||
const runMeasured = async ({
|
||||
db,
|
||||
query,
|
||||
label,
|
||||
}: {
|
||||
db: ReturnType<typeof initDrizzle>["db"];
|
||||
query: SQL;
|
||||
label: string;
|
||||
}) => {
|
||||
console.log(`\n=== ${label} ===`);
|
||||
printSqlQuery({ query, label });
|
||||
const startedAt = performance.now();
|
||||
const result = await db.execute(query);
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
console.log(`Rows returned: ${result.length}`);
|
||||
console.log(`Wall-clock: ${elapsedMs.toFixed(2)}ms`);
|
||||
if (label === "COUNT" && result.length > 0)
|
||||
console.log(`Count: ${(result[0] as Record<string, unknown>).count}`);
|
||||
await printExplainPlan({ db, query, label });
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const replicaUrl = process.env.DATABASE_REPLICA_URL;
|
||||
const usingReplica = Boolean(replicaUrl);
|
||||
if (!usingReplica)
|
||||
console.warn(
|
||||
"DATABASE_REPLICA_URL not set — falling back to DATABASE_URL (primary). Set the replica URL to test against the read replica.",
|
||||
);
|
||||
const { db } = initDrizzle({ replica: usingReplica });
|
||||
|
||||
console.log(
|
||||
`=== CUSTOMER FILTER EXPERIMENT (${usingReplica ? "REPLICA" : "PRIMARY"}) ===`,
|
||||
);
|
||||
console.log(JSON.stringify({ ORG_ID, ENV, FILTER }, null, 2));
|
||||
|
||||
const orgFeatures = await FeatureService.list({
|
||||
db,
|
||||
orgId: ORG_ID,
|
||||
env: ENV,
|
||||
});
|
||||
console.log(`\nLoaded ${orgFeatures.length} features for resolution context.`);
|
||||
|
||||
const ctx = { features: orgFeatures };
|
||||
|
||||
const countQuery = buildCustomerCount({
|
||||
orgId: ORG_ID,
|
||||
env: ENV,
|
||||
filter: FILTER,
|
||||
ctx,
|
||||
});
|
||||
const selectQuery = buildCustomerSelect({
|
||||
orgId: ORG_ID,
|
||||
env: ENV,
|
||||
filter: FILTER,
|
||||
ctx,
|
||||
limit: SAMPLE_LIMIT,
|
||||
});
|
||||
|
||||
await runMeasured({ db, query: countQuery, label: "COUNT" });
|
||||
await runMeasured({
|
||||
db,
|
||||
query: selectQuery,
|
||||
label: `SELECT (limit ${SAMPLE_LIMIT})`,
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
await main();
|
||||
@@ -80,6 +80,9 @@
|
||||
"@react-email/components": "^0.0.42",
|
||||
"@sentry/bun": "catalog:",
|
||||
"@supabase/supabase-js": "^2.46.2",
|
||||
"@tinybirdco/sdk": "^0.0.69",
|
||||
"@trigger.dev/build": "4.4.6",
|
||||
"@trigger.dev/sdk": "4.4.6",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20251114.1",
|
||||
|
||||
@@ -98,7 +98,7 @@ export const { db: dbCritical, client: clientCritical } = initDrizzle({
|
||||
|
||||
// -- General pool: used by all other endpoints --
|
||||
export const { db: dbGeneral, client: clientGeneral } = initDrizzle({
|
||||
// connectTimeout: 5,
|
||||
connectTimeout: isProd ? 5 : 30,
|
||||
});
|
||||
|
||||
// -- Replica pool: used as fallback when primary is degraded --
|
||||
|
||||
92
server/src/external/autumn/autumnCli.ts
vendored
92
server/src/external/autumn/autumnCli.ts
vendored
@@ -34,6 +34,10 @@ import {
|
||||
type FinalizeLockParamsV0,
|
||||
type LegacyVersion,
|
||||
type ListEntitiesParams,
|
||||
type Migration,
|
||||
type MigrationFilter,
|
||||
type MigrationRun,
|
||||
type Operations,
|
||||
type OrgConfig,
|
||||
type ProductItem,
|
||||
type RestoreParamsV1,
|
||||
@@ -47,6 +51,8 @@ import {
|
||||
} from "@autumn/shared";
|
||||
import { defaultApiVersion } from "@tests/constants.js";
|
||||
import { timeout } from "@tests/utils/genUtils";
|
||||
import type { TinybirdMigrationItemEvent } from "@/external/tinybird/migrations/migrationItemEventsDataSource.js";
|
||||
import type { PrepareResponse } from "@/internal/migrations/v2/prepare/types";
|
||||
|
||||
export default class AutumnError extends Error {
|
||||
message: string;
|
||||
@@ -961,6 +967,92 @@ export class AutumnInt {
|
||||
return data;
|
||||
};
|
||||
|
||||
migrationsV2 = {
|
||||
create: async (params: {
|
||||
id: string;
|
||||
filter?: MigrationFilter | null;
|
||||
operations?: Operations | null;
|
||||
}): Promise<Migration> => {
|
||||
const data = await this.post(`/migrations.create`, params);
|
||||
return data as Migration;
|
||||
},
|
||||
list: async (): Promise<{ list: Migration[] }> => {
|
||||
const data = await this.post(`/migrations.list`, {});
|
||||
return data as { list: Migration[] };
|
||||
},
|
||||
update: async (params: {
|
||||
id: string;
|
||||
updates: {
|
||||
id?: string;
|
||||
filter?: MigrationFilter | null;
|
||||
operations?: Operations | null;
|
||||
retry_failed?: boolean;
|
||||
};
|
||||
}): Promise<Migration> => {
|
||||
const data = await this.post(`/migrations.update`, params);
|
||||
return data as Migration;
|
||||
},
|
||||
delete: async (params: { id: string }): Promise<Migration> => {
|
||||
const data = await this.post(`/migrations.delete`, params);
|
||||
return data as Migration;
|
||||
},
|
||||
deleteAndCreate: async (params: {
|
||||
id: string;
|
||||
filter?: MigrationFilter | null;
|
||||
operations?: Operations | null;
|
||||
}): Promise<Migration> => {
|
||||
try {
|
||||
await this.post(`/migrations.delete`, { id: params.id });
|
||||
} catch {}
|
||||
const data = await this.post(`/migrations.create`, params);
|
||||
return data as Migration;
|
||||
},
|
||||
prepare: async (params: {
|
||||
id: string;
|
||||
dry_run: boolean;
|
||||
}): Promise<PrepareResponse> => {
|
||||
const data = await this.post(`/migrations.prepare`, params);
|
||||
return data as PrepareResponse;
|
||||
},
|
||||
run: async (params: {
|
||||
id: string;
|
||||
dry_run?: boolean;
|
||||
}): Promise<{
|
||||
migration_id: string;
|
||||
dry_run: boolean;
|
||||
run_id: string;
|
||||
}> => {
|
||||
const data = await this.post(`/migrations.run`, params);
|
||||
return data as {
|
||||
migration_id: string;
|
||||
dry_run: boolean;
|
||||
run_id: string;
|
||||
};
|
||||
},
|
||||
lazyRun: async (params: {
|
||||
id: string;
|
||||
}): Promise<{
|
||||
migration_id: string;
|
||||
run_id: string;
|
||||
}> => {
|
||||
const data = await this.post(`/migrations.lazy_run`, params);
|
||||
return data as { migration_id: string; run_id: string };
|
||||
},
|
||||
listRuns: async (params: {
|
||||
migrationId: string;
|
||||
}): Promise<{ list: MigrationRun[] }> => {
|
||||
const data = await this.post(`/migrations.runs.list`, params);
|
||||
return data as { list: MigrationRun[] };
|
||||
},
|
||||
listItemEvents: async (params: {
|
||||
migrationId: string;
|
||||
migrationRunId?: string;
|
||||
}): Promise<{ list: TinybirdMigrationItemEvent[] }> => {
|
||||
const data = await this.post(`/migrations.item_events.list`, params);
|
||||
return data as { list: TinybirdMigrationItemEvent[] };
|
||||
},
|
||||
};
|
||||
|
||||
balances = {
|
||||
create: async (params: CreateBalanceParamsV0) => {
|
||||
const data = await this.post(`/balances/create`, params);
|
||||
|
||||
115
server/src/external/infisical/fetchInfisicalSecrets.ts
vendored
Normal file
115
server/src/external/infisical/fetchInfisicalSecrets.ts
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Pure REST fetcher for Infisical secrets. Used at trigger.dev DEPLOY
|
||||
* time via `syncEnvVars` to push secrets to the cloud env. Kept SDK-free
|
||||
* so trigger.config.ts can import it without bloating the build.
|
||||
*
|
||||
* Runtime code uses `initInfisical` (SDK-based, populates process.env).
|
||||
*/
|
||||
|
||||
export type InfisicalSyncEnvVar = { name: string; value: string };
|
||||
|
||||
export type FetchInfisicalSecretsArgs = {
|
||||
clientId?: string | null;
|
||||
clientSecret?: string | null;
|
||||
projectId?: string | null;
|
||||
/** Defaults to "prod" if not set. */
|
||||
environment?: string | null;
|
||||
secretPath?: string;
|
||||
recursive?: boolean;
|
||||
includeImports?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate via Universal Auth, fetch secrets at the given path, and
|
||||
* return them as `{ name, value }[]`. Imported groups are flattened in
|
||||
* after primary secrets, with first-write-wins de-duplication.
|
||||
*/
|
||||
export const fetchInfisicalSecrets = async ({
|
||||
clientId,
|
||||
clientSecret,
|
||||
projectId,
|
||||
environment,
|
||||
secretPath = "/",
|
||||
recursive = true,
|
||||
includeImports = true,
|
||||
}: FetchInfisicalSecretsArgs): Promise<InfisicalSyncEnvVar[]> => {
|
||||
const env = environment ?? "prod";
|
||||
|
||||
if (!clientId || !clientSecret || !projectId) {
|
||||
throw new Error(
|
||||
"Missing Infisical credentials. Set INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET, INFISICAL_PROJECT_ID.",
|
||||
);
|
||||
}
|
||||
|
||||
const authRes = await fetch(
|
||||
"https://app.infisical.com/api/v1/auth/universal-auth/login",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId, clientSecret }),
|
||||
},
|
||||
);
|
||||
if (!authRes.ok)
|
||||
throw new Error(
|
||||
`Infisical auth failed: ${authRes.status} ${await authRes.text()}`,
|
||||
);
|
||||
const { accessToken } = (await authRes.json()) as { accessToken: string };
|
||||
|
||||
const params = new URLSearchParams({
|
||||
environment: env,
|
||||
workspaceId: projectId,
|
||||
secretPath,
|
||||
recursive: String(recursive),
|
||||
includeImports: String(includeImports),
|
||||
});
|
||||
const secretsRes = await fetch(
|
||||
`https://app.infisical.com/api/v3/secrets/raw?${params}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
if (!secretsRes.ok)
|
||||
throw new Error(
|
||||
`Infisical secrets failed: ${secretsRes.status} ${await secretsRes.text()}`,
|
||||
);
|
||||
const data = (await secretsRes.json()) as {
|
||||
secrets: Array<{ secretKey: string; secretValue: string }>;
|
||||
imports?: Array<{
|
||||
secrets: Array<{ secretKey: string; secretValue: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
const envVars: InfisicalSyncEnvVar[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const push = (key: string, value: string) => {
|
||||
if (!key || !value || seen.has(key)) return;
|
||||
envVars.push({ name: key, value });
|
||||
seen.add(key);
|
||||
};
|
||||
|
||||
for (const secret of data.secrets) push(secret.secretKey, secret.secretValue);
|
||||
for (const importGroup of data.imports ?? [])
|
||||
for (const secret of importGroup.secrets)
|
||||
push(secret.secretKey, secret.secretValue);
|
||||
|
||||
console.log(
|
||||
`[fetchInfisicalSecrets] Synced ${envVars.length} secrets from Infisical (env=${env}, path=${secretPath})`,
|
||||
);
|
||||
return envVars;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the four credential vars (`INFISICAL_CLIENT_ID`, `_SECRET`,
|
||||
* `_PROJECT_ID`, `_ENVIRONMENT`) from the local process env first then
|
||||
* trigger.dev's deploy-time `ctx.env`. Convenience for `syncEnvVars`.
|
||||
*/
|
||||
export const fetchInfisicalSecretsFromEnv = (
|
||||
ctxEnv: Record<string, string | undefined> = {},
|
||||
): Promise<InfisicalSyncEnvVar[]> =>
|
||||
fetchInfisicalSecrets({
|
||||
clientId: process.env.INFISICAL_CLIENT_ID ?? ctxEnv.INFISICAL_CLIENT_ID,
|
||||
clientSecret:
|
||||
process.env.INFISICAL_CLIENT_SECRET ?? ctxEnv.INFISICAL_CLIENT_SECRET,
|
||||
projectId: process.env.INFISICAL_PROJECT_ID ?? ctxEnv.INFISICAL_PROJECT_ID,
|
||||
environment:
|
||||
process.env.INFISICAL_ENVIRONMENT ?? ctxEnv.INFISICAL_ENVIRONMENT,
|
||||
});
|
||||
64
server/src/external/logtail/logtailUtils.ts
vendored
64
server/src/external/logtail/logtailUtils.ts
vendored
@@ -1,5 +1,6 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import type pino from "pino";
|
||||
import { initLogger } from "@/utils/logging/initLogger";
|
||||
|
||||
const pinoLogger = initLogger();
|
||||
@@ -72,34 +73,45 @@ const createLogMethod = (pinoMethod: any, logtailMethod?: any) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const createLogger = () => {
|
||||
// Helper function to create logger structure recursively
|
||||
const createLoggerStructure = (basePinoLogger: any) => {
|
||||
return {
|
||||
debug: createLogMethod(basePinoLogger.debug.bind(basePinoLogger)),
|
||||
info: createLogMethod(basePinoLogger.info.bind(basePinoLogger)),
|
||||
warn: createLogMethod(basePinoLogger.warn.bind(basePinoLogger)),
|
||||
error: createLogMethod(basePinoLogger.error.bind(basePinoLogger)),
|
||||
child: ({
|
||||
context,
|
||||
onlyProd = false,
|
||||
}: {
|
||||
context: any;
|
||||
onlyProd?: boolean;
|
||||
}) => {
|
||||
if (onlyProd && process.env.NODE_ENV !== "production") {
|
||||
return createLoggerStructure(basePinoLogger);
|
||||
}
|
||||
const createLoggerStructure = (basePinoLogger: pino.Logger): Logger => ({
|
||||
debug: createLogMethod(basePinoLogger.debug.bind(basePinoLogger)),
|
||||
info: createLogMethod(basePinoLogger.info.bind(basePinoLogger)),
|
||||
warn: createLogMethod(basePinoLogger.warn.bind(basePinoLogger)),
|
||||
error: createLogMethod(basePinoLogger.error.bind(basePinoLogger)),
|
||||
child: ({
|
||||
context,
|
||||
onlyProd = false,
|
||||
}: {
|
||||
context: any;
|
||||
onlyProd?: boolean;
|
||||
}) => {
|
||||
if (onlyProd && process.env.NODE_ENV !== "production") {
|
||||
return createLoggerStructure(basePinoLogger);
|
||||
}
|
||||
|
||||
const childPinoLogger = basePinoLogger.child(context);
|
||||
return createLoggerStructure(childPinoLogger);
|
||||
},
|
||||
};
|
||||
};
|
||||
const childPinoLogger = basePinoLogger.child(context);
|
||||
return createLoggerStructure(childPinoLogger);
|
||||
},
|
||||
});
|
||||
|
||||
// Create the root logger using the helper function
|
||||
return createLoggerStructure(pinoLogger);
|
||||
export const createLogger = () => createLoggerStructure(pinoLogger);
|
||||
|
||||
/**
|
||||
* Lazy dual-output logger (stdout JSON + axiom). Used only by long-running
|
||||
* trigger.dev tasks so their lines surface in both the trigger run UI and
|
||||
* our axiom store. Default `logger` / `createLogger` are unaffected.
|
||||
*/
|
||||
let dualPinoLogger: pino.Logger | null = null;
|
||||
export const createDualLogger = () => {
|
||||
if (!dualPinoLogger) dualPinoLogger = initLogger({ mode: "dual" });
|
||||
return createLoggerStructure(dualPinoLogger);
|
||||
};
|
||||
|
||||
export const logger = createLogger();
|
||||
export type Logger = ReturnType<typeof createLogger>;
|
||||
export type Logger = {
|
||||
debug: (...args: any[]) => void;
|
||||
info: (...args: any[]) => void;
|
||||
warn: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
child: (args: { context: any; onlyProd?: boolean }) => Logger;
|
||||
};
|
||||
|
||||
17
server/src/external/redis/initRedisV2.ts
vendored
17
server/src/external/redis/initRedisV2.ts
vendored
@@ -8,24 +8,19 @@ import {
|
||||
waitForRedisReady,
|
||||
} from "./initRedis.js";
|
||||
import {
|
||||
getRedisV2ConnectionConfig,
|
||||
REDIS_V2_COMMAND_TIMEOUT_MS,
|
||||
supportsUpstashShebangForRedisV2,
|
||||
} from "./initUtils/redisV2Config.js";
|
||||
|
||||
const redisV2Config = getRedisV2ConnectionConfig({
|
||||
cacheV2Url: process.env.CACHE_V2_UPSTASH_URL,
|
||||
primaryCacheUrl: process.env.CACHE_URL,
|
||||
currentRegion,
|
||||
export const redisV2: Redis = createRedisConnection({
|
||||
cacheUrl: process.env.CACHE_V2_DRAGONFLY_URL?.trim() || "",
|
||||
region: `${currentRegion}:v2`,
|
||||
supportsUpstashShebang: false,
|
||||
commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
export const hasRedisV2Config = Boolean(redisV2Config);
|
||||
|
||||
export const redisV2: Redis = redisV2Config
|
||||
? createRedisConnection(redisV2Config)
|
||||
: redis;
|
||||
|
||||
const alternateInstanceUrls: Partial<Record<RedisV2InstanceName, string>> = {
|
||||
upstash: process.env.CACHE_V2_UPSTASH_URL?.trim() || undefined,
|
||||
redis: process.env.CACHE_V2_REDIS_URL?.trim() || undefined,
|
||||
dragonfly: process.env.CACHE_V2_DRAGONFLY_URL?.trim() || undefined,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,15 @@ import { registerRedisCommands } from "./registerRedisCommands.js";
|
||||
const REDIS_COMMAND_TIMEOUT_MS =
|
||||
process.env.NODE_ENV === "production" ? 10_000 : 60_000;
|
||||
|
||||
const formatRedisEndpoint = ({ cacheUrl }: { cacheUrl: string }) => {
|
||||
try {
|
||||
const url = new URL(cacheUrl);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch {
|
||||
return "<invalid redis url>";
|
||||
}
|
||||
};
|
||||
|
||||
/** Create a Redis connection for a specific region.
|
||||
* `supportsUpstashShebang` defaults to true; set false for non-Upstash
|
||||
* providers (ElastiCache, Dragonfly, self-hosted) that reject the
|
||||
@@ -13,7 +22,7 @@ const REDIS_COMMAND_TIMEOUT_MS =
|
||||
export const createRedisClient = ({
|
||||
cacheUrl,
|
||||
region,
|
||||
supportsUpstashShebang = true,
|
||||
supportsUpstashShebang = false,
|
||||
commandTimeout = REDIS_COMMAND_TIMEOUT_MS,
|
||||
}: {
|
||||
cacheUrl: string;
|
||||
@@ -21,6 +30,10 @@ export const createRedisClient = ({
|
||||
supportsUpstashShebang?: boolean;
|
||||
commandTimeout?: number;
|
||||
}): Redis => {
|
||||
console.log(
|
||||
`[Redis] ${region}: connecting to ${formatRedisEndpoint({ cacheUrl })}`,
|
||||
);
|
||||
|
||||
const instance = new Redis(cacheUrl, {
|
||||
tls:
|
||||
process.env.CACHE_CERT && !cacheBackupUrl
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
hasRedisV2Config,
|
||||
redisV2,
|
||||
} from "../initRedisV2.js";
|
||||
import { redisV2 } from "../initRedisV2.js";
|
||||
import {
|
||||
createRedisAvailability,
|
||||
type RedisAvailabilitySnapshot,
|
||||
} from "./createRedisAvailability.js";
|
||||
import {
|
||||
getRedisAvailability,
|
||||
shouldUseRedis,
|
||||
} from "./redisAvailability.js";
|
||||
import { getRedisAvailability, shouldUseRedis } from "./redisAvailability.js";
|
||||
import { redis as primaryRedis } from "./redisClientRegistry.js";
|
||||
|
||||
const usesPrimaryRedis = redisV2 === primaryRedis;
|
||||
@@ -18,7 +12,7 @@ const getPrimaryBackedRedisV2Availability = (): RedisAvailabilitySnapshot => {
|
||||
const availability = getRedisAvailability();
|
||||
|
||||
return {
|
||||
configured: hasRedisV2Config,
|
||||
configured: true,
|
||||
state: availability.state,
|
||||
status: availability.status,
|
||||
};
|
||||
@@ -34,7 +28,7 @@ const redisV2Availability = usesPrimaryRedis
|
||||
}
|
||||
: createRedisAvailability({
|
||||
redis: redisV2,
|
||||
hasConfig: hasRedisV2Config,
|
||||
hasConfig: true,
|
||||
logPrefix: "RedisV2",
|
||||
logType: "redis_v2_availability_state_set",
|
||||
});
|
||||
@@ -50,7 +44,7 @@ const {
|
||||
export {
|
||||
getRedisV2Availability,
|
||||
primeRedisV2Monitor,
|
||||
shouldUseRedisV2,
|
||||
startRedisV2Monitor,
|
||||
stopRedisV2Monitor,
|
||||
shouldUseRedisV2,
|
||||
};
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { RedisV2InstanceName } from "@/internal/misc/redisV2Cache/redisV2CacheSchemas.js";
|
||||
|
||||
export const REDIS_V2_COMMAND_TIMEOUT_MS = 1_000;
|
||||
export const REDIS_V2_COMMAND_TIMEOUT_MS =
|
||||
process.env.NODE_ENV === "production" ? 1_000 : 10_000;
|
||||
|
||||
export const getRedisV2ConnectionConfig = ({
|
||||
cacheV2Url,
|
||||
primaryCacheUrl,
|
||||
currentRegion,
|
||||
instanceName,
|
||||
}: {
|
||||
cacheV2Url?: string;
|
||||
primaryCacheUrl?: string;
|
||||
currentRegion: string;
|
||||
instanceName: RedisV2InstanceName;
|
||||
}) =>
|
||||
cacheV2Url?.trim() && cacheV2Url.trim() !== primaryCacheUrl?.trim()
|
||||
cacheV2Url?.trim()
|
||||
? {
|
||||
cacheUrl: cacheV2Url.trim(),
|
||||
region: `${currentRegion}:v2`,
|
||||
supportsUpstashShebang: supportsUpstashShebangForRedisV2("upstash"),
|
||||
supportsUpstashShebang: supportsUpstashShebangForRedisV2(instanceName),
|
||||
commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS,
|
||||
}
|
||||
: null;
|
||||
|
||||
2
server/src/external/redis/resolveRedisV2.ts
vendored
2
server/src/external/redis/resolveRedisV2.ts
vendored
@@ -22,7 +22,7 @@ export const resolveRedisV2 = (): Redis => {
|
||||
lastLoggedInstance = activeInstance;
|
||||
}
|
||||
|
||||
if (activeInstance === "upstash") return redisV2Primary;
|
||||
if (activeInstance === "dragonfly") return redisV2Primary;
|
||||
|
||||
const alternate = getAlternateRedisV2Instance(activeInstance);
|
||||
return alternate ?? redisV2Primary;
|
||||
|
||||
@@ -19,9 +19,14 @@ export type SkipSubscriptionSyncResult =
|
||||
export const shouldSkipSubscriptionSync = ({
|
||||
subscription,
|
||||
fullCustomer,
|
||||
requireRecent = true,
|
||||
}: {
|
||||
subscription: Stripe.Subscription;
|
||||
fullCustomer: FullCustomer;
|
||||
/** For sub.created, pass false: any prior Autumn management is enough to
|
||||
* skip. For sub.updated (default), only a recent stamp suppresses sync so
|
||||
* later genuine changes still get picked up. */
|
||||
requireRecent?: boolean;
|
||||
}): SkipSubscriptionSyncResult => {
|
||||
const alreadyLinked = fullCustomer.customer_products?.some(
|
||||
(customerProduct) =>
|
||||
@@ -33,6 +38,7 @@ export const shouldSkipSubscriptionSync = ({
|
||||
|
||||
const metadataDecision = isAutumnManagedSubscriptionMetadata({
|
||||
metadata: subscription.metadata,
|
||||
requireRecent,
|
||||
});
|
||||
if (metadataDecision.skip) {
|
||||
return { skip: true, reason: metadataDecision.reason ?? "autumn metadata" };
|
||||
|
||||
@@ -3,7 +3,6 @@ import type Stripe from "stripe";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { getFullStripeSub } from "../../stripeSubUtils.js";
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
|
||||
import { shouldSkipSubscriptionSync } from "../common/subscriptionSync/shouldSkipSubscriptionSync.js";
|
||||
|
||||
export type StripeSubscriptionCreatedContext = {
|
||||
subscription: Stripe.Subscription;
|
||||
@@ -16,7 +15,7 @@ export const setupStripeSubscriptionCreatedContext = async ({
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
}): Promise<StripeSubscriptionCreatedContext | undefined> => {
|
||||
const { db, org, env, fullCustomer, stripeCli, stripeEvent, logger } = ctx;
|
||||
const { db, org, env, fullCustomer, stripeCli, stripeEvent } = ctx;
|
||||
const stripeObject = stripeEvent.data.object as Stripe.Subscription;
|
||||
|
||||
// No auto-provisioning — only sync subs for customers already in Autumn.
|
||||
@@ -27,13 +26,5 @@ export const setupStripeSubscriptionCreatedContext = async ({
|
||||
ProductService.listFull({ db, orgId: org.id, env }),
|
||||
]);
|
||||
|
||||
const skip = shouldSkipSubscriptionSync({ subscription, fullCustomer });
|
||||
if (skip.skip) {
|
||||
logger.info(
|
||||
`sub.created auto-sync: skipping stripe sub ${subscription.id} (${skip.reason})`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { subscription, fullCustomer, candidateProducts };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/
|
||||
import { billingActions } from "@/internal/billing/v2/actions";
|
||||
import { canAutoSync } from "@/internal/billing/v2/actions/sync/canAutoSync.js";
|
||||
import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams.js";
|
||||
import { isAutumnCheckoutSubscription } from "@/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.js";
|
||||
import { shouldSkipSubscriptionSync } from "../../common/subscriptionSync/shouldSkipSubscriptionSync.js";
|
||||
import type { StripeSubscriptionCreatedContext } from "../setupStripeSubscriptionCreatedContext.js";
|
||||
|
||||
/**
|
||||
@@ -21,10 +23,29 @@ export const autoSyncFromSubscription = async ({
|
||||
ctx: StripeWebhookContext;
|
||||
subscriptionCreatedContext: StripeSubscriptionCreatedContext;
|
||||
}) => {
|
||||
const { logger } = ctx;
|
||||
const { logger, stripeCli } = ctx;
|
||||
const { subscription, fullCustomer } = subscriptionCreatedContext;
|
||||
const customerId = fullCustomer.id ?? fullCustomer.internal_id;
|
||||
|
||||
const skip = shouldSkipSubscriptionSync({
|
||||
subscription,
|
||||
fullCustomer,
|
||||
requireRecent: false,
|
||||
});
|
||||
if (skip.skip) {
|
||||
logger.info(
|
||||
`sub.created auto-sync skipping ${subscription.id} (${skip.reason})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isAutumnCheckoutSubscription({ stripeCli, subscription })) {
|
||||
logger.info(
|
||||
`sub.created auto-sync skipping ${subscription.id}: originated from Autumn checkout session`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
|
||||
23
server/src/external/tinybird/initTinybird.ts
vendored
23
server/src/external/tinybird/initTinybird.ts
vendored
@@ -5,22 +5,16 @@ import { createAggregateSimplePipe } from "./pipes/aggregateSimplePipe.js";
|
||||
import { createEstimatedMrrPipe } from "./pipes/estimatedMrrPipe.js";
|
||||
import { createListEventNamesPipe } from "./pipes/listEventNamesPipe.js";
|
||||
import { createListEventsPaginatedPipe } from "./pipes/listEventsPaginatedPipe.js";
|
||||
import { tinybirdConfig } from "./tinybirdUtils.js";
|
||||
import { z } from "./tinybirdZod.js";
|
||||
|
||||
const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL;
|
||||
const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN;
|
||||
|
||||
/** Tinybird REST API client singleton. Null if not configured. */
|
||||
const tinybirdClient: Tinybird | null =
|
||||
TINYBIRD_API_URL && TINYBIRD_TOKEN
|
||||
? new Tinybird({
|
||||
baseUrl: TINYBIRD_API_URL,
|
||||
token: TINYBIRD_TOKEN,
|
||||
})
|
||||
: null;
|
||||
const tinybirdClient: Tinybird | null = tinybirdConfig
|
||||
? new Tinybird(tinybirdConfig)
|
||||
: null;
|
||||
|
||||
if (tinybirdClient) {
|
||||
console.log(`[Tinybird] Configured with URL: ${TINYBIRD_API_URL}`);
|
||||
if (tinybirdConfig) {
|
||||
console.log(`[Tinybird] Configured with URL: ${tinybirdConfig.baseUrl}`);
|
||||
}
|
||||
|
||||
/** Zod schema for TinybirdEvent (matches events.datasource) */
|
||||
@@ -82,11 +76,6 @@ export const getTinybirdIngest = () => {
|
||||
return tinybirdIngest;
|
||||
};
|
||||
|
||||
/** Check if Tinybird is configured */
|
||||
export const isTinybirdConfigured = (): boolean => {
|
||||
return tinybirdClient !== null;
|
||||
};
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
AggregateGroupablePipeParams,
|
||||
|
||||
124
server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts
vendored
Normal file
124
server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
defineDatasource,
|
||||
defineEndpoint,
|
||||
engine,
|
||||
type InferRow,
|
||||
node,
|
||||
p,
|
||||
Tinybird,
|
||||
t,
|
||||
} from "@tinybirdco/sdk";
|
||||
import { tinybirdConfig } from "../tinybirdUtils.js";
|
||||
|
||||
export type MigrationItemEventStatus = "succeeded" | "skipped" | "failed";
|
||||
|
||||
export type MigrationItemPreview = {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export type MigrationItemEventResponse = Record<string, unknown> | null;
|
||||
|
||||
export const migrationItemEventsDatasource = defineDatasource(
|
||||
"migration_item_events",
|
||||
{
|
||||
description: "One audit result row per migration item run.",
|
||||
schema: {
|
||||
timestamp: t.dateTime64(6),
|
||||
org_id: t.string(),
|
||||
env: t.string().lowCardinality(),
|
||||
migration_internal_id: t.string(),
|
||||
migration_run_id: t.string(),
|
||||
dry_run: t.bool(),
|
||||
item_kind: t.string().lowCardinality(),
|
||||
item_id: t.string(),
|
||||
item_preview: t.json<MigrationItemPreview | null>(),
|
||||
status: t.string<MigrationItemEventStatus>().lowCardinality(),
|
||||
response: t.json<MigrationItemEventResponse>(),
|
||||
},
|
||||
engine: engine.mergeTree({
|
||||
partitionKey: "toYYYYMM(timestamp)",
|
||||
sortingKey: [
|
||||
"org_id",
|
||||
"env",
|
||||
"migration_run_id",
|
||||
"item_kind",
|
||||
"item_id",
|
||||
"timestamp",
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
export type TinybirdMigrationItemEvent = InferRow<
|
||||
typeof migrationItemEventsDatasource
|
||||
>;
|
||||
|
||||
export const listMigrationItemEventsEndpoint = defineEndpoint(
|
||||
"list_migration_item_events",
|
||||
{
|
||||
description: "List audit result rows for a migration run.",
|
||||
params: {
|
||||
org_id: p.string(),
|
||||
env: p.string(),
|
||||
migration_internal_id: p.string(),
|
||||
migration_run_id: p.string().optional(""),
|
||||
limit: p.int32().optional(1000),
|
||||
},
|
||||
nodes: [
|
||||
node({
|
||||
name: "endpoint",
|
||||
sql: `
|
||||
SELECT
|
||||
timestamp,
|
||||
org_id,
|
||||
env,
|
||||
migration_internal_id,
|
||||
migration_run_id,
|
||||
dry_run,
|
||||
item_kind,
|
||||
item_id,
|
||||
item_preview,
|
||||
status,
|
||||
response
|
||||
FROM migration_item_events
|
||||
WHERE org_id = {{String(org_id)}}
|
||||
AND env = {{String(env)}}
|
||||
AND migration_internal_id = {{String(migration_internal_id)}}
|
||||
{% if defined(migration_run_id) and String(migration_run_id, '') != '' %}
|
||||
AND migration_run_id = {{String(migration_run_id)}}
|
||||
{% end %}
|
||||
ORDER BY timestamp DESC, item_kind ASC, item_id ASC
|
||||
LIMIT {{Int32(limit, 1000)}}
|
||||
`,
|
||||
}),
|
||||
],
|
||||
output: {
|
||||
timestamp: t.dateTime64(6),
|
||||
org_id: t.string(),
|
||||
env: t.string().lowCardinality(),
|
||||
migration_internal_id: t.string(),
|
||||
migration_run_id: t.string(),
|
||||
dry_run: t.bool(),
|
||||
item_kind: t.string().lowCardinality(),
|
||||
item_id: t.string(),
|
||||
item_preview: t.json<MigrationItemPreview | null>(),
|
||||
status: t.string<MigrationItemEventStatus>().lowCardinality(),
|
||||
response: t.json<MigrationItemEventResponse>(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const migrationTinybird = tinybirdConfig
|
||||
? new Tinybird({
|
||||
datasources: {
|
||||
itemEvents: migrationItemEventsDatasource,
|
||||
},
|
||||
pipes: {
|
||||
listItemEvents: listMigrationItemEventsEndpoint,
|
||||
},
|
||||
...tinybirdConfig,
|
||||
devMode: false,
|
||||
})
|
||||
: null;
|
||||
@@ -2,7 +2,8 @@ import * as crypto from "node:crypto";
|
||||
import type { EventInsert } from "@autumn/shared";
|
||||
import * as Sentry from "@sentry/bun";
|
||||
import type { Logger } from "@/external/logtail/logtailUtils.js";
|
||||
import { isTinybirdConfigured, tinybirdIngest } from "../initTinybird.js";
|
||||
import { tinybirdIngest } from "../initTinybird.js";
|
||||
import { isTinybirdConfigured } from "../tinybirdUtils.js";
|
||||
import { mapToTinybirdEvent } from "./mapEvent.js";
|
||||
|
||||
/** Generate a unique error ID for tracking */
|
||||
|
||||
21
server/src/external/tinybird/tinybirdUtils.ts
vendored
21
server/src/external/tinybird/tinybirdUtils.ts
vendored
@@ -1,9 +1,28 @@
|
||||
import { ErrCode, RecaseError } from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
|
||||
const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL;
|
||||
const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN;
|
||||
|
||||
export type TinybirdConfig = {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const tinybirdConfig: TinybirdConfig | null =
|
||||
TINYBIRD_API_URL && TINYBIRD_TOKEN
|
||||
? {
|
||||
baseUrl: TINYBIRD_API_URL,
|
||||
token: TINYBIRD_TOKEN,
|
||||
}
|
||||
: null;
|
||||
|
||||
/** Check if Tinybird is configured. */
|
||||
export const isTinybirdConfigured = (): boolean => tinybirdConfig !== null;
|
||||
|
||||
/** Throws SERVICE_UNAVAILABLE if Tinybird is not configured. */
|
||||
export const assertTinybirdAvailable = () => {
|
||||
if (!process.env.TINYBIRD_TOKEN) {
|
||||
if (!isTinybirdConfigured()) {
|
||||
throw new RecaseError({
|
||||
message: "Tinybird is not configured, cannot fetch analytics",
|
||||
code: ErrCode.TinybirdDisabled,
|
||||
|
||||
@@ -61,6 +61,13 @@ export type RequestContext = {
|
||||
expand: string[];
|
||||
skipCache: boolean;
|
||||
|
||||
/** True when the context is built by `createTriggerContext` — i.e. we're
|
||||
* executing inside a Trigger.dev task. Read by `checkPendingMigrationsForCustomer`
|
||||
* to short-circuit: a migration task loads `CusService.getFull` /
|
||||
* `getFullSubject` for its target customer, and that load must NOT
|
||||
* re-enqueue another migration task. */
|
||||
insideTriggerTask?: boolean;
|
||||
|
||||
extraLogs: Record<string, unknown>;
|
||||
|
||||
fullCustomer?: FullCustomer;
|
||||
|
||||
@@ -26,6 +26,8 @@ import "./internal/misc/edgeConfig/orgLimitsStore.js";
|
||||
import "./internal/misc/stripeSync/stripeSyncStore.js";
|
||||
import "./internal/misc/redisV2Cache/redisV2CacheStore.js";
|
||||
import "./internal/misc/jobQueues/jobQueueStore.js";
|
||||
// Side-effect: configures trigger.dev SDK to use TRIGGER_SERVER_SECRET_KEY.
|
||||
import "./trigger/configureTrigger.js";
|
||||
import { closeStripeSyncEngine } from "@autumn/stripe-sync";
|
||||
import {
|
||||
startRedisMonitor,
|
||||
@@ -51,8 +53,6 @@ let shuttingDown = false;
|
||||
const init = async ({ startupStartedAt }: { startupStartedAt: number }) => {
|
||||
logger.info(getRedactedDatabaseUrls(), "DB URLs");
|
||||
|
||||
console.log("DB URLs:", getRedactedDatabaseUrls());
|
||||
|
||||
const app = createHonoApp();
|
||||
|
||||
initPgHealthMonitor({ client: clientCritical });
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* True when `subscription` was created by an Autumn-managed Checkout Session.
|
||||
*
|
||||
* Why: `checkout.session.completed` materializes the cus_product itself, so
|
||||
* auto-sync from `customer.subscription.created` would race and produce a
|
||||
* duplicate row on the same Stripe sub.
|
||||
*/
|
||||
export const isAutumnCheckoutSubscription = async ({
|
||||
stripeCli,
|
||||
subscription,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
subscription: Stripe.Subscription;
|
||||
}): Promise<boolean> => {
|
||||
const sessions = await stripeCli.checkout.sessions.list({
|
||||
subscription: subscription.id,
|
||||
limit: 1,
|
||||
});
|
||||
const session = sessions.data[0];
|
||||
return Boolean(session?.metadata?.autumn_metadata_id);
|
||||
};
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
type AutumnBillingPlan,
|
||||
CusProductStatus,
|
||||
cp,
|
||||
type FullCusProduct,
|
||||
findMainActiveCustomerProductByGroup,
|
||||
isCustomerProductCanceling,
|
||||
isFutureStartDate,
|
||||
type UpdateSubscriptionBillingContext,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
@@ -39,6 +43,68 @@ const computeScheduledAddOnsToDelete = ({
|
||||
});
|
||||
};
|
||||
|
||||
const shouldDeleteCustomerProductBeforeBillingStarts = ({
|
||||
customerProduct,
|
||||
currentEpochMs,
|
||||
}: {
|
||||
customerProduct: FullCusProduct;
|
||||
currentEpochMs: number;
|
||||
}): boolean => {
|
||||
if (customerProduct.status === CusProductStatus.Scheduled) return true;
|
||||
|
||||
const hasStripeSchedule = (customerProduct.scheduled_ids?.length ?? 0) > 0;
|
||||
const hasStripeSubscription =
|
||||
(customerProduct.subscription_ids?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
hasStripeSchedule &&
|
||||
!hasStripeSubscription &&
|
||||
isFutureStartDate(customerProduct.starts_at, currentEpochMs)
|
||||
);
|
||||
};
|
||||
|
||||
const computeScheduledCancelPlan = ({
|
||||
billingContext,
|
||||
plan,
|
||||
}: {
|
||||
billingContext: UpdateSubscriptionBillingContext;
|
||||
plan: AutumnBillingPlan;
|
||||
}): AutumnBillingPlan => {
|
||||
const { customerProduct, fullCustomer } = billingContext;
|
||||
|
||||
const activeCustomerProduct = findMainActiveCustomerProductByGroup({
|
||||
fullCus: fullCustomer,
|
||||
productGroup: customerProduct.product.group,
|
||||
internalEntityId: customerProduct.internal_entity_id ?? undefined,
|
||||
});
|
||||
|
||||
const scheduledCancelPlan: AutumnBillingPlan = {
|
||||
...plan,
|
||||
updateCustomerProduct: undefined,
|
||||
deleteCustomerProduct: customerProduct,
|
||||
};
|
||||
|
||||
if (
|
||||
!activeCustomerProduct ||
|
||||
activeCustomerProduct.id === customerProduct.id ||
|
||||
!isCustomerProductCanceling(activeCustomerProduct)
|
||||
) {
|
||||
return scheduledCancelPlan;
|
||||
}
|
||||
|
||||
return {
|
||||
...scheduledCancelPlan,
|
||||
updateCustomerProduct: {
|
||||
customerProduct: activeCustomerProduct,
|
||||
updates: {
|
||||
canceled: false,
|
||||
canceled_at: null,
|
||||
ended_at: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes and applies the cancel plan for a subscription.
|
||||
*
|
||||
@@ -64,6 +130,18 @@ export const computeCancelPlan = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
shouldDeleteCustomerProductBeforeBillingStarts({
|
||||
customerProduct: billingContext.customerProduct,
|
||||
currentEpochMs: billingContext.currentEpochMs,
|
||||
})
|
||||
) {
|
||||
return computeScheduledCancelPlan({
|
||||
billingContext,
|
||||
plan,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: Calculate when the subscription ends
|
||||
const endOfCycleMs = computeEndOfCycleMs({ billingContext });
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ export const handleCurrentCustomerProductErrors = ({
|
||||
}) => {
|
||||
const { customerProduct } = billingContext;
|
||||
|
||||
if (isCustomerProductScheduled(customerProduct)) {
|
||||
if (
|
||||
isCustomerProductScheduled(customerProduct) &&
|
||||
!billingContext.cancelAction
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot update subscription for '${customerProduct.product.name}' because it is scheduled and not yet active`,
|
||||
});
|
||||
|
||||
@@ -85,5 +85,5 @@ export const handleUpdateSubscriptionErrors = async ({
|
||||
handleUpdateCheckoutErrors({ billingContext });
|
||||
|
||||
// 12. Stripe billing plan errors (validate Stripe resources)
|
||||
handleStripeBillingPlanErrors({ billingContext });
|
||||
handleStripeBillingPlanErrors({ billingContext, billingPlan });
|
||||
};
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
type UpdateSubscriptionV1Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { setupPatchContext } from "@/internal/billing/v2/setup/patch";
|
||||
import {
|
||||
type ReusePricesAndEntitlements,
|
||||
setupPatchContext,
|
||||
} from "@/internal/billing/v2/setup/patch";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
import { setupCustomFullProduct } from "../../../setup/setupCustomFullProduct";
|
||||
import { findTargetCustomerProduct } from "./findTargetCustomerProduct";
|
||||
@@ -18,11 +21,13 @@ export const setupUpdateSubscriptionProductContext = async ({
|
||||
fullCustomer,
|
||||
params,
|
||||
contextOverride = {},
|
||||
reusePricesAndEntitlements,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCustomer: FullCustomer;
|
||||
params: UpdateSubscriptionV1Params;
|
||||
contextOverride?: UpdateSubscriptionBillingContextOverride;
|
||||
reusePricesAndEntitlements?: ReusePricesAndEntitlements;
|
||||
}) => {
|
||||
const { productContext } = contextOverride;
|
||||
|
||||
@@ -63,6 +68,7 @@ export const setupUpdateSubscriptionProductContext = async ({
|
||||
params,
|
||||
customerProduct: targetCustomerProduct,
|
||||
fullProduct,
|
||||
reusePricesAndEntitlements,
|
||||
});
|
||||
|
||||
const {
|
||||
|
||||
@@ -91,12 +91,40 @@ const getScheduleScenario = ({
|
||||
return "multi_phase";
|
||||
};
|
||||
|
||||
const buildNoPhasesAction = ({
|
||||
hasSubscription,
|
||||
scheduleId,
|
||||
}: {
|
||||
hasSubscription: boolean;
|
||||
scheduleId: string | undefined;
|
||||
}): StripeSubscriptionScheduleResult => {
|
||||
if (!scheduleId) return {};
|
||||
|
||||
if (hasSubscription) {
|
||||
return {
|
||||
scheduleAction: {
|
||||
type: "release",
|
||||
stripeSubscriptionScheduleId: scheduleId,
|
||||
},
|
||||
subscriptionCancelAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleAction: {
|
||||
type: "cancel",
|
||||
stripeSubscriptionScheduleId: scheduleId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the appropriate action for each scenario.
|
||||
*/
|
||||
const buildActionForScenario = ({
|
||||
scenario,
|
||||
hasSchedule,
|
||||
hasSubscription,
|
||||
scheduleId,
|
||||
scheduledPhases,
|
||||
cancelAtSeconds,
|
||||
@@ -105,6 +133,7 @@ const buildActionForScenario = ({
|
||||
}: {
|
||||
scenario: ScheduleScenario;
|
||||
hasSchedule: boolean;
|
||||
hasSubscription: boolean;
|
||||
scheduleId: string | undefined;
|
||||
scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[];
|
||||
cancelAtSeconds: number | undefined;
|
||||
@@ -113,7 +142,10 @@ const buildActionForScenario = ({
|
||||
}): StripeSubscriptionScheduleResult => {
|
||||
switch (scenario) {
|
||||
case "no_phases":
|
||||
return {};
|
||||
return buildNoPhasesAction({
|
||||
hasSubscription,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
case "single_indefinite":
|
||||
// Product continues indefinitely: release schedule if exists, clear any cancel_at
|
||||
@@ -285,6 +317,7 @@ export const buildStripeSubscriptionScheduleAction = ({
|
||||
return buildActionForScenario({
|
||||
scenario,
|
||||
hasSchedule: !!stripeSubscriptionSchedule,
|
||||
hasSubscription: !!stripeSubscription,
|
||||
scheduleId: stripeSubscriptionSchedule?.id,
|
||||
scheduledPhases,
|
||||
cancelAtSeconds,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { RecaseError, type StripeBillingPlan } from "@autumn/shared";
|
||||
import {
|
||||
billingPlanWillCharge,
|
||||
getChargeReasonMessage,
|
||||
} from "@/internal/billing/v2/utils/billingPlan/billingPlanWillCharge.js";
|
||||
|
||||
export type StripePlanNoChargesViolation = {
|
||||
message: string;
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const hasStripePlanActions = (stripeBillingPlan: StripeBillingPlan) =>
|
||||
Object.values(stripeBillingPlan).some((action) => action !== undefined);
|
||||
|
||||
const getStripePlanNoChargesViolation = ({
|
||||
stripeBillingPlan,
|
||||
subscriptionId,
|
||||
}: {
|
||||
stripeBillingPlan: StripeBillingPlan;
|
||||
subscriptionId?: string;
|
||||
}): StripePlanNoChargesViolation | undefined => {
|
||||
const details = subscriptionId ? { subscriptionId } : {};
|
||||
const chargeResult = billingPlanWillCharge({
|
||||
billingPlan: { stripe: stripeBillingPlan },
|
||||
});
|
||||
|
||||
if (chargeResult.willCharge) {
|
||||
return {
|
||||
message: `Stripe billing plan will charge because ${getChargeReasonMessage(chargeResult.reason)}`,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
if (stripeBillingPlan.invoiceAction) {
|
||||
return {
|
||||
message: "Stripe billing plan produced an invoice action",
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
if (stripeBillingPlan.refundAction) {
|
||||
return {
|
||||
message: "Stripe billing plan produced a refund action",
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
const { subscriptionAction } = stripeBillingPlan;
|
||||
if (subscriptionAction?.type === "create") {
|
||||
return {
|
||||
message: "Stripe billing plan produced a subscription create action",
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
subscriptionAction?.type === "update" &&
|
||||
subscriptionAction.params.proration_behavior !== "none"
|
||||
) {
|
||||
return {
|
||||
message:
|
||||
"Stripe billing plan produced a subscription update without proration_behavior: none",
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const assertStripePlanNoCharges = ({
|
||||
stripeBillingPlan,
|
||||
subscriptionId,
|
||||
createError = (violation) =>
|
||||
new RecaseError({
|
||||
message: violation.message,
|
||||
data: violation.details,
|
||||
}),
|
||||
}: {
|
||||
stripeBillingPlan: StripeBillingPlan;
|
||||
subscriptionId?: string;
|
||||
createError?: (violation: StripePlanNoChargesViolation) => Error;
|
||||
}) => {
|
||||
const violation = getStripePlanNoChargesViolation({
|
||||
stripeBillingPlan,
|
||||
subscriptionId,
|
||||
});
|
||||
if (!violation) return;
|
||||
|
||||
throw createError(violation);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
BillingPlan,
|
||||
UpdateSubscriptionBillingContext,
|
||||
} from "@autumn/shared";
|
||||
import { ErrCode, InternalError } from "@autumn/shared";
|
||||
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Validates Stripe-specific billing context requirements before executing billing plan.
|
||||
@@ -7,22 +10,24 @@ import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
|
||||
*/
|
||||
export const handleStripeBillingPlanErrors = ({
|
||||
billingContext,
|
||||
billingPlan,
|
||||
}: {
|
||||
billingContext: UpdateSubscriptionBillingContext;
|
||||
billingPlan: BillingPlan;
|
||||
}) => {
|
||||
// If there's an existing subscription schedule, validate it has current_phase.start_date
|
||||
// This is required for schedule updates (Stripe requires anchoring phases to the current phase start)
|
||||
const { stripeSubscriptionSchedule } = billingContext;
|
||||
const { subscriptionScheduleAction } = billingPlan.stripe;
|
||||
|
||||
if (billingContext.stripeSubscriptionSchedule) {
|
||||
const currentPhaseStart =
|
||||
billingContext.stripeSubscriptionSchedule.current_phase?.start_date;
|
||||
if (subscriptionScheduleAction?.type !== "update") return;
|
||||
if (!stripeSubscriptionSchedule?.subscription) return;
|
||||
|
||||
if (!currentPhaseStart) {
|
||||
throw new InternalError({
|
||||
message:
|
||||
"Cannot update subscription schedule: missing current phase start_date",
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
const currentPhaseStart =
|
||||
stripeSubscriptionSchedule.current_phase?.start_date;
|
||||
if (!currentPhaseStart) {
|
||||
throw new InternalError({
|
||||
message:
|
||||
"Cannot update subscription schedule: missing current phase start_date",
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -257,5 +257,14 @@ export const executeStripeSubscriptionScheduleAction = async ({
|
||||
subscriptionScheduleAction.stripeSubscriptionScheduleId,
|
||||
);
|
||||
return null;
|
||||
|
||||
case "cancel":
|
||||
ctx.logger.debug(
|
||||
`[executeStripeSubscriptionScheduleAction] Canceling schedule: ${subscriptionScheduleAction.stripeSubscriptionScheduleId}`,
|
||||
);
|
||||
await stripeCli.subscriptionSchedules.cancel(
|
||||
subscriptionScheduleAction.stripeSubscriptionScheduleId,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,14 +26,23 @@ export const buildAutumnSubscriptionMetadata = ({
|
||||
return meta;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param requireRecent — when true (default), `autumn_managed_at` only counts
|
||||
* if it falls within `windowMs`. Used by sub.updated where a stale stamp
|
||||
* shouldn't suppress a genuinely new change. Pass false from sub.created:
|
||||
* once a sub has ever been Autumn-managed, auto-sync should never run on
|
||||
* its creation event.
|
||||
*/
|
||||
export const isAutumnManagedSubscriptionMetadata = ({
|
||||
metadata,
|
||||
windowMs = RECENT_AUTUMN_ACTION_WINDOW_MS,
|
||||
now = Date.now(),
|
||||
requireRecent = true,
|
||||
}: {
|
||||
metadata: Stripe.Metadata | null | undefined;
|
||||
windowMs?: number;
|
||||
now?: number;
|
||||
requireRecent?: boolean;
|
||||
}): { skip: boolean; reason?: string } => {
|
||||
if (!metadata) return { skip: false };
|
||||
|
||||
@@ -49,10 +58,14 @@ export const isAutumnManagedSubscriptionMetadata = ({
|
||||
if (!managedAtRaw) return { skip: false };
|
||||
|
||||
const managedAt = Number(managedAtRaw);
|
||||
if (!Number.isFinite(managedAt) || now - managedAt >= windowMs) {
|
||||
return { skip: false };
|
||||
if (!Number.isFinite(managedAt)) return { skip: false };
|
||||
|
||||
if (!requireRecent) {
|
||||
return { skip: true, reason: `autumn_managed_at present (source=unknown)` };
|
||||
}
|
||||
|
||||
if (now - managedAt >= windowMs) return { skip: false };
|
||||
|
||||
return {
|
||||
skip: true,
|
||||
reason: `recent autumn_managed_at (${now - managedAt}ms ago, source=unknown)`,
|
||||
|
||||
@@ -20,6 +20,52 @@ import {
|
||||
import { PriceService } from "@/internal/products/prices/PriceService";
|
||||
import { checkStripeProductExists } from "@/internal/products/productUtils";
|
||||
|
||||
export const initStripeResourcesForProducts = async ({
|
||||
ctx,
|
||||
products,
|
||||
internalEntityId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
products: FullProduct[];
|
||||
internalEntityId?: string;
|
||||
}) => {
|
||||
const { db, org, env, logger } = ctx;
|
||||
|
||||
const batchProductUpdates = [];
|
||||
for (const product of products) {
|
||||
if (product.processor?.id != null) continue;
|
||||
|
||||
batchProductUpdates.push(
|
||||
checkStripeProductExists({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
product,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(batchProductUpdates);
|
||||
|
||||
const batchPriceUpdates = [];
|
||||
|
||||
for (const product of products) {
|
||||
for (const price of product.prices) {
|
||||
batchPriceUpdates.push(
|
||||
createStripePriceIFNotExist({
|
||||
ctx,
|
||||
price,
|
||||
entitlements: product.entitlements,
|
||||
product,
|
||||
internalEntityId,
|
||||
useCheckout: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(batchPriceUpdates);
|
||||
};
|
||||
|
||||
const shouldInitializeStripePrice = ({ price }: { price: Price }) => {
|
||||
if (!isFixedPrice(price)) return true;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {
|
||||
BillingContext,
|
||||
StripeSubscriptionScheduleAction,
|
||||
} from "@autumn/shared";
|
||||
import { formatSecondsToDate } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
import type { StripeSubscriptionScheduleAction } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { billingContextFormatPriceByStripePriceId } from "@/internal/billing/v2/utils/billingContextPriceLookup";
|
||||
|
||||
@@ -45,7 +47,10 @@ export const logSubscriptionScheduleAction = ({
|
||||
billingContext: BillingContext;
|
||||
subscriptionScheduleAction: StripeSubscriptionScheduleAction;
|
||||
}): void => {
|
||||
if (subscriptionScheduleAction.type === "release") {
|
||||
if (
|
||||
subscriptionScheduleAction.type === "release" ||
|
||||
subscriptionScheduleAction.type === "cancel"
|
||||
) {
|
||||
ctx.logger.debug(
|
||||
`[logSubscriptionScheduleAction] Action type: ${subscriptionScheduleAction.type}`,
|
||||
);
|
||||
|
||||
@@ -7,15 +7,18 @@ import type {
|
||||
SharedContext,
|
||||
} from "@autumn/shared";
|
||||
import { planItemV1ToPriceAndEnt } from "@shared/api/products/items/mappers/planItemV1ToPriceAndEnt";
|
||||
import type { ReusePricesAndEntitlements } from "./types";
|
||||
|
||||
export const handleCustomizeAddItems = ({
|
||||
ctx,
|
||||
customize,
|
||||
fullProduct,
|
||||
reusePricesAndEntitlements,
|
||||
}: {
|
||||
ctx: SharedContext;
|
||||
customize: CustomizePlanV1;
|
||||
fullProduct: FullProduct;
|
||||
reusePricesAndEntitlements?: ReusePricesAndEntitlements;
|
||||
}): {
|
||||
prices: Price[];
|
||||
entitlements: Entitlement[];
|
||||
@@ -24,6 +27,19 @@ export const handleCustomizeAddItems = ({
|
||||
const entitlements: Entitlement[] = [];
|
||||
|
||||
for (const item of customize.add_items ?? []) {
|
||||
const overridePrice = item.price_id
|
||||
? reusePricesAndEntitlements?.pricesById.get(item.price_id)
|
||||
: undefined;
|
||||
const overrideEntitlement = item.entitlement_id
|
||||
? reusePricesAndEntitlements?.entitlementsById.get(item.entitlement_id)
|
||||
: undefined;
|
||||
|
||||
if (overridePrice || overrideEntitlement) {
|
||||
if (overridePrice) prices.push(overridePrice);
|
||||
if (overrideEntitlement) entitlements.push(overrideEntitlement);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { newPrice, newEnt } = planItemV1ToPriceAndEnt({
|
||||
ctx,
|
||||
item,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { basePriceToProductItem } from "@shared/api/products/components/basePrice/basePriceToProductItem";
|
||||
import { customerProductToBasePrice } from "@shared/utils/cusProductUtils/convertCusProduct/customerProductToPrice";
|
||||
import { itemToPriceAndEnt } from "@shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt";
|
||||
import type { ReusePricesAndEntitlements } from "./types";
|
||||
|
||||
const removeCurrentBasePrice = ({
|
||||
targetCustomerProduct,
|
||||
@@ -41,11 +42,13 @@ export const handleCustomizePrice = ({
|
||||
customize,
|
||||
targetCustomerProduct,
|
||||
fullProduct,
|
||||
reusePricesAndEntitlements,
|
||||
}: {
|
||||
ctx: SharedContext;
|
||||
customize: CustomizePlanV1;
|
||||
targetCustomerProduct: FullCusProduct;
|
||||
fullProduct: FullProduct;
|
||||
reusePricesAndEntitlements?: ReusePricesAndEntitlements;
|
||||
}): {
|
||||
customerPrices: FullCustomerPrice[];
|
||||
prices: Price[];
|
||||
@@ -60,18 +63,24 @@ export const handleCustomizePrice = ({
|
||||
return { customerPrices, prices: [] };
|
||||
}
|
||||
|
||||
const item = basePriceToProductItem({
|
||||
ctx,
|
||||
basePrice: customize.price,
|
||||
});
|
||||
const { newPrice, updatedPrice } = itemToPriceAndEnt({
|
||||
item,
|
||||
orgId: fullProduct.org_id,
|
||||
internalProductId: fullProduct.internal_id,
|
||||
isCustom: true,
|
||||
features: ctx.features,
|
||||
});
|
||||
const price = newPrice ?? updatedPrice;
|
||||
const overridePrice = customize.price.price_id
|
||||
? reusePricesAndEntitlements?.pricesById.get(customize.price.price_id)
|
||||
: undefined;
|
||||
let price = overridePrice;
|
||||
if (!price) {
|
||||
const item = basePriceToProductItem({
|
||||
ctx,
|
||||
basePrice: customize.price,
|
||||
});
|
||||
const { newPrice, updatedPrice } = itemToPriceAndEnt({
|
||||
item,
|
||||
orgId: fullProduct.org_id,
|
||||
internalProductId: fullProduct.internal_id,
|
||||
isCustom: true,
|
||||
features: ctx.features,
|
||||
});
|
||||
price = newPrice ?? updatedPrice ?? undefined;
|
||||
}
|
||||
const prices = price ? [price] : [];
|
||||
|
||||
fullProduct.prices.push(...prices);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./handleCustomizeAddItems";
|
||||
export * from "./handleCustomizeDeleteItems";
|
||||
export * from "./setupPatchContext";
|
||||
export * from "./types";
|
||||
|
||||
@@ -13,6 +13,7 @@ import { generateId } from "@/utils/genUtils";
|
||||
import { handleCustomizeAddItems } from "./handleCustomizeAddItems";
|
||||
import { handleCustomizeDeleteItems } from "./handleCustomizeDeleteItems";
|
||||
import { handleCustomizePrice } from "./handleCustomizePrice";
|
||||
import type { ReusePricesAndEntitlements } from "./types";
|
||||
|
||||
const applyProductDefinitionToCustomerProduct = ({
|
||||
fullProduct,
|
||||
@@ -52,8 +53,8 @@ const applyProductBasePriceToCustomerProduct = ({
|
||||
const productBasePrice = fullProduct.prices.find(isFixedPrice);
|
||||
if (!productBasePrice) return;
|
||||
|
||||
const currentBasePrice = customerProduct.customer_prices.find((customerPrice) =>
|
||||
isFixedPrice(customerPrice.price),
|
||||
const currentBasePrice = customerProduct.customer_prices.find(
|
||||
(customerPrice) => isFixedPrice(customerPrice.price),
|
||||
);
|
||||
|
||||
if (!currentBasePrice) {
|
||||
@@ -77,11 +78,13 @@ export const setupPatchContext = ({
|
||||
params,
|
||||
customerProduct,
|
||||
fullProduct,
|
||||
reusePricesAndEntitlements,
|
||||
}: {
|
||||
ctx: SharedContext;
|
||||
params: UpdateSubscriptionV1Params;
|
||||
customerProduct: FullCusProduct;
|
||||
fullProduct: FullProduct;
|
||||
reusePricesAndEntitlements?: ReusePricesAndEntitlements;
|
||||
}): PatchContext | undefined => {
|
||||
if (!isCustomizePlanPatchStyle(params.customize)) return undefined;
|
||||
|
||||
@@ -131,6 +134,7 @@ export const setupPatchContext = ({
|
||||
customize: params.customize,
|
||||
targetCustomerProduct: finalCustomerProduct,
|
||||
fullProduct: patchFullProduct,
|
||||
reusePricesAndEntitlements,
|
||||
});
|
||||
|
||||
const { prices: customItemPrices, entitlements: customEntitlements } =
|
||||
@@ -138,6 +142,7 @@ export const setupPatchContext = ({
|
||||
ctx,
|
||||
customize: params.customize,
|
||||
fullProduct: patchFullProduct,
|
||||
reusePricesAndEntitlements,
|
||||
});
|
||||
|
||||
const patchContext: PatchContext = {
|
||||
|
||||
6
server/src/internal/billing/v2/setup/patch/types.ts
Normal file
6
server/src/internal/billing/v2/setup/patch/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Entitlement, Price } from "@autumn/shared";
|
||||
|
||||
export type ReusePricesAndEntitlements = {
|
||||
pricesById: Map<string, Price>;
|
||||
entitlementsById: Map<string, Entitlement>;
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AutumnBillingPlan, BillingContext } from "@autumn/shared";
|
||||
import type {
|
||||
AutumnBillingPlan,
|
||||
BillingContext,
|
||||
FullCustomer,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
applyCustomerProductPatch,
|
||||
applyCustomerProductUpdate,
|
||||
@@ -7,11 +11,11 @@ import {
|
||||
getUpdateCustomerProducts,
|
||||
} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations";
|
||||
|
||||
export const autumnBillingPlanToFinalFullCustomer = ({
|
||||
billingContext,
|
||||
export const applyAutumnBillingPlanToFullCustomer = ({
|
||||
fullCustomer,
|
||||
autumnBillingPlan,
|
||||
}: {
|
||||
billingContext: BillingContext;
|
||||
fullCustomer: FullCustomer;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
}) => {
|
||||
const { insertCustomerProducts, updateCustomerEntitlements } =
|
||||
@@ -24,7 +28,7 @@ export const autumnBillingPlanToFinalFullCustomer = ({
|
||||
autumnBillingPlan,
|
||||
});
|
||||
|
||||
const finalFullCustomer = structuredClone(billingContext.fullCustomer);
|
||||
const finalFullCustomer = structuredClone(fullCustomer);
|
||||
|
||||
// 1. Combine existing customer products with new ones
|
||||
const combinedCustomerProducts = [
|
||||
@@ -33,23 +37,23 @@ export const autumnBillingPlanToFinalFullCustomer = ({
|
||||
];
|
||||
|
||||
let customerProducts = combinedCustomerProducts.map((customerProduct) => {
|
||||
const updateCustomerProduct = updateCustomerProducts.find(
|
||||
(updateCustomerProduct) =>
|
||||
updateCustomerProduct.customerProduct.id === customerProduct.id,
|
||||
);
|
||||
const patchCustomerProduct = patchCustomerProducts.find(
|
||||
(patchCustomerProduct) =>
|
||||
patchCustomerProduct.customerProduct.id === customerProduct.id,
|
||||
);
|
||||
|
||||
let result = customerProduct;
|
||||
if (updateCustomerProduct) {
|
||||
for (const updateCustomerProduct of updateCustomerProducts) {
|
||||
if (updateCustomerProduct.customerProduct.id !== customerProduct.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result = applyCustomerProductUpdate({
|
||||
customerProduct: result,
|
||||
updates: updateCustomerProduct.updates,
|
||||
});
|
||||
}
|
||||
if (patchCustomerProduct) {
|
||||
|
||||
for (const patchCustomerProduct of patchCustomerProducts) {
|
||||
if (patchCustomerProduct.customerProduct.id !== customerProduct.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result = applyCustomerProductPatch({
|
||||
customerProduct: result,
|
||||
patch: patchCustomerProduct,
|
||||
@@ -122,3 +126,15 @@ export const autumnBillingPlanToFinalFullCustomer = ({
|
||||
customer_products: customerProducts,
|
||||
};
|
||||
};
|
||||
|
||||
export const autumnBillingPlanToFinalFullCustomer = ({
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
}: {
|
||||
billingContext: BillingContext;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
}) =>
|
||||
applyAutumnBillingPlanToFullCustomer({
|
||||
fullCustomer: billingContext.fullCustomer,
|
||||
autumnBillingPlan,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export type BillingPlanChargeResult =
|
||||
export const billingPlanWillCharge = ({
|
||||
billingPlan,
|
||||
}: {
|
||||
billingPlan: BillingPlan;
|
||||
billingPlan: Pick<BillingPlan, "stripe">;
|
||||
}): BillingPlanChargeResult => {
|
||||
const { subscriptionAction, invoiceAction } = billingPlan.stripe;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { executeWithHealthTracking } from "@/db/pgHealthMonitor.js";
|
||||
import type { RepoContext } from "@/db/repoContext.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { checkPendingMigrationsForCustomer } from "@/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.js";
|
||||
import { withSpan } from "../analytics/tracer/spanUtils.js";
|
||||
import {
|
||||
getOrgCusProductLimit,
|
||||
@@ -179,6 +180,10 @@ export class CusService {
|
||||
fullCus,
|
||||
ctx,
|
||||
});
|
||||
await checkPendingMigrationsForCustomer({
|
||||
ctx,
|
||||
fullCustomer: fullCus,
|
||||
});
|
||||
}
|
||||
|
||||
return fullCus;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { type FullSubject, normalizedToFullSubject } from "@autumn/shared";
|
||||
import {
|
||||
type FullSubject,
|
||||
fullSubjectToFullCustomer,
|
||||
normalizedToFullSubject,
|
||||
} from "@autumn/shared";
|
||||
import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRouting.js";
|
||||
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
|
||||
import { checkPendingMigrationsForCustomer } from "@/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.js";
|
||||
import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
||||
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
|
||||
import { applyLiveAggregatedBalances } from "../balances/applyLiveAggregatedBalances.js";
|
||||
@@ -234,6 +239,10 @@ export const getCachedFullSubject = async ({
|
||||
|
||||
const fullSubject = normalizedToFullSubject({ normalized });
|
||||
await lazyResetSubjectEntitlements({ ctx, fullSubject });
|
||||
await checkPendingMigrationsForCustomer({
|
||||
ctx,
|
||||
fullCustomer: fullSubjectToFullCustomer({ fullSubject }),
|
||||
});
|
||||
return { fullSubject, subjectViewEpoch: currentSubjectViewEpoch };
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
CusProductSchema,
|
||||
CustomerSchema,
|
||||
CustomerPriceSchema,
|
||||
CustomerSchema,
|
||||
EntitlementWithFeatureSchema,
|
||||
EntityAggregationsSchema,
|
||||
EntitySchema,
|
||||
FreeTrialSchema,
|
||||
InvoiceSchema,
|
||||
MigrationItemRunSchema,
|
||||
type NormalizedFullSubject,
|
||||
PriceSchema,
|
||||
ProductSchema,
|
||||
@@ -62,6 +63,11 @@ export const CachedFullSubjectSchema = z.object({
|
||||
|
||||
entity_aggregations: EntityAggregationsSchema.optional(),
|
||||
|
||||
// `.default([])` makes pre-existing cache entries (written before this
|
||||
// field existed) hole-fill to an empty array via `normalizeFromSchema`.
|
||||
// The empty-array vs empty-object Lua quirk is also handled there.
|
||||
migration_item_runs: z.array(MigrationItemRunSchema).default([]),
|
||||
|
||||
_schemaVersion: z.number().optional(),
|
||||
_cachedAt: z.number(),
|
||||
meteredFeatures: z.array(z.string()),
|
||||
@@ -117,6 +123,7 @@ export const normalizedToCachedFullSubject = ({
|
||||
subscriptions: normalized.subscriptions,
|
||||
invoices: normalized.invoices,
|
||||
entity_aggregations: normalized.entity_aggregations,
|
||||
migration_item_runs: normalized.migration_item_runs ?? [],
|
||||
_schemaVersion: FULL_SUBJECT_CACHE_SCHEMA_VERSION,
|
||||
_cachedAt: Date.now(),
|
||||
meteredFeatures,
|
||||
@@ -151,5 +158,6 @@ export const cachedFullSubjectToNormalized = ({
|
||||
subscriptions: cached.subscriptions,
|
||||
invoices: cached.invoices,
|
||||
entity_aggregations: cached.entity_aggregations,
|
||||
migration_item_runs: cached.migration_item_runs ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getDbHealth, PgHealth } from "@/db/pgHealthMonitor.js";
|
||||
import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRouting.js";
|
||||
import { redis } from "@/external/redis/initRedis.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { checkPendingMigrationsForCustomer } from "@/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.js";
|
||||
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
|
||||
import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { normalizeFromSchema } from "@/utils/cacheUtils/normalizeFromSchema.js";
|
||||
@@ -235,6 +236,7 @@ export const getCachedFullCustomer = async ({
|
||||
// path to fail because the primary is down.
|
||||
if (getDbHealth() !== PgHealth.Degraded) {
|
||||
await resetCustomerEntitlements({ ctx, fullCus: fullCustomer });
|
||||
await checkPendingMigrationsForCustomer({ ctx, fullCustomer });
|
||||
}
|
||||
|
||||
// Round balance fields to handle floating-point precision from JSON.NUMINCRBY
|
||||
|
||||
@@ -366,6 +366,31 @@ export const getFullCusQuery = ({
|
||||
sqlChunks.push(buildInvoicesCTE(!!entityId));
|
||||
}
|
||||
|
||||
// Unconditional CTE for the customer's `migration_item_runs` scoped to
|
||||
// the org's active lazy migrations. Empty in steady state — joins through
|
||||
// `migration_runs` so callers don't have to thread the active list.
|
||||
sqlChunks.push(sql`, `);
|
||||
sqlChunks.push(sql`
|
||||
customer_migration_item_runs AS (
|
||||
SELECT mir.*
|
||||
FROM migration_item_runs mir
|
||||
WHERE mir.item_kind = 'customer'
|
||||
AND mir.dry_run = false
|
||||
AND mir.item_id = (SELECT internal_id FROM customer_record)
|
||||
AND mir.migration_internal_id IN (
|
||||
SELECT mr.migration_internal_id
|
||||
FROM migration_runs mr
|
||||
WHERE mr.org_id = ${orgId}
|
||||
AND mr.env = ${env}
|
||||
AND mr.status IN ('queued', 'running')
|
||||
AND mr.dry_run = false
|
||||
AND mr.lazy_run = true
|
||||
)
|
||||
ORDER BY mir.updated_at DESC NULLS LAST, mir.created_at DESC
|
||||
LIMIT 10
|
||||
)
|
||||
`);
|
||||
|
||||
// Conditionally add events CTE
|
||||
if (withEvents) {
|
||||
sqlChunks.push(sql`, `);
|
||||
@@ -456,6 +481,13 @@ export const getFullCusQuery = ({
|
||||
(SELECT events FROM customer_events) AS events`);
|
||||
}
|
||||
|
||||
selectFieldsChunks.push(sql`,
|
||||
COALESCE(
|
||||
(SELECT json_agg(row_to_json(mir) ORDER BY mir.updated_at DESC NULLS LAST, mir.created_at DESC)
|
||||
FROM customer_migration_item_runs mir),
|
||||
'[]'::json
|
||||
) AS migration_item_runs`);
|
||||
|
||||
sqlChunks.push(sql`
|
||||
SELECT ${sql.join(selectFieldsChunks, sql``)}
|
||||
FROM customer_record cr
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
type CusProductStatus,
|
||||
type FullSubject,
|
||||
fullSubjectToFullCustomer,
|
||||
type NormalizedFullSubject,
|
||||
normalizedToFullSubject,
|
||||
type SubjectQueryRow,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { checkPendingMigrationsForCustomer } from "@/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.js";
|
||||
import { lazyResetSubjectEntitlements } from "../../actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
|
||||
import { RELEVANT_STATUSES } from "../../cusProducts/CusProductService.js";
|
||||
import { getFullSubjectQuery } from "./getFullSubjectQuery.js";
|
||||
@@ -49,6 +51,10 @@ export async function getFullSubject({
|
||||
allowMissingEntity,
|
||||
});
|
||||
await lazyResetSubjectEntitlements({ ctx, fullSubject });
|
||||
await checkPendingMigrationsForCustomer({
|
||||
ctx,
|
||||
fullCustomer: fullSubjectToFullCustomer({ fullSubject }),
|
||||
});
|
||||
return fullSubject;
|
||||
}
|
||||
|
||||
@@ -92,6 +98,10 @@ export async function getFullSubjectNormalized({
|
||||
|
||||
const fullSubject = normalizedToFullSubject({ normalized });
|
||||
await lazyResetSubjectEntitlements({ ctx, fullSubject, normalized });
|
||||
await checkPendingMigrationsForCustomer({
|
||||
ctx,
|
||||
fullCustomer: fullSubjectToFullCustomer({ fullSubject }),
|
||||
});
|
||||
|
||||
return { normalized, fullSubject };
|
||||
}
|
||||
|
||||
@@ -266,6 +266,7 @@ export const subjectQueryRowToNormalized = ({
|
||||
subscriptions: row.subscriptions ?? [],
|
||||
invoices: row.invoices ?? [],
|
||||
entity_aggregations: entityAggregations,
|
||||
migration_item_runs: row.migration_item_runs ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ApiKey, AppEnv } from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import { ApiKeyService } from "../ApiKeyService.js";
|
||||
import { apiKeyRepo } from "../repos/index.js";
|
||||
import {
|
||||
getCachedSecretKeyVerification,
|
||||
SECRET_KEY_CACHE_TTL_SECONDS,
|
||||
@@ -148,19 +149,26 @@ export const verifyKey = async ({
|
||||
: AppEnv.Live;
|
||||
|
||||
const cached = await getCachedSecretKeyVerification<
|
||||
Awaited<ReturnType<typeof ApiKeyService.verifyAndFetch>>
|
||||
Awaited<ReturnType<typeof apiKeyRepo.verify>>
|
||||
>({
|
||||
hashedKey,
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
// Backfill `pendingMigrations` on payloads cached before the field
|
||||
// existed — guarantees consumers can rely on the shape.
|
||||
const pendingMigrations = cached.pendingMigrations ?? [];
|
||||
return {
|
||||
valid: true,
|
||||
data: cached,
|
||||
data: {
|
||||
...cached,
|
||||
pendingMigrations,
|
||||
org: { ...cached.org, pendingMigrations },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data = await ApiKeyService.verifyAndFetch({
|
||||
const data = await apiKeyRepo.verify({
|
||||
db,
|
||||
hashedKey,
|
||||
env,
|
||||
|
||||
5
server/src/internal/dev/repos/index.ts
Normal file
5
server/src/internal/dev/repos/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { verifyApiKey } from "./verifyApiKey.js";
|
||||
|
||||
export const apiKeyRepo = {
|
||||
verify: verifyApiKey,
|
||||
};
|
||||
35
server/src/internal/dev/repos/verifyApiKey.ts
Normal file
35
server/src/internal/dev/repos/verifyApiKey.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type AppEnv, apiKeys } from "@autumn/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { orgRepo } from "@/internal/orgs/repos/index.js";
|
||||
|
||||
export const verifyApiKey = async ({
|
||||
db,
|
||||
hashedKey,
|
||||
env,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
hashedKey: string;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
const apiKey = await db.query.apiKeys.findFirst({
|
||||
where: eq(apiKeys.hashed_key, hashedKey),
|
||||
with: { user: true },
|
||||
});
|
||||
|
||||
if (!apiKey?.org_id) return null;
|
||||
|
||||
const result = await orgRepo.findFull({ db, orgId: apiKey.org_id, env });
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
org: result.org,
|
||||
features: result.features,
|
||||
pendingMigrations: result.pendingMigrations,
|
||||
fullOrg: result.fullOrg,
|
||||
env,
|
||||
userId: apiKey.user_id,
|
||||
user: apiKey.user ?? null,
|
||||
scopes: apiKey.scopes,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { withMigrationItemTracking } from "./withMigrationItemTracking.js";
|
||||
|
||||
export const migrationItemActions = {
|
||||
withTracking: withMigrationItemTracking,
|
||||
} as const;
|
||||
|
||||
export { withMigrationItemTracking };
|
||||
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
MigrationItemEventResponse,
|
||||
MigrationItemEventStatus,
|
||||
MigrationItemPreview,
|
||||
} from "@/external/tinybird/migrations/migrationItemEventsDataSource.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import {
|
||||
migrationItemEventRepo,
|
||||
migrationItemRunRepo,
|
||||
} from "../../repos/index.js";
|
||||
import type { RunScopeItem } from "../../run/types/runScope.js";
|
||||
|
||||
export type MigrationItemTrackingResult = {
|
||||
itemPreview: MigrationItemPreview | null;
|
||||
status: Exclude<MigrationItemEventStatus, "failed">;
|
||||
response: MigrationItemEventResponse;
|
||||
};
|
||||
|
||||
const errorToResponse = (error: unknown): MigrationItemEventResponse => ({
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
|
||||
const itemToPreview = (item: RunScopeItem): MigrationItemPreview => ({
|
||||
id: item.id,
|
||||
name: null,
|
||||
email: null,
|
||||
});
|
||||
|
||||
const recordMigrationItemEvent = async ({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
item,
|
||||
status,
|
||||
itemPreview,
|
||||
response,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
migrationInternalId: string;
|
||||
migrationRunId: string;
|
||||
dryRun: boolean;
|
||||
item: RunScopeItem;
|
||||
status: MigrationItemEventStatus;
|
||||
itemPreview: MigrationItemPreview | null;
|
||||
response: MigrationItemEventResponse;
|
||||
}) => {
|
||||
await migrationItemEventRepo.insert({
|
||||
ctx,
|
||||
event: {
|
||||
timestamp: new Date().toISOString(),
|
||||
org_id: ctx.org.id,
|
||||
env: ctx.env,
|
||||
migration_internal_id: migrationInternalId,
|
||||
migration_run_id: migrationRunId,
|
||||
dry_run: dryRun,
|
||||
item_kind: item.kind,
|
||||
item_id: item.internal_id,
|
||||
item_preview: itemPreview,
|
||||
status,
|
||||
response,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const markItemRunFinished = async ({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
item,
|
||||
status,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
migrationInternalId: string;
|
||||
migrationRunId: string;
|
||||
dryRun: boolean;
|
||||
item: RunScopeItem;
|
||||
status: Exclude<MigrationItemEventStatus, "failed">;
|
||||
}) => {
|
||||
const params = {
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
itemKind: item.kind,
|
||||
itemId: item.internal_id,
|
||||
};
|
||||
|
||||
if (status === "skipped") {
|
||||
await migrationItemRunRepo.markSkipped(params);
|
||||
return;
|
||||
}
|
||||
|
||||
await migrationItemRunRepo.markSucceeded(params);
|
||||
};
|
||||
|
||||
const runTrackedItem = async <T extends MigrationItemTrackingResult>({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
item,
|
||||
run,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
migrationInternalId: string;
|
||||
migrationRunId: string;
|
||||
dryRun: boolean;
|
||||
item: RunScopeItem;
|
||||
run: () => Promise<T>;
|
||||
}): Promise<T> => {
|
||||
try {
|
||||
const result = await run();
|
||||
|
||||
await markItemRunFinished({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
item,
|
||||
status: result.status,
|
||||
});
|
||||
|
||||
await recordMigrationItemEvent({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
item,
|
||||
status: result.status,
|
||||
itemPreview: result.itemPreview,
|
||||
response: result.response,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await migrationItemRunRepo.markFailed({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
itemKind: item.kind,
|
||||
itemId: item.internal_id,
|
||||
});
|
||||
|
||||
await recordMigrationItemEvent({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
item,
|
||||
status: "failed",
|
||||
itemPreview: itemToPreview(item),
|
||||
response: errorToResponse(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const withMigrationItemTracking = async <
|
||||
T extends MigrationItemTrackingResult,
|
||||
>({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
item,
|
||||
dryRun,
|
||||
claimItemRun = false,
|
||||
retryFailed = false,
|
||||
run,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
migrationInternalId: string;
|
||||
migrationRunId: string;
|
||||
item: RunScopeItem;
|
||||
dryRun: boolean;
|
||||
claimItemRun?: boolean;
|
||||
retryFailed?: boolean;
|
||||
run: () => Promise<T>;
|
||||
}): Promise<T | undefined> => {
|
||||
if (claimItemRun) {
|
||||
const claim = await migrationItemRunRepo.claim({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
itemKind: item.kind,
|
||||
itemId: item.internal_id,
|
||||
claimBehavior: retryFailed ? "retry_failed" : "claim_new",
|
||||
});
|
||||
|
||||
if (!claim.claimed) {
|
||||
ctx.logger.info("run-migration: item already claimed", {
|
||||
data: {
|
||||
kind: item.kind,
|
||||
itemId: item.internal_id,
|
||||
status: claim.itemRun?.status,
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return runTrackedItem({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
item,
|
||||
run,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MigrationRunStatus } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
|
||||
import { migrationRunRepo } from "../../repos/index.js";
|
||||
|
||||
/**
|
||||
* Mark a lazy migration run as terminally done and bust the org's api-key
|
||||
* cache so `ctx.org.pendingMigrations` drops it on the next authed request.
|
||||
*
|
||||
* This is the "done with this lazy migration" hook — after calling, the
|
||||
* customer-fetch hot path stops checking item_runs for this migration.
|
||||
*
|
||||
* Idempotent: if the run is already at a terminal status the update is a
|
||||
* no-op; we still clear the org cache so callers can use this as a forced
|
||||
* reload mechanism.
|
||||
*/
|
||||
export const finishLazyMigrationRun = async ({
|
||||
ctx,
|
||||
runId,
|
||||
status = MigrationRunStatus.Succeeded,
|
||||
errorMessage,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
runId: string;
|
||||
status?: MigrationRunStatus;
|
||||
errorMessage?: string;
|
||||
}): Promise<void> => {
|
||||
await migrationRunRepo.update({
|
||||
ctx,
|
||||
internalId: runId,
|
||||
updates: {
|
||||
status,
|
||||
finished_at: Date.now(),
|
||||
...(errorMessage ? { error_message: errorMessage } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await clearOrgCache({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { finishLazyMigrationRun } from "./finishLazyMigrationRun.js";
|
||||
import { withMigrationRunClaim } from "./withMigrationRunClaim.js";
|
||||
import { withMigrationRunTracking } from "./withMigrationRunTracking.js";
|
||||
|
||||
export const migrationRunActions = {
|
||||
finishLazy: finishLazyMigrationRun,
|
||||
withClaim: withMigrationRunClaim,
|
||||
withTracking: withMigrationRunTracking,
|
||||
} as const;
|
||||
|
||||
export {
|
||||
finishLazyMigrationRun,
|
||||
withMigrationRunClaim,
|
||||
withMigrationRunTracking,
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
ErrCode,
|
||||
type Migration,
|
||||
MigrationRunStatus,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
|
||||
import { migrationRunRepo } from "../../repos/index.js";
|
||||
|
||||
/** Two-phase claim for a migration run.
|
||||
*
|
||||
* 1. Insert with `status='queued'` — locks the partial unique index
|
||||
* `(org_id, env) WHERE status IN ('queued','running')` so nothing
|
||||
* else can claim while the work is happening.
|
||||
* 2. Run `claimed` (e.g. `prepare`, or trigger.dev dispatch).
|
||||
* 3. On success, flip to `status='running'` with `started_at=now`.
|
||||
* On failure, flip to `failed` so the constraint releases.
|
||||
* 4. `claimed` may return `{ triggerRunId }` to persist a handle. */
|
||||
export const withMigrationRunClaim = async ({
|
||||
ctx,
|
||||
migration,
|
||||
dryRun,
|
||||
lazyRun = false,
|
||||
claimed,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
migration: Migration;
|
||||
dryRun: boolean;
|
||||
lazyRun?: boolean;
|
||||
claimed: (
|
||||
migrationRunId: string,
|
||||
) => Promise<{ triggerRunId?: string } | undefined>;
|
||||
}): Promise<{ migrationRunId: string; triggerRunId?: string }> => {
|
||||
const migrationRun = await migrationRunRepo.insert({
|
||||
ctx,
|
||||
insert: {
|
||||
migration_internal_id: migration.internal_id,
|
||||
dry_run: dryRun,
|
||||
lazy_run: lazyRun,
|
||||
},
|
||||
});
|
||||
|
||||
if (!migrationRun) {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"A migration is already running. Please try again when it completes.",
|
||||
code: ErrCode.MigrationAlreadyInProgress,
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
|
||||
// Lazy-mode runs need to land on `ctx.org.pendingMigrations` for every
|
||||
// authed request, so bust the cached api-key payload here. Non-lazy runs
|
||||
// have no effect on the hot path until the trigger task starts mutating.
|
||||
if (lazyRun) {
|
||||
await clearOrgCache({ db: ctx.db, orgId: ctx.org.id, env: ctx.env });
|
||||
}
|
||||
|
||||
let result: { triggerRunId?: string } | undefined;
|
||||
try {
|
||||
result = await claimed(migrationRun.internal_id);
|
||||
} catch (error) {
|
||||
await migrationRunRepo.update({
|
||||
ctx,
|
||||
internalId: migrationRun.internal_id,
|
||||
updates: {
|
||||
status: MigrationRunStatus.Failed,
|
||||
error_message: error instanceof Error ? error.message : String(error),
|
||||
finished_at: Date.now(),
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
await migrationRunRepo.update({
|
||||
ctx,
|
||||
internalId: migrationRun.internal_id,
|
||||
updates: {
|
||||
status: MigrationRunStatus.Running,
|
||||
started_at: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.triggerRunId) {
|
||||
try {
|
||||
await migrationRunRepo.update({
|
||||
ctx,
|
||||
internalId: migrationRun.internal_id,
|
||||
updates: { trigger_run_id: result.triggerRunId },
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.logger.error("run-migration: failed to persist trigger run id", {
|
||||
data: {
|
||||
migrationRunId: migrationRun.internal_id,
|
||||
triggerRunId: result.triggerRunId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
migrationRunId: migrationRun.internal_id,
|
||||
triggerRunId: result?.triggerRunId,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationRunStatus } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { migrationRunRepo } from "../../repos/index.js";
|
||||
|
||||
export const withMigrationRunTracking = async <T>({
|
||||
ctx,
|
||||
migrationRunId,
|
||||
run,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
migrationRunId: string;
|
||||
run: () => Promise<T>;
|
||||
}): Promise<T> => {
|
||||
await migrationRunRepo.update({
|
||||
ctx,
|
||||
internalId: migrationRunId,
|
||||
updates: {
|
||||
status: MigrationRunStatus.Running,
|
||||
started_at: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await run();
|
||||
await migrationRunRepo.update({
|
||||
ctx,
|
||||
internalId: migrationRunId,
|
||||
updates: {
|
||||
status: MigrationRunStatus.Succeeded,
|
||||
finished_at: Date.now(),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
await migrationRunRepo.update({
|
||||
ctx,
|
||||
internalId: migrationRunId,
|
||||
updates: {
|
||||
status: MigrationRunStatus.Failed,
|
||||
error_message: error instanceof Error ? error.message : String(error),
|
||||
finished_at: Date.now(),
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { MigrationItemTrackingResult } from "../actions/migrationItem/withMigrationItemTracking.js";
|
||||
import type { RunScopeItem, RunScopeKind } from "../run/types/runScope.js";
|
||||
import type { MigrationBatchFn, MigrationRunControls } from "./types.js";
|
||||
|
||||
export const runCloudScopeIteration = async ({
|
||||
batch,
|
||||
iterate,
|
||||
kind,
|
||||
controls,
|
||||
perItem,
|
||||
}: {
|
||||
batch: MigrationBatchFn;
|
||||
iterate: () => AsyncGenerator<RunScopeItem[]>;
|
||||
kind: RunScopeKind;
|
||||
controls?: MigrationRunControls;
|
||||
perItem: (args: {
|
||||
item: RunScopeItem;
|
||||
itemCtx: AutumnContext;
|
||||
}) => Promise<MigrationItemTrackingResult | undefined>;
|
||||
}): Promise<void> => {
|
||||
await batch({
|
||||
id: `run-${kind}-migration`,
|
||||
source: scopeItems({ iterate }),
|
||||
concurrency: controls?.concurrency,
|
||||
limit: controls?.limit,
|
||||
only: null,
|
||||
itemKey,
|
||||
checkpoint: false,
|
||||
onError: "continue",
|
||||
fn: async ({ item, ctx: itemCtx }) => {
|
||||
if (item.kind !== "customer")
|
||||
throw new Error(
|
||||
`runMigration: per-item handler missing for kind "${item.kind}"`,
|
||||
);
|
||||
const result = await perItem({ item, itemCtx });
|
||||
if (result) {
|
||||
itemCtx.logger.set({
|
||||
migrationResult: {
|
||||
status: result.status,
|
||||
response: result.response,
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const itemKey = (item: RunScopeItem) => item.id ?? item.internal_id;
|
||||
|
||||
async function* scopeItems({
|
||||
iterate,
|
||||
}: {
|
||||
iterate: () => AsyncGenerator<RunScopeItem[]>;
|
||||
}): AsyncGenerator<RunScopeItem> {
|
||||
for await (const batch of iterate()) {
|
||||
for (const item of batch) yield item;
|
||||
}
|
||||
}
|
||||
50
server/src/internal/migrations/v2/cloudAdapter/types.ts
Normal file
50
server/src/internal/migrations/v2/cloudAdapter/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { RunScopeItem } from "../run/types/runScope.js";
|
||||
|
||||
export type MigrationRunControls = {
|
||||
concurrency?: number;
|
||||
limit?: number | null;
|
||||
only?: string[] | null;
|
||||
checkpoint?: boolean;
|
||||
checkpointDryRun?: boolean;
|
||||
};
|
||||
|
||||
export type MigrationBatchResult<Row extends Record<string, unknown>> = {
|
||||
processed: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
duration: number;
|
||||
rows: Row[];
|
||||
errorDetails: Array<{ item: unknown; error: Error }>;
|
||||
};
|
||||
|
||||
export type MigrationScriptContext = Omit<AutumnContext, "logger"> & {
|
||||
logger: AutumnContext["logger"] & {
|
||||
set: (data: Record<string, unknown>) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export type MigrationBatchFn = <
|
||||
R extends Record<string, unknown> = Record<string, unknown>,
|
||||
Row extends Record<string, unknown> = R,
|
||||
>(opts: {
|
||||
id?: string;
|
||||
source?: AsyncIterable<RunScopeItem>;
|
||||
fn: (args: {
|
||||
item: RunScopeItem;
|
||||
ctx: MigrationScriptContext;
|
||||
}) => Promise<R | null | undefined>;
|
||||
onResult?: (args: {
|
||||
result: R;
|
||||
item: RunScopeItem;
|
||||
ctx: MigrationScriptContext;
|
||||
}) => Row | null | undefined;
|
||||
concurrency?: number;
|
||||
limit?: number | null;
|
||||
only?: string[] | null;
|
||||
itemKey?: (item: RunScopeItem) => string;
|
||||
checkpoint?: boolean;
|
||||
checkpointDryRun?: boolean;
|
||||
logItemResult?: boolean;
|
||||
onError?: "continue" | "throw";
|
||||
}) => Promise<MigrationBatchResult<Row>>;
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { CustomerFilter, MigrationItemRunStatus } from "@autumn/shared";
|
||||
import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js";
|
||||
import type { ResolutionContext } from "@autumn/shared/api/migrations/compiler/filterToIr/resolutionContext.js";
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import { rawWithParamsToDrizzle } from "../rawWithParamsToDrizzle.js";
|
||||
|
||||
export type CustomerQueryArgs = {
|
||||
orgId: string;
|
||||
env: string;
|
||||
filter: CustomerFilter;
|
||||
ctx: ResolutionContext;
|
||||
checkpoint?: CustomerCheckpointExclusion;
|
||||
};
|
||||
|
||||
const compileWhere = ({ orgId, env, filter, ctx }: CustomerQueryArgs): SQL =>
|
||||
rawWithParamsToDrizzle(
|
||||
compileFilter({ filter, ctx, ambient: { orgId, env } }),
|
||||
);
|
||||
|
||||
export type CustomerCheckpointExclusion = {
|
||||
migrationInternalId: string;
|
||||
migrationRunId: string;
|
||||
dryRun: boolean;
|
||||
excludedStatuses: MigrationItemRunStatus[];
|
||||
};
|
||||
|
||||
const buildCheckpointWhere = (
|
||||
checkpoint: CustomerCheckpointExclusion | undefined,
|
||||
): SQL => {
|
||||
if (!checkpoint || checkpoint.excludedStatuses.length === 0) return sql``;
|
||||
|
||||
const checkpointScope = checkpoint.dryRun
|
||||
? sql`AND (
|
||||
mir.dry_run = false
|
||||
OR (
|
||||
mir.dry_run = true
|
||||
AND mir.migration_run_id = ${checkpoint.migrationRunId}
|
||||
)
|
||||
)`
|
||||
: sql`AND mir.dry_run = false`;
|
||||
const statuses = sql.join(
|
||||
checkpoint.excludedStatuses.map((status) => sql`${status}`),
|
||||
sql`, `,
|
||||
);
|
||||
|
||||
return sql`
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM migration_item_runs mir
|
||||
WHERE mir.migration_internal_id = ${checkpoint.migrationInternalId}
|
||||
${checkpointScope}
|
||||
AND mir.item_kind = 'customer'
|
||||
AND mir.item_id = c.internal_id
|
||||
AND mir.status IN (${statuses})
|
||||
)
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Full SELECT. Returns `{ internal_id, id }` rows newest-first via keyset
|
||||
* pagination on `c.internal_id DESC`, so successive iterations over an
|
||||
* unchanged customer set yield rows in the same order.
|
||||
*/
|
||||
export const buildCustomerSelect = ({
|
||||
orgId,
|
||||
env,
|
||||
filter,
|
||||
ctx,
|
||||
checkpoint,
|
||||
limit,
|
||||
afterInternalId,
|
||||
}: CustomerQueryArgs & {
|
||||
limit?: number;
|
||||
afterInternalId?: string;
|
||||
}): SQL => {
|
||||
const where = compileWhere({ orgId, env, filter, ctx });
|
||||
const checkpointWhere = buildCheckpointWhere(checkpoint);
|
||||
const cursor = afterInternalId
|
||||
? sql`AND c.internal_id < ${afterInternalId}`
|
||||
: sql``;
|
||||
const limitClause = limit !== undefined ? sql`LIMIT ${limit}` : sql``;
|
||||
return sql`
|
||||
SELECT c.internal_id, c.id, c.name, c.email
|
||||
FROM customers c
|
||||
WHERE (${where}) ${checkpointWhere} ${cursor}
|
||||
ORDER BY c.internal_id DESC
|
||||
${limitClause}
|
||||
`;
|
||||
};
|
||||
|
||||
/** COUNT(*) applying the same filter. */
|
||||
export const buildCustomerCount = ({
|
||||
orgId,
|
||||
env,
|
||||
filter,
|
||||
ctx,
|
||||
checkpoint,
|
||||
}: CustomerQueryArgs): SQL => {
|
||||
const where = compileWhere({ orgId, env, filter, ctx });
|
||||
const checkpointWhere = buildCheckpointWhere(checkpoint);
|
||||
return sql`
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM customers c
|
||||
WHERE (${where}) ${checkpointWhere}
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { CustomerFilter } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { iterateOverFilterResults } from "../iterateOverFilterResults.js";
|
||||
import {
|
||||
buildCustomerCount,
|
||||
buildCustomerSelect,
|
||||
type CustomerCheckpointExclusion,
|
||||
} from "./buildCustomerSelect.js";
|
||||
|
||||
export type CustomerRow = {
|
||||
internal_id: string;
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure inner: takes a CustomerFilter directly. Used by `runFilter` shim
|
||||
* (Migration-fed) and reusable from scripts that don't have a Migration.
|
||||
*/
|
||||
export const filterCustomers = ({
|
||||
ctx,
|
||||
filter,
|
||||
checkpoint,
|
||||
batchSize,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
filter: CustomerFilter;
|
||||
checkpoint?: CustomerCheckpointExclusion;
|
||||
batchSize?: number;
|
||||
}): AsyncGenerator<CustomerRow[]> => {
|
||||
const args = {
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
filter,
|
||||
checkpoint,
|
||||
ctx: { features: ctx.features },
|
||||
};
|
||||
return iterateOverFilterResults<CustomerRow>({
|
||||
db: ctx.db,
|
||||
buildSelect: ({ limit, afterInternalId }) =>
|
||||
buildCustomerSelect({ ...args, limit, afterInternalId }),
|
||||
batchSize,
|
||||
});
|
||||
};
|
||||
|
||||
/** Count of customers matching `filter`. */
|
||||
export const countCustomers = async ({
|
||||
ctx,
|
||||
filter,
|
||||
checkpoint,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
filter: CustomerFilter;
|
||||
checkpoint?: CustomerCheckpointExclusion;
|
||||
}): Promise<number> => {
|
||||
const [{ count }] = (await ctx.db.execute(
|
||||
buildCustomerCount({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
filter,
|
||||
checkpoint,
|
||||
ctx: { features: ctx.features },
|
||||
}),
|
||||
)) as Array<{ count: bigint | number }>;
|
||||
return Number(count);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
|
||||
export const DEFAULT_BATCH_SIZE = 10_000;
|
||||
|
||||
/**
|
||||
* Iterate keyset-paginated rows in batches. Caller supplies a `buildSelect`
|
||||
* closure that returns the SQL for the next page given a cursor; rows
|
||||
* MUST include `internal_id` for the cursor to advance.
|
||||
*/
|
||||
export async function* iterateOverFilterResults<
|
||||
TRow extends { internal_id: string },
|
||||
>({
|
||||
db,
|
||||
buildSelect,
|
||||
batchSize = DEFAULT_BATCH_SIZE,
|
||||
}: {
|
||||
db: { execute: (query: SQL) => Promise<unknown> };
|
||||
buildSelect: (args: { limit: number; afterInternalId?: string }) => SQL;
|
||||
batchSize?: number;
|
||||
}): AsyncGenerator<TRow[]> {
|
||||
let cursor: string | undefined;
|
||||
while (true) {
|
||||
const query = buildSelect({ limit: batchSize, afterInternalId: cursor });
|
||||
const rows = (await db.execute(query)) as unknown as TRow[];
|
||||
if (rows.length === 0) return;
|
||||
yield rows;
|
||||
if (rows.length < batchSize) return;
|
||||
cursor = rows[rows.length - 1].internal_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
|
||||
/** Convert the compiler's `{ sql, params }` output to a Drizzle SQL chunk. */
|
||||
export const rawWithParamsToDrizzle = ({
|
||||
sql: raw,
|
||||
params,
|
||||
}: {
|
||||
sql: string;
|
||||
params: readonly unknown[];
|
||||
}): SQL => {
|
||||
const parts = raw.split("?");
|
||||
if (parts.length - 1 !== params.length)
|
||||
throw new Error(
|
||||
`Placeholder/param count mismatch: ${parts.length - 1} placeholders vs ${params.length} params`,
|
||||
);
|
||||
const chunks: SQL[] = [];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
chunks.push(sql.raw(parts[i]));
|
||||
if (i < params.length) chunks.push(sql`${params[i]}`);
|
||||
}
|
||||
return sql.join(chunks, sql.raw(""));
|
||||
};
|
||||
115
server/src/internal/migrations/v2/filters/runFilter.ts
Normal file
115
server/src/internal/migrations/v2/filters/runFilter.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { MigrationItemRunStatus } from "@autumn/shared";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
||||
import type { MigrationRunControls } from "../cloudAdapter/types.js";
|
||||
import type { RunScopeItem, RunScopeKind } from "../run/types/runScope.js";
|
||||
import type {
|
||||
MigrationRuntime,
|
||||
MigrationRuntimeWithEventId,
|
||||
} from "../types/migrationDefinition.js";
|
||||
import type { CustomerCheckpointExclusion } from "./customers/buildCustomerSelect.js";
|
||||
import {
|
||||
countCustomers,
|
||||
filterCustomers,
|
||||
} from "./customers/filterCustomers.js";
|
||||
|
||||
/**
|
||||
* Migration-fed shim. Dispatches by scope kind, unwraps the relevant
|
||||
* filter from `migration.filter`, and delegates to the pure inner fns
|
||||
* (`countCustomers` / `filterCustomers`). Empty filter ⇒ whole org+env.
|
||||
*/
|
||||
export const runFilter = async ({
|
||||
ctx,
|
||||
migration,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
kind,
|
||||
controls,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
migration: MigrationRuntimeWithEventId;
|
||||
migrationRunId: string;
|
||||
dryRun: boolean;
|
||||
kind: RunScopeKind;
|
||||
controls?: MigrationRunControls;
|
||||
}): Promise<{
|
||||
kind: RunScopeKind;
|
||||
count: number;
|
||||
iterate: () => AsyncGenerator<RunScopeItem[]>;
|
||||
}> => {
|
||||
if (kind !== "customer")
|
||||
throw new Error(
|
||||
`runFilter: scope kind "${kind}" not supported yet (phase 2+)`,
|
||||
);
|
||||
|
||||
const filter = narrowCustomerFilter({
|
||||
filter: migration.filter?.customer ?? {},
|
||||
controls,
|
||||
});
|
||||
const checkpoint = getCustomerCheckpointExclusion({
|
||||
migration,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
controls,
|
||||
});
|
||||
const count = await countCustomers({ ctx, filter, checkpoint });
|
||||
|
||||
const iterate = async function* () {
|
||||
for await (const batch of filterCustomers({ ctx, filter, checkpoint })) {
|
||||
yield batch.map(
|
||||
(row): RunScopeItem => ({
|
||||
kind: "customer",
|
||||
internal_id: row.internal_id,
|
||||
id: row.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return { kind, count, iterate };
|
||||
};
|
||||
|
||||
const getCustomerCheckpointExclusion = ({
|
||||
migration,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
controls,
|
||||
}: {
|
||||
migration: MigrationRuntimeWithEventId;
|
||||
migrationRunId: string;
|
||||
dryRun: boolean;
|
||||
controls?: MigrationRunControls;
|
||||
}): CustomerCheckpointExclusion | undefined => {
|
||||
const enabled =
|
||||
controls?.checkpoint !== false &&
|
||||
(!dryRun || controls?.checkpointDryRun === true);
|
||||
if (!enabled) return undefined;
|
||||
|
||||
const excludedStatuses = [
|
||||
MigrationItemRunStatus.Running,
|
||||
MigrationItemRunStatus.Succeeded,
|
||||
MigrationItemRunStatus.Skipped,
|
||||
...(migration.retry_failed ? [] : [MigrationItemRunStatus.Failed]),
|
||||
];
|
||||
|
||||
return {
|
||||
migrationInternalId: migration.event_internal_id,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
excludedStatuses,
|
||||
};
|
||||
};
|
||||
|
||||
const narrowCustomerFilter = ({
|
||||
filter,
|
||||
controls,
|
||||
}: {
|
||||
filter: NonNullable<MigrationRuntime["filter"]>["customer"];
|
||||
controls?: MigrationRunControls;
|
||||
}) => {
|
||||
const only = controls?.only;
|
||||
if (!only) return filter ?? {};
|
||||
return {
|
||||
...(filter ?? {}),
|
||||
customer_id: { $in: only },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Scopes } from "@autumn/shared";
|
||||
import { MigrationFilterSchema } from "@autumn/shared/api/migrations/filters/migrationFilter.js";
|
||||
import { OperationsSchema } from "@autumn/shared/api/migrations/operations/operations.js";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
|
||||
const CreateMigrationBody = z.object({
|
||||
id: z.string().min(1).max(200),
|
||||
filter: MigrationFilterSchema.nullable().optional(),
|
||||
operations: OperationsSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
/** POST /migrations.create — create a draft migration. */
|
||||
export const handleCreateMigration = createRoute({
|
||||
scopes: [Scopes.Migrations.Write],
|
||||
body: CreateMigrationBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const insert = c.req.valid("json");
|
||||
|
||||
const migration = await migrationRepo.insert({ ctx, insert });
|
||||
|
||||
return c.json(migration);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
|
||||
const DeleteMigrationBody = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
/** POST /migrations.delete — delete by user `id`. */
|
||||
export const handleDeleteMigration = createRoute({
|
||||
scopes: [Scopes.Migrations.Write],
|
||||
body: DeleteMigrationBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { id } = c.req.valid("json");
|
||||
|
||||
const deleted = await migrationRepo.delete({ ctx, id });
|
||||
if (!deleted)
|
||||
throw new RecaseError({
|
||||
message: `Migration ${id} not found`,
|
||||
code: ErrCode.MigrationNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
return c.json(deleted);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { withMigrationRunClaim } from "@/internal/migrations/v2/actions/migrationRun/index.js";
|
||||
import { prepare } from "@/internal/migrations/v2/prepare/index.js";
|
||||
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
|
||||
const LazyRunMigrationBody = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
/** POST /migrations.lazy_run — start a migration in lazy mode.
|
||||
* Reuses `withMigrationRunClaim` so the partial unique index enforces one
|
||||
* active run per (org, env), and prepare rolls back the claim on failure. */
|
||||
export const handleLazyRunMigration = createRoute({
|
||||
scopes: [Scopes.Migrations.Write],
|
||||
body: LazyRunMigrationBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { id } = c.req.valid("json");
|
||||
|
||||
const migration = await migrationRepo.find({ ctx, id });
|
||||
|
||||
if (!migration.operations)
|
||||
throw new RecaseError({
|
||||
message: `Migration ${id} has no operations to run`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
const { migrationRunId } = await withMigrationRunClaim({
|
||||
ctx,
|
||||
migration,
|
||||
dryRun: false,
|
||||
lazyRun: true,
|
||||
claimed: async () => {
|
||||
await prepare({ ctx, migration, dryRun: false });
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
migration_id: id,
|
||||
run_id: migrationRunId,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Scopes } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { migrationItemEventRepo } from "../repos/index.js";
|
||||
|
||||
const ListMigrationItemEventsBody = z.object({
|
||||
migrationId: z.string(),
|
||||
migrationRunId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const handleListMigrationItemEvents = createRoute({
|
||||
scopes: [Scopes.Migrations.Read],
|
||||
body: ListMigrationItemEventsBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { migrationId, migrationRunId } = c.req.valid("json");
|
||||
const events = await migrationItemEventRepo.list({
|
||||
ctx,
|
||||
migrationId,
|
||||
migrationRunId,
|
||||
});
|
||||
|
||||
return c.json({ list: events });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Scopes } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { migrationRepo, migrationRunRepo } from "../repos/index.js";
|
||||
|
||||
const ListMigrationRunsBody = z.object({
|
||||
migrationId: z.string(),
|
||||
});
|
||||
|
||||
export const handleListMigrationRuns = createRoute({
|
||||
scopes: [Scopes.Migrations.Read],
|
||||
body: ListMigrationRunsBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { migrationId } = c.req.valid("json");
|
||||
const migration = await migrationRepo.find({ ctx, id: migrationId });
|
||||
const runs = await migrationRunRepo.list({
|
||||
ctx,
|
||||
migrationInternalId: migration.internal_id,
|
||||
});
|
||||
|
||||
return c.json({ list: runs });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Scopes } from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
|
||||
/** POST /migrations.list — list migrations for the current org + env. */
|
||||
export const handleListMigrations = createRoute({
|
||||
scopes: [Scopes.Migrations.Read],
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const migrations = await migrationRepo.get({ ctx });
|
||||
return c.json({ list: migrations });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
|
||||
import { MigrationFilterSchema } from "@autumn/shared/api/migrations/filters/migrationFilter.js";
|
||||
import { OperationsSchema } from "@autumn/shared/api/migrations/operations/operations.js";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
|
||||
const PatchMigrationBody = z.object({
|
||||
id: z.string(),
|
||||
updates: z.object({
|
||||
id: z.string().min(1).max(200).optional(),
|
||||
filter: MigrationFilterSchema.nullable().optional(),
|
||||
operations: OperationsSchema.nullable().optional(),
|
||||
retry_failed: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/** POST /migrations.update — patch a migration's fields. */
|
||||
export const handlePatchMigration = createRoute({
|
||||
scopes: [Scopes.Migrations.Write],
|
||||
body: PatchMigrationBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { id, updates } = c.req.valid("json");
|
||||
|
||||
const updated = await migrationRepo.update({
|
||||
ctx,
|
||||
id,
|
||||
updates: {
|
||||
...updates,
|
||||
...(updates.operations !== undefined ? { prepared_state: null } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!updated)
|
||||
throw new RecaseError({
|
||||
message: `Migration ${id} not found`,
|
||||
code: ErrCode.MigrationNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { prepare } from "@/internal/migrations/v2/prepare/index.js";
|
||||
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
|
||||
const PrepareMigrationBody = z.object({
|
||||
id: z.string(),
|
||||
dry_run: z.boolean(),
|
||||
});
|
||||
|
||||
/** POST /migrations.prepare — run prep modules for a migration. */
|
||||
export const handlePrepareMigration = createRoute({
|
||||
scopes: [Scopes.Migrations.Write],
|
||||
body: PrepareMigrationBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { id, dry_run: dryRun } = c.req.valid("json");
|
||||
|
||||
const migration = await migrationRepo.find({ ctx, id });
|
||||
|
||||
if (!migration.operations)
|
||||
throw new RecaseError({
|
||||
message: `Migration ${id} has no operations to prepare`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
const { response } = await prepare({ ctx, migration, dryRun });
|
||||
return c.json(response);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
CustomerFilterSchema,
|
||||
customerProducts,
|
||||
customers,
|
||||
products,
|
||||
Scopes,
|
||||
} from "@autumn/shared";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod/v4";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import {
|
||||
countCustomers,
|
||||
filterCustomers,
|
||||
} from "@/internal/migrations/v2/filters/customers/filterCustomers.js";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
const PreviewFilterBody = z.object({
|
||||
filter: CustomerFilterSchema.optional().default({}),
|
||||
search: z.string().optional().default(""),
|
||||
page: z.number().int().min(0).optional().default(0),
|
||||
pageSize: z.number().int().min(1).max(500).optional().default(DEFAULT_PAGE_SIZE),
|
||||
});
|
||||
|
||||
/** POST /migrations.filter.preview — count + enriched paginated customers. */
|
||||
export const handlePreviewMigrationFilter = createRoute({
|
||||
scopes: [Scopes.Migrations.Read],
|
||||
body: PreviewFilterBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { filter, search, page, pageSize } = c.req.valid("json");
|
||||
|
||||
const [count, pageRows] = await Promise.all([
|
||||
countCustomers({ ctx, filter }),
|
||||
collectPage(
|
||||
filterCustomers({ ctx, filter, batchSize: pageSize }),
|
||||
page * pageSize,
|
||||
pageSize,
|
||||
),
|
||||
]);
|
||||
|
||||
if (pageRows.length === 0) {
|
||||
return c.json({ count, customers: [], page, pageSize });
|
||||
}
|
||||
|
||||
const enriched = await enrichCustomers(
|
||||
ctx.db,
|
||||
pageRows.map((r) => r.internal_id),
|
||||
);
|
||||
|
||||
let grouped = groupByCustomer(enriched);
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
grouped = grouped.filter((row) => {
|
||||
const name = (row.name as string | null)?.toLowerCase() ?? "";
|
||||
const email = (row.email as string | null)?.toLowerCase() ?? "";
|
||||
const id = (row.id as string | null)?.toLowerCase() ?? "";
|
||||
return name.includes(q) || email.includes(q) || id.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ count, customers: grouped, page, pageSize });
|
||||
},
|
||||
});
|
||||
|
||||
async function enrichCustomers(db: DrizzleCli, ids: string[]) {
|
||||
return db
|
||||
.select({
|
||||
internal_id: customers.internal_id,
|
||||
id: customers.id,
|
||||
name: customers.name,
|
||||
email: customers.email,
|
||||
created_at: customers.created_at,
|
||||
org_id: customers.org_id,
|
||||
env: customers.env,
|
||||
fingerprint: customers.fingerprint,
|
||||
metadata: customers.metadata,
|
||||
processor: customers.processor,
|
||||
processors: customers.processors,
|
||||
send_email_receipts: customers.send_email_receipts,
|
||||
auto_topups: customers.auto_topups,
|
||||
spend_limits: customers.spend_limits,
|
||||
usage_alerts: customers.usage_alerts,
|
||||
overage_allowed: customers.overage_allowed,
|
||||
config: customers.config,
|
||||
customer_product: {
|
||||
id: customerProducts.id,
|
||||
internal_product_id: customerProducts.internal_product_id,
|
||||
product_id: customerProducts.product_id,
|
||||
canceled_at: customerProducts.canceled_at,
|
||||
status: customerProducts.status,
|
||||
trial_ends_at: customerProducts.trial_ends_at,
|
||||
created_at: customerProducts.created_at,
|
||||
},
|
||||
product: {
|
||||
internal_id: products.internal_id,
|
||||
id: products.id,
|
||||
name: products.name,
|
||||
version: products.version,
|
||||
is_add_on: products.is_add_on,
|
||||
},
|
||||
})
|
||||
.from(customers)
|
||||
.leftJoin(customerProducts, eq(customers.internal_id, customerProducts.internal_customer_id))
|
||||
.leftJoin(products, eq(customerProducts.internal_product_id, products.internal_id))
|
||||
.where(inArray(customers.internal_id, ids));
|
||||
}
|
||||
|
||||
function groupByCustomer(rows: Array<Record<string, unknown>>) {
|
||||
const map = new Map<string, Record<string, unknown>>();
|
||||
for (const row of rows) {
|
||||
const id = row.internal_id as string;
|
||||
if (!map.has(id)) {
|
||||
const { customer_product, product, ...customer } = row;
|
||||
map.set(id, { ...customer, customer_products: [] });
|
||||
}
|
||||
if (row.customer_product && (row.customer_product as Record<string, unknown>).id) {
|
||||
const entry = map.get(id)!;
|
||||
(entry.customer_products as unknown[]).push({
|
||||
...(row.customer_product as Record<string, unknown>),
|
||||
product: row.product,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
async function collectPage<T>(
|
||||
gen: AsyncGenerator<T[]>,
|
||||
skip: number,
|
||||
take: number,
|
||||
): Promise<T[]> {
|
||||
const rows: T[] = [];
|
||||
let skipped = 0;
|
||||
for await (const batch of gen) {
|
||||
for (const row of batch) {
|
||||
if (skipped < skip) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
rows.push(row);
|
||||
if (rows.length >= take) return rows;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { withMigrationRunClaim } from "@/internal/migrations/v2/actions/migrationRun/index.js";
|
||||
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
import { runMigrationTask } from "@/trigger/migrations/runMigrationTask.js";
|
||||
|
||||
const RunMigrationBody = z.object({
|
||||
id: z.string(),
|
||||
dry_run: z.boolean().default(false),
|
||||
limit: z.number().int().min(1).optional(),
|
||||
only: z.array(z.string()).optional(),
|
||||
concurrency: z.number().int().min(1).optional(),
|
||||
});
|
||||
|
||||
const getRunMigrationTriggerOptions = ({
|
||||
orgId,
|
||||
isDev,
|
||||
}: {
|
||||
orgId: string;
|
||||
isDev: boolean;
|
||||
}) => ({
|
||||
...(isDev ? { region: "eu-west-1" } : {}),
|
||||
concurrencyKey: orgId,
|
||||
});
|
||||
|
||||
export const handleRunMigration = createRoute({
|
||||
scopes: [Scopes.Migrations.Write],
|
||||
body: RunMigrationBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const {
|
||||
id,
|
||||
dry_run: dryRun,
|
||||
limit,
|
||||
only,
|
||||
concurrency,
|
||||
} = c.req.valid("json");
|
||||
|
||||
const migration = await migrationRepo.find({ ctx, id });
|
||||
|
||||
if (!migration.operations)
|
||||
throw new RecaseError({
|
||||
message: `Migration ${id} has no operations to run`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const { migrationRunId, triggerRunId } = await withMigrationRunClaim({
|
||||
ctx,
|
||||
migration,
|
||||
dryRun,
|
||||
claimed: async (migrationRunId) => {
|
||||
const handle = await runMigrationTask.trigger(
|
||||
{
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
migrationId: id,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
controls: { limit, only, concurrency },
|
||||
},
|
||||
getRunMigrationTriggerOptions({
|
||||
orgId: ctx.org.id,
|
||||
isDev,
|
||||
}),
|
||||
);
|
||||
return { triggerRunId: handle.id };
|
||||
},
|
||||
});
|
||||
|
||||
let publicAccessToken: string | undefined;
|
||||
if (triggerRunId) {
|
||||
try {
|
||||
publicAccessToken = await auth.createPublicToken({
|
||||
scopes: { read: { runs: [triggerRunId] } },
|
||||
expirationTime: "1hr",
|
||||
});
|
||||
} catch {
|
||||
ctx.logger.warn("run-migration: failed to create public access token");
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
migration_id: id,
|
||||
dry_run: dryRun,
|
||||
run_id: migrationRunId,
|
||||
trigger_run_id: triggerRunId,
|
||||
public_access_token: publicAccessToken,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { MigrateCustomerContext } from "../../operations/types/index.js";
|
||||
import type {
|
||||
MigrateCustomerItemPreview,
|
||||
MigrateCustomerResult,
|
||||
} from "../../run/migrateCustomer/index.js";
|
||||
|
||||
export type AroundMigrateCustomerRun = () => Promise<MigrateCustomerResult>;
|
||||
|
||||
export type AroundMigrateCustomerArgs = {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
context: MigrateCustomerContext;
|
||||
preview: boolean;
|
||||
run: AroundMigrateCustomerRun;
|
||||
};
|
||||
|
||||
export type AroundMigrateCustomerResult =
|
||||
| Promise<MigrateCustomerResult>
|
||||
| MigrateCustomerResult;
|
||||
|
||||
export type AroundMigrateCustomerSkip = {
|
||||
reason: string;
|
||||
response?: Record<string, unknown> | null;
|
||||
itemPreview?: MigrateCustomerItemPreview | null;
|
||||
};
|
||||
|
||||
export const buildSkippedMigrateCustomerResult = ({
|
||||
context,
|
||||
skip,
|
||||
}: {
|
||||
context: MigrateCustomerContext;
|
||||
skip: AroundMigrateCustomerSkip;
|
||||
}): MigrateCustomerResult => ({
|
||||
itemPreview: skip.itemPreview ?? {
|
||||
id: context.fullCustomer.id ?? null,
|
||||
name: context.fullCustomer.name ?? null,
|
||||
email: context.fullCustomer.email ?? null,
|
||||
},
|
||||
status: "skipped",
|
||||
response: {
|
||||
skipped: {
|
||||
reason: skip.reason,
|
||||
},
|
||||
...(skip.response ?? {}),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./aroundMigrateCustomerHook.js";
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
AroundMigrateCustomerArgs,
|
||||
AroundMigrateCustomerRun,
|
||||
} from "./aroundMigrateCustomer/index.js";
|
||||
import type { MigrationHooks, MigrationPlugin } from "./types.js";
|
||||
|
||||
export const composeMigrationHooks = ({
|
||||
hooks,
|
||||
plugins = [],
|
||||
}: {
|
||||
hooks?: MigrationHooks;
|
||||
plugins?: MigrationPlugin[];
|
||||
}): MigrationHooks | undefined => {
|
||||
const entries = [...plugins.map((plugin) => plugin.hooks), hooks].filter(
|
||||
(entry): entry is MigrationHooks => Boolean(entry),
|
||||
);
|
||||
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
return {
|
||||
aroundMigrateCustomer: async (args: AroundMigrateCustomerArgs) => {
|
||||
const run = entries.reduceRight<AroundMigrateCustomerRun>(
|
||||
(next, entry) => async () => {
|
||||
if (!entry.aroundMigrateCustomer) return next();
|
||||
return entry.aroundMigrateCustomer({ ...args, run: next });
|
||||
},
|
||||
args.run,
|
||||
);
|
||||
return run();
|
||||
},
|
||||
};
|
||||
};
|
||||
4
server/src/internal/migrations/v2/hooks/index.ts
Normal file
4
server/src/internal/migrations/v2/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./aroundMigrateCustomer/index.js";
|
||||
export * from "./composeMigrationHooks.js";
|
||||
export * from "./plugins/index.js";
|
||||
export * from "./types.js";
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
type AroundMigrateCustomerArgs,
|
||||
buildSkippedMigrateCustomerResult,
|
||||
} from "../aroundMigrateCustomer/index.js";
|
||||
import type { MigrationPlugin } from "../types.js";
|
||||
|
||||
export type CustomerGuardResult =
|
||||
| undefined
|
||||
| null
|
||||
| {
|
||||
reason: string;
|
||||
response?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export const customerGuardPlugin = ({
|
||||
id,
|
||||
guard,
|
||||
}: {
|
||||
id: string;
|
||||
guard: (
|
||||
args: AroundMigrateCustomerArgs,
|
||||
) => Promise<CustomerGuardResult> | CustomerGuardResult;
|
||||
}): MigrationPlugin => ({
|
||||
id,
|
||||
hooks: {
|
||||
aroundMigrateCustomer: async (args) => {
|
||||
const result = await guard(args);
|
||||
if (!result) return args.run();
|
||||
|
||||
return buildSkippedMigrateCustomerResult({
|
||||
context: args.context,
|
||||
skip: {
|
||||
reason: result.reason,
|
||||
response: {
|
||||
guard: {
|
||||
pluginId: id,
|
||||
reason: result.reason,
|
||||
...(result.response ?? {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
1
server/src/internal/migrations/v2/hooks/plugins/index.ts
Normal file
1
server/src/internal/migrations/v2/hooks/plugins/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./customerGuardPlugin.js";
|
||||
15
server/src/internal/migrations/v2/hooks/types.ts
Normal file
15
server/src/internal/migrations/v2/hooks/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {
|
||||
AroundMigrateCustomerArgs,
|
||||
AroundMigrateCustomerResult,
|
||||
} from "./aroundMigrateCustomer/index.js";
|
||||
|
||||
export type MigrationHooks = {
|
||||
aroundMigrateCustomer?: (
|
||||
args: AroundMigrateCustomerArgs,
|
||||
) => AroundMigrateCustomerResult;
|
||||
};
|
||||
|
||||
export type MigrationPlugin = {
|
||||
id: string;
|
||||
hooks?: MigrationHooks;
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { FullCustomer, MigrationItemRunData } from "@autumn/shared";
|
||||
import { customerFilterMatchesFullCustomer } from "@autumn/shared/api/customers/utils/match/index.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { runMigrationCustomerTask } from "@/trigger/migrations/runMigrationCustomerTask.js";
|
||||
|
||||
/**
|
||||
* For each pending lazy migration on `ctx.org`, decide whether this customer
|
||||
* needs migrating and enqueue a per-customer Trigger.dev task if so.
|
||||
*
|
||||
* Reads `migration_item_runs` from the already-loaded `fullCustomer` (embedded
|
||||
* by the FullSubject / FullCustomer query) — no extra DB roundtrip.
|
||||
*
|
||||
* Fire-and-forget: the helper doesn't wait for the migration to complete.
|
||||
* `executeMigrateCustomerPlan` inside the task busts the customer cache,
|
||||
* so subsequent requests read post-migration state.
|
||||
*/
|
||||
export const checkPendingMigrationsForCustomer = async ({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCustomer: Pick<
|
||||
FullCustomer,
|
||||
"id" | "internal_id" | "customer_products" | "migration_item_runs"
|
||||
>;
|
||||
}): Promise<void> => {
|
||||
// Short-circuit when we're already inside a Trigger.dev task. A migration
|
||||
// worker loading the customer via `CusService.getFull` would otherwise
|
||||
// re-enter this helper and enqueue another task. Flag is set by
|
||||
// `createTriggerContext`.
|
||||
if (ctx.insideTriggerTask) return;
|
||||
|
||||
const pending = ctx.org.pendingMigrations ?? [];
|
||||
if (pending.length === 0) return;
|
||||
|
||||
const itemRunsByMigrationInternalId = new Map<string, MigrationItemRunData>();
|
||||
for (const itemRun of fullCustomer.migration_item_runs ?? []) {
|
||||
const existing = itemRunsByMigrationInternalId.get(
|
||||
itemRun.migration_internal_id,
|
||||
);
|
||||
if (
|
||||
!existing ||
|
||||
(itemRun.updated_at ?? 0) > (existing.updated_at ?? 0) ||
|
||||
(itemRun.updated_at === existing.updated_at &&
|
||||
itemRun.created_at > existing.created_at)
|
||||
) {
|
||||
itemRunsByMigrationInternalId.set(itemRun.migration_internal_id, itemRun);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pendingMigration of pending) {
|
||||
const { internal_id: migrationRunId, migration } = pendingMigration;
|
||||
|
||||
const matches = customerFilterMatchesFullCustomer({
|
||||
filter: migration.filter?.customer ?? {},
|
||||
fullCustomer,
|
||||
});
|
||||
if (!matches) continue;
|
||||
|
||||
const itemRun = itemRunsByMigrationInternalId.get(migration.internal_id);
|
||||
if (
|
||||
itemRun?.status === "succeeded" ||
|
||||
itemRun?.status === "skipped" ||
|
||||
itemRun?.status === "running"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await runMigrationCustomerTask.trigger(
|
||||
{
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
migrationInternalId: migration.internal_id,
|
||||
migrationRunId,
|
||||
customerInternalId: fullCustomer.internal_id,
|
||||
customerId: fullCustomer.id ?? null,
|
||||
},
|
||||
{
|
||||
concurrencyKey: `${migration.internal_id}:${fullCustomer.internal_id}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
36
server/src/internal/migrations/v2/migrationRouter.ts
Normal file
36
server/src/internal/migrations/v2/migrationRouter.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { handleCreateMigration } from "./handlers/handleCreateMigration.js";
|
||||
import { handleDeleteMigration } from "./handlers/handleDeleteMigration.js";
|
||||
import { handleLazyRunMigration } from "./handlers/handleLazyRunMigration.js";
|
||||
import { handleListMigrationItemEvents } from "./handlers/handleListMigrationItemEvents.js";
|
||||
import { handleListMigrationRuns } from "./handlers/handleListMigrationRuns.js";
|
||||
import { handleListMigrations } from "./handlers/handleListMigrations.js";
|
||||
import { handlePatchMigration } from "./handlers/handlePatchMigration.js";
|
||||
import { handlePrepareMigration } from "./handlers/handlePrepareMigration.js";
|
||||
import { handlePreviewMigrationFilter } from "./handlers/handlePreviewMigrationFilter.js";
|
||||
import { handleRunMigration } from "./handlers/handleRunMigration.js";
|
||||
|
||||
/**
|
||||
* V2 user-facing migrations RPC router. Distinct from the legacy
|
||||
* `migrationRouter` (product-version migration system in
|
||||
* `internal/products/productRouter.ts`).
|
||||
*/
|
||||
export const migrationRpcRouter = new Hono<HonoEnv>();
|
||||
|
||||
migrationRpcRouter.post("/migrations.create", ...handleCreateMigration);
|
||||
migrationRpcRouter.post("/migrations.list", ...handleListMigrations);
|
||||
migrationRpcRouter.post("/migrations.update", ...handlePatchMigration);
|
||||
migrationRpcRouter.post("/migrations.delete", ...handleDeleteMigration);
|
||||
migrationRpcRouter.post("/migrations.prepare", ...handlePrepareMigration);
|
||||
migrationRpcRouter.post(
|
||||
"/migrations.filter.preview",
|
||||
...handlePreviewMigrationFilter,
|
||||
);
|
||||
migrationRpcRouter.post("/migrations.run", ...handleRunMigration);
|
||||
migrationRpcRouter.post("/migrations.lazy_run", ...handleLazyRunMigration);
|
||||
migrationRpcRouter.post("/migrations.runs.list", ...handleListMigrationRuns);
|
||||
migrationRpcRouter.post(
|
||||
"/migrations.item_events.list",
|
||||
...handleListMigrationItemEvents,
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./processAddPlan.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user