built filter, operations, etc. for migrations
This commit is contained in:
145
.context/migration-interface/CATALOG.md
Normal file
145
.context/migration-interface/CATALOG.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Catalog: Migration & Import Patterns
|
||||
|
||||
Source: 4-agent parallel discovery on 2026-04-29 covering `scripts/src/common/migrations/`, `scripts-v2/runs/`, `scripts/mintlify-import.sh` chain, `scripts/firecrawl-import-from-csv.sh` chain, plus a deep read of billing v2 action machinery.
|
||||
|
||||
---
|
||||
|
||||
## A. Migration archetypes (what scripts actually do)
|
||||
|
||||
### A1. Version bump via `billingActions.migrate()`
|
||||
- `scripts/src/common/migrations/perform-migration.ts`, `lingo.ts`, `lingo-prepaid-to-payperuse.ts`
|
||||
- `scripts-v2/runs/browser-use/migrate-free/` (uses `billingActions.migrate` with `noBillingChanges: true`)
|
||||
- Trivially fits `{ customerId, plans: [{ expire: cusProductId, insert: { productId, version } }] }`.
|
||||
- Note: scripts loop multiple matching cusProducts per customer and reload state mid-loop because `migrate()` mutates state.
|
||||
|
||||
### A2. Multi-cusProduct subscription update — **the canonical complex case**
|
||||
- `scripts-v2/runs/mintlify/migrate-credits/multi-update/` — replaces legacy AI-credits prepaid with V2 prepaid+consumable across N cusProducts sharing one Stripe sub.
|
||||
- Operates on `CusProductGroup = { subscriptionId, cusProducts[] }`.
|
||||
- Per group: `setupStripeBillingContext` once → per-product `computeCustomPlanNewCustomerProduct` → aggregate into `AutumnBillingPlan` → `evaluateStripeBillingPlan` → `executeBillingPlan`.
|
||||
- This is structurally ~95% of the proposed interface, coupled to AI-credits-specific helpers (`resolveAiCreditsQuantity`, `setupUpdateFullProductContext`).
|
||||
|
||||
### A3. Pricing-shape swap, cusProduct identity preserved
|
||||
- `scripts/src/common/migrations/firecrawl-credit-pack-to-usage-price.ts`
|
||||
- Keeps the same `customer_product_id`, replaces its child cus_ents/cus_prices with custom rows. Preserves balance/reset/expiry.
|
||||
- **Does NOT fit `{expire, insert}`** — cusProduct stays.
|
||||
|
||||
### A4. 1→N cusProduct split
|
||||
- `scripts/src/common/migrations/split-one-cus-product-into-two.ts` (Email → Domain + Inbox).
|
||||
- Expires one cusProduct, inserts N new ones referencing the SAME `subscription_ids`.
|
||||
- Fits `{ expire, insert: [...] }` only if interface explicitly supports shared subscription_ids and balance/option carryover per inserted plan.
|
||||
|
||||
### A5. Flag-flip / balance-clamp on existing rows
|
||||
- `cont-use-to-single-use.ts`, `fix-creator-connections-interval.ts`, `browser-use-reset-spam-users.ts`, `additive-entitlement-migration.ts` (gifting branch), `retroactively-add-plan-item-to-customers.ts` "update" mode.
|
||||
- Pure UPDATE on `entitlements` / `customer_entitlements`. No cusProduct churn, no Stripe.
|
||||
- **Does not fit `{expire, insert}` at all** — separate primitive.
|
||||
|
||||
### A6. Raw-DB bulk replace (no Stripe, no billing v2)
|
||||
- `scripts-v2/runs/sebipaps/sandbox-product-sync/migrate-plan.ts` — chunked `delete cus_ent + delete cus_price + update cusProduct.internal_product_id`.
|
||||
- Free-tier or 1:1 product remap. Bypasses billing v2 entirely.
|
||||
- Should be subsumed by a "DB-only bulk swap" mode of the unified interface.
|
||||
|
||||
---
|
||||
|
||||
## B. Catalog-prep archetypes (what runs BEFORE migrations)
|
||||
|
||||
These are *preparation* patterns — extending the product catalog so a migration can land.
|
||||
|
||||
### B1. Add entitlement (+optional price) to all versions of a product
|
||||
- `retroactively-add-plan-item-to-versions.ts` — uses latest version that already has the ent as a TEMPLATE, clones onto older versions.
|
||||
- `FINAL-add-entitlement-to-plans.ts` — adds feature to all versions matching id-prefix.
|
||||
- `add-creator-connections-entitlement.ts`, `add-boolean-customer-entitlement.ts`, `add-feature-to-customers.ts`, `add-new-boolean-feature.ts`.
|
||||
- Pure catalog (`entitlements`, `prices`) mutations. No customer-side touch.
|
||||
|
||||
### B2. Backfill cus_ents for existing customers on extended products
|
||||
- `retroactively-add-plan-item-to-customers.ts` — sibling of B1; per active cusProduct on listed products, insert missing `customer_entitlements` (and raw `customer_prices` if `requirePrice`).
|
||||
- `add-creator-connections-cus-entitlement.ts`, `add-boolean-customer-entitlement.ts` (also does B1).
|
||||
- Uses `initCusEntitlement` + direct `CusEntService.insert`. **Zero scripts use** `createFullCusProduct`.
|
||||
|
||||
### B3. Catalog clone (sandbox → live, or version bump)
|
||||
- `scripts-v2/runs/sebipaps/sandbox-product-sync/copy-products.ts` — `handleCopyFeatures`, `handleVersionProductV2`, `createProduct`.
|
||||
- `scripts-v2/runs/mintlify/migrate-credits/steps/init-plan-resources/` — deterministic-id init for V2 prepaid/consumable price+entitlement; sibling entrypoint `setup-credits-catalog.ts`.
|
||||
- Pattern: prep entrypoint shares folder + `data/` with the migration entrypoint.
|
||||
|
||||
### B4. Stripe-side prep (clone archived Stripe prices)
|
||||
- `scripts-v2/runs/mintlify/fix-archived-price/` — clones archived Stripe prices, updates `Price.config.stripe_price_id`, then patches subscription items.
|
||||
- Doesn't fit `{expire, insert}` — it's a price-id swap on the same cusProduct.
|
||||
|
||||
---
|
||||
|
||||
## C. Import patterns (Stripe → Autumn)
|
||||
|
||||
### C1. Mintlify import (`scripts/src/mintlify/import/`)
|
||||
- Source: Mongo orgs/deployments/period_usage. Stripe customers/subs.
|
||||
- Steps: create customers → create entities (per deployment) → attach base plans via `createFullCusProduct` → derive addons from Stripe subs → "loose" custom entitlements via direct Drizzle insert → sync period usage into balances → cleanup.
|
||||
- Plan resolver: Mongo `plan` string field.
|
||||
- Custom allowances: signature-deduped per-customer entitlement rows.
|
||||
|
||||
### C2. Firecrawl import (`scripts/src/firecrawl/import/`)
|
||||
- Source: BigQuery `autumn_shared` (teams, orgs, balances, coupons). Stripe subs/prices/customers.
|
||||
- Steps: select Stripe subs → resolve plan from `lookup_key` → resolve/create customer → ensure team entity → build target product state with per-customer overrides (custom allowance, custom topup price, signature dedup) → flush via `customerProductService.insertIfNotExists` → repair Stripe sub link → schedule end-of-cycle plan switches via `billingActions.attach` with `no_billing_changes: true` → sync team credit state → import coupon balances directly to `customer_entitlements.balance` with deterministic ids.
|
||||
- Plan resolver: Stripe `price.lookup_key`.
|
||||
- Custom allowances: per-customer freshly-created `entitlements` + `prices` rows with signature dedup cache.
|
||||
|
||||
### Cross-cutting import friction (top 5)
|
||||
1. Stripe price → Autumn plan resolver is org-specific (Mongo field vs lookup_key vs price.metadata).
|
||||
2. Per-customer catalog overrides require signature-deduped per-customer `entitlements`+`prices` rows.
|
||||
3. Linking to existing Stripe sub (idempotent), not creating it. `initOptions.subscriptionId` + repair.
|
||||
4. Balance carryover is a separate concern: deterministic `balanceId`, direct write to `customer_entitlements.balance`, bypass `createFullCusProduct`.
|
||||
5. Entity scoping + duplicate cleanup (same Stripe sub re-attached to wrong entity, stale entities, duplicate plans across entities).
|
||||
|
||||
---
|
||||
|
||||
## D. Billing v2 architecture (the substrate)
|
||||
|
||||
### D1. The 5-stage pipeline (every action follows this)
|
||||
```
|
||||
setup<Action>BillingContext({ ctx, params }) -> <Action>BillingContext
|
||||
handle<Action>Errors({ ctx, billingContext }) -> throws RecaseError
|
||||
compute<Action>Plan({ ctx, billingContext }) -> AutumnBillingPlan
|
||||
evaluateStripeBillingPlan({ ctx, billingContext, autumnBillingPlan }) -> StripeBillingPlan
|
||||
executeBillingPlan({ ctx, billingContext, billingPlan }) -> BillingResult
|
||||
```
|
||||
|
||||
### D2. AutumnBillingPlan already expresses every migration shape
|
||||
- `insertCustomerProducts: FullCusProduct[]`
|
||||
- `updateCustomerProduct(s)?: CustomerProductUpdate[]` — patch options/status/canceled/anchor/sub_ids
|
||||
- `deleteCustomerProduct(s)?: FullCusProduct[]` — for scheduled product replacement
|
||||
- `customPrices?: Price[]`, `customEntitlements?: Entitlement[]`, `customFreeTrial?`
|
||||
- `updateByStripeScheduleId?` — for schedule swaps
|
||||
- `insertCustomerEntitlements?`, `updateCustomerEntitlements?`
|
||||
- `lineItems?`, `customLineItems?`
|
||||
- `autoTopupRebalance`, `upsertSubscription`, `upsertInvoice`, `refundPlan`
|
||||
|
||||
### D3. evaluateStripeBillingPlan capabilities
|
||||
- ✅ Multi-product, mixed prepaid/metered, schedules, cancel+replace, multi-entity, refunds, manual invoices.
|
||||
- ✅ DB-only mode: `billingContext.skipBillingChanges = true` returns `{}` and execute no-ops Stripe.
|
||||
- ⚠️ Single-customer per evaluation. No batch mode.
|
||||
|
||||
### D4. Existing `migrate` action
|
||||
- `actions/migrate/migrate.ts` is already in the registry. Need to read what it actually does — likely the simple version-bump path; we may extend or replace.
|
||||
|
||||
### D5. Critical gap: shared custom rows
|
||||
- `setupCustomFullProduct` always mints fresh per-customer `Price`/`Entitlement` rows when `hasCustomItems` is true.
|
||||
- For bulk migration where 10k customers should share the same prepared price/ent rows, we need either:
|
||||
- A `prepared` mode that reuses existing catalog rows, OR
|
||||
- A separate one-shot prep phase that creates the shared rows once, with the migration action only inserting `customer_products` + `customer_prices`/`customer_entitlements` referencing those existing IDs.
|
||||
|
||||
### D6. customizePlanV1 is PUT-style
|
||||
- `items?: ApiPlanItemV1[]` — when provided, replaces full list.
|
||||
- For migrations we want PATCH-style (incremental add/remove/update of plan items).
|
||||
|
||||
---
|
||||
|
||||
## E. Verdict on `{ customerId, plans: [{ expire, insert }] }`
|
||||
|
||||
Fits cleanly: **A1, A2 (with subscription-group dimension), A4 (with shared sub_ids), A6 (with DB-only mode).**
|
||||
|
||||
Does NOT fit, needs separate primitive(s):
|
||||
- **A3** — pricing-shape swap on same cusProduct identity.
|
||||
- **A5** — flag-flip / balance-clamp on existing rows.
|
||||
- **B1, B2** — catalog prep & cus_ent backfill (different layer entirely).
|
||||
- **B3** — catalog clone (different layer).
|
||||
- **B4** — Stripe price-id swap (different layer).
|
||||
- **C1, C2 imports** — need 5 additional dimensions on top of migration shape.
|
||||
|
||||
**Implication: the unified "migration interface" is really a layered system, not a single action.**
|
||||
29
.context/migration-interface/DECISIONS.md
Normal file
29
.context/migration-interface/DECISIONS.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Decisions
|
||||
|
||||
Append-only log of architectural and design decisions.
|
||||
|
||||
## 2026-04-29 — Project lives in `autumn/.context/`, not the outer worktree
|
||||
The work targets first-class billing actions in `autumn/server/src/internal/billing/v2/actions/` and ships with tests in `autumn/`. Context is colocated with the code it describes. The outer `.context/migration-interface/` holds only a redirect README.
|
||||
|
||||
## 2026-04-29 — Phase 1 is cataloging, not designing
|
||||
The user explicitly asked to survey existing migration & import scripts before committing to interface shape. Risk of premature lock-in is high — there are at least three classes of patterns (DB-only mutations, DB+Stripe mutations, imports with balance carryover) and each could break the proposed shape differently. Output of Phase 1 is a verdict-per-script catalog.
|
||||
|
||||
## 2026-04-29 — Build as core product feature, not internal tooling
|
||||
Will eventually surface to users on the dashboard. Implications: lives in `autumn/server/src/internal/operations/` (peer to `billing/v2/`), has integration tests, no `as any`, follows full billing-action conventions for the heavyweight executors (`setupBillingContext` → compute Autumn plan → `evaluateStripeBillingPlan` → execute).
|
||||
|
||||
## 2026-04-29 — Planner + Executor model, not single billing action
|
||||
Migrations and imports differ in HOW they produce a plan but converge in HOW the plan executes. A single `migrate` action would conflate heterogeneous mutation surfaces (catalog, customer product graph, balance, Stripe repair) into one sprawling compute function. Splitting produces:
|
||||
- Durable, inspectable, persistable `OperationPlan` artifact
|
||||
- Dry-run identical to live (only `apply()` differs)
|
||||
- Pluggable planners (MigrationDefinition, ImportDefinition) emit same operation graph
|
||||
- Specialized executors per mutation surface, each with own idempotency/guards
|
||||
- Reuses billing v2 pipeline INSIDE the CustomerProductGraphOperation executor — no replacement, just orchestration above
|
||||
|
||||
## 2026-04-29 — Operations typed by mutation surface, not lifecycle intent
|
||||
Initial design used `PlanOp = expire | insert | swap | patch` (lifecycle-flavored). Better: `Operation = CatalogOperation | CustomerProductGraphOperation | CustomerEntitlementOperation | BalanceOperation | StripeRepairOperation | CacheInvalidationOperation`. Each surface has different idempotency, transaction boundaries, and concurrency rules — surface-typed ops let executors specialize cleanly.
|
||||
|
||||
## 2026-04-29 — `is_custom` decoupled from "uses shared catalog rows"
|
||||
Today `setupCustomFullProduct` always mints fresh per-customer Price/Entitlement rows when customize is provided. For 10k-customer migrations this creates 10k Stripe prices unnecessarily. New model: `ProductItemPatch.add_existing { entitlement_id, price_id }` references shared catalog rows by id; `is_custom` becomes purely an ownership flag (one-off vs catalog), not "this row is per-customer." Existing custom rows untouched; new code uses new model.
|
||||
|
||||
## 2026-04-29 — Ownership is first-class
|
||||
Today repair logic is bespoke per-script because we can't distinguish imported from user-created customer_products. New model: `Ownership { origin: "imported"|"user_created"|"migrated", source?, imported_at? }` on every TargetAssignment. Likely stored as new column on `customer_products`. Unblocks safe re-runnable imports and convergence/repair semantics.
|
||||
71
.context/migration-interface/PLAN.md
Normal file
71
.context/migration-interface/PLAN.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# migration-interface Plan
|
||||
|
||||
## Phase 1: Catalog & Stress-Test (IN PROGRESS)
|
||||
Goal: enumerate every migration/import pattern we've shipped, then check the proposed interface against each one. Output: a catalog doc with per-script summaries and "interface covers? / what breaks?" verdicts.
|
||||
|
||||
- Survey `scripts/src/common/migrations/` (retroactively-add-plan-item-to-customers, -to-versions, -plan-items, plus siblings)
|
||||
- Survey `scripts-v2/runs/` migration scripts (mintlify migrate-credits is the canonical example)
|
||||
- Survey `scripts/mintlify-import.sh` and `scripts/firecrawl-import.sh` import flows
|
||||
- Categorize each script along axes:
|
||||
- DB-only vs DB + Stripe
|
||||
- Single-customer vs bulk
|
||||
- Plan transformation shape (add item / swap plan / change pricing / cancel-and-replace / schedule)
|
||||
- Custom per-customer overrides (rollover, lifetime balance, balance carryover)
|
||||
- Preparation step required (new prices/products created before migration)
|
||||
- Verdict per script: covered by proposed interface? What's missing?
|
||||
|
||||
## Phase 2: Interface Design
|
||||
- Lock the migration action signature(s) — likely one new `migrate` action alongside `multiAttach` and `createSchedule`
|
||||
- Solve the "insert + customize" friction:
|
||||
- Move customize from PUT-style (full item list) to PATCH-style (incremental add/remove/update)
|
||||
- Decide: do migrations create new custom price/entitlement rows, or reuse a single per-plan row? (Stripe price/product creation is the binding constraint)
|
||||
- Decide whether "preparation" (creating shared prices/entitlements ahead of migration) is a separate action or a step in the same one
|
||||
- Validate design against catalog from Phase 1
|
||||
|
||||
## Phase 3: Implementation
|
||||
- Build the action(s) under `autumn/server/src/internal/billing/v2/actions/`
|
||||
- Tests: integration tests covering catalog scenarios from Phase 1
|
||||
- Reuse `evaluateStripeBillingPlan`, `setupBillingContext` patterns from existing actions
|
||||
|
||||
## Phase 4: Migrate Existing Scripts
|
||||
- Port mintlify migrate-credits to use the new action
|
||||
- Port retroactively-add-plan-item-to-customers
|
||||
- Each port validates the interface; refine as needed
|
||||
|
||||
## Phase 5 (stretch): Imports
|
||||
- Extend or sibling action for Stripe→Autumn linkage
|
||||
- Handle the hard cases: balance carryover for metered features, custom configurations (rollovers, lifetime balances), many-plans Stripe accounts
|
||||
- Goal: collapse the bespoke mintlify-import.sh / firecrawl-import.sh logic
|
||||
|
||||
---
|
||||
|
||||
## Initial Design Sketch (from kickoff conversation)
|
||||
|
||||
**Scope of one call:** one customer, one Stripe subscription's worth of customer_products.
|
||||
|
||||
**Proposed shape:**
|
||||
```ts
|
||||
{
|
||||
customerId: string,
|
||||
subscriptionId?: string, // inferable from plans
|
||||
plans: [
|
||||
{
|
||||
expire?: { customer_product_id?: string; plan_id?: string; entity_id?: string },
|
||||
insert?: { plan_id: string; customize?: ... },
|
||||
}
|
||||
],
|
||||
schedules?: [...] // future, optional
|
||||
}
|
||||
```
|
||||
|
||||
**Open questions:**
|
||||
1. Customize: PATCH vs PUT — strongly leaning PATCH (incremental add/remove/update of plan items)
|
||||
2. Custom prices: per-customer rows vs shared per-plan row reused across migrations (Stripe-side cost is the constraint)
|
||||
3. Preparation step (creating shared prices/entitlements + backfilling onto prior plan versions and customer_products): part of this interface or a separate "prepare migration" action?
|
||||
4. Mapping public `apiPlanItemV1` ↔ DB `price + entitlement` pair — how does the interface accept input?
|
||||
|
||||
## Architecture Notes
|
||||
- Build on `evaluateStripeBillingPlan` — the Autumn-plan → Stripe-state mapper already handles schedules, multi-entity, mixed prepaid/metered. Don't reinvent.
|
||||
- `customer.processor?.id` is Stripe customer ID (per autumn-operations-constraints rule). Watch the ID confusion.
|
||||
- Stripe writes must respect: amount conversion via `atmnToStripeAmount`, idempotent price creation via `createStripePriceIFNotExist`, `proration_behavior: "none"` for surgical item changes.
|
||||
- This is product code, not script code — full TypeScript discipline, no `as any`, full test coverage.
|
||||
306
.context/migration-interface/PROPOSAL.md
Normal file
306
.context/migration-interface/PROPOSAL.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Proposal: Operation Plan + Pluggable Planners
|
||||
|
||||
## Mental model
|
||||
|
||||
> **Planning produces a typed `OperationPlan`. Execution consumes it.**
|
||||
> Migrations and imports differ in how they *produce* the plan; everything downstream — guards, dry-run, persistence, execution, reconciliation — is shared.
|
||||
|
||||
This is a strict departure from "build one big billing action." The migration interface is not a single action — it is a **planner / executor pair**, where the plan is a durable, inspectable artifact and execution is a typed reducer over operations.
|
||||
|
||||
The reason: scripts today informally do exactly this lifecycle (scope → load → resolve → prepare → plan → guard → execute → verify), but the contracts between stages are bespoke. The win is canonizing the stage contracts, not unifying the verbs.
|
||||
|
||||
---
|
||||
|
||||
## Why planner + executors (vs. single billing action)
|
||||
|
||||
1. **Operations are heterogeneous.** Catalog mutations, customer-product graph changes, balance syncs, Stripe repair, cache invalidation — each has different idempotency, different transaction boundaries, different blast radius. One "migrate" action that handles all of them gets a sprawling `compute*Plan` function. Separate executors keep each surface clean.
|
||||
2. **Plans are first-class artifacts.** Persistable. Diffable. Re-runnable. Approval-able. Eventually user-facing on the dashboard ("preview migration → approve → apply").
|
||||
3. **Dry-run = real plan.** Same code path produces the plan; only `apply()` differs. No drift.
|
||||
4. **Imports vs migrations diverge cleanly.** They're different *planners* emitting the same operation graph. Shared execution; specialized planning.
|
||||
5. **Concrete reuse path.** `CustomerProductGraphOperation.replace` executor wraps the existing 5-stage billing v2 pipeline. We're not replacing billing v2 — we're putting an orchestration layer above it.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Module placement
|
||||
|
||||
```
|
||||
autumn/server/src/internal/operations/
|
||||
types/
|
||||
operation.ts -- Operation discriminated union
|
||||
operationPlan.ts -- OperationPlan, CustomerOperationPlan, RunContext
|
||||
operationResult.ts -- OperationResult, GuardResult, MigrationDiff
|
||||
productItemPatch.ts -- ProductItemPatch union (PATCH-style customize)
|
||||
executors/
|
||||
catalog/ -- CatalogOperation executor
|
||||
customerProductGraph/ -- CustomerProductGraphOperation executor (wraps billing v2)
|
||||
customerEntitlement/ -- CustomerEntitlementOperation executor
|
||||
balance/ -- BalanceOperation executor
|
||||
stripeRepair/ -- StripeRepairOperation executor
|
||||
cache/ -- CacheInvalidationOperation executor
|
||||
planners/
|
||||
migration/ -- MigrationDefinition runner
|
||||
import/ -- ImportDefinition runner
|
||||
guards/ -- composable, named guard sets
|
||||
reporting/ -- diff, audit, CSV export
|
||||
persistence/ -- migration_runs / migration_plans schema + service
|
||||
execute.ts -- top-level executePlan(plan, { dryRun })
|
||||
```
|
||||
|
||||
This sits **as a peer** to `billing/v2/`, not under it. `billing/v2/` is the substrate; `operations/` is the orchestration layer.
|
||||
|
||||
### Type model
|
||||
|
||||
```ts
|
||||
// The durable artifact
|
||||
type OperationPlan = {
|
||||
run: RunContext; // org_id, env, dry_run, source description, created_at
|
||||
catalog: Operation[]; // run-level (executes once, before per-customer)
|
||||
customers: CustomerOperationPlan[];// per-customer
|
||||
warnings: GuardResult[]; // non-blocking
|
||||
blockers: GuardResult[]; // execution refuses if any blockers
|
||||
};
|
||||
|
||||
type CustomerOperationPlan = {
|
||||
customer_id: string;
|
||||
// Operations declared in execution order. Within a customer, ops execute serially.
|
||||
// Operations targeting the same Stripe subscription are batched by the executor
|
||||
// into a single subscriptions.update — this is the existing multi-update atomicity
|
||||
// boundary.
|
||||
operations: Operation[];
|
||||
guards: GuardResult[];
|
||||
preview: CustomerPreview; // human-readable diff of intended changes
|
||||
};
|
||||
|
||||
type Operation =
|
||||
| CatalogOperation
|
||||
| CustomerProductGraphOperation
|
||||
| CustomerEntitlementOperation
|
||||
| BalanceOperation
|
||||
| StripeRepairOperation
|
||||
| CacheInvalidationOperation;
|
||||
|
||||
// Mutation surface 1: catalog
|
||||
type CatalogOperation =
|
||||
| { type: "ensure_feature"; spec: FeatureSpec }
|
||||
| { type: "ensure_plan_item"; product_selector: ProductSelector; item: ProductItemSpec }
|
||||
| { type: "ensure_shared_price"; signature: string; spec: PriceSpec }
|
||||
| { type: "ensure_shared_entitlement"; signature: string; spec: EntitlementSpec }
|
||||
| { type: "link_stripe_resource"; price_id: string; stripe_price_id?: string; stripe_product_id?: string }
|
||||
| { type: "swap_archived_stripe_price"; price_id: string };
|
||||
|
||||
// Mutation surface 2: customer product graph (the big one)
|
||||
type CustomerProductGraphOperation =
|
||||
| { type: "attach"; assignment: TargetAssignment; ownership: Ownership }
|
||||
| { type: "replace"; subscription_id?: string;
|
||||
from: CusProductSelector[]; // expire these
|
||||
to: TargetAssignment[]; // insert these
|
||||
carry?: { balance: boolean; subscription_ids: boolean; anchors: boolean } }
|
||||
| { type: "expire"; targets: CusProductSelector[]; reason: string }
|
||||
| { type: "schedule"; phases: ScheduledPhase[] };
|
||||
|
||||
// Mutation surface 3: customer entitlement (B-layer in old proposal)
|
||||
type CustomerEntitlementOperation =
|
||||
| { type: "backfill_for_customers"; product_selector; feature_id; require_price?: boolean }
|
||||
| { type: "patch_flag"; selector; flags: Partial<Pick<Entitlement, "usage_allowed" | "carry_from_previous">> }
|
||||
| { type: "clamp_balance"; selector; ceiling: number };
|
||||
|
||||
// Mutation surface 4: balance (imports especially)
|
||||
type BalanceOperation =
|
||||
| { type: "seed_balance"; balance_id: string; cus_ent_selector; amount: number; entity_id?: string }
|
||||
| { type: "set_balance"; cus_ent_id: string; balance: number }
|
||||
| { type: "delete_stale_balance"; cus_ent_id: string; reason: string };
|
||||
|
||||
// Mutation surface 5: stripe repair (imports especially)
|
||||
type StripeRepairOperation =
|
||||
| { type: "link_subscription"; cus_product_id: string; stripe_subscription_id: string }
|
||||
| { type: "expire_orphaned_imported_cus_product"; cus_product_id: string };
|
||||
|
||||
type CacheInvalidationOperation =
|
||||
| { type: "invalidate_customer_cache"; customer_ids: string[] }
|
||||
| { type: "invalidate_products_cache" };
|
||||
```
|
||||
|
||||
### TargetAssignment + ProductItemPatch
|
||||
|
||||
The two key composite types.
|
||||
|
||||
```ts
|
||||
type TargetAssignment = {
|
||||
product: { id: string; version?: number };
|
||||
entity_id?: string | null;
|
||||
subscription_id?: string | null; // for imports linking to existing Stripe sub
|
||||
feature_quantities?: FeatureQuantity[];
|
||||
item_patches?: ProductItemPatch[]; // PATCH-style customize
|
||||
billing_dates?: { trial_ends_at?: number; reset_cycle_anchor?: number; billing_cycle_anchor?: number };
|
||||
ownership?: Ownership; // imported / user-created / migrated
|
||||
};
|
||||
|
||||
// PATCH-style. Compiles into FullProduct + customPrices + customEnts
|
||||
// that existing billing v2 compute already understands.
|
||||
type ProductItemPatch =
|
||||
| { op: "add_existing"; entitlement_id: string; price_id?: string } // reference shared catalog rows
|
||||
| { op: "replace_feature"; feature_id: string; entitlement_id: string; price_id?: string }
|
||||
| { op: "remove_feature"; feature_id: string }
|
||||
| { op: "override_allowance"; feature_id: string; allowance: number; unlimited?: boolean }
|
||||
| { op: "override_price"; feature_id?: string; price: PricePatch } // mints a custom Price row
|
||||
| { op: "set_options"; feature_id: string; options: { quantity?: number } };
|
||||
|
||||
type Ownership = {
|
||||
origin: "imported" | "user_created" | "migrated";
|
||||
source?: string; // e.g. "stripe_subscription:sub_abc"
|
||||
imported_at?: number;
|
||||
};
|
||||
```
|
||||
|
||||
`add_existing` is the load-bearing fix: it lets a 10k-customer migration reference one shared catalog row instead of minting 10k custom rows. **`is_custom` becomes an ownership flag (one-off vs catalog) decoupled from "uses custom prepared rows."**
|
||||
|
||||
### Execution model
|
||||
|
||||
```ts
|
||||
// Top-level entry point
|
||||
async function executePlan(plan: OperationPlan, opts: { dryRun: boolean }): Promise<PlanResult>
|
||||
|
||||
// Strict execution rules:
|
||||
// 1. If plan.blockers is non-empty AND !dryRun → throw, refuse.
|
||||
// 2. Catalog ops execute first, in declared order, before any customer ops.
|
||||
// 3. Customer plans execute in parallel (configurable concurrency); same-customer
|
||||
// operations execute serially in declared order.
|
||||
// 4. Within one customer, operations targeting the same Stripe subscription are
|
||||
// GROUPED by the CustomerProductGraphOperation executor and emitted as ONE
|
||||
// subscriptions.update via the existing evaluateStripeBillingPlan + executeBillingPlan
|
||||
// path. This preserves multi-update atomicity.
|
||||
// 5. Each operation returns OperationResult { status, diff?, error?, artifacts? }.
|
||||
// 6. Dry-run produces the same operations list; executors emit `would_apply` results
|
||||
// with diffs but mutate nothing.
|
||||
```
|
||||
|
||||
**The CustomerProductGraphOperation executor for `replace` is essentially today's `multi-update/`** generalized: build the per-product context list, call `computeCustomPlanNewCustomerProduct` per item, aggregate into `AutumnBillingPlan`, run through `evaluateStripeBillingPlan` + `executeBillingPlan`. Everything downstream of the Operation type is reused.
|
||||
|
||||
### Persistence
|
||||
|
||||
Plans persist to DB for non-trivial runs. Schema:
|
||||
|
||||
```
|
||||
migration_runs -- one per orchestrated run (org, env, source, status)
|
||||
migration_plans -- the OperationPlan JSON, snapshot at planning time
|
||||
migration_ops -- one row per operation, with status + diff + result
|
||||
migration_artifacts -- CSV exports, snapshots, audit rows
|
||||
```
|
||||
|
||||
Trivial cases (single-customer one-off) can skip persistence and go straight through `executePlan`. Bulk runs persist.
|
||||
|
||||
States: `draft → planned → applying → completed | failed | partial`. A `partial` failed run is resumable: the executor skips ops with `status: applied` and re-runs `pending` / `failed` ops.
|
||||
|
||||
### Planners
|
||||
|
||||
```ts
|
||||
type MigrationDefinition = {
|
||||
id: string;
|
||||
selectCandidates(ctx): Promise<CustomerScope[]>;
|
||||
buildTargetState(ctx, customer): Promise<TargetState>;
|
||||
plan(ctx, target): OperationPlan["customers"][number];
|
||||
};
|
||||
|
||||
type ImportDefinition = {
|
||||
id: string;
|
||||
loadExternalSource(ctx): Promise<SourceSnapshot>;
|
||||
resolveIdentities(ctx, source): Promise<IdentityMap>;
|
||||
mapTargets(ctx, source, identities): Promise<TargetState[]>;
|
||||
plan(ctx, target): OperationPlan["customers"][number];
|
||||
};
|
||||
```
|
||||
|
||||
Mintlify import becomes `ImportDefinition`. Mintlify migrate-credits becomes `MigrationDefinition`. Both emit the same `OperationPlan` shape.
|
||||
|
||||
---
|
||||
|
||||
## Phased build order
|
||||
|
||||
### v0 — Migration Core (3-4 weeks)
|
||||
**Concrete deliverable: port mintlify migrate-credits' multi-update path to use this system.** That single port validates 80% of the abstraction.
|
||||
|
||||
1. `Operation` type + `ProductItemPatch` compiler (`patches → FullProduct + customPrices + customEnts`).
|
||||
2. `CustomerProductGraphOperation.replace` executor wrapping the existing multi-update pipeline.
|
||||
3. `OperationResult` + `CustomerPreview` shapes (Autumn diff + Stripe sub-update preview).
|
||||
4. Guard set: `subscription-replace` (mismatched-sub-in-group, missing payment, anchor drift, collection_method preservation).
|
||||
5. `executePlan` for in-memory plans (no DB persistence yet).
|
||||
6. Integration tests: port the mintlify migrate-credits scenarios.
|
||||
7. Migration runner harness for scripts-v2 (one-line: `runPlan(planner, opts)`).
|
||||
|
||||
### v0.5 — Catalog Prep (1-2 weeks)
|
||||
8. `CatalogOperation` executor: `ensure_feature`, `ensure_plan_item`, `ensure_shared_price`, `ensure_shared_entitlement`, `link_stripe_resource`.
|
||||
9. Run-level catalog ops execute before per-customer ops.
|
||||
10. Port `retroactively-add-plan-item-to-versions` + `retroactively-add-plan-item-to-customers` to the new system (validates catalog → customer-entitlement composition).
|
||||
|
||||
### v0.75 — Persistence + Resume (1 week)
|
||||
11. `migration_runs` / `migration_plans` / `migration_ops` schema.
|
||||
12. `executePlan` writes operation results; partial-failure resume.
|
||||
13. Read-only view of past runs (no UI yet, just service methods).
|
||||
|
||||
### v1 — Auxiliary surfaces (2 weeks)
|
||||
14. `BalanceOperation` executor (Mintlify period_usage, Firecrawl coupon balances).
|
||||
15. `CustomerEntitlementOperation` executor (flag-flips, balance clamps, backfills).
|
||||
16. `StripeRepairOperation` executor.
|
||||
17. Port one full Firecrawl import slice as a smoke test.
|
||||
|
||||
### v1.5 — Imports as planner (2-3 weeks)
|
||||
18. `ImportDefinition` runner with identity resolution + source normalization stages.
|
||||
19. Port mintlify-import to use this. Firecrawl-import follows.
|
||||
|
||||
### v2 (deferred) — Dashboard + API
|
||||
20. Dashboard view: list runs, inspect plan, dry-run preview, approve & apply.
|
||||
21. Public API for self-serve migrations.
|
||||
|
||||
---
|
||||
|
||||
## Synthesis: what changed from v1 of this proposal
|
||||
|
||||
| v1 (PROPOSAL.md before) | v2 (now) | Why |
|
||||
|---|---|---|
|
||||
| Single `migrate` action with `PlanOp` discriminated union | Planner + executors with typed operations by mutation surface | Heterogeneous mutation surfaces don't belong in one action |
|
||||
| 3 layers (catalog / backfill / transition) | Flat operation graph with execution ordering rules | Layers were artificial; ops at the same level with declarative dependencies is cleaner |
|
||||
| `use_shared_rows` flag bolt-on | `ProductItemPatch.add_existing { entitlement_id, price_id }` first-class | Semantics are clearer when they're a discrete patch op |
|
||||
| `is_custom` overloaded for "one-off" + "uses custom rows" | Decouple: `is_custom` = ownership flag; shared rows = first-class catalog rows | Resolves a long-standing concept conflation |
|
||||
| Imports = migration + 5 extensions | Imports = different planner emitting same operation graph | Cleaner separation of concerns; identity/source/mapping is genuinely different from migration |
|
||||
| Action-level `preview` flag | First-class plan artifact; dry-run = identical plan path | Plan becomes inspectable, persistable, approvable |
|
||||
| No persistence story | `migration_runs/_plans/_ops` schema; resumable partial runs | User asked for "core feature, not internal tool" — first-class needs persistence |
|
||||
| No ownership concept | `Ownership` first-class on every TargetAssignment | Repair logic is currently bespoke per-script because we can't tell imported from user-created |
|
||||
|
||||
---
|
||||
|
||||
## Open design questions (the load-bearing ones)
|
||||
|
||||
1. **Plan persistence: opt-in or default-on?**
|
||||
Lean: **default-on for `bulk` planners (>1 customer); opt-out for one-shot operations.** Persistence enables resume, audit, dashboard surfacing — worth the schema cost.
|
||||
|
||||
2. **Operation ordering within a customer plan: declared or topologically derived?**
|
||||
Lean: **declared.** Planners produce ops in execution order. Simpler, and the planner already knows what depends on what (e.g. `attach` before `seed_balance`). No DAG solver needed.
|
||||
|
||||
3. **Subscription-group atomicity: explicit or implicit?**
|
||||
Lean: **implicit, in the executor.** The executor groups operations by Stripe sub before executing. This keeps the operation list flat and forgiving — caller doesn't have to think about Stripe's batching boundary, the executor handles it.
|
||||
|
||||
4. **Ownership flag: stored where?**
|
||||
Options: (a) new column on `customer_products`, (b) jsonb metadata, (c) separate `customer_product_ownership` table.
|
||||
Lean: **new column** — it's load-bearing for repair queries; jsonb is too soft.
|
||||
|
||||
5. **Existing `billingActions.migrate`: keep, deprecate, or absorb?**
|
||||
Need to read it. If it's a thin version-bump helper, keep as a shorthand that produces a one-op plan. If it's heavier, deprecate in favor of `MigrationDefinition`.
|
||||
|
||||
6. **Where does identity resolution live for imports?**
|
||||
In the `ImportDefinition.resolveIdentities` stage. The output is an `IdentityMap` that subsequent stages consume. Don't bake identity logic into Operations — keep operations identity-agnostic (they take `customer_id`, not "find or create customer with this email").
|
||||
|
||||
7. **Public-facing types: when?**
|
||||
Defer until v2 (dashboard). Internal types can move freely; public types lock when we expose this to users. Avoid premature stability commitments.
|
||||
|
||||
8. **`is_custom` decoupling: backwards compat?**
|
||||
Existing custom rows have `is_custom: true`. New "shared catalog rows referenced by patch" don't set this flag. We need a cleanup or co-existence story for current per-customer custom rows. Lean: leave existing rows alone; new code uses the new model; eventually a `null is_custom` migration cleans up.
|
||||
|
||||
---
|
||||
|
||||
## Open question deferred from v1
|
||||
|
||||
- **PATCH vs PUT customize:** resolved by `ProductItemPatch` discriminated union (PATCH-style, with `add_existing` for shared rows).
|
||||
- **Custom prices: per-customer rows vs reused per-plan rows:** resolved by `add_existing` / `ensure_shared_*` catalog ops.
|
||||
- **Preparation step placement:** resolved — it's `CatalogOperation` ops inside the same plan.
|
||||
35
.context/migration-interface/STATUS.md
Normal file
35
.context/migration-interface/STATUS.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# migration-interface
|
||||
|
||||
## Goal
|
||||
Design a shared billing-action interface for customer migrations and imports so scripts stop hand-rolling Stripe + Autumn DB orchestration. Eventually exposed to users on the dashboard, so it must be a first-class product feature with tests — not an internal tool.
|
||||
|
||||
## Current Phase
|
||||
Phase 1: Discovery complete (catalog written). PROPOSAL.md v2 drafted — planner + executor model with typed operations. Awaiting user review of architecture and 8 open design questions.
|
||||
|
||||
## Branch
|
||||
john/migration-interface
|
||||
|
||||
## Active Tasks
|
||||
- (none yet — will create parallel cataloging tasks if Phase 1 surface gets large)
|
||||
|
||||
## What's Done
|
||||
- Phase 1 discovery: cataloged `scripts/src/common/migrations/` (23 files), `scripts-v2/runs/` migration scripts, mintlify+firecrawl import chains, and billing v2 action machinery (`multiAttach`, `createSchedule`, `updateSubscription`, `evaluateStripeBillingPlan`, `AutumnBillingPlan`, `customizePlanV1`, `setupCustomFullProduct`).
|
||||
- See `CATALOG.md` for full pattern inventory and `PROPOSAL.md` for the layered-interface design.
|
||||
|
||||
## What's Next
|
||||
- Walk through `PROPOSAL.md` with user — confirm 3-layer model and discuss the 7 open design questions
|
||||
- Read existing `actions/migrate/migrate.ts` to decide extend-vs-replace
|
||||
- Lock Layer 3 scope (include `patch` op? or split into `migrate` + extended `updateSubscription`?)
|
||||
- Begin Phase 2: design `MigrateParams`, `MigrateBillingContext`, `computeMigratePlan` signatures concretely
|
||||
|
||||
## Active Blockers
|
||||
- None
|
||||
|
||||
## Key Files
|
||||
- `server/src/internal/billing/v2/actions/index.ts` — where new action(s) will register
|
||||
- `server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts` — closest existing analog
|
||||
- `server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts` — schedule pattern reference
|
||||
- `server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts` — single-subscription mutation reference
|
||||
- `server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts` — Autumn plan → Stripe state mapper
|
||||
- `shared/api/billing/common/customizePlan/customizePlanV1.ts` — public customize schema
|
||||
- `shared/api/products/items/apiPlanItemV1.ts` — public plan item shape
|
||||
2
ai
2
ai
Submodule ai updated: e21275f18e...18d68d3c2a
2
bun.lock
2
bun.lock
@@ -209,7 +209,7 @@
|
||||
},
|
||||
"packages/autumn-js": {
|
||||
"name": "autumn-js",
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"query-string": "^9.2.2",
|
||||
"rou3": "^0.6.1",
|
||||
|
||||
170
server/experiments/explainCustomerFilter.ts
Normal file
170
server/experiments/explainCustomerFilter.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import {
|
||||
buildCustomerCount,
|
||||
buildCustomerSelect,
|
||||
} from "@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();
|
||||
29
server/src/external/autumn/autumnCli.ts
vendored
29
server/src/external/autumn/autumnCli.ts
vendored
@@ -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);
|
||||
|
||||
7
server/src/internal/migrations/v2/actions/index.ts
Normal file
7
server/src/internal/migrations/v2/actions/index.ts
Normal 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;
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Scopes } from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
|
||||
/** POST /migrations.list — list migrations for the current org + env. */
|
||||
export const handleListMigrations = createRoute({
|
||||
scopes: [Scopes.Migrations.Read],
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const migrations = await migrationRepo.get({ ctx });
|
||||
return c.json({ list: migrations });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
},
|
||||
});
|
||||
16
server/src/internal/migrations/v2/migrationRouter.ts
Normal file
16
server/src/internal/migrations/v2/migrationRouter.ts
Normal 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);
|
||||
32
server/src/internal/migrations/v2/repos/getMigration.ts
Normal file
32
server/src/internal/migrations/v2/repos/getMigration.ts
Normal 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));
|
||||
};
|
||||
9
server/src/internal/migrations/v2/repos/index.ts
Normal file
9
server/src/internal/migrations/v2/repos/index.ts
Normal 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,
|
||||
};
|
||||
35
server/src/internal/migrations/v2/repos/insertMigration.ts
Normal file
35
server/src/internal/migrations/v2/repos/insertMigration.ts
Normal 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;
|
||||
};
|
||||
37
server/src/internal/migrations/v2/repos/updateMigration.ts
Normal file
37
server/src/internal/migrations/v2/repos/updateMigration.ts
Normal 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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
122
server/tests/unit/compiler/customer/basic.test.ts
Normal file
122
server/tests/unit/compiler/customer/basic.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
191
server/tests/unit/compiler/customer/derived-and-or.test.ts
Normal file
191
server/tests/unit/compiler/customer/derived-and-or.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
169
server/tests/unit/compiler/customer/nested-item.test.ts
Normal file
169
server/tests/unit/compiler/customer/nested-item.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
39
server/tests/unit/compiler/plan/basic.test.ts
Normal file
39
server/tests/unit/compiler/plan/basic.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
123
shared/api/migrations/compiler/buildCustomerQuery.ts
Normal file
123
shared/api/migrations/compiler/buildCustomerQuery.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
shared/api/migrations/compiler/compileFilter.ts
Normal file
29
shared/api/migrations/compiler/compileFilter.ts
Normal 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 });
|
||||
}
|
||||
26
shared/api/migrations/compiler/compilePlanFilter.ts
Normal file
26
shared/api/migrations/compiler/compilePlanFilter.ts
Normal 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 });
|
||||
}
|
||||
209
shared/api/migrations/compiler/filterToIr/filterToIr.ts
Normal file
209
shared/api/migrations/compiler/filterToIr/filterToIr.ts
Normal 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 };
|
||||
}
|
||||
2
shared/api/migrations/compiler/filterToIr/index.ts
Normal file
2
shared/api/migrations/compiler/filterToIr/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./filterToIr.js";
|
||||
export * from "./resolutionContext.js";
|
||||
65
shared/api/migrations/compiler/filterToIr/parseLeaf.ts
Normal file
65
shared/api/migrations/compiler/filterToIr/parseLeaf.ts
Normal 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"],
|
||||
};
|
||||
}
|
||||
@@ -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 }>;
|
||||
};
|
||||
39
shared/api/migrations/compiler/filterToIr/translateValue.ts
Normal file
39
shared/api/migrations/compiler/filterToIr/translateValue.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
7
shared/api/migrations/compiler/index.ts
Normal file
7
shared/api/migrations/compiler/index.ts
Normal 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";
|
||||
1
shared/api/migrations/compiler/ir/index.ts
Normal file
1
shared/api/migrations/compiler/ir/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./irTypes.js";
|
||||
50
shared/api/migrations/compiler/ir/irTypes.ts
Normal file
50
shared/api/migrations/compiler/ir/irTypes.ts
Normal 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;
|
||||
1
shared/api/migrations/compiler/irToSql/index.ts
Normal file
1
shared/api/migrations/compiler/irToSql/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./irToSql.js";
|
||||
190
shared/api/migrations/compiler/irToSql/irToSql.ts
Normal file
190
shared/api/migrations/compiler/irToSql/irToSql.ts
Normal 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}`);
|
||||
}
|
||||
133
shared/api/migrations/compiler/registry/customerRegistry.ts
Normal file
133
shared/api/migrations/compiler/registry/customerRegistry.ts
Normal 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 },
|
||||
},
|
||||
};
|
||||
3
shared/api/migrations/compiler/registry/index.ts
Normal file
3
shared/api/migrations/compiler/registry/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./customerRegistry.js";
|
||||
export * from "./planRegistry.js";
|
||||
export * from "./registryTypes.js";
|
||||
16
shared/api/migrations/compiler/registry/planRegistry.ts
Normal file
16
shared/api/migrations/compiler/registry/planRegistry.ts
Normal 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" },
|
||||
},
|
||||
};
|
||||
58
shared/api/migrations/compiler/registry/registryTypes.ts
Normal file
58
shared/api/migrations/compiler/registry/registryTypes.ts
Normal 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>;
|
||||
};
|
||||
21
shared/api/migrations/filters/arrayFilter.ts
Normal file
21
shared/api/migrations/filters/arrayFilter.ts
Normal 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(),
|
||||
}),
|
||||
]);
|
||||
18
shared/api/migrations/filters/customerFilter.ts
Normal file
18
shared/api/migrations/filters/customerFilter.ts
Normal 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 = {};
|
||||
6
shared/api/migrations/filters/index.ts
Normal file
6
shared/api/migrations/filters/index.ts
Normal 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";
|
||||
80
shared/api/migrations/filters/matcher.ts
Normal file
80
shared/api/migrations/filters/matcher.ts
Normal 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,
|
||||
]);
|
||||
29
shared/api/migrations/filters/migrationFilter.ts
Normal file
29
shared/api/migrations/filters/migrationFilter.ts
Normal 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>;
|
||||
69
shared/api/migrations/filters/planFilter.ts
Normal file
69
shared/api/migrations/filters/planFilter.ts
Normal 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 = {};
|
||||
32
shared/api/migrations/filters/planItemFilter.ts
Normal file
32
shared/api/migrations/filters/planItemFilter.ts
Normal 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 = {};
|
||||
@@ -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>;
|
||||
2
shared/api/migrations/operations/customer/index.ts
Normal file
2
shared/api/migrations/operations/customer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./customerOperations.js";
|
||||
export * from "./updatePlan/index.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./updatePlanOp.js";
|
||||
@@ -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>;
|
||||
2
shared/api/migrations/operations/index.ts
Normal file
2
shared/api/migrations/operations/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./customer/index.js";
|
||||
export * from "./operations.js";
|
||||
25
shared/api/migrations/operations/operations.ts
Normal file
25
shared/api/migrations/operations/operations.ts
Normal 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>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
51
shared/models/migrationV2Models/migrationTable.ts
Normal file
51
shared/models/migrationV2Models/migrationTable.ts
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
24
vite/src/hooks/queries/useMigrationsQuery.tsx
Normal file
24
vite/src/hooks/queries/useMigrationsQuery.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
19
vite/src/services/MigrationService.ts
Normal file
19
vite/src/services/MigrationService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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" />}
|
||||
|
||||
9
vite/src/views/migrations/MigrationsView.tsx
Normal file
9
vite/src/views/migrations/MigrationsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
107
vite/src/views/migrations/components/CreateMigrationSheet.tsx
Normal file
107
vite/src/views/migrations/components/CreateMigrationSheet.tsx
Normal 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;
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user