built filter, operations, etc. for migrations

This commit is contained in:
John Yeo
2026-05-06 17:54:59 +08:00
parent 3b0a82fd53
commit 38a811ebe7
69 changed files with 3307 additions and 3 deletions

View File

@@ -0,0 +1,145 @@
# Catalog: Migration & Import Patterns
Source: 4-agent parallel discovery on 2026-04-29 covering `scripts/src/common/migrations/`, `scripts-v2/runs/`, `scripts/mintlify-import.sh` chain, `scripts/firecrawl-import-from-csv.sh` chain, plus a deep read of billing v2 action machinery.
---
## A. Migration archetypes (what scripts actually do)
### A1. Version bump via `billingActions.migrate()`
- `scripts/src/common/migrations/perform-migration.ts`, `lingo.ts`, `lingo-prepaid-to-payperuse.ts`
- `scripts-v2/runs/browser-use/migrate-free/` (uses `billingActions.migrate` with `noBillingChanges: true`)
- Trivially fits `{ customerId, plans: [{ expire: cusProductId, insert: { productId, version } }] }`.
- Note: scripts loop multiple matching cusProducts per customer and reload state mid-loop because `migrate()` mutates state.
### A2. Multi-cusProduct subscription update — **the canonical complex case**
- `scripts-v2/runs/mintlify/migrate-credits/multi-update/` — replaces legacy AI-credits prepaid with V2 prepaid+consumable across N cusProducts sharing one Stripe sub.
- Operates on `CusProductGroup = { subscriptionId, cusProducts[] }`.
- Per group: `setupStripeBillingContext` once → per-product `computeCustomPlanNewCustomerProduct` → aggregate into `AutumnBillingPlan``evaluateStripeBillingPlan``executeBillingPlan`.
- This is structurally ~95% of the proposed interface, coupled to AI-credits-specific helpers (`resolveAiCreditsQuantity`, `setupUpdateFullProductContext`).
### A3. Pricing-shape swap, cusProduct identity preserved
- `scripts/src/common/migrations/firecrawl-credit-pack-to-usage-price.ts`
- Keeps the same `customer_product_id`, replaces its child cus_ents/cus_prices with custom rows. Preserves balance/reset/expiry.
- **Does NOT fit `{expire, insert}`** — cusProduct stays.
### A4. 1→N cusProduct split
- `scripts/src/common/migrations/split-one-cus-product-into-two.ts` (Email → Domain + Inbox).
- Expires one cusProduct, inserts N new ones referencing the SAME `subscription_ids`.
- Fits `{ expire, insert: [...] }` only if interface explicitly supports shared subscription_ids and balance/option carryover per inserted plan.
### A5. Flag-flip / balance-clamp on existing rows
- `cont-use-to-single-use.ts`, `fix-creator-connections-interval.ts`, `browser-use-reset-spam-users.ts`, `additive-entitlement-migration.ts` (gifting branch), `retroactively-add-plan-item-to-customers.ts` "update" mode.
- Pure UPDATE on `entitlements` / `customer_entitlements`. No cusProduct churn, no Stripe.
- **Does not fit `{expire, insert}` at all** — separate primitive.
### A6. Raw-DB bulk replace (no Stripe, no billing v2)
- `scripts-v2/runs/sebipaps/sandbox-product-sync/migrate-plan.ts` — chunked `delete cus_ent + delete cus_price + update cusProduct.internal_product_id`.
- Free-tier or 1:1 product remap. Bypasses billing v2 entirely.
- Should be subsumed by a "DB-only bulk swap" mode of the unified interface.
---
## B. Catalog-prep archetypes (what runs BEFORE migrations)
These are *preparation* patterns — extending the product catalog so a migration can land.
### B1. Add entitlement (+optional price) to all versions of a product
- `retroactively-add-plan-item-to-versions.ts` — uses latest version that already has the ent as a TEMPLATE, clones onto older versions.
- `FINAL-add-entitlement-to-plans.ts` — adds feature to all versions matching id-prefix.
- `add-creator-connections-entitlement.ts`, `add-boolean-customer-entitlement.ts`, `add-feature-to-customers.ts`, `add-new-boolean-feature.ts`.
- Pure catalog (`entitlements`, `prices`) mutations. No customer-side touch.
### B2. Backfill cus_ents for existing customers on extended products
- `retroactively-add-plan-item-to-customers.ts` — sibling of B1; per active cusProduct on listed products, insert missing `customer_entitlements` (and raw `customer_prices` if `requirePrice`).
- `add-creator-connections-cus-entitlement.ts`, `add-boolean-customer-entitlement.ts` (also does B1).
- Uses `initCusEntitlement` + direct `CusEntService.insert`. **Zero scripts use** `createFullCusProduct`.
### B3. Catalog clone (sandbox → live, or version bump)
- `scripts-v2/runs/sebipaps/sandbox-product-sync/copy-products.ts``handleCopyFeatures`, `handleVersionProductV2`, `createProduct`.
- `scripts-v2/runs/mintlify/migrate-credits/steps/init-plan-resources/` — deterministic-id init for V2 prepaid/consumable price+entitlement; sibling entrypoint `setup-credits-catalog.ts`.
- Pattern: prep entrypoint shares folder + `data/` with the migration entrypoint.
### B4. Stripe-side prep (clone archived Stripe prices)
- `scripts-v2/runs/mintlify/fix-archived-price/` — clones archived Stripe prices, updates `Price.config.stripe_price_id`, then patches subscription items.
- Doesn't fit `{expire, insert}` — it's a price-id swap on the same cusProduct.
---
## C. Import patterns (Stripe → Autumn)
### C1. Mintlify import (`scripts/src/mintlify/import/`)
- Source: Mongo orgs/deployments/period_usage. Stripe customers/subs.
- Steps: create customers → create entities (per deployment) → attach base plans via `createFullCusProduct` → derive addons from Stripe subs → "loose" custom entitlements via direct Drizzle insert → sync period usage into balances → cleanup.
- Plan resolver: Mongo `plan` string field.
- Custom allowances: signature-deduped per-customer entitlement rows.
### C2. Firecrawl import (`scripts/src/firecrawl/import/`)
- Source: BigQuery `autumn_shared` (teams, orgs, balances, coupons). Stripe subs/prices/customers.
- Steps: select Stripe subs → resolve plan from `lookup_key` → resolve/create customer → ensure team entity → build target product state with per-customer overrides (custom allowance, custom topup price, signature dedup) → flush via `customerProductService.insertIfNotExists` → repair Stripe sub link → schedule end-of-cycle plan switches via `billingActions.attach` with `no_billing_changes: true` → sync team credit state → import coupon balances directly to `customer_entitlements.balance` with deterministic ids.
- Plan resolver: Stripe `price.lookup_key`.
- Custom allowances: per-customer freshly-created `entitlements` + `prices` rows with signature dedup cache.
### Cross-cutting import friction (top 5)
1. Stripe price → Autumn plan resolver is org-specific (Mongo field vs lookup_key vs price.metadata).
2. Per-customer catalog overrides require signature-deduped per-customer `entitlements`+`prices` rows.
3. Linking to existing Stripe sub (idempotent), not creating it. `initOptions.subscriptionId` + repair.
4. Balance carryover is a separate concern: deterministic `balanceId`, direct write to `customer_entitlements.balance`, bypass `createFullCusProduct`.
5. Entity scoping + duplicate cleanup (same Stripe sub re-attached to wrong entity, stale entities, duplicate plans across entities).
---
## D. Billing v2 architecture (the substrate)
### D1. The 5-stage pipeline (every action follows this)
```
setup<Action>BillingContext({ ctx, params }) -> <Action>BillingContext
handle<Action>Errors({ ctx, billingContext }) -> throws RecaseError
compute<Action>Plan({ ctx, billingContext }) -> AutumnBillingPlan
evaluateStripeBillingPlan({ ctx, billingContext, autumnBillingPlan }) -> StripeBillingPlan
executeBillingPlan({ ctx, billingContext, billingPlan }) -> BillingResult
```
### D2. AutumnBillingPlan already expresses every migration shape
- `insertCustomerProducts: FullCusProduct[]`
- `updateCustomerProduct(s)?: CustomerProductUpdate[]` — patch options/status/canceled/anchor/sub_ids
- `deleteCustomerProduct(s)?: FullCusProduct[]` — for scheduled product replacement
- `customPrices?: Price[]`, `customEntitlements?: Entitlement[]`, `customFreeTrial?`
- `updateByStripeScheduleId?` — for schedule swaps
- `insertCustomerEntitlements?`, `updateCustomerEntitlements?`
- `lineItems?`, `customLineItems?`
- `autoTopupRebalance`, `upsertSubscription`, `upsertInvoice`, `refundPlan`
### D3. evaluateStripeBillingPlan capabilities
- ✅ Multi-product, mixed prepaid/metered, schedules, cancel+replace, multi-entity, refunds, manual invoices.
- ✅ DB-only mode: `billingContext.skipBillingChanges = true` returns `{}` and execute no-ops Stripe.
- ⚠️ Single-customer per evaluation. No batch mode.
### D4. Existing `migrate` action
- `actions/migrate/migrate.ts` is already in the registry. Need to read what it actually does — likely the simple version-bump path; we may extend or replace.
### D5. Critical gap: shared custom rows
- `setupCustomFullProduct` always mints fresh per-customer `Price`/`Entitlement` rows when `hasCustomItems` is true.
- For bulk migration where 10k customers should share the same prepared price/ent rows, we need either:
- A `prepared` mode that reuses existing catalog rows, OR
- A separate one-shot prep phase that creates the shared rows once, with the migration action only inserting `customer_products` + `customer_prices`/`customer_entitlements` referencing those existing IDs.
### D6. customizePlanV1 is PUT-style
- `items?: ApiPlanItemV1[]` — when provided, replaces full list.
- For migrations we want PATCH-style (incremental add/remove/update of plan items).
---
## E. Verdict on `{ customerId, plans: [{ expire, insert }] }`
Fits cleanly: **A1, A2 (with subscription-group dimension), A4 (with shared sub_ids), A6 (with DB-only mode).**
Does NOT fit, needs separate primitive(s):
- **A3** — pricing-shape swap on same cusProduct identity.
- **A5** — flag-flip / balance-clamp on existing rows.
- **B1, B2** — catalog prep & cus_ent backfill (different layer entirely).
- **B3** — catalog clone (different layer).
- **B4** — Stripe price-id swap (different layer).
- **C1, C2 imports** — need 5 additional dimensions on top of migration shape.
**Implication: the unified "migration interface" is really a layered system, not a single action.**

