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": {
|
"packages/autumn-js": {
|
||||||
"name": "autumn-js",
|
"name": "autumn-js",
|
||||||
"version": "1.2.10",
|
"version": "1.2.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"query-string": "^9.2.2",
|
"query-string": "^9.2.2",
|
||||||
"rou3": "^0.6.1",
|
"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,
|
ErrCode,
|
||||||
type FinalizeLockParamsV0,
|
type FinalizeLockParamsV0,
|
||||||
type LegacyVersion,
|
type LegacyVersion,
|
||||||
|
type Migration,
|
||||||
|
type MigrationFilter,
|
||||||
|
type Operations,
|
||||||
type OrgConfig,
|
type OrgConfig,
|
||||||
type ProductItem,
|
type ProductItem,
|
||||||
type RestoreParamsV1,
|
type RestoreParamsV1,
|
||||||
@@ -928,6 +931,32 @@ export class AutumnInt {
|
|||||||
return data;
|
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 = {
|
balances = {
|
||||||
create: async (params: CreateBalanceParamsV0) => {
|
create: async (params: CreateBalanceParamsV0) => {
|
||||||
const data = await this.post(`/balances/create`, params);
|
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 { internalAnalyticsRouter } from "../internal/analytics/internalAnalyticsRouter";
|
||||||
import { internalCusRouter } from "../internal/customers/internalCusRouter";
|
import { internalCusRouter } from "../internal/customers/internalCusRouter";
|
||||||
import { internalDevRouter } from "../internal/dev/devRouter";
|
import { internalDevRouter } from "../internal/dev/devRouter";
|
||||||
|
import { migrationRpcRouter } from "../internal/migrations/v2/migrationRouter";
|
||||||
import { consentRouter } from "../internal/misc/consent/consentRouter";
|
import { consentRouter } from "../internal/misc/consent/consentRouter";
|
||||||
import { feedbackRouter } from "../internal/misc/feedback/feedbackRouter";
|
import { feedbackRouter } from "../internal/misc/feedback/feedbackRouter";
|
||||||
import { pricingAgentRouter } from "../internal/misc/pricingAgent/pricingAgentRouter";
|
import { pricingAgentRouter } from "../internal/misc/pricingAgent/pricingAgentRouter";
|
||||||
@@ -45,6 +46,7 @@ internalRouter.route("/trmnl", internalTrmnlRouter);
|
|||||||
internalRouter.route("/feedback", feedbackRouter);
|
internalRouter.route("/feedback", feedbackRouter);
|
||||||
internalRouter.route("/saved_views", savedViewsRouter);
|
internalRouter.route("/saved_views", savedViewsRouter);
|
||||||
internalRouter.route("/query", internalAnalyticsRouter);
|
internalRouter.route("/query", internalAnalyticsRouter);
|
||||||
|
internalRouter.route("", migrationRpcRouter);
|
||||||
|
|
||||||
// Autumn SDK handler (requires session auth)
|
// Autumn SDK handler (requires session auth)
|
||||||
if (process.env.AUTUMN_SECRET_KEY) {
|
if (process.env.AUTUMN_SECRET_KEY) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { billingRpcRouter } from "@/internal/billing/billingRouter";
|
|||||||
import { entityRpcRouter } from "@/internal/entities/entityRouter";
|
import { entityRpcRouter } from "@/internal/entities/entityRouter";
|
||||||
import { eventsRpcRouter } from "@/internal/events/eventsRouter";
|
import { eventsRpcRouter } from "@/internal/events/eventsRouter";
|
||||||
import { featureRpcRouter } from "@/internal/features/featureRouter";
|
import { featureRpcRouter } from "@/internal/features/featureRouter";
|
||||||
|
import { migrationRpcRouter } from "@/internal/migrations/v2/migrationRouter";
|
||||||
import { plansRpcRouter } from "@/internal/products/productRouter";
|
import { plansRpcRouter } from "@/internal/products/productRouter";
|
||||||
import type { HonoEnv } from "../honoUtils/HonoEnv";
|
import type { HonoEnv } from "../honoUtils/HonoEnv";
|
||||||
import { customerRpcRouter } from "../internal/customers/cusRouter";
|
import { customerRpcRouter } from "../internal/customers/cusRouter";
|
||||||
@@ -30,3 +31,4 @@ rpcRouter.route("", eventsRpcRouter);
|
|||||||
rpcRouter.route("", referralRpcRouter);
|
rpcRouter.route("", referralRpcRouter);
|
||||||
rpcRouter.route("", entityRpcRouter);
|
rpcRouter.route("", entityRpcRouter);
|
||||||
rpcRouter.route("", featureRpcRouter);
|
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",
|
OrgNotFound: "org_not_found",
|
||||||
OrgHasCustomers: "org_has_customers",
|
OrgHasCustomers: "org_has_customers",
|
||||||
|
|
||||||
|
// Migrations (v2)
|
||||||
|
MigrationNotFound: "migration_not_found",
|
||||||
|
|
||||||
// Feature
|
// Feature
|
||||||
FeatureNotFound: "feature_not_found",
|
FeatureNotFound: "feature_not_found",
|
||||||
InvalidFeature: "invalid_feature",
|
InvalidFeature: "invalid_feature",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export * from "./api/billing/createSchedule/createScheduleResponse";
|
|||||||
export * from "./api/billing/openBillingPortal/openBillingPortalParamsV1";
|
export * from "./api/billing/openBillingPortal/openBillingPortalParamsV1";
|
||||||
export * from "./api/billing/openBillingPortal/openBillingPortalResponse";
|
export * from "./api/billing/openBillingPortal/openBillingPortalResponse";
|
||||||
export * from "./api/billing/updateSubscription/previewUpdateSubscriptionResponse";
|
export * from "./api/billing/updateSubscription/previewUpdateSubscriptionResponse";
|
||||||
|
// Migrations v2 (operations + entity schemas)
|
||||||
|
export * from "./api/migrations/operations/index";
|
||||||
// Cursor pagination utilities
|
// Cursor pagination utilities
|
||||||
export * from "./api/common/cursorPaginationSchemas";
|
export * from "./api/common/cursorPaginationSchemas";
|
||||||
export * from "./api/customers/components/customerExpand/customerExpand";
|
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/migrationErrorTable";
|
||||||
export * from "./models/migrationModels/migrationJobTable";
|
export * from "./models/migrationModels/migrationJobTable";
|
||||||
export * from "./models/migrationModels/migrationModels";
|
export * from "./models/migrationModels/migrationModels";
|
||||||
|
export * from "./models/migrationV2Models/migrationTable";
|
||||||
export * from "./models/orgModels/frontendOrg";
|
export * from "./models/orgModels/frontendOrg";
|
||||||
// 1. Org Models
|
// 1. Org Models
|
||||||
export * from "./models/orgModels/frontendOrg";
|
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",
|
"rewards",
|
||||||
"balances",
|
"balances",
|
||||||
"billing",
|
"billing",
|
||||||
|
"migrations",
|
||||||
"analytics",
|
"analytics",
|
||||||
"apiKeys",
|
"apiKeys",
|
||||||
"platform",
|
"platform",
|
||||||
@@ -146,6 +147,10 @@ export const Scopes = {
|
|||||||
Read: "billing:read",
|
Read: "billing:read",
|
||||||
Write: "billing:write",
|
Write: "billing:write",
|
||||||
},
|
},
|
||||||
|
Migrations: {
|
||||||
|
Read: "migrations:read",
|
||||||
|
Write: "migrations:write",
|
||||||
|
},
|
||||||
Analytics: {
|
Analytics: {
|
||||||
Read: "analytics:read",
|
Read: "analytics:read",
|
||||||
},
|
},
|
||||||
@@ -211,6 +216,8 @@ export const MODERN_SCOPES: readonly ScopeString[] = [
|
|||||||
Scopes.Balances.Write,
|
Scopes.Balances.Write,
|
||||||
Scopes.Billing.Read,
|
Scopes.Billing.Read,
|
||||||
Scopes.Billing.Write,
|
Scopes.Billing.Write,
|
||||||
|
Scopes.Migrations.Read,
|
||||||
|
Scopes.Migrations.Write,
|
||||||
Scopes.Analytics.Read,
|
Scopes.Analytics.Read,
|
||||||
Scopes.ApiKeys.Read,
|
Scopes.ApiKeys.Read,
|
||||||
Scopes.ApiKeys.Write,
|
Scopes.ApiKeys.Write,
|
||||||
@@ -373,6 +380,7 @@ export const ROLE_SCOPES: Record<Role, ScopeString[]> = {
|
|||||||
Scopes.Plans.Write,
|
Scopes.Plans.Write,
|
||||||
Scopes.Balances.Write,
|
Scopes.Balances.Write,
|
||||||
Scopes.Billing.Write,
|
Scopes.Billing.Write,
|
||||||
|
Scopes.Migrations.Write,
|
||||||
Scopes.Analytics.Read,
|
Scopes.Analytics.Read,
|
||||||
Scopes.ApiKeys.Write,
|
Scopes.ApiKeys.Write,
|
||||||
Scopes.Platform.Write,
|
Scopes.Platform.Write,
|
||||||
@@ -394,6 +402,7 @@ export const ROLE_SCOPES: Record<Role, ScopeString[]> = {
|
|||||||
Scopes.Rewards.Read,
|
Scopes.Rewards.Read,
|
||||||
Scopes.Balances.Read,
|
Scopes.Balances.Read,
|
||||||
Scopes.Billing.Read,
|
Scopes.Billing.Read,
|
||||||
|
Scopes.Migrations.Read,
|
||||||
Scopes.Analytics.Read,
|
Scopes.Analytics.Read,
|
||||||
Scopes.ApiKeys.Read,
|
Scopes.ApiKeys.Read,
|
||||||
Scopes.Platform.Read,
|
Scopes.Platform.Read,
|
||||||
@@ -450,6 +459,11 @@ export const RESOURCE_METADATA: Record<
|
|||||||
namePlural: "Billing",
|
namePlural: "Billing",
|
||||||
description: "Attach, cancel, and update subscriptions",
|
description: "Attach, cancel, and update subscriptions",
|
||||||
},
|
},
|
||||||
|
migrations: {
|
||||||
|
name: "Migration",
|
||||||
|
namePlural: "Migrations",
|
||||||
|
description: "Bulk customer migrations and operations",
|
||||||
|
},
|
||||||
analytics: {
|
analytics: {
|
||||||
name: "Analytics",
|
name: "Analytics",
|
||||||
namePlural: "Analytics",
|
namePlural: "Analytics",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import CustomerPlanEditor from "./views/customers2/customer-plan/CustomerPlanEdi
|
|||||||
import { DefaultView } from "./views/DefaultView";
|
import { DefaultView } from "./views/DefaultView";
|
||||||
import DevScreen from "./views/developer/DevView";
|
import DevScreen from "./views/developer/DevView";
|
||||||
import { CloseScreen } from "./views/general/CloseScreen";
|
import { CloseScreen } from "./views/general/CloseScreen";
|
||||||
|
import { MigrationsView } from "./views/migrations/MigrationsView";
|
||||||
import QuickstartView from "./views/onboarding4/QuickstartView";
|
import QuickstartView from "./views/onboarding4/QuickstartView";
|
||||||
import ProductsView from "./views/products/ProductsView";
|
import ProductsView from "./views/products/ProductsView";
|
||||||
import PlanEditorView from "./views/products/plan/PlanEditorView";
|
import PlanEditorView from "./views/products/plan/PlanEditorView";
|
||||||
@@ -106,6 +107,8 @@ export default function App() {
|
|||||||
path="/sandbox/products"
|
path="/sandbox/products"
|
||||||
element={<ProductsView env={AppEnv.Sandbox} />}
|
element={<ProductsView env={AppEnv.Sandbox} />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/migrations" element={<MigrationsView />} />
|
||||||
|
<Route path="/sandbox/migrations" element={<MigrationsView />} />
|
||||||
<Route
|
<Route
|
||||||
path="/products/:product_id"
|
path="/products/:product_id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export const EmptyState = ({
|
|||||||
| "rewards"
|
| "rewards"
|
||||||
| "archived-plans"
|
| "archived-plans"
|
||||||
| "no-customers-found"
|
| "no-customers-found"
|
||||||
| "analytics";
|
| "analytics"
|
||||||
|
| "migrations";
|
||||||
actionButton?: React.ReactNode;
|
actionButton?: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const getEmptyStateContent = () => {
|
const getEmptyStateContent = () => {
|
||||||
@@ -76,6 +77,14 @@ export const EmptyState = ({
|
|||||||
"Create an API key to authenticate requests to the Autumn API",
|
"Create an API key to authenticate requests to the Autumn API",
|
||||||
svg: apiKeysSvg,
|
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 {
|
import {
|
||||||
|
ArrowsClockwiseIcon,
|
||||||
BalloonIcon,
|
BalloonIcon,
|
||||||
BasketIcon,
|
BasketIcon,
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
@@ -180,6 +181,12 @@ export const MainSidebar = ({
|
|||||||
title="Customers"
|
title="Customers"
|
||||||
env={env}
|
env={env}
|
||||||
/>
|
/>
|
||||||
|
<NavButton
|
||||||
|
value="migrations"
|
||||||
|
icon={<ArrowsClockwiseIcon size={16} weight="fill" />}
|
||||||
|
title="Migrations"
|
||||||
|
env={env}
|
||||||
|
/>
|
||||||
<NavButton
|
<NavButton
|
||||||
value="analytics"
|
value="analytics"
|
||||||
icon={<ChartBarIcon size={16} weight="fill" />}
|
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