diff --git a/.context/migration-interface/CATALOG.md b/.context/migration-interface/CATALOG.md new file mode 100644 index 000000000..b40d19609 --- /dev/null +++ b/.context/migration-interface/CATALOG.md @@ -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) +``` +setupBillingContext({ ctx, params }) -> BillingContext +handleErrors({ ctx, billingContext }) -> throws RecaseError +computePlan({ 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.** diff --git a/.context/migration-interface/DECISIONS.md b/.context/migration-interface/DECISIONS.md new file mode 100644 index 000000000..60d09bed3 --- /dev/null +++ b/.context/migration-interface/DECISIONS.md @@ -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. diff --git a/.context/migration-interface/PLAN.md b/.context/migration-interface/PLAN.md new file mode 100644 index 000000000..11af39ba0 --- /dev/null +++ b/.context/migration-interface/PLAN.md @@ -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. diff --git a/.context/migration-interface/PROPOSAL.md b/.context/migration-interface/PROPOSAL.md new file mode 100644 index 000000000..1415f1d24 --- /dev/null +++ b/.context/migration-interface/PROPOSAL.md @@ -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> } + | { 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 + +// 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; + buildTargetState(ctx, customer): Promise; + plan(ctx, target): OperationPlan["customers"][number]; +}; + +type ImportDefinition = { + id: string; + loadExternalSource(ctx): Promise; + resolveIdentities(ctx, source): Promise; + mapTargets(ctx, source, identities): Promise; + 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. diff --git a/.context/migration-interface/STATUS.md b/.context/migration-interface/STATUS.md new file mode 100644 index 000000000..dcb9cbf3c --- /dev/null +++ b/.context/migration-interface/STATUS.md @@ -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 diff --git a/ai b/ai index e21275f18..18d68d3c2 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit e21275f18e1b6b52b2d123ae1e723779d2200526 +Subproject commit 18d68d3c2a8f1ca2773fd6f66362dc6e804eee81 diff --git a/bun.lock b/bun.lock index 2c002482d..dccfc9120 100644 --- a/bun.lock +++ b/bun.lock @@ -209,7 +209,7 @@ }, "packages/autumn-js": { "name": "autumn-js", - "version": "1.2.10", + "version": "1.2.17", "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", diff --git a/server/experiments/explainCustomerFilter.ts b/server/experiments/explainCustomerFilter.ts new file mode 100644 index 000000000..9ee1774da --- /dev/null +++ b/server/experiments/explainCustomerFilter.ts @@ -0,0 +1,170 @@ +import { AppEnv } from "@autumn/shared"; +import { + buildCustomerCount, + buildCustomerSelect, +} from "@autumn/shared/api/migrations/compiler/buildCustomerQuery.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["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)["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["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).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(); diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 6b8874da6..f70728fe6 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -33,6 +33,9 @@ import { ErrCode, type FinalizeLockParamsV0, type LegacyVersion, + type Migration, + type MigrationFilter, + type Operations, type OrgConfig, type ProductItem, type RestoreParamsV1, @@ -928,6 +931,32 @@ export class AutumnInt { return data; }; + migrationsV2 = { + create: async (params: { + id: string; + filter?: MigrationFilter | null; + operations?: Operations | null; + }): Promise => { + 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; + }; + }): Promise => { + const data = await this.post(`/migrations.update`, params); + return data as Migration; + }, + }; + balances = { create: async (params: CreateBalanceParamsV0) => { const data = await this.post(`/balances/create`, params); diff --git a/server/src/internal/migrations/v2/actions/index.ts b/server/src/internal/migrations/v2/actions/index.ts new file mode 100644 index 000000000..ed859ef95 --- /dev/null +++ b/server/src/internal/migrations/v2/actions/index.ts @@ -0,0 +1,7 @@ +/** + * Higher-level migration business logic (preview, run, idempotency, + * billing-path bridging). Empty in phase 1 — phase 1 is pure CRUD on the + * migration entity, handled directly via `migrationRepo`. Phase 2+ adds + * verbs here as ops grow runtime semantics. + */ +export const migrationActions = {} as const; diff --git a/server/src/internal/migrations/v2/handlers/handleCreateMigration/handleCreateMigration.ts b/server/src/internal/migrations/v2/handlers/handleCreateMigration/handleCreateMigration.ts new file mode 100644 index 000000000..bd95e46dc --- /dev/null +++ b/server/src/internal/migrations/v2/handlers/handleCreateMigration/handleCreateMigration.ts @@ -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, 201); + }, +}); diff --git a/server/src/internal/migrations/v2/handlers/handleListMigrations/handleListMigrations.ts b/server/src/internal/migrations/v2/handlers/handleListMigrations/handleListMigrations.ts new file mode 100644 index 000000000..884b28058 --- /dev/null +++ b/server/src/internal/migrations/v2/handlers/handleListMigrations/handleListMigrations.ts @@ -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 }); + }, +}); diff --git a/server/src/internal/migrations/v2/handlers/handlePatchMigration/handlePatchMigration.ts b/server/src/internal/migrations/v2/handlers/handlePatchMigration/handlePatchMigration.ts new file mode 100644 index 000000000..e63973ed6 --- /dev/null +++ b/server/src/internal/migrations/v2/handlers/handlePatchMigration/handlePatchMigration.ts @@ -0,0 +1,36 @@ +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(), + }), +}); + +/** 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 }); + + if (!updated) + throw new RecaseError({ + message: `Migration ${id} not found`, + code: ErrCode.MigrationNotFound, + statusCode: 404, + }); + + return c.json(updated); + }, +}); diff --git a/server/src/internal/migrations/v2/migrationRouter.ts b/server/src/internal/migrations/v2/migrationRouter.ts new file mode 100644 index 000000000..8f3353afa --- /dev/null +++ b/server/src/internal/migrations/v2/migrationRouter.ts @@ -0,0 +1,16 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleCreateMigration } from "./handlers/handleCreateMigration/handleCreateMigration.js"; +import { handleListMigrations } from "./handlers/handleListMigrations/handleListMigrations.js"; +import { handlePatchMigration } from "./handlers/handlePatchMigration/handlePatchMigration.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(); + +migrationRpcRouter.post("/migrations.create", ...handleCreateMigration); +migrationRpcRouter.post("/migrations.list", ...handleListMigrations); +migrationRpcRouter.post("/migrations.update", ...handlePatchMigration); diff --git a/server/src/internal/migrations/v2/repos/getMigration.ts b/server/src/internal/migrations/v2/repos/getMigration.ts new file mode 100644 index 000000000..7808866d2 --- /dev/null +++ b/server/src/internal/migrations/v2/repos/getMigration.ts @@ -0,0 +1,32 @@ +import { type Migration, migrations } from "@autumn/shared"; +import { and, desc, eq, type SQL } from "drizzle-orm"; +import type { RepoContext } from "@/db/repoContext.js"; + +/** + * Fetch migrations matching any combination of filter fields. Always + * returns an array; callers expecting a single row take `[0] ?? null`. + * Org + env scope is enforced via `ctx`. + */ +export const getMigration = async ({ + ctx, + id, + internalId, +}: { + ctx: RepoContext; + id?: string; + internalId?: string; +}): Promise => { + const where: SQL[] = [ + eq(migrations.org_id, ctx.org.id), + eq(migrations.env, ctx.env), + ]; + if (id !== undefined) where.push(eq(migrations.id, id)); + if (internalId !== undefined) + where.push(eq(migrations.internal_id, internalId)); + + return ctx.db + .select() + .from(migrations) + .where(and(...where)) + .orderBy(desc(migrations.created_at)); +}; diff --git a/server/src/internal/migrations/v2/repos/index.ts b/server/src/internal/migrations/v2/repos/index.ts new file mode 100644 index 000000000..d26691e6a --- /dev/null +++ b/server/src/internal/migrations/v2/repos/index.ts @@ -0,0 +1,9 @@ +import { getMigration } from "./getMigration.js"; +import { insertMigration } from "./insertMigration.js"; +import { updateMigration } from "./updateMigration.js"; + +export const migrationRepo = { + insert: insertMigration, + get: getMigration, + update: updateMigration, +}; diff --git a/server/src/internal/migrations/v2/repos/insertMigration.ts b/server/src/internal/migrations/v2/repos/insertMigration.ts new file mode 100644 index 000000000..4fbe42c04 --- /dev/null +++ b/server/src/internal/migrations/v2/repos/insertMigration.ts @@ -0,0 +1,35 @@ +import { + type Migration, + type MigrationInsert, + migrations, +} from "@autumn/shared"; +import type { RepoContext } from "@/db/repoContext.js"; +import { generateId } from "@/utils/genUtils.js"; + +/** + * Insert a new migration row and return the persisted entity. `filter` + * and `operations` default to null — users typically create a migration + * first, then author them via PATCH. + */ +export const insertMigration = async ({ + ctx, + insert, +}: { + ctx: RepoContext; + insert: Pick; +}): Promise => { + const row: MigrationInsert = { + internal_id: generateId("mig"), + id: insert.id, + org_id: ctx.org.id, + env: ctx.env, + filter: insert.filter ?? null, + operations: insert.operations ?? null, + created_at: Date.now(), + updated_at: null, + }; + + await ctx.db.insert(migrations).values(row); + + return row as Migration; +}; diff --git a/server/src/internal/migrations/v2/repos/updateMigration.ts b/server/src/internal/migrations/v2/repos/updateMigration.ts new file mode 100644 index 000000000..4295458fd --- /dev/null +++ b/server/src/internal/migrations/v2/repos/updateMigration.ts @@ -0,0 +1,37 @@ +import { + type Migration, + type MigrationInsert, + migrations, +} from "@autumn/shared"; +import { and, eq } from "drizzle-orm"; +import type { RepoContext } from "@/db/repoContext.js"; + +/** + * Patch a migration. Drizzle's `set()` only updates keys present in the + * passed object, so callers pass only the fields they want changed. + * Scoped to the current org + env. Returns the persisted row, or null + * if not found. + */ +export const updateMigration = async ({ + ctx, + id, + updates, +}: { + ctx: RepoContext; + id: string; + updates: Partial>; +}): Promise => { + const [row] = await ctx.db + .update(migrations) + .set({ ...updates, updated_at: Date.now() }) + .where( + and( + eq(migrations.id, id), + eq(migrations.org_id, ctx.org.id), + eq(migrations.env, ctx.env), + ), + ) + .returning(); + + return row ?? null; +}; diff --git a/server/src/routers/internalRouter.ts b/server/src/routers/internalRouter.ts index a792697d9..012742ee3 100644 --- a/server/src/routers/internalRouter.ts +++ b/server/src/routers/internalRouter.ts @@ -14,6 +14,7 @@ import { honoAdminRouter } from "../internal/admin/adminRouter"; import { internalAnalyticsRouter } from "../internal/analytics/internalAnalyticsRouter"; import { internalCusRouter } from "../internal/customers/internalCusRouter"; import { internalDevRouter } from "../internal/dev/devRouter"; +import { migrationRpcRouter } from "../internal/migrations/v2/migrationRouter"; import { consentRouter } from "../internal/misc/consent/consentRouter"; import { feedbackRouter } from "../internal/misc/feedback/feedbackRouter"; import { pricingAgentRouter } from "../internal/misc/pricingAgent/pricingAgentRouter"; @@ -45,6 +46,7 @@ internalRouter.route("/trmnl", internalTrmnlRouter); internalRouter.route("/feedback", feedbackRouter); internalRouter.route("/saved_views", savedViewsRouter); internalRouter.route("/query", internalAnalyticsRouter); +internalRouter.route("", migrationRpcRouter); // Autumn SDK handler (requires session auth) if (process.env.AUTUMN_SECRET_KEY) { diff --git a/server/src/routers/rpcRouter.ts b/server/src/routers/rpcRouter.ts index 4da4bfa24..b36b08d42 100644 --- a/server/src/routers/rpcRouter.ts +++ b/server/src/routers/rpcRouter.ts @@ -5,6 +5,7 @@ import { billingRpcRouter } from "@/internal/billing/billingRouter"; import { entityRpcRouter } from "@/internal/entities/entityRouter"; import { eventsRpcRouter } from "@/internal/events/eventsRouter"; import { featureRpcRouter } from "@/internal/features/featureRouter"; +import { migrationRpcRouter } from "@/internal/migrations/v2/migrationRouter"; import { plansRpcRouter } from "@/internal/products/productRouter"; import type { HonoEnv } from "../honoUtils/HonoEnv"; import { customerRpcRouter } from "../internal/customers/cusRouter"; @@ -30,3 +31,4 @@ rpcRouter.route("", eventsRpcRouter); rpcRouter.route("", referralRpcRouter); rpcRouter.route("", entityRpcRouter); rpcRouter.route("", featureRpcRouter); +rpcRouter.route("", migrationRpcRouter); diff --git a/server/tests/integration/billing/migrations-v2/migrations-add-items.test.ts b/server/tests/integration/billing/migrations-v2/migrations-add-items.test.ts new file mode 100644 index 000000000..e88e13d29 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/migrations-add-items.test.ts @@ -0,0 +1,73 @@ +/** + * Migrations V2 — add_items + * + * Phase 1 acceptance: a user can create a migration definition whose + * filter selects customers on a given plan and whose operation adds a + * plan item to their matching cusproducts. + * + * This test only verifies the CREATE path (storing the migration + * definition end-to-end). Execution / preview is phase 2+. + */ + +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +test.concurrent( + `${chalk.yellowBright("migrations-v2 add-items: create migration on pro customers adding a feature item")}`, + async () => { + const customerId = "migrations-v2-add-items"; + + const proMessages = items.monthlyMessages({ includedUsage: 200 }); + const pro = products.pro({ id: "pro", items: [proMessages] }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const migrationId = `add-dashboard-to-pro-${Date.now()}`; + + const created = await autumnV2_2.migrationsV2.create({ + id: migrationId, + filter: { + customer: { + plan: { plan_id: "pro" }, + }, + }, + operations: { + customer: { + update_plans: [ + { + target: { plan_id: "pro" }, + add_items: [ + { + feature_id: TestFeature.Dashboard, + }, + ], + }, + ], + }, + }, + }); + + expect(created.id).toBe(migrationId); + expect(created.internal_id).toBeTruthy(); + expect(created.filter?.customer?.plan).toMatchObject({ plan_id: "pro" }); + expect(created.operations?.customer?.update_plans?.[0]).toMatchObject({ + target: { plan_id: "pro" }, + add_items: [{ feature_id: TestFeature.Dashboard }], + }); + + // Round-trip: list and confirm presence. + const { list } = await autumnV2_2.migrationsV2.list(); + expect(list.some((m) => m.id === migrationId)).toBe(true); + }, +); diff --git a/server/tests/unit/compiler/customer/basic.test.ts b/server/tests/unit/compiler/customer/basic.test.ts new file mode 100644 index 000000000..6a3822082 --- /dev/null +++ b/server/tests/unit/compiler/customer/basic.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import type { Feature } from "@autumn/shared"; +import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; + +const features: Feature[] = [ + { id: "credits", internal_id: "fea_credits_internal" } as Feature, +]; + +const ctx = contexts.create({ features }); +const ambient = { orgId: "org_test", env: "live" }; + +const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?"; +const PLAN_AMBIENT = "cp.status IN (?, ?)"; +const PLAN_AMBIENT_PARAMS = ["active", "past_due"]; + +const normalize = (sql: string) => + sql.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim(); + +const BASE_PRICE_EXISTS = [ + "(SELECT base_cpr.id FROM customer_prices base_cpr", + "JOIN prices base_pr ON base_pr.id = base_cpr.price_id", + "WHERE base_cpr.customer_product_id = cp.id", + "AND base_pr.entitlement_id IS NULL LIMIT 1)", +].join(" "); + +describe("compileFilter — customer / basic plan-level filters", () => { + test("plan.plan_id eq", () => { + const result = compileFilter({ + filter: { plan: { plan_id: "pro" } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND p.id = ? + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + "pro", + ]); + }); + + test("paid plan — plan.price: { $ne: null }", () => { + const result = compileFilter({ + filter: { plan: { price: { $ne: null } } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND ${BASE_PRICE_EXISTS} IS NOT NULL + ) + `), + ); + expect(result.params).toEqual(["org_test", "live", ...PLAN_AMBIENT_PARAMS]); + }); + + test("free plan — plan.price: null (bare)", () => { + const result = compileFilter({ + filter: { plan: { price: null } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND ${BASE_PRICE_EXISTS} IS NULL + ) + `), + ); + expect(result.params).toEqual(["org_test", "live", ...PLAN_AMBIENT_PARAMS]); + }); + + test("plan.plan_id $in", () => { + const result = compileFilter({ + filter: { plan: { plan_id: { $in: ["pro", "team"] } } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND p.id IN (?, ?) + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + "pro", + "team", + ]); + }); +}); diff --git a/server/tests/unit/compiler/customer/derived-and-or.test.ts b/server/tests/unit/compiler/customer/derived-and-or.test.ts new file mode 100644 index 000000000..273c20e93 --- /dev/null +++ b/server/tests/unit/compiler/customer/derived-and-or.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import type { Feature } from "@autumn/shared"; +import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; + +const features: Feature[] = [ + { id: "credits", internal_id: "fea_credits_internal" } as Feature, +]; + +const ctx = contexts.create({ features }); +const ambient = { orgId: "org_test", env: "live" }; + +const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?"; +const PLAN_AMBIENT = "cp.status IN (?, ?)"; +const PLAN_AMBIENT_PARAMS = ["active", "past_due"]; + +const BASE_PRICE_EXISTS = [ + "(SELECT base_cpr.id FROM customer_prices base_cpr", + "JOIN prices base_pr ON base_pr.id = base_cpr.price_id", + "WHERE base_cpr.customer_product_id = cp.id", + "AND base_pr.entitlement_id IS NULL LIMIT 1)", +].join(" "); + +const PAID_EXISTS = + "EXISTS (SELECT 1 FROM customer_prices cpr WHERE cpr.customer_product_id = cp.id)"; + +const RECURRING_EXISTS = [ + "EXISTS (SELECT 1 FROM customer_prices cpr", + "JOIN prices pr ON pr.id = cpr.price_id", + "WHERE cpr.customer_product_id = cp.id", + "AND pr.config->>'interval' <> 'one_off')", +].join(" "); + +const ITEM_PAID_FROM = [ + "customer_prices cpr", + "JOIN prices pr ON pr.id = cpr.price_id", + "JOIN entitlements e ON e.id = pr.entitlement_id", +].join(" "); + +const normalize = (sql: string) => + sql.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim(); + +describe("compileFilter — derived boolean filters", () => { + test("plan.paid: true → EXISTS over customer_prices", () => { + const result = compileFilter({ + filter: { plan: { paid: true } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND ${PAID_EXISTS} = ? + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + true, + ]); + }); + + test("plan.paid: false → EXISTS = false (i.e. no customer_prices)", () => { + const result = compileFilter({ + filter: { plan: { paid: false } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + false, + ]); + }); + + test("plan.recurring: true → EXISTS with interval <> 'one_off'", () => { + const result = compileFilter({ + filter: { plan: { recurring: true } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND ${RECURRING_EXISTS} = ? + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + true, + ]); + }); +}); + +describe("compileFilter — $or operator on plan", () => { + test("$or: [{ price: $ne: null }, { item.price: $ne: null }] — base OR paid item", () => { + const result = compileFilter({ + filter: { + plan: { + $or: [{ price: { $ne: null } }, { item: { price: { $ne: null } } }], + }, + }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND ( + ${BASE_PRICE_EXISTS} IS NOT NULL + OR EXISTS ( + SELECT 1 + FROM ${ITEM_PAID_FROM} + WHERE cpr.customer_product_id = cp.id + AND cpr.id IS NOT NULL + ) + ) + ) + `), + ); + expect(result.params).toEqual(["org_test", "live", ...PLAN_AMBIENT_PARAMS]); + }); + + test("$or alongside sibling field — sibling is AND'd with the OR group", () => { + const result = compileFilter({ + filter: { + plan: { + plan_id: "pro", + $or: [{ paid: true }, { recurring: true }], + }, + }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND ( + p.id = ? + AND (${PAID_EXISTS} = ? OR ${RECURRING_EXISTS} = ?) + ) + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + "pro", + true, + true, + ]); + }); + + test("empty $or array throws", () => { + expect(() => + compileFilter({ + filter: { plan: { $or: [] } }, + ctx: { features: ctx.features }, + ambient, + }), + ).toThrow("$or requires at least one branch"); + }); +}); diff --git a/server/tests/unit/compiler/customer/nested-item.test.ts b/server/tests/unit/compiler/customer/nested-item.test.ts new file mode 100644 index 000000000..3414b9790 --- /dev/null +++ b/server/tests/unit/compiler/customer/nested-item.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; +import type { Feature } from "@autumn/shared"; +import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; + +const features: Feature[] = [ + { id: "credits", internal_id: "fea_credits_internal" } as Feature, +]; + +const ctx = contexts.create({ features }); +const ambient = { orgId: "org_test", env: "live" }; + +const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?"; +const PLAN_AMBIENT = "cp.status IN (?, ?)"; +const PLAN_AMBIENT_PARAMS = ["active", "past_due"]; + +const ITEM_FROM = [ + "customer_entitlements ce", + "JOIN entitlements e ON e.id = ce.entitlement_id", + "LEFT JOIN prices pr ON pr.entitlement_id = e.id", + "LEFT JOIN customer_prices cpr ON cpr.price_id = pr.id AND cpr.customer_product_id = ce.customer_product_id", +].join(" "); + +const normalize = (sql: string) => + sql.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim(); + +describe("compileFilter — customer / nested item filters", () => { + test("plan.item.feature_id eq — resolves through entitlements", () => { + const result = compileFilter({ + filter: { plan: { item: { feature_id: "credits" } } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND EXISTS ( + SELECT 1 + FROM ${ITEM_FROM} + WHERE ce.customer_product_id = cp.id + AND e.internal_feature_id = ? + ) + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + "fea_credits_internal", + ]); + }); + + test("paid credits — uses paid-side join from customer_prices", () => { + const result = compileFilter({ + filter: { + plan: { item: { feature_id: "credits", price: { $ne: null } } }, + }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND EXISTS ( + SELECT 1 + FROM customer_prices cpr JOIN prices pr ON pr.id = cpr.price_id JOIN entitlements e ON e.id = pr.entitlement_id + WHERE cpr.customer_product_id = cp.id + AND (e.internal_feature_id = ? AND cpr.id IS NOT NULL) + ) + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + "fea_credits_internal", + ]); + }); + + test("free credits — feature_id + price: null (bare)", () => { + const result = compileFilter({ + filter: { plan: { item: { feature_id: "credits", price: null } } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND EXISTS ( + SELECT 1 + FROM ${ITEM_FROM} + WHERE ce.customer_product_id = cp.id + AND (e.internal_feature_id = ? AND cpr.id IS NULL) + ) + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + "fea_credits_internal", + ]); + }); + + test("plan_id + nested item.feature_id — AND on plan scope", () => { + const result = compileFilter({ + filter: { + plan: { plan_id: "pro", item: { feature_id: "credits" } }, + }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(` + ${ROOT_AMBIENT} AND EXISTS ( + SELECT 1 + FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = c.internal_id + AND ${PLAN_AMBIENT} + AND ( + p.id = ? + AND EXISTS ( + SELECT 1 + FROM ${ITEM_FROM} + WHERE ce.customer_product_id = cp.id + AND e.internal_feature_id = ? + ) + ) + ) + `), + ); + expect(result.params).toEqual([ + "org_test", + "live", + ...PLAN_AMBIENT_PARAMS, + "pro", + "fea_credits_internal", + ]); + }); + + test("unknown feature_id throws at parse", () => { + expect(() => + compileFilter({ + filter: { plan: { item: { feature_id: "nonexistent" } } }, + ctx: { features: ctx.features }, + ambient, + }), + ).toThrow("Unknown feature_id: nonexistent"); + }); +}); diff --git a/server/tests/unit/compiler/plan/basic.test.ts b/server/tests/unit/compiler/plan/basic.test.ts new file mode 100644 index 000000000..dc557a0d8 --- /dev/null +++ b/server/tests/unit/compiler/plan/basic.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { compilePlanFilter } from "@autumn/shared/api/migrations/compiler/compilePlanFilter.js"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; + +const ctx = contexts.create({ features: [] }); +const ambient = { orgId: "org_test", env: "live" }; + +const PLAN_AMBIENT = "p.org_id = ? AND p.env = ?"; + +const normalize = (sql: string) => + sql.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim(); + +describe("compilePlanFilter — basic plan-rooted filters", () => { + test("plan_id eq", () => { + const result = compilePlanFilter({ + filter: { plan_id: "pro" }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(`${PLAN_AMBIENT} AND p.id = ?`), + ); + expect(result.params).toEqual(["org_test", "live", "pro"]); + }); + + test("plan_id $in", () => { + const result = compilePlanFilter({ + filter: { plan_id: { $in: ["pro", "team"] } }, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(result.sql)).toBe( + normalize(`${PLAN_AMBIENT} AND p.id IN (?, ?)`), + ); + expect(result.params).toEqual(["org_test", "live", "pro", "team"]); + }); +}); diff --git a/shared/api/migrations/compiler/buildCustomerQuery.ts b/shared/api/migrations/compiler/buildCustomerQuery.ts new file mode 100644 index 000000000..093dbaf86 --- /dev/null +++ b/shared/api/migrations/compiler/buildCustomerQuery.ts @@ -0,0 +1,123 @@ +import { type SQL, sql } from "drizzle-orm"; +import type { CustomerFilter } from "../filters/customerFilter.js"; +import { compileFilter } from "./compileFilter.js"; +import type { ResolutionContext } from "./filterToIr/resolutionContext.js"; + +/** + * Top-level query builders for customer-rooted migration filters. + * + * `compileFilter` returns the full WHERE expression (including org/env + * pushdown via the registry's ambient predicates). These helpers wrap it + * into a complete, parameterized Drizzle `SQL` with cursor-based + * pagination for iteration over large customer sets. + */ + +export const DEFAULT_BATCH_SIZE = 10_000; + +/** Convert the compiler's `{ sql, params }` output to a Drizzle SQL chunk. */ +function 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("")); +} + +type BuildArgs = { + orgId: string; + env: string; + filter: CustomerFilter; + ctx: ResolutionContext; +}; + +const compileWhere = ({ orgId, env, filter, ctx }: BuildArgs): SQL => + rawWithParamsToDrizzle( + compileFilter({ filter, ctx, ambient: { orgId, env } }), + ); + +/** Full SELECT. Returns `{ internal_id, id }` rows. */ +export function buildCustomerSelect({ + orgId, + env, + filter, + ctx, + limit, + afterInternalId, +}: BuildArgs & { limit?: number; afterInternalId?: string }): SQL { + const where = compileWhere({ orgId, env, filter, ctx }); + 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 + FROM customers c + WHERE (${where}) ${cursor} + ORDER BY c.internal_id + ${limitClause} + `; +} + +/** COUNT(*) applying the same filter. */ +export function buildCustomerCount({ + orgId, + env, + filter, + ctx, +}: BuildArgs): SQL { + const where = compileWhere({ orgId, env, filter, ctx }); + return sql` + SELECT COUNT(*)::bigint AS count + FROM customers c + WHERE (${where}) + `; +} + +/** + * Iterate matching customers in batches (default 10k per step) using + * keyset pagination on `c.internal_id`. Yields one batch at a time so + * callers can stream-process without loading the full result set. + */ +export async function* iterateCustomers({ + db, + orgId, + env, + filter, + ctx, + batchSize = DEFAULT_BATCH_SIZE, +}: BuildArgs & { + db: { execute: (query: SQL) => Promise }; + batchSize?: number; +}): AsyncGenerator> { + let cursor: string | undefined; + while (true) { + const query = buildCustomerSelect({ + orgId, + env, + filter, + ctx, + limit: batchSize, + afterInternalId: cursor, + }); + const rows = (await db.execute(query)) as unknown as Array<{ + internal_id: string; + id: string | null; + }>; + if (rows.length === 0) return; + yield rows; + if (rows.length < batchSize) return; + cursor = rows[rows.length - 1].internal_id; + } +} diff --git a/shared/api/migrations/compiler/compileFilter.ts b/shared/api/migrations/compiler/compileFilter.ts new file mode 100644 index 000000000..0faf59724 --- /dev/null +++ b/shared/api/migrations/compiler/compileFilter.ts @@ -0,0 +1,29 @@ +import type { CustomerFilter } from "../filters/customerFilter.js"; +import { filterToIr } from "./filterToIr/filterToIr.js"; +import type { ResolutionContext } from "./filterToIr/resolutionContext.js"; +import { + type AmbientContext, + type CompiledSql, + irToSql, +} from "./irToSql/irToSql.js"; +import { customerRegistry } from "./registry/customerRegistry.js"; + +/** + * End-to-end: validated `CustomerFilter` → parameterized SQL fragment. + * + * `ambient` carries org/env scoping (and any other ambient values the + * registry expects). Required because the customer registry declares + * `org_id` / `env` predicates at every scope. + */ +export function compileFilter({ + filter, + ctx, + ambient, +}: { + filter: CustomerFilter; + ctx: ResolutionContext; + ambient: AmbientContext; +}): CompiledSql { + const ir = filterToIr({ filter, ctx }); + return irToSql({ ir, root: customerRegistry, ambient }); +} diff --git a/shared/api/migrations/compiler/compilePlanFilter.ts b/shared/api/migrations/compiler/compilePlanFilter.ts new file mode 100644 index 000000000..f1cf1ce61 --- /dev/null +++ b/shared/api/migrations/compiler/compilePlanFilter.ts @@ -0,0 +1,26 @@ +import type { PlanFilter } from "../filters/planFilter.js"; +import { planFilterToIr } from "./filterToIr/filterToIr.js"; +import type { ResolutionContext } from "./filterToIr/resolutionContext.js"; +import { + type AmbientContext, + type CompiledSql, + irToSql, +} from "./irToSql/irToSql.js"; +import { planRegistry } from "./registry/planRegistry.js"; + +/** + * End-to-end: validated `PlanFilter` → parameterized SQL fragment, rooted + * at the catalog `products` table. + */ +export function compilePlanFilter({ + filter, + ctx, + ambient, +}: { + filter: PlanFilter; + ctx: ResolutionContext; + ambient: AmbientContext; +}): CompiledSql { + const ir = planFilterToIr({ filter, ctx }); + return irToSql({ ir, root: planRegistry, ambient }); +} diff --git a/shared/api/migrations/compiler/filterToIr/filterToIr.ts b/shared/api/migrations/compiler/filterToIr/filterToIr.ts new file mode 100644 index 000000000..8efe50507 --- /dev/null +++ b/shared/api/migrations/compiler/filterToIr/filterToIr.ts @@ -0,0 +1,209 @@ +import type { CustomerFilter } from "../../filters/customerFilter.js"; +import type { PlanFilter } from "../../filters/planFilter.js"; +import type { PlanItemFilter } from "../../filters/planItemFilter.js"; +import type { IRNav, IRNode } from "../ir/irTypes.js"; +import { parseLeaf } from "./parseLeaf.js"; +import type { ResolutionContext } from "./resolutionContext.js"; + +/** + * Top-level entry: take a validated `CustomerFilter` plus a resolution + * context (features, products) and produce IR ready for compilation. + * + * The parser walks the filter shape, recurses into each scope (customer → + * plan → item), and converts each field-matcher pair into one or more IR + * leaves. AND is always implicit between sibling fields. Bare nested + * filters become `nav` nodes with implicit `$some` quantifier. + */ +export function filterToIr({ + filter, + ctx, +}: { + filter: CustomerFilter; + ctx: ResolutionContext; +}): IRNode { + return parseCustomerFilter({ filter, ctx }); +} + +/** + * Plan-rooted entry: validated `PlanFilter` → IR. Used when the migration + * targets the catalog directly rather than a customer's plan instance. + */ +export function planFilterToIr({ + filter, + ctx, +}: { + filter: PlanFilter; + ctx: ResolutionContext; +}): IRNode { + return parsePlanFilter({ filter, ctx }); +} + +function parseCustomerFilter({ + filter, + ctx, +}: { + filter: CustomerFilter; + ctx: ResolutionContext; +}): IRNode { + const children: IRNode[] = []; + + if (filter.plan !== undefined) + children.push(parsePlanNav({ raw: filter.plan, ctx })); + + return wrapAnd(children); +} + +function parsePlanNav({ + raw, + ctx, +}: { + raw: NonNullable; + ctx: ResolutionContext; +}): IRNav { + // Phase 1: only $some (implicit if bare). $every / $none deferred. + const planFilter = isQuantifierWrapper(raw) ? raw.$some : (raw as PlanFilter); + if (!planFilter) throw new Error("plan: only $some is supported in phase 1"); + return { + kind: "nav", + name: "plan", + quantifier: "some", + child: parsePlanFilter({ filter: planFilter, ctx }), + }; +} + +function parsePlanFilter({ + filter, + ctx, +}: { + filter: PlanFilter; + ctx: ResolutionContext; +}): IRNode { + const children: IRNode[] = []; + + if (filter.plan_id !== undefined) + children.push( + parseLeaf({ field: "plan_id", rawValue: filter.plan_id, ctx }), + ); + if (filter.price !== undefined) + children.push(parsePriceExistence(filter.price)); + if (filter.paid !== undefined) + children.push(parseLeaf({ field: "paid", rawValue: filter.paid, ctx })); + if (filter.recurring !== undefined) + children.push( + parseLeaf({ field: "recurring", rawValue: filter.recurring, ctx }), + ); + if (filter.item !== undefined) + children.push(parseItemNav({ raw: filter.item, ctx })); + if (filter.$or !== undefined) { + if (filter.$or.length === 0) + throw new Error("$or requires at least one branch"); + const orChildren = filter.$or.map((sub) => + parsePlanFilter({ filter: sub, ctx }), + ); + children.push({ kind: "or", children: orChildren }); + } + + return wrapAnd(children); +} + +function parseItemNav({ + raw, + ctx, +}: { + raw: NonNullable; + ctx: ResolutionContext; +}): IRNav { + const itemFilter = isQuantifierWrapper(raw) + ? raw.$some + : (raw as PlanItemFilter); + if (!itemFilter) throw new Error("item: only $some is supported in phase 1"); + // Route to the paid-only scope when the filter requires a non-null + // price. Walks `customer_prices` forward instead of the entitlement + // spine — substantially fewer rows on paid-feature migrations. + const navName = isPaidOnlyPriceFilter(itemFilter.price) + ? "item_paid" + : "item"; + return { + kind: "nav", + name: navName, + quantifier: "some", + child: parsePlanItemFilter({ filter: itemFilter, ctx }), + }; +} + +function isPaidOnlyPriceFilter(price: PlanItemFilter["price"]): boolean { + if (price === undefined || price === null) return false; + if (typeof price !== "object") return false; + const ops = price as Record; + return "$ne" in ops && ops.$ne === null; +} + +function parsePlanItemFilter({ + filter, + ctx, +}: { + filter: PlanItemFilter; + ctx: ResolutionContext; +}): IRNode { + const children: IRNode[] = []; + + if (filter.feature_id !== undefined) + children.push( + parseLeaf({ field: "feature_id", rawValue: filter.feature_id, ctx }), + ); + + if (filter.price !== undefined) + children.push(parsePriceExistence(filter.price)); + + // `unlimited` deferred to phase 2. + if (filter.unlimited !== undefined) + throw new Error("plan.item.unlimited is not supported in phase 1"); + + return wrapAnd(children); +} + +/** + * Phase 1 supports only existence checks on `price` (paid vs free): + * price: null → entitlement-only item (no price) + * price: { $eq: null } → same + * price: { $ne: null } → has a price (paid item) + * Filtering on nested price fields (billing_method, etc.) is deferred. + */ +function parsePriceExistence(raw: unknown): IRNode { + if (raw === null) + return { kind: "leaf", field: "price", op: "exists", value: false }; + + if (typeof raw !== "object") + throw new Error("plan.item.price must be null or an object"); + + const ops = raw as Record; + const hasOnlyNullOps = + Object.keys(ops).every((k) => k === "$eq" || k === "$ne") && + Object.values(ops).every((v) => v === null || v === undefined); + if (!hasOnlyNullOps) + throw new Error( + "plan.item.price filtering on nested fields is not supported in phase 1", + ); + + if ("$ne" in ops && ops.$ne === null) + return { kind: "leaf", field: "price", op: "exists", value: true }; + if ("$eq" in ops && ops.$eq === null) + return { kind: "leaf", field: "price", op: "exists", value: false }; + + throw new Error("plan.item.price requires $eq: null or $ne: null"); +} + +function isQuantifierWrapper( + raw: unknown, +): raw is { $some?: unknown; $every?: unknown; $none?: unknown } { + if (!raw || typeof raw !== "object") return false; + const keys = Object.keys(raw as object); + return keys.some((k) => k === "$some" || k === "$every" || k === "$none"); +} + +function wrapAnd(children: IRNode[]): IRNode { + if (children.length === 0) + throw new Error("Empty filter scope: at least one field is required"); + if (children.length === 1) return children[0]; + return { kind: "and", children }; +} diff --git a/shared/api/migrations/compiler/filterToIr/index.ts b/shared/api/migrations/compiler/filterToIr/index.ts new file mode 100644 index 000000000..60c928d87 --- /dev/null +++ b/shared/api/migrations/compiler/filterToIr/index.ts @@ -0,0 +1,2 @@ +export * from "./filterToIr.js"; +export * from "./resolutionContext.js"; diff --git a/shared/api/migrations/compiler/filterToIr/parseLeaf.ts b/shared/api/migrations/compiler/filterToIr/parseLeaf.ts new file mode 100644 index 000000000..e8d0ac6fa --- /dev/null +++ b/shared/api/migrations/compiler/filterToIr/parseLeaf.ts @@ -0,0 +1,65 @@ +import type { IRLeaf, IRNode, LeafOp } from "../ir/irTypes.js"; +import type { ResolutionContext } from "./resolutionContext.js"; +import { translateValue } from "./translateValue.js"; + +/** + * Parse a single field's matcher value into one IR leaf or a small AND of + * leaves. Handles the four supported operators: eq, ne, in, exists. + * + * Spelling normalization: + * - bare value → eq + * - { $eq: x } → eq + * - { $ne: null } → exists (true) + * - { $eq: null } → eq null + * - { $in: [...] } → in + * + * Multiple operators on one field are combined with AND. + */ +export function parseLeaf({ + field, + rawValue, + ctx, +}: { + field: string; + rawValue: unknown; + ctx: ResolutionContext; +}): IRNode { + if (isBareValue(rawValue)) return makeLeaf(field, "eq", rawValue, ctx); + + const ops = rawValue as Record; + const leaves: IRLeaf[] = []; + + if ("$eq" in ops) leaves.push(makeLeaf(field, "eq", ops.$eq, ctx) as IRLeaf); + if ("$ne" in ops) { + // $ne: null is the existence-check shorthand for nullable fields. + if (ops.$ne === null) + leaves.push(makeLeaf(field, "exists", true, ctx) as IRLeaf); + else leaves.push(makeLeaf(field, "ne", ops.$ne, ctx) as IRLeaf); + } + if ("$in" in ops) leaves.push(makeLeaf(field, "in", ops.$in, ctx) as IRLeaf); + + if (leaves.length === 0) + throw new Error(`No supported operator found on field "${field}"`); + if (leaves.length === 1) return leaves[0]; + return { kind: "and", children: leaves }; +} + +function isBareValue(v: unknown): boolean { + if (v === null) return true; + const t = typeof v; + return t === "string" || t === "number" || t === "boolean"; +} + +function makeLeaf( + field: string, + op: LeafOp, + value: unknown, + ctx: ResolutionContext, +): IRLeaf { + return { + kind: "leaf", + field, + op, + value: translateValue({ field, value, ctx }) as IRLeaf["value"], + }; +} diff --git a/shared/api/migrations/compiler/filterToIr/resolutionContext.ts b/shared/api/migrations/compiler/filterToIr/resolutionContext.ts new file mode 100644 index 000000000..c4b669dc3 --- /dev/null +++ b/shared/api/migrations/compiler/filterToIr/resolutionContext.ts @@ -0,0 +1,16 @@ +/** + * Public IDs that map 1:1 to internal IDs are translated at parse time, + * before the IR is built. This keeps the compiler pure — it only ever + * sees resolved internal IDs for those fields. + * + * Currently only `feature_id` is translated (features are not versioned, + * so the mapping is 1:1). `plan_id` is NOT translated — a public plan_id + * maps to many internal_product_ids (one per version), so we resolve it + * via a JOIN to `products` instead. + * + * Unknown IDs throw at parse time, never silently miss. + */ + +export type ResolutionContext = { + features: ReadonlyArray<{ id: string; internal_id: string }>; +}; diff --git a/shared/api/migrations/compiler/filterToIr/translateValue.ts b/shared/api/migrations/compiler/filterToIr/translateValue.ts new file mode 100644 index 000000000..ead67a0d3 --- /dev/null +++ b/shared/api/migrations/compiler/filterToIr/translateValue.ts @@ -0,0 +1,39 @@ +import type { ResolutionContext } from "./resolutionContext.js"; + +/** + * Translate a public ID (or array of public IDs) to internal IDs for the + * fields that need it: `feature_id`. All other fields pass through + * unchanged. Unknown IDs throw. + * + * Note: `plan_id` is NOT translated. A public plan_id (e.g. "pro") maps + * to many `internal_product_id`s — one per plan version — so we resolve + * it via a JOIN to `products` instead, filtering on `products.id`. + */ +export function translateValue({ + field, + value, + ctx, +}: { + field: string; + value: unknown; + ctx: ResolutionContext; +}): unknown { + const lookup = TRANSLATORS[field]; + if (!lookup) return value; + if (value === null) return value; + if (Array.isArray(value)) return value.map((v) => lookup(v, ctx)); + return lookup(value, ctx); +} + +const TRANSLATORS: Record< + string, + (value: unknown, ctx: ResolutionContext) => unknown +> = { + feature_id: (value, ctx) => { + if (typeof value !== "string") + throw new Error(`feature_id must be a string, got ${typeof value}`); + const feature = ctx.features.find((f) => f.id === value); + if (!feature) throw new Error(`Unknown feature_id: ${value}`); + return feature.internal_id; + }, +}; diff --git a/shared/api/migrations/compiler/index.ts b/shared/api/migrations/compiler/index.ts new file mode 100644 index 000000000..bcf840951 --- /dev/null +++ b/shared/api/migrations/compiler/index.ts @@ -0,0 +1,7 @@ +export * from "./buildCustomerQuery.js"; +export * from "./compileFilter.js"; +export * from "./compilePlanFilter.js"; +export * from "./filterToIr/index.js"; +export * from "./ir/index.js"; +export * from "./irToSql/index.js"; +export * from "./registry/index.js"; diff --git a/shared/api/migrations/compiler/ir/index.ts b/shared/api/migrations/compiler/ir/index.ts new file mode 100644 index 000000000..da63ed22f --- /dev/null +++ b/shared/api/migrations/compiler/ir/index.ts @@ -0,0 +1 @@ +export * from "./irTypes.js"; diff --git a/shared/api/migrations/compiler/ir/irTypes.ts b/shared/api/migrations/compiler/ir/irTypes.ts new file mode 100644 index 000000000..c254732c2 --- /dev/null +++ b/shared/api/migrations/compiler/ir/irTypes.ts @@ -0,0 +1,50 @@ +/** + * Intermediate Representation (IR) for migration filters. + * + * The filter pipeline is: + * Filter (Zod, public DSL) → IR (canonical AST) → SQL (Drizzle fragment) + * + * The IR is the compiler-friendly middle layer. It collapses every spelling + * of the public DSL (`feature_id: "x"` ≡ `{ $eq: "x" }`, `price: null` ≡ + * `{ $eq: null }`) into one canonical shape, so the compiler only handles + * one form per concept. + */ + +export type LeafOp = "eq" | "ne" | "in" | "exists"; + +export type LeafValue = + | string + | number + | boolean + | null + | readonly string[] + | readonly number[]; + +export type IRLeaf = { + kind: "leaf"; + /** Field name in the current scope's registry, e.g. "plan_id". */ + field: string; + op: LeafOp; + value: LeafValue; +}; + +export type IRAnd = { + kind: "and"; + children: readonly IRNode[]; +}; + +export type IROr = { + kind: "or"; + children: readonly IRNode[]; +}; + +export type IRNav = { + kind: "nav"; + /** Single segment naming the nav, e.g. "plan" or "item". */ + name: string; + /** Phase 1: only "some" is supported. */ + quantifier: "some"; + child: IRNode; +}; + +export type IRNode = IRLeaf | IRAnd | IROr | IRNav; diff --git a/shared/api/migrations/compiler/irToSql/index.ts b/shared/api/migrations/compiler/irToSql/index.ts new file mode 100644 index 000000000..024e6e1ea --- /dev/null +++ b/shared/api/migrations/compiler/irToSql/index.ts @@ -0,0 +1 @@ +export * from "./irToSql.js"; diff --git a/shared/api/migrations/compiler/irToSql/irToSql.ts b/shared/api/migrations/compiler/irToSql/irToSql.ts new file mode 100644 index 000000000..bb3ba86fa --- /dev/null +++ b/shared/api/migrations/compiler/irToSql/irToSql.ts @@ -0,0 +1,190 @@ +import type { IRLeaf, IRNode } from "../ir/irTypes.js"; +import type { + AmbientPredicate, + FieldDef, + NavScope, + RootScope, +} from "../registry/registryTypes.js"; + +export type CompiledSql = { + /** WHERE-clause fragment with `?` placeholders. */ + sql: string; + /** Values to bind in placeholder order. */ + params: unknown[]; +}; + +/** Values supplied to ambient predicates with `source.kind === "context"`. */ +export type AmbientContext = Record; + +/** + * Compile an IR tree into a parameterized SQL fragment. The root scope's + * ambient predicates are emitted at the top of the WHERE clause; every + * nested scope's ambient predicates are auto-injected into its EXISTS + * subquery. + */ +export function irToSql({ + ir, + root, + ambient, +}: { + ir: IRNode; + root: RootScope; + ambient: AmbientContext; +}): CompiledSql { + const params: unknown[] = []; + const rootAmbient = compileAmbient({ + predicates: root.ambient, + ambient, + params, + }); + const irSql = compileNode({ + node: ir, + fields: root.fields, + params, + ambient, + }); + const parts = [...rootAmbient, irSql].filter((s) => s.length > 0); + return { sql: parts.join(" AND "), params }; +} + +function compileAmbient({ + predicates, + ambient, + params, +}: { + predicates: AmbientPredicate[] | undefined; + ambient: AmbientContext; + params: unknown[]; +}): string[] { + if (!predicates) return []; + return predicates.map((pred) => { + if (pred.source.kind === "context") { + const value = ambient[pred.source.key]; + if (value === undefined) + throw new Error( + `Missing ambient value for key "${pred.source.key}" (column ${pred.column})`, + ); + params.push(value); + return `${pred.column} = ?`; + } + // values: static IN list + if (pred.source.values.length === 0) return "FALSE"; + const placeholders = pred.source.values + .map((v) => { + params.push(v); + return "?"; + }) + .join(", "); + return `${pred.column} IN (${placeholders})`; + }); +} + +function compileNode({ + node, + fields, + params, + ambient, +}: { + node: IRNode; + fields: Record; + params: unknown[]; + ambient: AmbientContext; +}): string { + if (node.kind === "leaf") return compileLeaf({ leaf: node, fields, params }); + if (node.kind === "and") { + if (node.children.length === 0) return "TRUE"; + const parts = node.children.map((c) => + compileNode({ node: c, fields, params, ambient }), + ); + return `(${parts.join(" AND ")})`; + } + if (node.kind === "or") { + if (node.children.length === 0) return "FALSE"; + const parts = node.children.map((c) => + compileNode({ node: c, fields, params, ambient }), + ); + return `(${parts.join(" OR ")})`; + } + // nav + const def = fields[node.name]; + if (!def) throw new Error(`Unknown nav field: ${node.name}`); + if (def.kind !== "nav") throw new Error(`Field "${node.name}" is not a nav`); + return existsForScope({ + scope: def.scope, + child: node.child, + params, + ambient, + }); +} + +function existsForScope({ + scope, + child, + params, + ambient, +}: { + scope: NavScope; + child: IRNode; + params: unknown[]; + ambient: AmbientContext; +}): string { + const ambientPreds = compileAmbient({ + predicates: scope.ambient, + ambient, + params, + }); + const childSql = compileNode({ + node: child, + fields: scope.fields, + params, + ambient, + }); + const conditions = [scope.correlation, ...ambientPreds, childSql].join( + " AND ", + ); + return `EXISTS (SELECT 1 FROM ${scope.from} WHERE ${conditions})`; +} + +function compileLeaf({ + leaf, + fields, + params, +}: { + leaf: IRLeaf; + fields: Record; + params: unknown[]; +}): string { + const def = fields[leaf.field]; + if (!def) throw new Error(`Unknown leaf field: ${leaf.field}`); + if (def.kind !== "leaf") + throw new Error(`Field "${leaf.field}" is not a leaf`); + + const col = def.sql; + + if (leaf.op === "exists") { + return leaf.value === true ? `${col} IS NOT NULL` : `${col} IS NULL`; + } + if (leaf.op === "eq") { + if (leaf.value === null) return `${col} IS NULL`; + params.push(leaf.value); + return `${col} = ?`; + } + if (leaf.op === "ne") { + if (leaf.value === null) return `${col} IS NOT NULL`; + params.push(leaf.value); + return `${col} <> ?`; + } + if (leaf.op === "in") { + if (!Array.isArray(leaf.value)) + throw new Error(`$in expects an array on field "${leaf.field}"`); + if (leaf.value.length === 0) return "FALSE"; + const placeholders = leaf.value + .map((v) => { + params.push(v); + return "?"; + }) + .join(", "); + return `${col} IN (${placeholders})`; + } + throw new Error(`Unsupported op: ${(leaf as IRLeaf).op}`); +} diff --git a/shared/api/migrations/compiler/registry/customerRegistry.ts b/shared/api/migrations/compiler/registry/customerRegistry.ts new file mode 100644 index 000000000..b24f325ca --- /dev/null +++ b/shared/api/migrations/compiler/registry/customerRegistry.ts @@ -0,0 +1,133 @@ +import { ACTIVE_STATUSES } from "../../../../utils/cusProductUtils/cusProductConstants.js"; +import type { NavScope, RootScope } from "./registryTypes.js"; + +/** + * Phase 1 field registry, rooted at the `customers` table. + * + * Supported paths: + * plan.plan_id + * plan.item.feature_id, plan.item.price (existence only: null / $ne null) + * + * Aliases: + * c customers + * cp customer_products + * p products + * ce customer_entitlements + * e entitlements + * pr prices (LEFT JOIN — only present for paid items) + * cpr customer_prices (LEFT JOIN — only present for paid items) + * + * Item resolution: a plan item is identified by its entitlement. + * `feature_id` ALWAYS resolves via `customer_entitlements → entitlements`. + * The price side is reached by reversing the link: `prices.entitlement_id + * = entitlements.id`. Customer prices are LEFT JOINed so existence checks + * compile to `cpr.id IS NULL` / `IS NOT NULL`. + * + * Ambient predicates push `org_id` / `env` down into every scope whose + * table has those columns. Without this, multi-tenant scans bloat 10x+. + * + * `cp.status IN ACTIVE_STATUSES` is also baked in — customer-rooted + * filters always operate on active plan instances. + */ + +/** + * Default item scope: entitlement spine. Handles both free and paid items. + * `price` filtering is done by LEFT JOINing prices/customer_prices and + * checking `cpr.id IS NULL / NOT NULL`. + */ +const itemScope: NavScope = { + from: [ + "customer_entitlements ce", + "JOIN entitlements e ON e.id = ce.entitlement_id", + "LEFT JOIN prices pr ON pr.entitlement_id = e.id", + "LEFT JOIN customer_prices cpr ON cpr.price_id = pr.id AND cpr.customer_product_id = ce.customer_product_id", + ].join(" "), + correlation: "ce.customer_product_id = cp.id", + fields: { + feature_id: { kind: "leaf", sql: "e.internal_feature_id" }, + price: { kind: "leaf", sql: "cpr.id" }, + }, +}; + +/** + * Paid-only optimization: when the filter requires a non-null price, walk + * customer_prices forward instead of the entitlement spine. Skips every + * free entitlement row entirely — typically ~10x fewer rows scanned. + * + * Selected by the parser when it sees `price: { $ne: null }`. + */ +const itemPaidScope: NavScope = { + from: [ + "customer_prices cpr", + "JOIN prices pr ON pr.id = cpr.price_id", + "JOIN entitlements e ON e.id = pr.entitlement_id", + ].join(" "), + correlation: "cpr.customer_product_id = cp.id", + fields: { + feature_id: { kind: "leaf", sql: "e.internal_feature_id" }, + // `cpr.id` is the driving column — always non-null in this scope. + // Emitting `cpr.id IS NOT NULL` is redundant but harmless; Postgres + // elides it. + price: { kind: "leaf", sql: "cpr.id" }, + }, +}; + +const planScope: NavScope = { + from: "customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id", + correlation: "cp.internal_customer_id = c.internal_id", + ambient: [ + { + column: "cp.status", + source: { kind: "values", values: ACTIVE_STATUSES }, + }, + ], + fields: { + plan_id: { kind: "leaf", sql: "p.id" }, + // Base price existence: a leaf whose SQL is a scalar subquery that + // evaluates to NULL when the customer has no base customer_price on + // this cusproduct, non-NULL otherwise. The `exists` op (compiled + // via `IS NULL` / `IS NOT NULL`) works against this exactly the + // same way it works against `cpr.id` inside `itemScope` — the leaf + // abstraction unifies "existence-of-related-row" semantics across + // any scope that needs them. + price: { + kind: "leaf", + sql: [ + "(SELECT base_cpr.id FROM customer_prices base_cpr", + "JOIN prices base_pr ON base_pr.id = base_cpr.price_id", + "WHERE base_cpr.customer_product_id = cp.id", + "AND base_pr.entitlement_id IS NULL LIMIT 1)", + ].join(" "), + }, + // Derived boolean filters: SQL is a boolean EXISTS expression so + // `paid: true` compiles to `EXISTS(...) = true` (Postgres treats + // this as the same as `EXISTS(...)`). See SKILL.md "Derived + // boolean filters" for the pattern. + paid: { + kind: "leaf", + sql: "EXISTS (SELECT 1 FROM customer_prices cpr WHERE cpr.customer_product_id = cp.id)", + }, + recurring: { + kind: "leaf", + sql: [ + "EXISTS (SELECT 1 FROM customer_prices cpr", + "JOIN prices pr ON pr.id = cpr.price_id", + "WHERE cpr.customer_product_id = cp.id", + "AND pr.config->>'interval' <> 'one_off')", + ].join(" "), + }, + item: { kind: "nav", scope: itemScope }, + item_paid: { kind: "nav", scope: itemPaidScope }, + }, +}; + +export const customerRegistry: RootScope = { + from: "customers c", + ambient: [ + { column: "c.org_id", source: { kind: "context", key: "orgId" } }, + { column: "c.env", source: { kind: "context", key: "env" } }, + ], + fields: { + plan: { kind: "nav", scope: planScope }, + }, +}; diff --git a/shared/api/migrations/compiler/registry/index.ts b/shared/api/migrations/compiler/registry/index.ts new file mode 100644 index 000000000..2475fca80 --- /dev/null +++ b/shared/api/migrations/compiler/registry/index.ts @@ -0,0 +1,3 @@ +export * from "./customerRegistry.js"; +export * from "./planRegistry.js"; +export * from "./registryTypes.js"; diff --git a/shared/api/migrations/compiler/registry/planRegistry.ts b/shared/api/migrations/compiler/registry/planRegistry.ts new file mode 100644 index 000000000..749a2fa26 --- /dev/null +++ b/shared/api/migrations/compiler/registry/planRegistry.ts @@ -0,0 +1,16 @@ +import type { RootScope } from "./registryTypes.js"; + +/** + * Phase 1 plan-rooted registry. Used for migrations that target the + * catalog directly (e.g. "find all plans where plan_id = 'pro'"). + */ +export const planRegistry: RootScope = { + from: "products p", + ambient: [ + { column: "p.org_id", source: { kind: "context", key: "orgId" } }, + { column: "p.env", source: { kind: "context", key: "env" } }, + ], + fields: { + plan_id: { kind: "leaf", sql: "p.id" }, + }, +}; diff --git a/shared/api/migrations/compiler/registry/registryTypes.ts b/shared/api/migrations/compiler/registry/registryTypes.ts new file mode 100644 index 000000000..c864ff87d --- /dev/null +++ b/shared/api/migrations/compiler/registry/registryTypes.ts @@ -0,0 +1,58 @@ +/** + * Field registry — the single source of truth mapping each logical filter + * field to its physical SQL shape. Adding a filter field = adding a + * registry entry. Compiler stays untouched. + * + * Two field kinds: + * - `leaf`: a column or expression. Used for direct comparisons. + * - `nav`: a 1:N relation. Compiles to `EXISTS (SELECT 1 FROM + * WHERE AND AND )`. + * + * Ambient predicates: each scope can declare predicates that get + * auto-injected when the scope is entered. Two flavors: + * - `context` source: value pulled from an ambient context (e.g. orgId, + * env). Pushed as a `?` param. + * - `values` source: static list known at registry-construction time + * (e.g. `cp.status IN ACTIVE_STATUSES`). Each element is pushed as a + * `?` param so quoting/escaping is the driver's responsibility. + */ + +export type AmbientPredicate = { + /** SQL column reference, e.g. "cp.org_id". */ + column: string; + source: + | { kind: "context"; key: string } + | { kind: "values"; values: readonly (string | number)[] }; +}; + +export type LeafField = { + kind: "leaf"; + /** SQL expression for this field, ready to drop into a WHERE clause. */ + sql: string; +}; + +export type NavScope = { + /** Source clause (without leading FROM), incl. JOINs. */ + from: string; + /** Predicate correlating this scope to the parent scope. */ + correlation: string; + /** Ambient predicates auto-injected on scope entry. */ + ambient?: AmbientPredicate[]; + /** Fields available in this scope. */ + fields: Record; +}; + +export type NavField = { + kind: "nav"; + scope: NavScope; +}; + +export type FieldDef = LeafField | NavField; + +export type RootScope = { + /** Root table aliased into the WHERE clause. */ + from: string; + /** Ambient predicates auto-injected at the root. */ + ambient?: AmbientPredicate[]; + fields: Record; +}; diff --git a/shared/api/migrations/filters/arrayFilter.ts b/shared/api/migrations/filters/arrayFilter.ts new file mode 100644 index 000000000..850ca08e2 --- /dev/null +++ b/shared/api/migrations/filters/arrayFilter.ts @@ -0,0 +1,21 @@ +import { z } from "zod/v4"; + +/** + * Array-navigation aggregations. Use one of: + * - `$some` — at least one element matches (the implicit default if you + * pass a bare element filter, e.g. `item: { feature_id: "credits" }`). + * - `$every` — all elements match. + * - `$none` — no elements match. + * + * `$count` and group-by predicates are intentionally NOT supported here — + * those belong in a higher-level Selector layer. + */ +export const arrayFilter = (element: T) => + z.union([ + element, + z.object({ + $some: element.optional(), + $every: element.optional(), + $none: element.optional(), + }), + ]); diff --git a/shared/api/migrations/filters/customerFilter.ts b/shared/api/migrations/filters/customerFilter.ts new file mode 100644 index 000000000..c4cc6534a --- /dev/null +++ b/shared/api/migrations/filters/customerFilter.ts @@ -0,0 +1,18 @@ +import { z } from "zod/v4"; +import { arrayFilter } from "./arrayFilter.js"; +import { PlanFilterSchema } from "./planFilter.js"; + +/** + * Filter over Autumn customers. Migration-scoped — stable contract + * decoupled from `ApiCustomerV5`. + * + * `plan` is array-navigation: a bare `PlanFilter` is implicit `$some`. + * Use `{ $every: ... }` or `{ $none: ... }` for stricter checks. + */ +export const CustomerFilterSchema = z.object({ + plan: arrayFilter(PlanFilterSchema).optional(), +}); + +export type CustomerFilter = z.infer; + +export const DEFAULT_CUSTOMER_FILTER: CustomerFilter = {}; diff --git a/shared/api/migrations/filters/index.ts b/shared/api/migrations/filters/index.ts new file mode 100644 index 000000000..2a8c17290 --- /dev/null +++ b/shared/api/migrations/filters/index.ts @@ -0,0 +1,6 @@ +export * from "./arrayFilter.js"; +export * from "./customerFilter.js"; +export * from "./matcher.js"; +export * from "./migrationFilter.js"; +export * from "./planFilter.js"; +export * from "./planItemFilter.js"; diff --git a/shared/api/migrations/filters/matcher.ts b/shared/api/migrations/filters/matcher.ts new file mode 100644 index 000000000..6eba96a7f --- /dev/null +++ b/shared/api/migrations/filters/matcher.ts @@ -0,0 +1,80 @@ +import { z } from "zod/v4"; + +/** + * Mongo-style matchers for migration filters. + * + * Rules: + * - Bare value = equality (Mongo convention). `feature_id: "credits"` ≡ + * `feature_id: { $eq: "credits" }`. + * - `null` is a valid bare value: `price: null` matches null fields. + * - Object form lets the caller use operators ($in, $ne, $gt, $regex, ...). + * - Operator keys are prefixed with `$` to disambiguate from nested-object + * filters. Keys without `$` are treated as field names on the resource. + */ + +export const StringMatcherSchema = z.union([ + z.string(), + z.null(), + z.object({ + $eq: z.union([z.string(), z.null()]).optional(), + $ne: z.union([z.string(), z.null()]).optional(), + $in: z.array(z.string()).optional(), + $nin: z.array(z.string()).optional(), + $regex: z.string().optional(), + $startsWith: z.string().optional(), + }), +]); + +export type StringMatcher = z.infer; + +export const NumberMatcherSchema = z.union([ + z.number(), + z.null(), + z.object({ + $eq: z.union([z.number(), z.null()]).optional(), + $ne: z.union([z.number(), z.null()]).optional(), + $in: z.array(z.number()).optional(), + $nin: z.array(z.number()).optional(), + $gt: z.number().optional(), + $gte: z.number().optional(), + $lt: z.number().optional(), + $lte: z.number().optional(), + }), +]); + +export type NumberMatcher = z.infer; + +export const BooleanMatcherSchema = z.boolean(); +export type BooleanMatcher = z.infer; + +/** Build a string-enum matcher: bare literal, null, or { $eq, $ne, $in, $nin }. */ +export const enumMatcher = ( + values: T, +) => { + const literal = z.enum(values); + return z.union([ + literal, + z.null(), + z.object({ + $eq: z.union([literal, z.null()]).optional(), + $ne: z.union([literal, z.null()]).optional(), + $in: z.array(literal).optional(), + $nin: z.array(literal).optional(), + }), + ]); +}; + +/** + * Wrap a nested-object filter with null-equality shorthand. Allows callers to + * write `price: null` (matches null), `price: { $ne: null }` (matches + * non-null), or `price: { interval: "month" }` (non-null AND nested matches). + */ +export const nullableObjectFilter = (inner: T) => + z.union([ + z.null(), + z.object({ + $eq: z.null().optional(), + $ne: z.null().optional(), + }), + inner, + ]); diff --git a/shared/api/migrations/filters/migrationFilter.ts b/shared/api/migrations/filters/migrationFilter.ts new file mode 100644 index 000000000..aa66068ea --- /dev/null +++ b/shared/api/migrations/filters/migrationFilter.ts @@ -0,0 +1,29 @@ +import { z } from "zod/v4"; +import { CustomerFilterSchema } from "./customerFilter.js"; +import { PlanFilterSchema } from "./planFilter.js"; + +/** + * Top-level migration filter, scoped by resource. Mirrors the + * operations shape (`{ customer, plan }`). + * + * - `customer` selects customers via `CustomerFilter`. + * - `plan` selects catalog plans via `PlanFilter` (phase 2+ resource). + * + * At least one resource block is required at runtime. + */ +export const MigrationFilterSchema = z + .object({ + customer: CustomerFilterSchema.optional(), + plan: PlanFilterSchema.optional(), + }) + .check((ctx) => { + if (ctx.value.customer === undefined && ctx.value.plan === undefined) { + ctx.issues.push({ + code: "custom", + message: "filter requires at least one resource block", + input: ctx.value, + }); + } + }); + +export type MigrationFilter = z.infer; diff --git a/shared/api/migrations/filters/planFilter.ts b/shared/api/migrations/filters/planFilter.ts new file mode 100644 index 000000000..92c1b4571 --- /dev/null +++ b/shared/api/migrations/filters/planFilter.ts @@ -0,0 +1,69 @@ +import { z } from "zod/v4"; +import { arrayFilter } from "./arrayFilter.js"; +import { + BooleanMatcherSchema, + nullableObjectFilter, + StringMatcherSchema, +} from "./matcher.js"; +import { PlanItemFilterSchema } from "./planItemFilter.js"; + +/** + * Filter over a plan. Migration-scoped: stable contract decoupled from + * `ApiPlanV1`. + * + * Customer-rooted filters automatically scope to active customer-product + * status (`cp.status IN ACTIVE_STATUSES`). + * + * `price` is the plan's BASE price (customer_price linked to a price with + * `entitlement_id IS NULL`). Use `price: null` for free plans, + * `price: { $ne: null }` for paid plans. + * + * `paid` / `recurring` are DERIVED filters — boolean shortcuts that + * compile to EXISTS expressions: + * - `paid: true` → has at least one customer_price (base or item) + * - `recurring: true`→ has at least one customer_price whose price's + * interval is not 'one_off' + * + * Note: `recurring: true` is a strict subset of `paid: true`. Every + * recurring plan is paid (a recurring price is still a price), but a + * paid plan may be one-off. Combine them only when you mean it — e.g. + * `paid: true, recurring: false` selects one-off-paid plans. + * + * `item` is array-navigation: a bare `PlanItemFilter` is implicit `$some`. + * + * `$or` joins sibling filters with OR instead of the default AND. Sibling + * fields outside `$or` continue to be ANDed with the OR group. + */ + +const PlanPriceFilterInner = z.object({}); + +export type PlanFilter = { + plan_id?: z.infer; + price?: + | null + | { $eq?: null; $ne?: null } + | z.infer; + paid?: z.infer; + recurring?: z.infer; + item?: + | z.infer + | { + $some?: z.infer; + $every?: z.infer; + $none?: z.infer; + }; + $or?: PlanFilter[]; +}; + +export const PlanFilterSchema: z.ZodType = z.lazy(() => + z.object({ + plan_id: StringMatcherSchema.optional(), + price: nullableObjectFilter(PlanPriceFilterInner).optional(), + paid: BooleanMatcherSchema.optional(), + recurring: BooleanMatcherSchema.optional(), + item: arrayFilter(PlanItemFilterSchema).optional(), + $or: z.array(PlanFilterSchema).optional(), + }), +); + +export const DEFAULT_PLAN_FILTER: PlanFilter = {}; diff --git a/shared/api/migrations/filters/planItemFilter.ts b/shared/api/migrations/filters/planItemFilter.ts new file mode 100644 index 000000000..23586c16b --- /dev/null +++ b/shared/api/migrations/filters/planItemFilter.ts @@ -0,0 +1,32 @@ +import { z } from "zod/v4"; +import { + BooleanMatcherSchema, + enumMatcher, + nullableObjectFilter, + StringMatcherSchema, +} from "./matcher.js"; + +/** + * Filter over plan items (the unified price + entitlement view of a feature + * within a plan). Migration-scoped: a stable contract decoupled from the + * public API schema so plan-side changes don't break existing migrations. + * + * Use `price: null` for "free" items (entitlement-only) and + * `price: { $ne: null }` for paid items. + */ + +const BillingMethodSchema = enumMatcher(["prepaid", "usage_based"]); + +const PriceFilterInner = z.object({ + billing_method: BillingMethodSchema.optional(), +}); + +export const PlanItemFilterSchema = z.object({ + feature_id: StringMatcherSchema.optional(), + unlimited: BooleanMatcherSchema.optional(), + price: nullableObjectFilter(PriceFilterInner).optional(), +}); + +export type PlanItemFilter = z.infer; + +export const DEFAULT_PLAN_ITEM_FILTER: PlanItemFilter = {}; diff --git a/shared/api/migrations/operations/customer/customerOperations.ts b/shared/api/migrations/operations/customer/customerOperations.ts new file mode 100644 index 000000000..093b8693c --- /dev/null +++ b/shared/api/migrations/operations/customer/customerOperations.ts @@ -0,0 +1,23 @@ +import { z } from "zod/v4"; +import { UpdatePlanOpSchema } from "./updatePlan/index.js"; + +/** + * Operations applied to a matched customer's resources. Phase 1 ships + * only `update_plans`. `add_plans` / `remove_plans` slots reserved for + * phase 2+. + */ +export const CustomerOperationsSchema = z + .object({ + update_plans: z.array(UpdatePlanOpSchema).optional(), + }) + .check((ctx) => { + if ((ctx.value.update_plans?.length ?? 0) === 0) { + ctx.issues.push({ + code: "custom", + message: "operations.customer requires at least one operation", + input: ctx.value, + }); + } + }); + +export type CustomerOperations = z.infer; diff --git a/shared/api/migrations/operations/customer/index.ts b/shared/api/migrations/operations/customer/index.ts new file mode 100644 index 000000000..0aad6e797 --- /dev/null +++ b/shared/api/migrations/operations/customer/index.ts @@ -0,0 +1,2 @@ +export * from "./customerOperations.js"; +export * from "./updatePlan/index.js"; diff --git a/shared/api/migrations/operations/customer/updatePlan/index.ts b/shared/api/migrations/operations/customer/updatePlan/index.ts new file mode 100644 index 000000000..5a7d02a90 --- /dev/null +++ b/shared/api/migrations/operations/customer/updatePlan/index.ts @@ -0,0 +1 @@ +export * from "./updatePlanOp.js"; diff --git a/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts b/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts new file mode 100644 index 000000000..2242a36f2 --- /dev/null +++ b/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts @@ -0,0 +1,39 @@ +import { z } from "zod/v4"; +import { CreatePlanItemParamsV1Schema } from "../../../../products/items/crud/createPlanItemParamsV1.js"; +import { PlanFilterSchema } from "../../../filters/planFilter.js"; +import { PlanItemFilterSchema } from "../../../filters/planItemFilter.js"; + +/** + * Patch a customer's matching plan instances in place. Mirrors the patch + * fields of CustomizePlanV1. + * + * Phase 1 fields: `target`, `add_items`, `delete_items`. Slots reserved + * for phase 2+: `cancel_at`, `price`, `free_trial`, `update_items`, + * `replace_items`. + * + * - `add_items` uses `CreatePlanItemParamsV1` (the create-plan-item + * shape). Phase 1 expects entitlement-only items; priced items defer + * to phase 2 auto-prep, enforced by handler-side validation. + * - `delete_items` uses `PlanItemFilter` to match items on each target + * plan and remove them. + */ +export const UpdatePlanOpSchema = z + .object({ + target: PlanFilterSchema, + add_items: z.array(CreatePlanItemParamsV1Schema).optional(), + delete_items: z.array(PlanItemFilterSchema).optional(), + }) + .check((ctx) => { + const hasAdds = (ctx.value.add_items?.length ?? 0) > 0; + const hasDeletes = (ctx.value.delete_items?.length ?? 0) > 0; + if (!hasAdds && !hasDeletes) { + ctx.issues.push({ + code: "custom", + message: + "update_plans op requires non-empty add_items or delete_items", + input: ctx.value, + }); + } + }); + +export type UpdatePlanOp = z.infer; diff --git a/shared/api/migrations/operations/index.ts b/shared/api/migrations/operations/index.ts new file mode 100644 index 000000000..9429d6d6b --- /dev/null +++ b/shared/api/migrations/operations/index.ts @@ -0,0 +1,2 @@ +export * from "./customer/index.js"; +export * from "./operations.js"; diff --git a/shared/api/migrations/operations/operations.ts b/shared/api/migrations/operations/operations.ts new file mode 100644 index 000000000..b817f50cc --- /dev/null +++ b/shared/api/migrations/operations/operations.ts @@ -0,0 +1,25 @@ +import { z } from "zod/v4"; +import { CustomerOperationsSchema } from "./customer/customerOperations.js"; + +/** + * Top-level migration operations payload, scoped by resource. Mirrors + * the filter shape (`{ customer, plan }`). + * + * Phase 1: only `customer` operations are implemented. `plan` + * (catalog-level operations) is a phase 2+ slot. + */ +export const OperationsSchema = z + .object({ + customer: CustomerOperationsSchema.optional(), + }) + .check((ctx) => { + if (ctx.value.customer === undefined) { + ctx.issues.push({ + code: "custom", + message: "operations requires at least one resource block", + input: ctx.value, + }); + } + }); + +export type Operations = z.infer; diff --git a/shared/enums/ErrCode.ts b/shared/enums/ErrCode.ts index 19b006f98..a091bbc57 100644 --- a/shared/enums/ErrCode.ts +++ b/shared/enums/ErrCode.ts @@ -35,6 +35,9 @@ export const ErrCode = { OrgNotFound: "org_not_found", OrgHasCustomers: "org_has_customers", + // Migrations (v2) + MigrationNotFound: "migration_not_found", + // Feature FeatureNotFound: "feature_not_found", InvalidFeature: "invalid_feature", diff --git a/shared/index.ts b/shared/index.ts index 09cdf05fa..7b90a9bc4 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -16,6 +16,8 @@ export * from "./api/billing/createSchedule/createScheduleResponse"; export * from "./api/billing/openBillingPortal/openBillingPortalParamsV1"; export * from "./api/billing/openBillingPortal/openBillingPortalResponse"; export * from "./api/billing/updateSubscription/previewUpdateSubscriptionResponse"; +// Migrations v2 (operations + entity schemas) +export * from "./api/migrations/operations/index"; // Cursor pagination utilities export * from "./api/common/cursorPaginationSchemas"; export * from "./api/customers/components/customerExpand/customerExpand"; @@ -117,6 +119,7 @@ export * from "./models/genModels/processorSchemas"; export * from "./models/migrationModels/migrationErrorTable"; export * from "./models/migrationModels/migrationJobTable"; export * from "./models/migrationModels/migrationModels"; +export * from "./models/migrationV2Models/migrationTable"; export * from "./models/orgModels/frontendOrg"; // 1. Org Models export * from "./models/orgModels/frontendOrg"; diff --git a/shared/models/migrationV2Models/migrationTable.ts b/shared/models/migrationV2Models/migrationTable.ts new file mode 100644 index 000000000..cf0baa749 --- /dev/null +++ b/shared/models/migrationV2Models/migrationTable.ts @@ -0,0 +1,51 @@ +import { + foreignKey, + jsonb, + numeric, + pgTable, + text, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { MigrationFilter } from "../../api/migrations/filters/migrationFilter.js"; +import type { Operations } from "../../api/migrations/operations/operations.js"; +import { organizations } from "../orgModels/orgTable.js"; + +/** + * User-authored, customer-state-mutating migrations. Distinct from the + * legacy `migration_jobs` table (product-version migration system). + * + * `internal_id` is the ksuid primary key; `id` is the user-provided + * slug, unique per `(org_id, env)`. `filter` and `operations` are + * typed jsonb blobs validated by the Zod schemas at + * `shared/api/migrations/{filters,operations}/`. + */ +export const migrations = pgTable( + "migrations", + { + internal_id: text().primaryKey().notNull(), + id: text().notNull(), + org_id: text().notNull(), + env: text().notNull(), + + filter: jsonb().$type(), + operations: jsonb().$type(), + + created_at: numeric({ mode: "number" }).notNull(), + updated_at: numeric({ mode: "number" }), + }, + (table) => [ + foreignKey({ + columns: [table.org_id], + foreignColumns: [organizations.id], + name: "migrations_org_id_fkey", + }).onDelete("cascade"), + uniqueIndex("migrations_org_env_id_unique").on( + table.org_id, + table.env, + table.id, + ), + ], +); + +export type Migration = typeof migrations.$inferSelect; +export type MigrationInsert = typeof migrations.$inferInsert; diff --git a/shared/utils/scopeDefinitions.ts b/shared/utils/scopeDefinitions.ts index 3516b3e8e..ed3073396 100644 --- a/shared/utils/scopeDefinitions.ts +++ b/shared/utils/scopeDefinitions.ts @@ -34,6 +34,7 @@ export const RESOURCES = [ "rewards", "balances", "billing", + "migrations", "analytics", "apiKeys", "platform", @@ -146,6 +147,10 @@ export const Scopes = { Read: "billing:read", Write: "billing:write", }, + Migrations: { + Read: "migrations:read", + Write: "migrations:write", + }, Analytics: { Read: "analytics:read", }, @@ -211,6 +216,8 @@ export const MODERN_SCOPES: readonly ScopeString[] = [ Scopes.Balances.Write, Scopes.Billing.Read, Scopes.Billing.Write, + Scopes.Migrations.Read, + Scopes.Migrations.Write, Scopes.Analytics.Read, Scopes.ApiKeys.Read, Scopes.ApiKeys.Write, @@ -373,6 +380,7 @@ export const ROLE_SCOPES: Record = { Scopes.Plans.Write, Scopes.Balances.Write, Scopes.Billing.Write, + Scopes.Migrations.Write, Scopes.Analytics.Read, Scopes.ApiKeys.Write, Scopes.Platform.Write, @@ -394,6 +402,7 @@ export const ROLE_SCOPES: Record = { Scopes.Rewards.Read, Scopes.Balances.Read, Scopes.Billing.Read, + Scopes.Migrations.Read, Scopes.Analytics.Read, Scopes.ApiKeys.Read, Scopes.Platform.Read, @@ -450,6 +459,11 @@ export const RESOURCE_METADATA: Record< namePlural: "Billing", description: "Attach, cancel, and update subscriptions", }, + migrations: { + name: "Migration", + namePlural: "Migrations", + description: "Bulk customer migrations and operations", + }, analytics: { name: "Analytics", namePlural: "Analytics", diff --git a/vite/src/App.tsx b/vite/src/App.tsx index ff358ac5b..32cf333e3 100644 --- a/vite/src/App.tsx +++ b/vite/src/App.tsx @@ -24,6 +24,7 @@ import CustomerPlanEditor from "./views/customers2/customer-plan/CustomerPlanEdi import { DefaultView } from "./views/DefaultView"; import DevScreen from "./views/developer/DevView"; import { CloseScreen } from "./views/general/CloseScreen"; +import { MigrationsView } from "./views/migrations/MigrationsView"; import QuickstartView from "./views/onboarding4/QuickstartView"; import ProductsView from "./views/products/ProductsView"; import PlanEditorView from "./views/products/plan/PlanEditorView"; @@ -106,6 +107,8 @@ export default function App() { path="/sandbox/products" element={} /> + } /> + } /> { const getEmptyStateContent = () => { @@ -76,6 +77,14 @@ export const EmptyState = ({ "Create an API key to authenticate requests to the Autumn API", svg: apiKeysSvg, }; + case "migrations": + return { + title: "Migrations", + description: + "Define filters and operations to migrate sets of customers in bulk", + // Reuses plans illustration for now — replace once we have a dedicated svg. + svg: plansSvg, + }; } }; diff --git a/vite/src/hooks/queries/useMigrationsQuery.tsx b/vite/src/hooks/queries/useMigrationsQuery.tsx new file mode 100644 index 000000000..f483d460c --- /dev/null +++ b/vite/src/hooks/queries/useMigrationsQuery.tsx @@ -0,0 +1,24 @@ +import type { Migration } from "@autumn/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { MigrationService } from "@/services/MigrationService"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export const useMigrationsQuery = () => { + const axiosInstance = useAxiosInstance(); + const buildKey = useQueryKeyFactory(); + + const { data, isLoading, error, refetch } = useQuery<{ + list: Migration[]; + }>({ + queryKey: buildKey(["migrations"]), + queryFn: () => MigrationService.list(axiosInstance), + }); + + return { + migrations: (data?.list ?? []) as Migration[], + isLoading, + error, + refetch, + }; +}; diff --git a/vite/src/services/MigrationService.ts b/vite/src/services/MigrationService.ts new file mode 100644 index 000000000..a6d454ec1 --- /dev/null +++ b/vite/src/services/MigrationService.ts @@ -0,0 +1,19 @@ +import type { Migration } from "@autumn/shared"; +import type { AxiosInstance } from "axios"; + +export const MigrationService = { + list: async (axiosInstance: AxiosInstance) => { + const { data } = await axiosInstance.post<{ list: Migration[] }>( + "/migrations.list", + ); + return data; + }, + + create: async (axiosInstance: AxiosInstance, body: { id: string }) => { + const { data } = await axiosInstance.post( + "/migrations.create", + body, + ); + return data; + }, +}; diff --git a/vite/src/views/main-sidebar/MainSidebar.tsx b/vite/src/views/main-sidebar/MainSidebar.tsx index 1ce395bd3..92dc8bc52 100644 --- a/vite/src/views/main-sidebar/MainSidebar.tsx +++ b/vite/src/views/main-sidebar/MainSidebar.tsx @@ -1,4 +1,5 @@ import { + ArrowsClockwiseIcon, BalloonIcon, BasketIcon, ChartBarIcon, @@ -180,6 +181,12 @@ export const MainSidebar = ({ title="Customers" env={env} /> + } + title="Migrations" + env={env} + /> } diff --git a/vite/src/views/migrations/MigrationsView.tsx b/vite/src/views/migrations/MigrationsView.tsx new file mode 100644 index 000000000..4c951bac0 --- /dev/null +++ b/vite/src/views/migrations/MigrationsView.tsx @@ -0,0 +1,9 @@ +import { MigrationListTable } from "./migration-list/MigrationListTable"; + +export const MigrationsView = () => { + return ( +
+ +
+ ); +}; diff --git a/vite/src/views/migrations/components/CreateMigrationSheet.tsx b/vite/src/views/migrations/components/CreateMigrationSheet.tsx new file mode 100644 index 000000000..f3c308f0b --- /dev/null +++ b/vite/src/views/migrations/components/CreateMigrationSheet.tsx @@ -0,0 +1,107 @@ +import type { AxiosError } from "axios"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; +import { Input } from "@/components/v2/inputs/Input"; +import { + SheetFooter, + SheetHeader, + SheetSection, +} from "@/components/v2/sheets/SharedSheetComponents"; +import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet"; +import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; +import { MigrationService } from "@/services/MigrationService"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +function CreateMigrationSheet({ + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + onSuccess, +}: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + onSuccess?: (migrationId: string) => void; +} = {}) { + const [loading, setLoading] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const [id, setId] = useState(""); + + const open = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setOpen = controlledOnOpenChange || setInternalOpen; + + const axiosInstance = useAxiosInstance(); + const { refetch } = useMigrationsQuery(); + + const handleCreateMigration = async () => { + if (!id.trim()) { + toast.error("Migration ID is required"); + return; + } + + setLoading(true); + try { + const created = await MigrationService.create(axiosInstance, { + id: id.trim(), + }); + await refetch(); + toast.success("Migration created"); + setOpen(false); + onSuccess?.(created.id); + } catch (error: unknown) { + toast.error( + getBackendErr(error as AxiosError, "Failed to create migration"), + ); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => setOpen(false); + + useEffect(() => { + if (open) setId(""); + }, [open]); + + return ( + + + + +
+ + setId(e.target.value)} + /> + +
+ + + + Cancel + + + Create migration + + +
+
+ ); +} + +export default CreateMigrationSheet; diff --git a/vite/src/views/migrations/migration-list/MigrationListColumns.tsx b/vite/src/views/migrations/migration-list/MigrationListColumns.tsx new file mode 100644 index 000000000..f93c47449 --- /dev/null +++ b/vite/src/views/migrations/migration-list/MigrationListColumns.tsx @@ -0,0 +1,48 @@ +import type { Migration } from "@autumn/shared"; +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { format } from "date-fns"; +import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; + +export const createMigrationListColumns = (): ColumnDef< + Migration, + unknown +>[] => [ + { + header: "ID", + size: 240, + accessorKey: "id", + cell: ({ row }: { row: Row }) => ( +
+ +
+ ), + }, + { + header: "Filter", + size: 120, + cell: ({ row }: { row: Row }) => ( + + {row.original.filter ? "Configured" : "—"} + + ), + }, + { + header: "Operations", + size: 120, + cell: ({ row }: { row: Row }) => ( + + {row.original.operations ? "Configured" : "—"} + + ), + }, + { + header: "Created", + size: 160, + accessorKey: "created_at", + cell: ({ row }: { row: Row }) => ( + + {format(new Date(row.original.created_at), "PP")} + + ), + }, +]; diff --git a/vite/src/views/migrations/migration-list/MigrationListCreateButton.tsx b/vite/src/views/migrations/migration-list/MigrationListCreateButton.tsx new file mode 100644 index 000000000..af02e215b --- /dev/null +++ b/vite/src/views/migrations/migration-list/MigrationListCreateButton.tsx @@ -0,0 +1,26 @@ +import { useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { Button } from "@/components/v2/buttons/Button"; +import CreateMigrationSheet from "../components/CreateMigrationSheet"; + +export function MigrationListCreateButton() { + const [open, setOpen] = useState(false); + + useHotkeys( + "n", + (event) => { + event.preventDefault(); + setOpen(true); + }, + { enableOnFormTags: false }, + ); + + return ( + <> + + + + ); +} diff --git a/vite/src/views/migrations/migration-list/MigrationListTable.tsx b/vite/src/views/migrations/migration-list/MigrationListTable.tsx new file mode 100644 index 000000000..94b8b021a --- /dev/null +++ b/vite/src/views/migrations/migration-list/MigrationListTable.tsx @@ -0,0 +1,72 @@ +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { useMemo } from "react"; +import { Table } from "@/components/general/table"; +import { EmptyState } from "@/components/v2/empty-states/EmptyState"; +import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; +import { useProductTable } from "@/views/products/hooks/useProductTable"; +import { createMigrationListColumns } from "./MigrationListColumns"; +import { MigrationListCreateButton } from "./MigrationListCreateButton"; + +export function MigrationListTable() { + const { migrations, isLoading } = useMigrationsQuery(); + + const columns = useMemo(() => createMigrationListColumns(), []); + + const table = useProductTable({ + data: migrations, + columns, + options: { + globalFilterFn: "includesString", + enableGlobalFilter: true, + }, + }); + + const hasRows = table.getRowModel().rows.length > 0; + + if (!isLoading && !hasRows) { + return ( + } + /> + ); + } + + return ( + + +
+ + + Migrations + + +
+ +
+
+
+
+
+ + + + + + +
+
+ ); +}