View File

@@ -0,0 +1,29 @@
# Decisions
Append-only log of architectural and design decisions.
## 2026-04-29 — Project lives in `autumn/.context/`, not the outer worktree
The work targets first-class billing actions in `autumn/server/src/internal/billing/v2/actions/` and ships with tests in `autumn/`. Context is colocated with the code it describes. The outer `.context/migration-interface/` holds only a redirect README.
## 2026-04-29 — Phase 1 is cataloging, not designing
The user explicitly asked to survey existing migration & import scripts before committing to interface shape. Risk of premature lock-in is high — there are at least three classes of patterns (DB-only mutations, DB+Stripe mutations, imports with balance carryover) and each could break the proposed shape differently. Output of Phase 1 is a verdict-per-script catalog.
## 2026-04-29 — Build as core product feature, not internal tooling
Will eventually surface to users on the dashboard. Implications: lives in `autumn/server/src/internal/operations/` (peer to `billing/v2/`), has integration tests, no `as any`, follows full billing-action conventions for the heavyweight executors (`setupBillingContext` → compute Autumn plan → `evaluateStripeBillingPlan` → execute).
## 2026-04-29 — Planner + Executor model, not single billing action
Migrations and imports differ in HOW they produce a plan but converge in HOW the plan executes. A single `migrate` action would conflate heterogeneous mutation surfaces (catalog, customer product graph, balance, Stripe repair) into one sprawling compute function. Splitting produces:
- Durable, inspectable, persistable `OperationPlan` artifact
- Dry-run identical to live (only `apply()` differs)
- Pluggable planners (MigrationDefinition, ImportDefinition) emit same operation graph
- Specialized executors per mutation surface, each with own idempotency/guards
- Reuses billing v2 pipeline INSIDE the CustomerProductGraphOperation executor — no replacement, just orchestration above
## 2026-04-29 — Operations typed by mutation surface, not lifecycle intent
Initial design used `PlanOp = expire | insert | swap | patch` (lifecycle-flavored). Better: `Operation = CatalogOperation | CustomerProductGraphOperation | CustomerEntitlementOperation | BalanceOperation | StripeRepairOperation | CacheInvalidationOperation`. Each surface has different idempotency, transaction boundaries, and concurrency rules — surface-typed ops let executors specialize cleanly.
## 2026-04-29 — `is_custom` decoupled from "uses shared catalog rows"
Today `setupCustomFullProduct` always mints fresh per-customer Price/Entitlement rows when customize is provided. For 10k-customer migrations this creates 10k Stripe prices unnecessarily. New model: `ProductItemPatch.add_existing { entitlement_id, price_id }` references shared catalog rows by id; `is_custom` becomes purely an ownership flag (one-off vs catalog), not "this row is per-customer." Existing custom rows untouched; new code uses new model.
## 2026-04-29 — Ownership is first-class
Today repair logic is bespoke per-script because we can't distinguish imported from user-created customer_products. New model: `Ownership { origin: "imported"|"user_created"|"migrated", source?, imported_at? }` on every TargetAssignment. Likely stored as new column on `customer_products`. Unblocks safe re-runnable imports and convergence/repair semantics.

View File

@@ -0,0 +1,71 @@
# migration-interface Plan
## Phase 1: Catalog & Stress-Test (IN PROGRESS)
Goal: enumerate every migration/import pattern we've shipped, then check the proposed interface against each one. Output: a catalog doc with per-script summaries and "interface covers? / what breaks?" verdicts.
- Survey `scripts/src/common/migrations/` (retroactively-add-plan-item-to-customers, -to-versions, -plan-items, plus siblings)
- Survey `scripts-v2/runs/` migration scripts (mintlify migrate-credits is the canonical example)
- Survey `scripts/mintlify-import.sh` and `scripts/firecrawl-import.sh` import flows
- Categorize each script along axes:
- DB-only vs DB + Stripe
- Single-customer vs bulk
- Plan transformation shape (add item / swap plan / change pricing / cancel-and-replace / schedule)
- Custom per-customer overrides (rollover, lifetime balance, balance carryover)
- Preparation step required (new prices/products created before migration)
- Verdict per script: covered by proposed interface? What's missing?
## Phase 2: Interface Design
- Lock the migration action signature(s) — likely one new `migrate` action alongside `multiAttach` and `createSchedule`
- Solve the "insert + customize" friction:
- Move customize from PUT-style (full item list) to PATCH-style (incremental add/remove/update)
- Decide: do migrations create new custom price/entitlement rows, or reuse a single per-plan row? (Stripe price/product creation is the binding constraint)
- Decide whether "preparation" (creating shared prices/entitlements ahead of migration) is a separate action or a step in the same one
- Validate design against catalog from Phase 1
## Phase 3: Implementation
- Build the action(s) under `autumn/server/src/internal/billing/v2/actions/`
- Tests: integration tests covering catalog scenarios from Phase 1
- Reuse `evaluateStripeBillingPlan`, `setupBillingContext` patterns from existing actions
## Phase 4: Migrate Existing Scripts
- Port mintlify migrate-credits to use the new action
- Port retroactively-add-plan-item-to-customers
- Each port validates the interface; refine as needed
## Phase 5 (stretch): Imports
- Extend or sibling action for Stripe→Autumn linkage
- Handle the hard cases: balance carryover for metered features, custom configurations (rollovers, lifetime balances), many-plans Stripe accounts
- Goal: collapse the bespoke mintlify-import.sh / firecrawl-import.sh logic
---
## Initial Design Sketch (from kickoff conversation)
**Scope of one call:** one customer, one Stripe subscription's worth of customer_products.
**Proposed shape:**
```ts
{
customerId: string,
subscriptionId?: string, // inferable from plans
plans: [
{
expire?: { customer_product_id?: string; plan_id?: string; entity_id?: string },
insert?: { plan_id: string; customize?: ... },
}
],
schedules?: [...] // future, optional
}
```
**Open questions:**
1. Customize: PATCH vs PUT — strongly leaning PATCH (incremental add/remove/update of plan items)
2. Custom prices: per-customer rows vs shared per-plan row reused across migrations (Stripe-side cost is the constraint)
3. Preparation step (creating shared prices/entitlements + backfilling onto prior plan versions and customer_products): part of this interface or a separate "prepare migration" action?
4. Mapping public `apiPlanItemV1` ↔ DB `price + entitlement` pair — how does the interface accept input?
## Architecture Notes
- Build on `evaluateStripeBillingPlan` — the Autumn-plan → Stripe-state mapper already handles schedules, multi-entity, mixed prepaid/metered. Don't reinvent.
- `customer.processor?.id` is Stripe customer ID (per autumn-operations-constraints rule). Watch the ID confusion.
- Stripe writes must respect: amount conversion via `atmnToStripeAmount`, idempotent price creation via `createStripePriceIFNotExist`, `proration_behavior: "none"` for surgical item changes.
- This is product code, not script code — full TypeScript discipline, no `as any`, full test coverage.

View File

@@ -0,0 +1,306 @@
# Proposal: Operation Plan + Pluggable Planners
## Mental model
> **Planning produces a typed `OperationPlan`. Execution consumes it.**
> Migrations and imports differ in how they *produce* the plan; everything downstream — guards, dry-run, persistence, execution, reconciliation — is shared.
This is a strict departure from "build one big billing action." The migration interface is not a single action — it is a **planner / executor pair**, where the plan is a durable, inspectable artifact and execution is a typed reducer over operations.
The reason: scripts today informally do exactly this lifecycle (scope → load → resolve → prepare → plan → guard → execute → verify), but the contracts between stages are bespoke. The win is canonizing the stage contracts, not unifying the verbs.
---
## Why planner + executors (vs. single billing action)
1. **Operations are heterogeneous.** Catalog mutations, customer-product graph changes, balance syncs, Stripe repair, cache invalidation — each has different idempotency, different transaction boundaries, different blast radius. One "migrate" action that handles all of them gets a sprawling `compute*Plan` function. Separate executors keep each surface clean.
2. **Plans are first-class artifacts.** Persistable. Diffable. Re-runnable. Approval-able. Eventually user-facing on the dashboard ("preview migration → approve → apply").
3. **Dry-run = real plan.** Same code path produces the plan; only `apply()` differs. No drift.
4. **Imports vs migrations diverge cleanly.** They're different *planners* emitting the same operation graph. Shared execution; specialized planning.
5. **Concrete reuse path.** `CustomerProductGraphOperation.replace` executor wraps the existing 5-stage billing v2 pipeline. We're not replacing billing v2 — we're putting an orchestration layer above it.
---
## Architecture
### Module placement
```
autumn/server/src/internal/operations/
types/
operation.ts -- Operation discriminated union
operationPlan.ts -- OperationPlan, CustomerOperationPlan, RunContext
operationResult.ts -- OperationResult, GuardResult, MigrationDiff
productItemPatch.ts -- ProductItemPatch union (PATCH-style customize)
executors/
catalog/ -- CatalogOperation executor
customerProductGraph/ -- CustomerProductGraphOperation executor (wraps billing v2)
customerEntitlement/ -- CustomerEntitlementOperation executor
balance/ -- BalanceOperation executor
stripeRepair/ -- StripeRepairOperation executor
cache/ -- CacheInvalidationOperation executor
planners/
migration/ -- MigrationDefinition runner
import/ -- ImportDefinition runner
guards/ -- composable, named guard sets
reporting/ -- diff, audit, CSV export
persistence/ -- migration_runs / migration_plans schema + service
execute.ts -- top-level executePlan(plan, { dryRun })
```
This sits **as a peer** to `billing/v2/`, not under it. `billing/v2/` is the substrate; `operations/` is the orchestration layer.
### Type model
```ts
// The durable artifact
type OperationPlan = {
run: RunContext; // org_id, env, dry_run, source description, created_at
catalog: Operation[]; // run-level (executes once, before per-customer)
customers: CustomerOperationPlan[];// per-customer
warnings: GuardResult[]; // non-blocking
blockers: GuardResult[]; // execution refuses if any blockers
};
type CustomerOperationPlan = {
customer_id: string;
// Operations declared in execution order. Within a customer, ops execute serially.
// Operations targeting the same Stripe subscription are batched by the executor
// into a single subscriptions.update — this is the existing multi-update atomicity
// boundary.
operations: Operation[];
guards: GuardResult[];
preview: CustomerPreview; // human-readable diff of intended changes
};
type Operation =
| CatalogOperation
| CustomerProductGraphOperation
| CustomerEntitlementOperation
| BalanceOperation
| StripeRepairOperation
| CacheInvalidationOperation;
// Mutation surface 1: catalog
type CatalogOperation =
| { type: "ensure_feature"; spec: FeatureSpec }
| { type: "ensure_plan_item"; product_selector: ProductSelector; item: ProductItemSpec }
| { type: "ensure_shared_price"; signature: string; spec: PriceSpec }
| { type: "ensure_shared_entitlement"; signature: string; spec: EntitlementSpec }
| { type: "link_stripe_resource"; price_id: string; stripe_price_id?: string; stripe_product_id?: string }
| { type: "swap_archived_stripe_price"; price_id: string };
// Mutation surface 2: customer product graph (the big one)
type CustomerProductGraphOperation =
| { type: "attach"; assignment: TargetAssignment; ownership: Ownership }
| { type: "replace"; subscription_id?: string;
from: CusProductSelector[]; // expire these
to: TargetAssignment[]; // insert these
carry?: { balance: boolean; subscription_ids: boolean; anchors: boolean } }
| { type: "expire"; targets: CusProductSelector[]; reason: string }
| { type: "schedule"; phases: ScheduledPhase[] };
// Mutation surface 3: customer entitlement (B-layer in old proposal)
type CustomerEntitlementOperation =
| { type: "backfill_for_customers"; product_selector; feature_id; require_price?: boolean }
| { type: "patch_flag"; selector; flags: Partial<Pick<Entitlement, "usage_allowed" | "carry_from_previous">> }
| { type: "clamp_balance"; selector; ceiling: number };
// Mutation surface 4: balance (imports especially)
type BalanceOperation =
| { type: "seed_balance"; balance_id: string; cus_ent_selector; amount: number; entity_id?: string }
| { type: "set_balance"; cus_ent_id: string; balance: number }
| { type: "delete_stale_balance"; cus_ent_id: string; reason: string };
// Mutation surface 5: stripe repair (imports especially)
type StripeRepairOperation =
| { type: "link_subscription"; cus_product_id: string; stripe_subscription_id: string }
| { type: "expire_orphaned_imported_cus_product"; cus_product_id: string };
type CacheInvalidationOperation =
| { type: "invalidate_customer_cache"; customer_ids: string[] }
| { type: "invalidate_products_cache" };
```
### TargetAssignment + ProductItemPatch
The two key composite types.
```ts
type TargetAssignment = {
product: { id: string; version?: number };
entity_id?: string | null;
subscription_id?: string | null; // for imports linking to existing Stripe sub
feature_quantities?: FeatureQuantity[];
item_patches?: ProductItemPatch[]; // PATCH-style customize
billing_dates?: { trial_ends_at?: number; reset_cycle_anchor?: number; billing_cycle_anchor?: number };
ownership?: Ownership; // imported / user-created / migrated
};
// PATCH-style. Compiles into FullProduct + customPrices + customEnts
// that existing billing v2 compute already understands.
type ProductItemPatch =
| { op: "add_existing"; entitlement_id: string; price_id?: string } // reference shared catalog rows
| { op: "replace_feature"; feature_id: string; entitlement_id: string; price_id?: string }
| { op: "remove_feature"; feature_id: string }
| { op: "override_allowance"; feature_id: string; allowance: number; unlimited?: boolean }
| { op: "override_price"; feature_id?: string; price: PricePatch } // mints a custom Price row
| { op: "set_options"; feature_id: string; options: { quantity?: number } };
type Ownership = {
origin: "imported" | "user_created" | "migrated";
source?: string; // e.g. "stripe_subscription:sub_abc"
imported_at?: number;
};
```
`add_existing` is the load-bearing fix: it lets a 10k-customer migration reference one shared catalog row instead of minting 10k custom rows. **`is_custom` becomes an ownership flag (one-off vs catalog) decoupled from "uses custom prepared rows."**
### Execution model
```ts
// Top-level entry point
async function executePlan(plan: OperationPlan, opts: { dryRun: boolean }): Promise<PlanResult>
// Strict execution rules:
// 1. If plan.blockers is non-empty AND !dryRun → throw, refuse.
// 2. Catalog ops execute first, in declared order, before any customer ops.
// 3. Customer plans execute in parallel (configurable concurrency); same-customer
// operations execute serially in declared order.
// 4. Within one customer, operations targeting the same Stripe subscription are
// GROUPED by the CustomerProductGraphOperation executor and emitted as ONE
// subscriptions.update via the existing evaluateStripeBillingPlan + executeBillingPlan
// path. This preserves multi-update atomicity.
// 5. Each operation returns OperationResult { status, diff?, error?, artifacts? }.
// 6. Dry-run produces the same operations list; executors emit `would_apply` results
// with diffs but mutate nothing.
```
**The CustomerProductGraphOperation executor for `replace` is essentially today's `multi-update/`** generalized: build the per-product context list, call `computeCustomPlanNewCustomerProduct` per item, aggregate into `AutumnBillingPlan`, run through `evaluateStripeBillingPlan` + `executeBillingPlan`. Everything downstream of the Operation type is reused.
### Persistence
Plans persist to DB for non-trivial runs. Schema:
```
migration_runs -- one per orchestrated run (org, env, source, status)
migration_plans -- the OperationPlan JSON, snapshot at planning time
migration_ops -- one row per operation, with status + diff + result
migration_artifacts -- CSV exports, snapshots, audit rows
```
Trivial cases (single-customer one-off) can skip persistence and go straight through `executePlan`. Bulk runs persist.
States: `draft → planned → applying → completed | failed | partial`. A `partial` failed run is resumable: the executor skips ops with `status: applied` and re-runs `pending` / `failed` ops.
### Planners
```ts
type MigrationDefinition = {
id: string;
selectCandidates(ctx): Promise<CustomerScope[]>;
buildTargetState(ctx, customer): Promise<TargetState>;
plan(ctx, target): OperationPlan["customers"][number];
};
type ImportDefinition = {
id: string;
loadExternalSource(ctx): Promise<SourceSnapshot>;
resolveIdentities(ctx, source): Promise<IdentityMap>;
mapTargets(ctx, source, identities): Promise<TargetState[]>;
plan(ctx, target): OperationPlan["customers"][number];
};
```
Mintlify import becomes `ImportDefinition`. Mintlify migrate-credits becomes `MigrationDefinition`. Both emit the same `OperationPlan` shape.
---
## Phased build order
### v0 — Migration Core (3-4 weeks)
**Concrete deliverable: port mintlify migrate-credits' multi-update path to use this system.** That single port validates 80% of the abstraction.
1. `Operation` type + `ProductItemPatch` compiler (`patches → FullProduct + customPrices + customEnts`).
2. `CustomerProductGraphOperation.replace` executor wrapping the existing multi-update pipeline.
3. `OperationResult` + `CustomerPreview` shapes (Autumn diff + Stripe sub-update preview).
4. Guard set: `subscription-replace` (mismatched-sub-in-group, missing payment, anchor drift, collection_method preservation).
5. `executePlan` for in-memory plans (no DB persistence yet).
6. Integration tests: port the mintlify migrate-credits scenarios.
7. Migration runner harness for scripts-v2 (one-line: `runPlan(planner, opts)`).
### v0.5 — Catalog Prep (1-2 weeks)
8. `CatalogOperation` executor: `ensure_feature`, `ensure_plan_item`, `ensure_shared_price`, `ensure_shared_entitlement`, `link_stripe_resource`.
9. Run-level catalog ops execute before per-customer ops.
10. Port `retroactively-add-plan-item-to-versions` + `retroactively-add-plan-item-to-customers` to the new system (validates catalog → customer-entitlement composition).
### v0.75 — Persistence + Resume (1 week)
11. `migration_runs` / `migration_plans` / `migration_ops` schema.
12. `executePlan` writes operation results; partial-failure resume.
13. Read-only view of past runs (no UI yet, just service methods).
### v1 — Auxiliary surfaces (2 weeks)
14. `BalanceOperation` executor (Mintlify period_usage, Firecrawl coupon balances).
15. `CustomerEntitlementOperation` executor (flag-flips, balance clamps, backfills).
16. `StripeRepairOperation` executor.
17. Port one full Firecrawl import slice as a smoke test.
### v1.5 — Imports as planner (2-3 weeks)
18. `ImportDefinition` runner with identity resolution + source normalization stages.
19. Port mintlify-import to use this. Firecrawl-import follows.
### v2 (deferred) — Dashboard + API
20. Dashboard view: list runs, inspect plan, dry-run preview, approve & apply.
21. Public API for self-serve migrations.
---
## Synthesis: what changed from v1 of this proposal
| v1 (PROPOSAL.md before) | v2 (now) | Why |
|---|---|---|
| Single `migrate` action with `PlanOp` discriminated union | Planner + executors with typed operations by mutation surface | Heterogeneous mutation surfaces don't belong in one action |
| 3 layers (catalog / backfill / transition) | Flat operation graph with execution ordering rules | Layers were artificial; ops at the same level with declarative dependencies is cleaner |
| `use_shared_rows` flag bolt-on | `ProductItemPatch.add_existing { entitlement_id, price_id }` first-class | Semantics are clearer when they're a discrete patch op |
| `is_custom` overloaded for "one-off" + "uses custom rows" | Decouple: `is_custom` = ownership flag; shared rows = first-class catalog rows | Resolves a long-standing concept conflation |
| Imports = migration + 5 extensions | Imports = different planner emitting same operation graph | Cleaner separation of concerns; identity/source/mapping is genuinely different from migration |
| Action-level `preview` flag | First-class plan artifact; dry-run = identical plan path | Plan becomes inspectable, persistable, approvable |
| No persistence story | `migration_runs/_plans/_ops` schema; resumable partial runs | User asked for "core feature, not internal tool" — first-class needs persistence |
| No ownership concept | `Ownership` first-class on every TargetAssignment | Repair logic is currently bespoke per-script because we can't tell imported from user-created |
---
## Open design questions (the load-bearing ones)
1. **Plan persistence: opt-in or default-on?**
Lean: **default-on for `bulk` planners (>1 customer); opt-out for one-shot operations.** Persistence enables resume, audit, dashboard surfacing — worth the schema cost.
2. **Operation ordering within a customer plan: declared or topologically derived?**
Lean: **declared.** Planners produce ops in execution order. Simpler, and the planner already knows what depends on what (e.g. `attach` before `seed_balance`). No DAG solver needed.
3. **Subscription-group atomicity: explicit or implicit?**
Lean: **implicit, in the executor.** The executor groups operations by Stripe sub before executing. This keeps the operation list flat and forgiving — caller doesn't have to think about Stripe's batching boundary, the executor handles it.
4. **Ownership flag: stored where?**
Options: (a) new column on `customer_products`, (b) jsonb metadata, (c) separate `customer_product_ownership` table.
Lean: **new column** — it's load-bearing for repair queries; jsonb is too soft.
5. **Existing `billingActions.migrate`: keep, deprecate, or absorb?**
Need to read it. If it's a thin version-bump helper, keep as a shorthand that produces a one-op plan. If it's heavier, deprecate in favor of `MigrationDefinition`.
6. **Where does identity resolution live for imports?**
In the `ImportDefinition.resolveIdentities` stage. The output is an `IdentityMap` that subsequent stages consume. Don't bake identity logic into Operations — keep operations identity-agnostic (they take `customer_id`, not "find or create customer with this email").
7. **Public-facing types: when?**
Defer until v2 (dashboard). Internal types can move freely; public types lock when we expose this to users. Avoid premature stability commitments.
8. **`is_custom` decoupling: backwards compat?**
Existing custom rows have `is_custom: true`. New "shared catalog rows referenced by patch" don't set this flag. We need a cleanup or co-existence story for current per-customer custom rows. Lean: leave existing rows alone; new code uses the new model; eventually a `null is_custom` migration cleans up.
---
## Open question deferred from v1
- **PATCH vs PUT customize:** resolved by `ProductItemPatch` discriminated union (PATCH-style, with `add_existing` for shared rows).
- **Custom prices: per-customer rows vs reused per-plan rows:** resolved by `add_existing` / `ensure_shared_*` catalog ops.
- **Preparation step placement:** resolved — it's `CatalogOperation` ops inside the same plan.

View File

@@ -0,0 +1,35 @@
# migration-interface
## Goal
Design a shared billing-action interface for customer migrations and imports so scripts stop hand-rolling Stripe + Autumn DB orchestration. Eventually exposed to users on the dashboard, so it must be a first-class product feature with tests — not an internal tool.
## Current Phase
Phase 1: Discovery complete (catalog written). PROPOSAL.md v2 drafted — planner + executor model with typed operations. Awaiting user review of architecture and 8 open design questions.
## Branch
john/migration-interface
## Active Tasks
- (none yet — will create parallel cataloging tasks if Phase 1 surface gets large)
## What's Done
- Phase 1 discovery: cataloged `scripts/src/common/migrations/` (23 files), `scripts-v2/runs/` migration scripts, mintlify+firecrawl import chains, and billing v2 action machinery (`multiAttach`, `createSchedule`, `updateSubscription`, `evaluateStripeBillingPlan`, `AutumnBillingPlan`, `customizePlanV1`, `setupCustomFullProduct`).
- See `CATALOG.md` for full pattern inventory and `PROPOSAL.md` for the layered-interface design.
## What's Next
- Walk through `PROPOSAL.md` with user — confirm 3-layer model and discuss the 7 open design questions
- Read existing `actions/migrate/migrate.ts` to decide extend-vs-replace
- Lock Layer 3 scope (include `patch` op? or split into `migrate` + extended `updateSubscription`?)
- Begin Phase 2: design `MigrateParams`, `MigrateBillingContext`, `computeMigratePlan` signatures concretely
## Active Blockers
- None
## Key Files
- `server/src/internal/billing/v2/actions/index.ts` — where new action(s) will register
- `server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts` — closest existing analog
- `server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts` — schedule pattern reference
- `server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts` — single-subscription mutation reference
- `server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts` — Autumn plan → Stripe state mapper
- `shared/api/billing/common/customizePlan/customizePlanV1.ts` — public customize schema
- `shared/api/products/items/apiPlanItemV1.ts` — public plan item shape

2
ai

Submodule ai updated: e21275f18e...18d68d3c2a

View File

@@ -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",

View File

@@ -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<typeof initDrizzle>["db"];
query: SQL;
label: string;
}) => {
console.log(`\n--- EXPLAIN ANALYZE: ${label} ---`);
const explainResult = await db.execute(
sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`,
);
const lines: string[] = [];
for (const row of explainResult)
lines.push(String((row as Record<string, unknown>)["QUERY PLAN"]));
const joined = lines.join("\n");
console.log(
TRUNCATE_EXPLAIN ? truncateExplainText(joined, EXPLAIN_MAX_LINES) : joined,
);
};
const dialect = new PgDialect();
const inlineParams = (text: string, params: readonly unknown[]): string =>
text.replace(/\$(\d+)/g, (_, n) => {
const v = params[Number(n) - 1];
if (v === null || v === undefined) return "NULL";
if (typeof v === "number" || typeof v === "boolean") return String(v);
return `'${String(v).replace(/'/g, "''")}'`;
});
const printSqlQuery = ({ query, label }: { query: SQL; label: string }) => {
const { sql: text, params } = dialect.sqlToQuery(query);
console.log(`\n--- SQL: ${label} ---`);
console.log(inlineParams(text, params));
};
const runMeasured = async ({
db,
query,
label,
}: {
db: ReturnType<typeof initDrizzle>["db"];
query: SQL;
label: string;
}) => {
console.log(`\n=== ${label} ===`);
printSqlQuery({ query, label });
const startedAt = performance.now();
const result = await db.execute(query);
const elapsedMs = performance.now() - startedAt;
console.log(`Rows returned: ${result.length}`);
console.log(`Wall-clock: ${elapsedMs.toFixed(2)}ms`);
if (label === "COUNT" && result.length > 0)
console.log(`Count: ${(result[0] as Record<string, unknown>).count}`);
await printExplainPlan({ db, query, label });
};
const main = async () => {
const replicaUrl = process.env.DATABASE_REPLICA_URL;
const usingReplica = Boolean(replicaUrl);
if (!usingReplica)
console.warn(
"DATABASE_REPLICA_URL not set — falling back to DATABASE_URL (primary). Set the replica URL to test against the read replica.",
);
const { db } = initDrizzle({ replica: usingReplica });
console.log(
`=== CUSTOMER FILTER EXPERIMENT (${usingReplica ? "REPLICA" : "PRIMARY"}) ===`,
);
console.log(JSON.stringify({ ORG_ID, ENV, FILTER }, null, 2));
const orgFeatures = await FeatureService.list({
db,
orgId: ORG_ID,
env: ENV,
});
console.log(`\nLoaded ${orgFeatures.length} features for resolution context.`);
const ctx = { features: orgFeatures };
const countQuery = buildCustomerCount({
orgId: ORG_ID,
env: ENV,
filter: FILTER,
ctx,
});
const selectQuery = buildCustomerSelect({
orgId: ORG_ID,
env: ENV,
filter: FILTER,
ctx,
limit: SAMPLE_LIMIT,
});
await runMeasured({ db, query: countQuery, label: "COUNT" });
await runMeasured({
db,
query: selectQuery,
label: `SELECT (limit ${SAMPLE_LIMIT})`,
});
process.exit(0);
};
await main();

View File

@@ -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<Migration> => {
const data = await this.post(`/migrations.create`, params);
return data as Migration;
},
list: async (): Promise<{ list: Migration[] }> => {
const data = await this.post(`/migrations.list`, {});
return data as { list: Migration[] };
},
update: async (params: {
id: string;
updates: {
id?: string;
filter?: MigrationFilter | null;
operations?: Operations | null;
};
}): Promise<Migration> => {
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);

View File

@@ -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;

View File

@@ -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);
},
});

View File

@@ -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 });
},
});

View File

@@ -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);
},
});

View File

@@ -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<HonoEnv>();
migrationRpcRouter.post("/migrations.create", ...handleCreateMigration);
migrationRpcRouter.post("/migrations.list", ...handleListMigrations);
migrationRpcRouter.post("/migrations.update", ...handlePatchMigration);

View File

@@ -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<Migration[]> => {
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));
};

View File

@@ -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,
};

View File

@@ -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<MigrationInsert, "id" | "filter" | "operations">;
}): Promise<Migration> => {
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;
};

View File

@@ -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<Pick<MigrationInsert, "id" | "filter" | "operations">>;
}): Promise<Migration | null> => {
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;
};

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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);
},
);

View File

@@ -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",
]);
});
});

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -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"]);
});
});

View File

@@ -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<unknown> };
batchSize?: number;
}): AsyncGenerator<Array<{ internal_id: string; id: string | null }>> {
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;
}
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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<CustomerFilter["plan"]>;
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<PlanFilter["item"]>;
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<string, unknown>;
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<string, unknown>;
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 };
}

View File

@@ -0,0 +1,2 @@
export * from "./filterToIr.js";
export * from "./resolutionContext.js";

View File

@@ -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<string, unknown>;
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"],
};
}

View File

@@ -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 }>;
};

View File

@@ -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;
},
};

View File

@@ -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";

View File

@@ -0,0 +1 @@
export * from "./irTypes.js";

View File

@@ -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;

View File

@@ -0,0 +1 @@
export * from "./irToSql.js";

View File

@@ -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<string, unknown>;
/**
* 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<string, FieldDef>;
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<string, FieldDef>;
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}`);
}

View File

@@ -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 },
},
};

View File

@@ -0,0 +1,3 @@
export * from "./customerRegistry.js";
export * from "./planRegistry.js";
export * from "./registryTypes.js";

View File

@@ -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" },
},
};

View File

@@ -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 <from>
* WHERE <correlation> AND <ambient> AND <child>)`.
*
* 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<string, FieldDef>;
};
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<string, FieldDef>;
};

View File

@@ -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 = <T extends z.ZodTypeAny>(element: T) =>
z.union([
element,
z.object({
$some: element.optional(),
$every: element.optional(),
$none: element.optional(),
}),
]);

View File

@@ -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<typeof CustomerFilterSchema>;
export const DEFAULT_CUSTOMER_FILTER: CustomerFilter = {};

View File

@@ -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";

View File

@@ -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<typeof StringMatcherSchema>;
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<typeof NumberMatcherSchema>;
export const BooleanMatcherSchema = z.boolean();
export type BooleanMatcher = z.infer<typeof BooleanMatcherSchema>;
/** Build a string-enum matcher: bare literal, null, or { $eq, $ne, $in, $nin }. */
export const enumMatcher = <T extends readonly [string, ...string[]]>(
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 = <T extends z.ZodTypeAny>(inner: T) =>
z.union([
z.null(),
z.object({
$eq: z.null().optional(),
$ne: z.null().optional(),
}),
inner,
]);

View File

@@ -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<typeof MigrationFilterSchema>;

View File

@@ -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<typeof StringMatcherSchema>;
price?:
| null
| { $eq?: null; $ne?: null }
| z.infer<typeof PlanPriceFilterInner>;
paid?: z.infer<typeof BooleanMatcherSchema>;
recurring?: z.infer<typeof BooleanMatcherSchema>;
item?:
| z.infer<typeof PlanItemFilterSchema>
| {
$some?: z.infer<typeof PlanItemFilterSchema>;
$every?: z.infer<typeof PlanItemFilterSchema>;
$none?: z.infer<typeof PlanItemFilterSchema>;
};
$or?: PlanFilter[];
};
export const PlanFilterSchema: z.ZodType<PlanFilter> = 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 = {};

View File

@@ -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<typeof PlanItemFilterSchema>;
export const DEFAULT_PLAN_ITEM_FILTER: PlanItemFilter = {};

View File

@@ -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<typeof CustomerOperationsSchema>;

View File

@@ -0,0 +1,2 @@
export * from "./customerOperations.js";
export * from "./updatePlan/index.js";

View File

@@ -0,0 +1 @@
export * from "./updatePlanOp.js";

View File

@@ -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<typeof UpdatePlanOpSchema>;

View File

@@ -0,0 +1,2 @@
export * from "./customer/index.js";
export * from "./operations.js";

View File

@@ -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<typeof OperationsSchema>;

View File

@@ -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",

View File

@@ -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";

View File

@@ -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<MigrationFilter>(),
operations: jsonb().$type<Operations>(),
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;

View File

@@ -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<Role, ScopeString[]> = {
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<Role, ScopeString[]> = {
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",

View File

@@ -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={<ProductsView env={AppEnv.Sandbox} />}
/>
<Route path="/migrations" element={<MigrationsView />} />
<Route path="/sandbox/migrations" element={<MigrationsView />} />
<Route
path="/products/:product_id"
element={

View File

@@ -17,7 +17,8 @@ export const EmptyState = ({
| "rewards"
| "archived-plans"
| "no-customers-found"
| "analytics";
| "analytics"
| "migrations";
actionButton?: React.ReactNode;
}) => {
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,
};
}
};

View File

@@ -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,
};
};

View File

@@ -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<Migration>(
"/migrations.create",
body,
);
return data;
},
};

View File

@@ -1,4 +1,5 @@
import {
ArrowsClockwiseIcon,
BalloonIcon,
BasketIcon,
ChartBarIcon,
@@ -180,6 +181,12 @@ export const MainSidebar = ({
title="Customers"
env={env}
/>
<NavButton
value="migrations"
icon={<ArrowsClockwiseIcon size={16} weight="fill" />}
title="Migrations"
env={env}
/>
<NavButton
value="analytics"
icon={<ChartBarIcon size={16} weight="fill" />}

View File

@@ -0,0 +1,9 @@
import { MigrationListTable } from "./migration-list/MigrationListTable";
export const MigrationsView = () => {
return (
<div className="h-fit max-h-full px-4 sm:px-10">
<MigrationListTable />
</div>
);
};

View File

@@ -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 (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="flex flex-col overflow-hidden">
<SheetHeader
title="Create a migration"
description="Give your migration a unique ID. You can configure its filter and operations after creation."
/>
<div className="flex-1 overflow-y-auto">
<SheetSection title="Migration ID">
<Input
placeholder="add-credits-to-free"
value={id}
onChange={(e) => setId(e.target.value)}
/>
</SheetSection>
</div>
<SheetFooter>
<ShortcutButton
variant="secondary"
className="w-full"
onClick={handleCancel}
singleShortcut="escape"
>
Cancel
</ShortcutButton>
<ShortcutButton
className="w-full"
onClick={handleCreateMigration}
metaShortcut="enter"
isLoading={loading}
>
Create migration
</ShortcutButton>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
export default CreateMigrationSheet;

View File

@@ -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<Migration> }) => (
<div className="font-mono justify-start flex w-full group overflow-hidden">
<MiniCopyButton text={row.original.id} />
</div>
),
},
{
header: "Filter",
size: 120,
cell: ({ row }: { row: Row<Migration> }) => (
<span className="text-xs text-t3">
{row.original.filter ? "Configured" : "—"}
</span>
),
},
{
header: "Operations",
size: 120,
cell: ({ row }: { row: Row<Migration> }) => (
<span className="text-xs text-t3">
{row.original.operations ? "Configured" : "—"}
</span>
),
},
{
header: "Created",
size: 160,
accessorKey: "created_at",
cell: ({ row }: { row: Row<Migration> }) => (
<span className="text-xs text-t3">
{format(new Date(row.original.created_at), "PP")}
</span>
),
},
];

View File

@@ -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 (
<>
<CreateMigrationSheet open={open} onOpenChange={setOpen} />
<Button variant="primary" size="default" onClick={() => setOpen(true)}>
Create Migration
</Button>
</>
);
}

View File

@@ -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 (
<EmptyState
type="migrations"
actionButton={<MigrationListCreateButton />}
/>
);
}
return (
<Table.Provider
config={{
table,
numberOfColumns: columns.length,
enableSorting: false,
isLoading,
rowClassName: "h-10",
}}
>
<Table.Toolbar>
<div className="flex w-full justify-between items-center">
<Table.Heading>
<ArrowsClockwiseIcon
size={16}
weight="fill"
className="text-subtle"
/>
Migrations
</Table.Heading>
<Table.Actions>
<div className="flex items-center gap-2">
<MigrationListCreateButton />
</div>
</Table.Actions>
</div>
</Table.Toolbar>
<div>
<Table.Container>
<Table.Content>
<Table.Header />
<Table.Body />
</Table.Content>
</Table.Container>
</div>
</Table.Provider>
);
}