initial full customer queries

This commit is contained in:
John Yeo
2026-04-01 21:44:36 +01:00
parent b811a923ba
commit 18e38a57a1
36 changed files with 3974 additions and 128 deletions

View File

@@ -2,6 +2,12 @@
"plugins": {
"linear": {
"enabled": true
},
"redis-development": {
"enabled": true
},
"planetscale": {
"enabled": true
}
}
}

View File

@@ -0,0 +1,70 @@
# Part B: Cache Invalidation + Missing Indexes
## Goal
Update cache invalidation for the entity-split architecture, and add missing Postgres indexes that impact the V2 query.
## Cache Invalidation
### New entity cache invalidation
- `deleteCachedFullEntity` — delete entity key + set guard, across all regions
- Entity mutations (create/delete/update) → invalidate that entity's cache
- Product attachment/detachment → invalidate correct cache:
- Entity-scoped product (`internal_entity_id` set) → invalidate that entity cache
- Customer-level product (`internal_entity_id IS NULL`) → invalidate customer cache AND all entity caches (they embed inherited products)
### Batch deletion
- `batchDeleteCachedFullCustomers` must also delete entity caches
- Two approaches:
1. `SCAN` with pattern `{orgId}:env:fullentity:*:customerId:*` (scoped to hash slot, efficient)
2. Maintain entity key registry on the customer cache (avoids SCAN)
- Use Redis pipelining for batch operations across regions
### Version-aware invalidation
- V1 customers: delete single blob key
- V2 customers: delete bounded customer key + all entity keys
- During rolling migration: check customer bucket to determine which keys to delete
- After billing actions: always delete both customer + all entity caches
## Missing Postgres Indexes
### P0 — List customers (sequential scan)
```sql
CREATE INDEX idx_customers_org_env_created_at
ON customers (org_id, env, created_at DESC);
```
There's a commented-out index in `shared/models/cusModels/cusTable.ts` — uncomment or replace.
### P1 — Entity LATERAL pattern
```sql
CREATE INDEX idx_customer_products_customer_product_created
ON customer_products (internal_customer_id, internal_product_id, created_at DESC);
```
### P2 — Partial index for non-entity products
```sql
CREATE INDEX idx_customer_products_customer_status_non_entity
ON customer_products (internal_customer_id, status)
WHERE internal_entity_id IS NULL;
```
## V2 Query Anti-Patterns to Fix
1. **Correlated subqueries against CTEs** → pre-aggregate + LEFT JOIN
2. **OFFSET pagination** → keyset/cursor pagination on `(created_at, internal_id)`
3. **Triple-nested correlated subquery in rollovers** → pre-compute cus_ent_id set
4. **`row_to_json()::jsonb - 'key'`** → `json_build_object()` with explicit columns
5. **`jsonb_each()` row explosion** → phase out old-style entity balance JSONB
## Scalability Monitoring
- TTL jitter (3 days +/- random hours) to prevent thundering herd
- `work_mem` for CTE materialization in list queries
- Connection pooling during cache miss storms after deployments

View File

@@ -0,0 +1,359 @@
# V2 Cache Object Design: Bounded FullCustomer + FullEntity
## Decision: Flat entitlement structure
We use a **flat entitlement-keyed** cache format instead of the current nested `customer_products[i].customer_entitlements[j]` structure. This eliminates the path index Hash entirely and future-proofs for product-level splitting.
Current (nested):
```
$.customer_products[0].customer_entitlements[1].balance
→ requires path index HGET to resolve array indices
```
New (flat):
```
$.entitlements["cusEnt_abc"].balance
→ cusEntId IS the path, no index needed
```
## Cache Format: `CachedFullCustomer`
Stored at `{orgId}:env:fullcustomer:2.0.0:customerId`
```json
{
"customer": {
"id": "cus_123",
"internal_id": "cus_abc",
"org_id": "org_xyz",
"env": "live",
"name": "Acme",
"email": "billing@acme.com",
"fingerprint": "fp_...",
"processor": { "id": "cus_stripe_123", "type": "stripe" },
"processors": { ... },
"metadata": { ... },
"send_email_receipts": true,
"auto_topups": [ ... ],
"spend_limits": [ ... ],
"usage_alerts": [ ... ],
"overage_allowed": [ ... ],
"created_at": 1700000000000
},
"products": {
"cp_001": {
"id": "cp_001",
"internal_product_id": "prod_int_1",
"product_id": "pro_plan",
"internal_customer_id": "cus_abc",
"internal_entity_id": null,
"status": "active",
"subscription_ids": ["sub_stripe_1"],
"options": [ ... ],
"created_at": 1700000000000,
"product": { "id": "pro_plan", "name": "Pro Plan", ... },
"customer_prices": [
{ "id": "cp_price_1", "price_id": "price_1", "price": { ... } }
],
"free_trial": null
}
},
"entitlements": {
"ce_001": {
"id": "ce_001",
"internal_customer_id": "cus_abc",
"internal_entity_id": null,
"internal_feature_id": "feat_int_1",
"customer_product_id": "cp_001",
"entitlement_id": "ent_1",
"balance": 950,
"adjustment": 0,
"additional_balance": 0,
"unlimited": false,
"usage_allowed": true,
"next_reset_at": 1703000000000,
"cache_version": 5,
"entities": null,
"entitlement": {
"id": "ent_1",
"feature": { "id": "api_calls", "internal_id": "feat_int_1", ... },
"entity_feature_id": null,
...
},
"rollovers": [
{ "id": "ro_1", "balance": 100, "expires_at": 1705000000000, ... }
],
"replaceables": []
}
},
"extraEntitlements": {
"ce_loose_1": {
"id": "ce_loose_1",
"customer_product_id": null,
...
}
},
"entities": [
{ "id": "ety_1", "internal_id": "ety_int_1", "name": "Team A", ... }
],
"subscriptions": [ ... ],
"invoices": [ ... ],
"aggregatedEntitlements": [ ... ],
"aggregatedProducts": { ... },
"aggregatedPrices": [ ... ]
}
```
### Key design decisions
- **`products`**: keyed by `cusProductId`, NOT an array. Contains the product definition, prices, and free_trial — but NOT customer_entitlements (those are in `entitlements`).
- **`entitlements`**: keyed by `cusEntId`. Each entitlement has a `customer_product_id` field to link back to its product. Contains the full entitlement definition, rollovers, and replaceables inline.
- **`extraEntitlements`**: keyed by `cusEntId`, for loose entitlements where `customer_product_id` is null.
- **`entities`**: flat array (not keyed) since this is only used for entity lookup, not deduction. Only present on FullCustomer, not FullEntity.
- **`aggregated*`**: only on the bounded FullCustomer (customer-level view).
## Cache Format: `CachedFullEntity`
Stored at `{orgId}:env:fullentity:1.0.0:customerId:entityId`
```json
{
"customer": {
"id": "cus_123",
"internal_id": "cus_abc",
"processor": { ... },
"spend_limits": [ ... ],
"usage_alerts": [ ... ],
"overage_allowed": [ ... ],
"fingerprint": "fp_..."
},
"entity": {
"id": "ety_1",
"internal_id": "ety_int_1",
"internal_customer_id": "cus_abc",
"name": "Team A",
"feature_id": "seats",
"spend_limits": [ ... ],
"usage_alerts": [ ... ],
"overage_allowed": [ ... ]
},
"products": {
"cp_entity_1": { ... },
"cp_customer_level_1": { ... }
},
"entitlements": {
"ce_entity_1": { "customer_product_id": "cp_entity_1", "balance": 500, ... },
"ce_customer_level_1": { "customer_product_id": "cp_customer_level_1", "balance": 950, ... }
},
"extraEntitlements": {
"ce_loose_1": { ... }
}
}
```
### What's included
- **`customer`**: subset of Customer fields needed by check/track (processor for Stripe lookup, billing controls, fingerprint for trial dedup)
- **`entity`**: the full entity record (billing controls, feature_id)
- **`products`**: entity-scoped products (`internal_entity_id = this entity`) + inherited customer-level products (`internal_entity_id IS NULL`). Keyed by cusProductId.
- **`entitlements`**: all entitlements from the included products. Keyed by cusEntId.
- **`extraEntitlements`**: loose entitlements matching this entity
- **No** `entities` array, `aggregated*`, `subscriptions`, `invoices`, `trials_used`
### What's NOT included
- Other entities' products/entitlements
- Aggregated data (not needed for entity-level operations)
- Invoices, subscriptions (only needed for expand fields, fetched lazily)
## Deduction Lua Script Changes
### Current flow (path index)
```
1. HGET pathidx_key "cus_ent:{id}" → { cp: 0, ce: 1 }
2. Build path: $.customer_products[0].customer_entitlements[1]
3. JSON.GET cache_key {path} → entitlement object
4. JSON.NUMINCRBY cache_key {path}.balance -delta
```
### New flow (flat, no path index)
```
1. Build path: $.entitlements["{cusEntId}"]
(or $.extraEntitlements["{cusEntId}"] for loose entitlements)
2. JSON.GET cache_key {path} → entitlement object
3. JSON.NUMINCRBY cache_key {path}.balance -delta
```
**Changes to Lua scripts:**
- `find_entitlement_from_index` → replaced with direct path construction: `'$.entitlements["' .. cus_ent_id .. '"]'`
- `build_customer_entitlement_base_path` / `build_extra_customer_entitlement_base_path` → replaced with single function that takes cusEntId and whether it's loose
- `find_entitlement` fallback (O(n) scan) → `JSON.GET cache_key $.entitlements["{cusEntId}"]` (O(1))
- **Path index Hash is eliminated entirely** — no `HGET`, no `HSET`, no separate Redis key to manage
- The `entity_feature_id` (previously stored in path index) needs to be on the entitlement object itself — it already is, via `entitlement.entity_feature_id`
**Rollover paths:**
- Current: `$.customer_products[cp].customer_entitlements[ce].rollovers[i].balance`
- New: `$.entitlements["{cusEntId}"].rollovers[i].balance`
- Rollover array index (`i`) is still needed but this is small (typically 0-3 rollovers)
**Entity balance paths (old-style):**
- Current: `$.customer_products[cp].customer_entitlements[ce].entities["{entityId}"].balance`
- New: `$.entitlements["{cusEntId}"].entities["{entityId}"].balance`
## TypeScript Types
### In-memory types (used by all endpoint logic)
```typescript
// Bounded FullCustomer — no entity-scoped products
type BoundedFullCustomer = Customer & {
customer_products: FullCusProduct[]; // internal_entity_id IS NULL only
entities: Entity[];
extra_customer_entitlements: FullCustomerEntitlement[];
subscriptions?: Subscription[];
invoices?: Invoice[];
trials_used?: { product_id: string; customer_id: string; fingerprint: string }[];
aggregated_customer_products?: FullCusProduct[];
aggregated_customer_entitlements?: AggregatedCustomerEntitlement[];
aggregated_customer_prices?: CustomerPrice[];
};
// FullEntity — entity-scoped + inherited customer-level products
type FullEntity = {
customer: Customer;
entity: Entity;
customer_products: FullCusProduct[]; // entity + inherited
extra_customer_entitlements: FullCustomerEntitlement[];
};
```
**Note**: `BoundedFullCustomer` has the same shape as the existing `FullCustomer` type (just fewer products). Existing code that operates on `FullCustomer` works unchanged. The `FullEntity` is a new type.
### Cache serialization types
```typescript
// What's stored in Redis (flat format)
type CachedCustomerDoc = {
customer: Customer;
products: Record<string, CachedProduct>;
entitlements: Record<string, CachedEntitlement>;
extraEntitlements: Record<string, CachedEntitlement>;
entities?: Entity[];
subscriptions?: Subscription[];
invoices?: Invoice[];
aggregatedEntitlements?: AggregatedCustomerEntitlement[];
aggregatedProducts?: Record<string, CachedProduct>;
aggregatedPrices?: CustomerPrice[];
};
type CachedEntityDoc = {
customer: Customer; // subset of fields
entity: Entity;
products: Record<string, CachedProduct>;
entitlements: Record<string, CachedEntitlement>;
extraEntitlements: Record<string, CachedEntitlement>;
};
type CachedProduct = Omit<FullCusProduct, 'customer_entitlements'>;
type CachedEntitlement = FullCustomerEntitlement & {
customer_product_id: string | null; // link back to product
};
```
## Hydration: Cache → In-Memory
```typescript
// CachedCustomerDoc → FullCustomer
const hydrateFullCustomer = (doc: CachedCustomerDoc): FullCustomer => {
const entitlementsByProduct = groupBy(
Object.values(doc.entitlements),
(e) => e.customer_product_id
);
const customerProducts = Object.values(doc.products).map((product) => ({
...product,
customer_entitlements: entitlementsByProduct[product.id] ?? [],
}));
return {
...doc.customer,
customer_products: customerProducts,
extra_customer_entitlements: Object.values(doc.extraEntitlements),
entities: doc.entities ?? [],
subscriptions: doc.subscriptions,
invoices: doc.invoices,
aggregated_customer_products: ...,
aggregated_customer_entitlements: doc.aggregatedEntitlements,
aggregated_customer_prices: doc.aggregatedPrices,
};
};
// CachedEntityDoc → FullEntity
const hydrateFullEntity = (doc: CachedEntityDoc): FullEntity => {
// Same groupBy pattern
...
};
```
## Dehydration: In-Memory → Cache
```typescript
// FullCustomer → CachedCustomerDoc
const dehydrateFullCustomer = (fullCustomer: FullCustomer): CachedCustomerDoc => {
const products: Record<string, CachedProduct> = {};
const entitlements: Record<string, CachedEntitlement> = {};
for (const cusProduct of fullCustomer.customer_products) {
const { customer_entitlements, ...productWithoutEnts } = cusProduct;
products[cusProduct.id] = productWithoutEnts;
for (const cusEnt of customer_entitlements) {
entitlements[cusEnt.id] = { ...cusEnt, customer_product_id: cusProduct.id };
}
}
const extraEntitlements: Record<string, CachedEntitlement> = {};
for (const cusEnt of fullCustomer.extra_customer_entitlements) {
extraEntitlements[cusEnt.id] = { ...cusEnt, customer_product_id: null };
}
return {
customer: extractCustomerFields(fullCustomer),
products,
entitlements,
extraEntitlements,
entities: fullCustomer.entities,
subscriptions: fullCustomer.subscriptions,
invoices: fullCustomer.invoices,
aggregatedEntitlements: fullCustomer.aggregated_customer_entitlements,
...
};
};
```
## What doesn't change
- **All endpoint logic** continues to use `FullCustomer`/`FullEntity` in-memory types
- **`getApiBalances`**, **`getApiSubscriptions`**, **`fullCustomerToCustomerEntitlements`** — all unchanged
- **`syncItemV3`** — reads from cache, but the Lua deduction already writes `cusEntId` into sync messages, so sync just needs to know which cache key to read from
- **Postgres schema** — no changes
- **V2 SQL query** — no changes (returns flat rows, hydration produces `FullCustomer`)
## What changes
- **Cache set/get utilities** — serialize to flat format, deserialize back
- **Lua scripts** — use direct `$.entitlements["{id}"]` paths instead of path index
- **Path index** — eliminated entirely (no separate Redis Hash key)
- **`buildPathIndex.ts`** — deleted
- **`fullCustomerCacheConfig.ts`** — new version + entity config
- **Cache invalidation** — entity-aware (see separate plan)
## Migration path to product-level splitting (future)
If needed later, the flat structure makes this straightforward:
1. Move each product group (`products[cpId]` + its entitlements from `entitlements` where `customer_product_id === cpId`) to a separate Redis key
2. The Lua deduction script just receives a different cache key — the entitlement path `$.entitlements["{cusEntId}"]` stays identical within each product sub-doc
3. Full-read endpoints pipeline `JSON.GET` across product keys
No structural changes needed — just key routing.

View File

@@ -0,0 +1,120 @@
# Part A: V2 Full Customer Cache — Bounded Split + Rolling Migration
## Goal
Replace the single unbounded FullCustomer Redis blob with a bounded FullCustomer (customer-level) + per-entity FullEntity split, rolled out incrementally via percentage-based hashing.
## Architecture
```mermaid
graph TD
subgraph current [Current: Single Unbounded Blob]
FC1["FullCustomer"]
FC1 --> CL1["Customer-level products"]
FC1 --> EP1["Entity products (N x M)"]
FC1 --> ENT1["entities array"]
end
subgraph target [Target: Bounded Split]
FC2["FullCustomer (bounded)"]
FC2 --> CL2["Customer-level products\n(internal_entity_id IS NULL)"]
FC2 --> AGG["Aggregated entity balances"]
FE["FullEntity (per entity)"]
FE --> ICP["Inherited customer-level products"]
FE --> EP2["This entity's products"]
FE --> EB["Entity + inherited balances"]
end
```
Cache keys: `{orgId}:env:fullcustomer:2.0.0:customerId` and `{orgId}:env:fullentity:1.0.0:customerId:entityId`
**Entity inheritance**: FullEntity includes customer-level products because `filterCusProductsByEntity` passes through `internal_entity_id IS NULL` products. Deduction priority: entity's own balances first, then customer-level.
## Existing V2 work
- [server/src/internal/customers/repos/sql/getSubjectCoreQuery.ts](server/src/internal/customers/repos/sql/getSubjectCoreQuery.ts) — flat normalized query
- [server/src/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.ts](server/src/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.ts) — TS hydration
- [server/src/internal/customers/repos/getFullCustomerV2.ts](server/src/internal/customers/repos/getFullCustomerV2.ts) — repo function
- **Gap**: entity query doesn't include customer-level products (`internal_entity_id IS NULL`)
## Phases
### Phase 0 — Comparison tests
- Set up customer with entities, products, balances
- Call `getOrCreateCustomer` + `getEntity` endpoints
- Snapshot `subscriptions` array + `balances` object (minus `breakdown`)
- After V2 rollout, re-run and assert equivalence
### Phase 1 — FullEntity type + cache utilities
**Type** (`shared/models/cusModels/fullEntityModel.ts`):
```typescript
type FullEntity = {
entity: Entity;
customer: Customer;
customer_products: FullCusProduct[]; // entity-scoped + inherited
extra_customer_entitlements: FullCustomerEntitlement[];
};
```
**Cache utilities** (in `fullCustomerCacheUtils/`):
- `getCachedFullEntity.ts`, `setCachedFullEntity.ts`, `deleteCachedFullEntity.ts`, `getOrSetCachedFullEntity.ts`
- Path index at `{orgId}:env:fullentity:pathidx:customerId:entityId`
- Existing Lua scripts (deduction, etc.) are key-agnostic — reuse by passing entity cache key
**Config**: bump customer cache to `2.0.0`, add entity cache config
### Phase 2 — Wire V2 query into CusService
- `CusService.getFullV2` — flat query, customer-level only
- Update `getSubjectCoreQuery` — entity mode must also include `internal_entity_id IS NULL` products
- `CusService.getFullEntity` — hydrates to FullEntity
- `getOrSetCachedFullCustomer` uses V2 query on miss
- New `getOrSetCachedFullEntity` for entity flow
### Phase 3 — Endpoint migration
- `/check`: entity_id → FullEntity, else → bounded FullCustomer
- `/track`: same routing; Lua deduction targets entity or customer cache key
- `/customers.get`: bounded FullCustomer + aggregated entity data
- `/entities.get`: FullEntity directly (no more fetch-all-then-filter)
- **Dual-cache deduction**: inherited customer entitlements embedded in entity cache; sync writes from entity cache; customer cache refreshes on next miss
### Phase 4 — syncItemV3 compatibility
- Sync message includes `entityId` or cache key
- Entity-scoped cusEnts → read from entity cache
- Customer-level cusEnts → read from customer cache
- `sync_balances_v2` Postgres function unchanged
- During rollout: V1 fallback if cache version is 1.x
### Rolling migration (percentage-based)
Reuse from `origin/feat/custom-redis`:
- `getCustomerBucket(customerId)``Bun.hash(id) % 100`
- `resolveCustomerId` middleware
- `isCacheStale()` pattern
Rollout: deploy at 0% → ramp 5 → 10 → 25 → 50 → 75 → 100. Monitor Redis latency, Postgres load, API correctness at each step. Staleness detection invalidates old-format cache when a customer's routing flips.
### Future: product-level splitting (design consideration)
If customer-level products grow large (50+ add-ons), the bounded FullCustomer could be further split:
- Customer core: fields + product ID index (fixed-size)
- Product sub-docs: `{orgId}:env:fullcusproduct:1.0.0:customerId:cusProductId`
- Path index maps `cusEntId` → product cache key + sub-path
- Assembly via pipelined `JSON.GET` across product keys
**Not implementing now** — monitor p99 product count. But cache key format and path index design should not preclude this.
## Billing actions (NOT in scope)
Billing (attach, updateSubscription) needs ALL customer products across ALL entities for Stripe subscription merging. These skip the bounded cache and query Postgres directly. After billing, invalidate all customer + entity caches.
## Key files
**Create**: `fullEntityModel.ts`, `getCachedFullEntity.ts`, `setCachedFullEntity.ts`, `deleteCachedFullEntity.ts`, `getOrSetCachedFullEntity.ts`, test files
**Modify**: `CusService.ts`, `getSubjectCoreQuery.ts`, `fullCustomerCacheConfig.ts`, `getOrSetCachedFullCustomer.ts`, `handleCheck.ts`, `handleTrack.ts`, `handleGetOrCreateCustomerV2.ts`, `handleGetEntityV2.ts`, `syncItemV3.ts`

155
.plans/v2-handoff-prompt.md Normal file
View File

@@ -0,0 +1,155 @@
# V2 Full Customer Cache — Handoff Prompt
Use this prompt in a new agent conversation to continue the implementation.
---
## Prompt
Read the following plans before starting:
- `.plans/v2-full-customer-cache.md` — high-level architecture and phases
- `.plans/v2-cache-invalidation-indexes.md` — cache invalidation + missing Postgres indexes
- `.cursor/plans/v2_full_customer_query_49c5cc3a.plan.md` — comprehensive research (rolling migration details, billing action constraints, Postgres gotchas, Redis best practices)
### Problem
The current `FullCustomer` object stored in Redis as a single JSON blob grows unboundedly with entities. For a customer with N entities, the blob includes N entity-scoped `customer_products` (each with nested entitlements, prices). Our largest customer has ~76 entities, producing a ~4MB blob. This causes `JSON.GET`/`JSON.SET` latency spikes on Redis.
### Solution: Bounded FullCustomer + Per-Entity FullEntity
Split into two cache objects:
**Bounded FullCustomer** — cached at `{orgId}:env:fullcustomer:2.0.0:customerId`
- Contains only customer-level products (`internal_entity_id IS NULL`)
- Contains aggregated entity balance data (from V2 query's entity aggregation CTEs)
- Contains entities array, subscriptions, invoices
- For our largest customer, this is ~10KB
**FullEntity** — cached at `{orgId}:env:fullentity:1.0.0:customerId:entityId`
- Contains entity-scoped products (`internal_entity_id = this entity`) PLUS inherited customer-level products (`internal_entity_id IS NULL`)
- Entity inheritance is critical: in default mode (`org.config.entity_product !== true`), `filterCusProductsByEntity` in `shared/utils/cusProductUtils/filterCusProductUtils.ts` includes both entity-scoped and customer-level products
- Contains entity record, customer core fields (processor, billing controls, fingerprint)
- Contains extra_customer_entitlements matching this entity
- For our largest entity, this is ~105KB (11 products, 22 entitlements)
- Does NOT contain: other entities' data, aggregated data, invoices, subscriptions
### Key Design Decisions (already finalized)
1. **Same nested `FullCustomer` shape everywhere** — the cache stores the exact same nested `customer_products[].customer_entitlements[]` structure used in TypeScript in-memory. No flat/normalized format. No hydrate/dehydrate layer. The path index stays as-is.
2. **Lua scripts are key-agnostic** — the existing deduction Lua scripts (`deductFromCustomerEntitlements.lua`) accept a `cache_key` and `pathidx_key`. For entity operations, just pass the entity cache key and entity path index key instead of the customer ones. No Lua script changes needed.
3. **Size cap safety net** — if a serialized entity/customer doc exceeds 500KB, skip caching and fall back to Postgres. This guarantees Redis objects are never unbounded.
4. **Billing actions (attach, updateSubscription) are OUT OF SCOPE** — they need ALL customer products across ALL entities for Stripe subscription merging (`buildStripeSubscriptionItemsUpdate` diffs all products on a subscription). Billing actions will continue to query Postgres directly via `CusService.getFull`. After billing, invalidate all customer + entity caches.
5. **Rolling migration via percentage-based hashing** — cherry-pick `getCustomerBucket(customerId)` and `resolveCustomerId` middleware from `origin/feat/custom-redis`. Use `Bun.hash(customerId) % 100` to deterministically route customers to V1 or V2 cache format. Deploy at 0%, ramp 5 → 10 → 25 → 50 → 75 → 100. Staleness detection (`isCacheStale` pattern) invalidates old-format cache when a customer's routing flips due to percentage change.
### Existing V2 Query Work
The SQL layer is already built:
- `server/src/internal/customers/repos/sql/getSubjectCoreQuery.ts` — flat normalized CTE query. Handles both customer-level (no `entityId`) and entity-level (with `entityId`) modes.
- `server/src/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.ts` — TypeScript hydration from flat query rows to nested `FullCustomer`
- `server/src/internal/customers/repos/getFullCustomerV2.ts` — repo function
**CRITICAL GAP**: The current `getSubjectCoreQuery` with `entityId` only fetches entity-scoped products (`cp.internal_entity_id = entity.internal_id`). It does NOT include customer-level products (`internal_entity_id IS NULL`). This must be updated so the entity query returns BOTH — supporting the inheritance model where entities inherit customer-level products.
### How Endpoints Use the FullCustomer Today
**check/track (hot path, 1-5K req/sec):**
- Fetches FullCustomer from cache via `getOrSetCachedFullCustomer`
- `prepareFeatureDeduction` calls `fullCustomerToCustomerEntitlements` which flattens `customer_products[].customer_entitlements` + `extra_customer_entitlements` into a single array, filtered by entity via `cusEntMatchesEntity`
- Lua deduction uses path index for O(1) sub-path reads — never reads the full doc
- After deduction, `applyDeductionUpdateToFullCustomer` directly walks `customer_products[i].customer_entitlements[j]` to mutate in-place
**getCustomer:**
- Fetches FullCustomer, calls `getApiCustomerBase` which builds subscriptions (from `customer_products`), balances (from `fullCustomerToCustomerEntitlements`), and flags
- Also returns entities array, invoices, billing controls
**getEntity:**
- Currently fetches the ENTIRE FullCustomer, then calls `filterCusProductsByEntity` to get entity-relevant products
- After the V2 split: fetches FullEntity directly (already contains the filtered products)
### Implementation Phases
**Phase 0 — Comparison Tests (do first)**
Write integration tests that:
- Set up a customer with multiple entities, each with products/entitlements/balances
- Call `getOrCreateCustomer` and `getEntity` endpoints
- Snapshot the `subscriptions` array and `balances` object (minus `breakdown` field)
- These tests serve as the baseline — after the V2 rollout, re-run and assert equivalence
- Use existing test infrastructure (read `server/tests/_guides/general-test-guide.md` first)
**Phase 1 — FullEntity type + cache utilities**
- Create `FullEntity` type in `shared/models/cusModels/fullEntityModel.ts`
- Create entity cache utilities in `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/`: `getCachedFullEntity.ts`, `setCachedFullEntity.ts`, `deleteCachedFullEntity.ts`, `getOrSetCachedFullEntity.ts`
- Entity path index at `{orgId}:env:fullentity:pathidx:customerId:entityId`
- Update `fullCustomerCacheConfig.ts`: bump customer cache version to `2.0.0`, add entity cache config
**Phase 2 — Wire V2 query into CusService**
- Add `CusService.getFullV2` — calls `getSubjectCoreQuery` + `resultToFullCustomer` (customer-level, no entityId)
- Fix `getSubjectCoreQuery` entity mode — include `internal_entity_id IS NULL` products alongside entity-scoped ones
- Add `CusService.getFullEntity` — calls updated query with entityId, hydrates to FullEntity
- Update `getOrSetCachedFullCustomer` to use V2 query on cache miss
- Create `getOrSetCachedFullEntity` for entity-specific cache flow
**Phase 3 — Endpoint Migration**
- `/check`: if `entity_id``getOrSetCachedFullEntity`, else → bounded `getOrSetCachedFullCustomer`
- `/track`: same routing as check
- `/customers.get`: bounded FullCustomer (V2 query provides aggregated entity data)
- `/entities.get`: FullEntity directly (no more fetch-all-then-filter)
- Dual-cache deduction: inherited customer entitlements are embedded in entity cache. Deductions update the entity cache copy. Sync writes to Postgres from entity cache. Customer cache refreshes on next miss.
**Phase 4 — syncItemV3 Compatibility**
- Sync message includes `entityId` (or cache key info)
- Entity-scoped cusEnts → read from entity cache
- Customer-level cusEnts → read from customer cache
- `sync_balances_v2` Postgres function unchanged
**Phase 5 — Rolling Migration**
- Cherry-pick from `origin/feat/custom-redis`:
- `getCustomerBucket(customerId)` from `server/src/external/redis/customerRedisRouting.ts``Bun.hash(id) % 100`
- `resolveCustomerId` middleware from `server/src/honoMiddlewares/utils/resolveCustomerId.ts`
- `isCacheStale()` pattern
- Add `resolveCacheVersion()` function: bucket < migrationPercent → V2, else → V1
- Wire into all cache read/write paths
- V1 path: existing single-blob FullCustomer (`fullcustomer:1.0.0`)
- V2 path: bounded FullCustomer (`fullcustomer:2.0.0`) + per-entity FullEntity (`fullentity:1.0.0`)
### Key Files Reference
**Existing files to understand:**
- `shared/models/cusModels/fullCusModel.ts` — FullCustomer type
- `shared/models/cusProductModels/cusProductModels.ts` — FullCusProduct, CusProduct types
- `shared/models/cusProductModels/cusEntModels/cusEntModels.ts` — FullCustomerEntitlement type
- `shared/utils/cusProductUtils/filterCusProductUtils.ts``filterCusProductsByEntity` (entity inheritance logic)
- `shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts` — flattens products + extra entitlements
- `shared/utils/cusEntUtils/filterCusEntUtils.ts``cusEntMatchesEntity`
- `server/src/internal/customers/CusService.ts` — current getFull
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/` — all cache utilities
- `server/src/internal/customers/cache/pathIndex/buildPathIndex.ts` — path index builder
- `server/src/_luaScriptsV2/fullCustomer/fullCustomerUtils.lua` — path index reader in Lua
- `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua` — deduction hot path
- `server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts` — post-deduction mutation
- `server/src/internal/balances/utils/sync/syncItemV3.ts` — Redis → Postgres sync
**Files to create:**
- `shared/models/cusModels/fullEntityModel.ts`
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullEntity.ts`
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullEntity.ts`
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullEntity.ts`
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullEntity.ts`
- Test files in `server/tests/`
**Files to modify:**
- `server/src/internal/customers/repos/sql/getSubjectCoreQuery.ts` — entity query must include customer-level products
- `server/src/internal/customers/CusService.ts` — add getFullV2, getFullEntity
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.ts` — bump version, add entity config
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts` — use V2 query
- `server/src/internal/api/check/handleCheck.ts` — entity-aware cache routing
- `server/src/internal/balances/handlers/handleTrack.ts` — entity-aware cache routing
- `server/src/internal/customers/handlers/handleGetOrCreateCustomer/handleGetOrCreateCustomerV2.ts` — bounded FullCustomer
- `server/src/internal/entities/handlers/handleGetEntity/handleGetEntityV2.ts` — FullEntity
- `server/src/internal/balances/utils/sync/syncItemV3.ts` — entity cache awareness
### Start with Phase 0 — comparison tests.

View File

@@ -2,7 +2,7 @@ import { AppEnv, type FullCustomer } from "@autumn/shared";
import { initDrizzle } from "@server/db/initDrizzle.js";
import { RELEVANT_STATUSES } from "@server/internal/customers/cusProducts/CusProductService.js";
import { getFullCusQuery } from "@server/internal/customers/getFullCusQuery.js";
import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullCustomerV2.js";
import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullSubject.js";
import { resultToFullCustomer } from "@server/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.js";
import { getSubjectCoreQuery } from "@server/internal/customers/repos/sql/getSubjectCoreQuery.js";
import { loadLocalEnv } from "@server/utils/envUtils.js";

View File

@@ -17,5 +17,8 @@ export const prodTestOrgId = requireEnv({ key: "PROD_TEST_ORG_ID" });
export const prodTestCustomerId = requireEnv({
key: "PROD_TEST_CUSTOMER_ID",
});
export const prodTestEntityId = requireEnv({
key: "PROD_TEST_ENTITY_ID",
});
export const { initDrizzle } = await import("../src/db/initDrizzle");

View File

@@ -5,7 +5,7 @@ import {
prodTestOrgId,
} from "./experimentEnv";
const { getSubjectCoreQuery } = await import(
"../src/internal/customers/repos/getFullCustomerV2"
"../src/internal/customers/repos/sql/getSubjectCoreQuery"
);
import { AppEnv } from "@autumn/shared";

View File

@@ -1,6 +1,6 @@
import { AppEnv } from "@autumn/shared";
import { initDrizzle } from "@server/db/initDrizzle.js";
import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullCustomerV2.js";
import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullSubject.js";
import { resultToFullCustomer } from "@server/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.js";
import { getSubjectCoreQuery } from "@server/internal/customers/repos/sql/getSubjectCoreQuery.js";
import { loadLocalEnv } from "@server/utils/envUtils.js";

View File

@@ -0,0 +1,246 @@
import { AppEnv } from "@autumn/shared";
import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullSubject.js";
import { resultToFullCustomer } from "@server/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.js";
import { getSubjectCoreQuery } from "@server/internal/customers/repos/sql/getSubjectCoreQuery.js";
import { filterCusProductsByEntity } from "@shared/utils/cusProductUtils/filterCusProductUtils.js";
import { logFullCustomer } from "@shared/utils/cusUtils/fullCusUtils/logFullCustomer.js";
import Redis from "ioredis";
import {
initDrizzle,
prodTestCustomerId,
prodTestEntityId,
prodTestOrgId,
} from "./experimentEnv";
const ORG_ID = prodTestOrgId;
const ENV = AppEnv.Live;
const CUSTOMER_ID = prodTestCustomerId;
const ENTITY_ID = prodTestEntityId;
async function main() {
const redis = new Redis(process.env.CACHE_URL!);
const { db, client } = initDrizzle({ maxConnections: 2 });
try {
// 1. Run entity-scoped V2 query (current behavior: entity products only)
const entityQuery = getSubjectCoreQuery({
orgId: ORG_ID,
env: ENV,
customerId: CUSTOMER_ID,
entityId: ENTITY_ID,
});
console.log("=== Full Entity V2 Experiment ===");
console.log(` orgId: ${ORG_ID}`);
console.log(` env: ${ENV}`);
console.log(` customerId: ${CUSTOMER_ID}`);
console.log(` entityId: ${ENTITY_ID}`);
console.log("");
const entityStart = performance.now();
const entityResult = await db.execute(entityQuery);
const entityQueryMs = (performance.now() - entityStart).toFixed(2);
if (!entityResult || entityResult.length === 0) {
console.log("No entity found.");
return;
}
const entityRow = entityResult[0] as unknown as SubjectCoreRow;
const entityHydrateStart = performance.now();
const entityFullCustomer = resultToFullCustomer({ row: entityRow });
const entityHydrateMs = (performance.now() - entityHydrateStart).toFixed(2);
const entityJson = JSON.stringify(entityFullCustomer);
const entitySizeBytes = Buffer.byteLength(entityJson, "utf8");
const entitySizeKb = (entitySizeBytes / 1024).toFixed(2);
await redis.call("JSON.SET", `entity-hydrated-${ENTITY_ID}`, "$", entityJson);
const normalizedDoc = JSON.stringify(entityRow);
const normalizedSizeBytes = Buffer.byteLength(normalizedDoc, "utf8");
const normalizedSizeKb = (normalizedSizeBytes / 1024).toFixed(2);
await redis.call("JSON.SET", `entity-normalized-${ENTITY_ID}`, "$", normalizedDoc);
console.log("--- Entity-scoped query (entity products only) ---");
console.log(` Query: ${entityQueryMs}ms`);
console.log(` Hydration: ${entityHydrateMs}ms`);
console.log(` Hydrated JSON size: ${entitySizeKb} KB (${entitySizeBytes} bytes)`);
console.log(` Normalized JSON size: ${normalizedSizeKb} KB (${normalizedSizeBytes} bytes)`);
console.log(` Savings: ${(100 - (normalizedSizeBytes / entitySizeBytes) * 100).toFixed(1)}%`);
console.log(` Products: ${entityFullCustomer.customer_products.length}`);
console.log(
` CusEnts: ${entityFullCustomer.customer_products.reduce((sum, cp) => sum + cp.customer_entitlements.length, 0)}`,
);
console.log(
` Extra CusEnts: ${entityFullCustomer.extra_customer_entitlements.length}`,
);
console.log("");
// 2. Run customer-level V2 query (no entityId — bounded customer)
const customerQuery = getSubjectCoreQuery({
orgId: ORG_ID,
env: ENV,
customerId: CUSTOMER_ID,
});
const customerStart = performance.now();
const customerResult = await db.execute(customerQuery);
const customerQueryMs = (performance.now() - customerStart).toFixed(2);
const customerRow = customerResult[0] as unknown as SubjectCoreRow;
const customerHydrateStart = performance.now();
const customerFullCustomer = resultToFullCustomer({ row: customerRow });
const customerHydrateMs = (
performance.now() - customerHydrateStart
).toFixed(2);
const customerJson = JSON.stringify(customerFullCustomer);
const customerSizeBytes = Buffer.byteLength(customerJson, "utf8");
const customerSizeKb = (customerSizeBytes / 1024).toFixed(2);
console.log("--- Customer-level query (no entity filter) ---");
console.log(` Query: ${customerQueryMs}ms`);
console.log(` Hydration: ${customerHydrateMs}ms`);
console.log(
` JSON size: ${customerSizeKb} KB (${customerSizeBytes} bytes)`,
);
console.log(
` Products: ${customerFullCustomer.customer_products.length}`,
);
console.log(
` Entities: ${customerFullCustomer.entities?.length ?? 0}`,
);
console.log("");
// 3. Simulate what a FullEntity would look like (entity products + inherited customer-level)
const entity = customerFullCustomer.entities?.find(
(e) => e.id === ENTITY_ID || e.internal_id === ENTITY_ID,
);
if (entity) {
const inheritedProducts = filterCusProductsByEntity({
cusProducts: customerFullCustomer.customer_products,
entity,
});
const inheritedJson = JSON.stringify({
customer: {
id: customerFullCustomer.id,
internal_id: customerFullCustomer.internal_id,
processor: customerFullCustomer.processor,
},
entity,
customer_products: inheritedProducts,
extra_customer_entitlements:
customerFullCustomer.extra_customer_entitlements,
});
const inheritedSizeBytes = Buffer.byteLength(inheritedJson, "utf8");
const inheritedSizeKb = (inheritedSizeBytes / 1024).toFixed(2);
const customerLevelProducts = inheritedProducts.filter(
(p) => !p.internal_entity_id,
);
const entityScopedProducts = inheritedProducts.filter(
(p) => p.internal_entity_id,
);
console.log(
"--- Simulated FullEntity (entity + inherited customer products) ---",
);
console.log(
` JSON size: ${inheritedSizeKb} KB (${inheritedSizeBytes} bytes)`,
);
console.log(` Total products: ${inheritedProducts.length}`);
console.log(
` Customer-level (inherited): ${customerLevelProducts.length}`,
);
console.log(
` Entity-scoped (own): ${entityScopedProducts.length}`,
);
console.log("");
} else {
console.log(
`Entity ${ENTITY_ID} not found in customer's entities array.`,
);
}
// 4. Size breakdown by category
console.log("--- Size breakdown (customer-level query) ---");
const breakdown = {
customer_core: Buffer.byteLength(
JSON.stringify({
id: customerFullCustomer.id,
internal_id: customerFullCustomer.internal_id,
name: customerFullCustomer.name,
email: customerFullCustomer.email,
processor: customerFullCustomer.processor,
}),
"utf8",
),
entities_array: Buffer.byteLength(
JSON.stringify(customerFullCustomer.entities ?? []),
"utf8",
),
customer_products: Buffer.byteLength(
JSON.stringify(customerFullCustomer.customer_products),
"utf8",
),
extra_customer_entitlements: Buffer.byteLength(
JSON.stringify(customerFullCustomer.extra_customer_entitlements),
"utf8",
),
aggregated_customer_products: Buffer.byteLength(
JSON.stringify(
(customerFullCustomer as Record<string, unknown>)
.aggregated_customer_products ?? [],
),
"utf8",
),
aggregated_customer_entitlements: Buffer.byteLength(
JSON.stringify(
(customerFullCustomer as Record<string, unknown>)
.aggregated_customer_entitlements ?? [],
),
"utf8",
),
};
for (const [key, bytes] of Object.entries(breakdown)) {
console.log(` ${key}: ${(bytes / 1024).toFixed(2)} KB`);
}
console.log(
` TOTAL: ${(Object.values(breakdown).reduce((a, b) => a + b, 0) / 1024).toFixed(2)} KB`,
);
console.log("");
// 5. Per-product size stats
const productSizes = customerFullCustomer.customer_products.map((cp) => ({
productId: cp.product_id,
entityId: cp.internal_entity_id ?? "(customer-level)",
sizeBytes: Buffer.byteLength(JSON.stringify(cp), "utf8"),
entitlements: cp.customer_entitlements.length,
prices: cp.customer_prices.length,
}));
productSizes.sort((a, b) => b.sizeBytes - a.sizeBytes);
console.log(
`--- Top 10 largest products (of ${productSizes.length} total) ---`,
);
for (const p of productSizes.slice(0, 10)) {
console.log(
` ${p.productId} [${p.entityId}]: ${(p.sizeBytes / 1024).toFixed(2)} KB (${p.entitlements} ents, ${p.prices} prices)`,
);
}
console.log("");
logFullCustomer({ fullCustomer: customerFullCustomer });
} finally {
await redis.quit();
await client.end();
}
}
main().catch(console.error);

View File

@@ -0,0 +1,134 @@
import { AppEnv } from "@autumn/shared";
import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullSubject.js";
import { resultToFullSubject } from "@server/internal/customers/repos/getFullSubject.js";
import { getSubjectCoreQuery } from "@server/internal/customers/repos/sql/getSubjectCoreQuery.js";
import { sql } from "drizzle-orm";
import {
initDrizzle,
prodTestCustomerId,
prodTestEntityId,
prodTestOrgId,
} from "./experimentEnv";
// Run with: bun run experiments/getFullSubjectExperiment.ts
const ORG_ID = prodTestOrgId;
const ENV = AppEnv.Live;
const CUSTOMER_ID = prodTestCustomerId;
const ENTITY_ID = prodTestEntityId;
const runPath = async ({
db,
label,
customerId,
entityId,
}: {
db: ReturnType<typeof initDrizzle>["db"];
label: string;
customerId?: string;
entityId?: string;
}) => {
const query = getSubjectCoreQuery({
orgId: ORG_ID,
env: ENV,
customerId,
entityId,
});
console.log(`\n=== ${label} ===`);
console.log(` orgId: ${ORG_ID}`);
console.log(` customerId: ${customerId ?? "(none)"}`);
console.log(` entityId: ${entityId ?? "(none)"}`);
console.log("");
const queryStart = performance.now();
const result = await db.execute(query);
const queryMs = (performance.now() - queryStart).toFixed(2);
if (!result || result.length === 0) {
console.log(` No rows returned. Query: ${queryMs}ms`);
return;
}
const row = result[0] as unknown as SubjectCoreRow;
console.log(` Query: ${queryMs}ms`);
console.log(` Rows returned: ${result.length}`);
console.log(
` customer_products: ${(row.customer_products as unknown[])?.length ?? 0}`,
);
console.log(
` customer_ents: ${(row.customer_entitlements as unknown[])?.length ?? 0}`,
);
console.log(
` extra_cus_ents: ${(row.extra_customer_entitlements as unknown[])?.length ?? 0}`,
);
console.log(` products: ${(row.products as unknown[])?.length ?? 0}`);
console.log(
` entitlements: ${(row.entitlements as unknown[])?.length ?? 0}`,
);
console.log(` prices: ${(row.prices as unknown[])?.length ?? 0}`);
console.log(
` rollovers: ${(row.rollovers as unknown[])?.length ?? 0}`,
);
console.log(
` free_trials: ${(row.free_trials as unknown[])?.length ?? 0}`,
);
console.log(
` subscriptions: ${(row.subscriptions as unknown[])?.length ?? 0}`,
);
console.log(` invoices: ${(row.invoices as unknown[])?.length ?? 0}`);
console.log(` entity: ${row.entity ? "yes" : "no"}`);
console.log(
` entity_aggregations: ${row.entity_aggregations ? "yes" : "no"}`,
);
const hydrateStart = performance.now();
const fullSubject = resultToFullSubject({ row });
const hydrateMs = (performance.now() - hydrateStart).toFixed(2);
const jsonOutput = JSON.stringify(fullSubject);
const sizeBytes = Buffer.byteLength(jsonOutput, "utf8");
const sizeKb = (sizeBytes / 1024).toFixed(2);
console.log(` Hydration: ${hydrateMs}ms`);
console.log(` FullSubject type: ${fullSubject.subjectType}`);
console.log(` JSON size: ${sizeKb} KB (${sizeBytes} bytes)`);
console.log(`\n --- EXPLAIN (ANALYZE, BUFFERS) ---\n`);
const explain = sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`;
const explainResult = await db.execute(explain);
for (const r of explainResult) {
console.log(` ${(r as Record<string, unknown>)["QUERY PLAN"]}`);
}
};
async function main() {
const { db, client } = initDrizzle({ maxConnections: 2 });
try {
await db.execute(sql`SELECT 1`);
await runPath({
db,
label: "Path 1: Customer subject (customerId only)",
customerId: CUSTOMER_ID,
});
await runPath({
db,
label: "Path 2: Entity subject (customerId + entityId)",
customerId: CUSTOMER_ID,
entityId: ENTITY_ID,
});
await runPath({
db,
label: "Path 3: Entity subject (entityId only — entity-first CTE)",
entityId: ENTITY_ID,
});
} finally {
await client.end();
}
}
main().catch(console.error);

View File

@@ -0,0 +1,73 @@
import {
getConfiguredRegions,
getRegionalRedis,
redis,
} from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import {
buildFullSubjectCacheKey,
FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS,
} from "./fullSubjectCacheConfig.js";
/**
* Delete FullSubject from Redis cache across ALL regions.
* Routes key based on entityId presence.
*/
export const deleteCachedFullSubject = async ({
customerId,
entityId,
ctx,
source,
skipGuard = false,
}: {
customerId: string;
entityId?: string;
ctx: AutumnContext;
source?: string;
skipGuard?: boolean;
}): Promise<void> => {
const { org, env, logger } = ctx;
if (redis.status !== "ready" || !customerId) return;
const cacheKey = buildFullSubjectCacheKey({
orgId: org.id,
env,
customerId,
entityId,
});
const regions = getConfiguredRegions();
const guardTimestamp = Date.now().toString();
const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId;
const deletePromises = regions.map(async (region) => {
try {
const regionalRedis = getRegionalRedis(region);
if (regionalRedis.status !== "ready") {
logger.warn(`[deleteCachedFullSubject] ${region}: not_ready`);
return;
}
const result = await regionalRedis.deleteFullCustomerCache(
cacheKey,
org.id,
env,
customerId,
guardTimestamp,
FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS.toString(),
skipGuard.toString(),
);
logger.info(
`[deleteCachedFullSubject] ${region}: ${result}, subject: ${subjectLabel}, source: ${source}`,
);
} catch (error) {
logger.error(
`[deleteCachedFullSubject] ${region}: error, subject: ${subjectLabel}, source: ${source}, error: ${error}`,
);
}
});
await Promise.all(deletePromises);
};

View File

@@ -0,0 +1,37 @@
import { seconds } from "@autumn/shared";
/** Cache TTL in seconds (3 days) */
export const FULL_SUBJECT_CACHE_TTL_SECONDS = seconds.days(3);
/** Guard TTL in seconds — prevents stale writes after deletion */
export const FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS = 1;
export const buildFullSubjectCacheKey = ({
orgId,
env,
customerId,
entityId,
}: {
orgId: string;
env: string;
customerId: string;
entityId?: string;
}) =>
entityId
? `{${orgId}}:${env}:fullentity:1.0.0:${customerId}:${entityId}`
: `{${orgId}}:${env}:fullcustomer:2.0.0:${customerId}`;
export const buildFullSubjectGuardKey = ({
orgId,
env,
customerId,
entityId,
}: {
orgId: string;
env: string;
customerId: string;
entityId?: string;
}) =>
entityId
? `{${orgId}}:${env}:fullentity:guard:${customerId}:${entityId}`
: `{${orgId}}:${env}:fullcustomer:guard:v2:${customerId}`;

View File

@@ -0,0 +1,151 @@
import {
CusProductStatus,
type FullCusProduct,
type FullSubject,
FullSubjectSchema,
type Invoice,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { Redis } from "ioredis";
import { getDbHealth, PgHealth } from "@/db/pgHealthMonitor.js";
import { redis } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { resetCustomerEntitlements } from "@/internal/customers/actions/resetCustomerEntitlements/resetCustomerEntitlements.js";
import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js";
import { normalizeFromSchema } from "@/utils/cacheUtils/normalizeFromSchema.js";
import { buildFullSubjectCacheKey } from "./fullSubjectCacheConfig.js";
const roundBalance = (value: number | null | undefined): number => {
if (value === null || value === undefined) return 0;
return new Decimal(value).toDecimalPlaces(10).toNumber();
};
const roundFullSubjectBalances = (fullSubject: FullSubject): FullSubject => {
if (!fullSubject.customer_products) return fullSubject;
for (const cusProduct of fullSubject.customer_products) {
if (!cusProduct.customer_entitlements) continue;
for (const cusEnt of cusProduct.customer_entitlements) {
if (cusEnt.balance !== null && cusEnt.balance !== undefined)
cusEnt.balance = roundBalance(cusEnt.balance);
if (cusEnt.adjustment !== null && cusEnt.adjustment !== undefined)
cusEnt.adjustment = roundBalance(cusEnt.adjustment);
if (
cusEnt.additional_balance !== null &&
cusEnt.additional_balance !== undefined
)
cusEnt.additional_balance = roundBalance(cusEnt.additional_balance);
if (cusEnt.entities && typeof cusEnt.entities === "object") {
for (const entityId of Object.keys(cusEnt.entities)) {
const entityData = cusEnt.entities[entityId];
if (entityData && typeof entityData === "object") {
if (entityData.balance !== null && entityData.balance !== undefined)
entityData.balance = roundBalance(entityData.balance);
if (
entityData.adjustment !== null &&
entityData.adjustment !== undefined
)
entityData.adjustment = roundBalance(entityData.adjustment);
}
}
}
if (cusEnt.rollovers && Array.isArray(cusEnt.rollovers)) {
for (const rollover of cusEnt.rollovers) {
if (rollover.balance !== null && rollover.balance !== undefined)
rollover.balance = roundBalance(rollover.balance);
}
}
}
}
return fullSubject;
};
const deduplicateInvoices = (fullSubject: FullSubject): Invoice[] => {
const idToInvoice = new Map<string, Invoice>();
for (const invoice of fullSubject.invoices ?? []) {
idToInvoice.set(invoice.id, invoice);
}
return Array.from(idToInvoice.values()).sort((a, b) => {
if (b.created_at !== a.created_at) return b.created_at - a.created_at;
return b.id < a.id ? -1 : b.id > a.id ? 1 : 0;
});
};
const filterExpiredCustomerProducts = (
fullSubject: FullSubject,
): FullCusProduct[] => {
return (
fullSubject.customer_products?.filter((cusProduct) => {
return cusProduct.status !== CusProductStatus.Expired;
}) ?? []
);
};
/**
* Get FullSubject from Redis cache. Lazily resets stale entitlements.
* @returns FullSubject if found, undefined if not in cache
*/
export const getCachedFullSubject = async ({
ctx,
customerId,
entityId,
redisInstance,
}: {
ctx: AutumnContext;
customerId: string;
entityId?: string;
redisInstance?: Redis;
}): Promise<FullSubject | undefined> => {
const { org, env } = ctx;
const cacheKey = buildFullSubjectCacheKey({
orgId: org.id,
env,
customerId,
entityId,
});
const redisClient = redisInstance || redis;
const cached = await tryRedisRead(
() => redisClient.call("JSON.GET", cacheKey) as Promise<string | null>,
);
if (!cached) return undefined;
const fullSubject = normalizeFromSchema<FullSubject>({
schema: FullSubjectSchema,
data: JSON.parse(cached),
});
if (!fullSubject.extra_customer_entitlements) {
fullSubject.extra_customer_entitlements = [];
}
if (fullSubject.subjectType === "customer") {
fullSubject.invoices = deduplicateInvoices(fullSubject);
if (!fullSubject.customer.send_email_receipts) {
fullSubject.customer.send_email_receipts = false;
}
}
fullSubject.customer_products = filterExpiredCustomerProducts(fullSubject);
if (getDbHealth() !== PgHealth.Degraded) {
await resetCustomerEntitlements({
ctx,
fullCus: {
...fullSubject.customer,
customer_products: fullSubject.customer_products,
extra_customer_entitlements: fullSubject.extra_customer_entitlements,
entities: [],
},
});
}
return roundFullSubjectBalances(fullSubject);
};

View File

@@ -0,0 +1,70 @@
import {
CustomerNotFoundError,
EntityNotFoundError,
type FullSubject,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getFullSubject } from "@/internal/customers/repos/getFullSubject.js";
import { getCachedFullSubject } from "./getCachedFullSubject.js";
import { setCachedFullSubject } from "./setCachedFullSubject.js";
/**
* Get FullSubject from Redis cache, or fetch from DB and set cache if not found.
* Throws CustomerNotFoundError / EntityNotFoundError if subject doesn't exist.
*/
export const getOrSetCachedFullSubject = async ({
ctx,
customerId,
entityId,
source,
}: {
ctx: AutumnContext;
customerId: string;
entityId?: string;
source?: string;
}): Promise<FullSubject> => {
const { skipCache, logger } = ctx;
if (!skipCache) {
const cached = await getCachedFullSubject({
ctx,
customerId,
entityId,
});
if (cached) {
logger.debug(
`[getOrSetCachedFullSubject] Cache hit for ${customerId}${entityId ? `:${entityId}` : ""}, source: ${source}`,
);
return cached;
}
}
logger.debug(
`[getOrSetCachedFullSubject] Cache miss for ${customerId}${entityId ? `:${entityId}` : ""}, fetching from DB, source: ${source}`,
);
const fetchTimeMs = Date.now();
const fullSubject = await getFullSubject({
ctx,
customerId,
entityId,
});
if (!fullSubject) {
if (entityId) throw new EntityNotFoundError({ entityId });
throw new CustomerNotFoundError({ customerId });
}
if (!skipCache) {
await setCachedFullSubject({
ctx,
fullSubject,
fetchTimeMs,
source,
});
}
return fullSubject;
};

View File

@@ -0,0 +1,90 @@
import type { FullCustomer, FullSubject } from "@autumn/shared";
import { redis } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { buildPathIndex } from "@/internal/customers/cache/pathIndex/buildPathIndex.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs.js";
import {
buildFullSubjectCacheKey,
FULL_SUBJECT_CACHE_TTL_SECONDS,
} from "./fullSubjectCacheConfig.js";
type SetCacheResult = "OK" | "STALE_WRITE" | "CACHE_EXISTS" | "FAILED";
/**
* Set FullSubject in Redis cache.
* Reuses the existing setFullCustomerCache Lua script — it's key-agnostic.
*/
export const setCachedFullSubject = async ({
ctx,
fullSubject,
fetchTimeMs,
source,
overwrite = false,
}: {
ctx: AutumnContext;
fullSubject: FullSubject;
fetchTimeMs: number;
source?: string;
overwrite?: boolean;
}): Promise<SetCacheResult> => {
const { org, env, logger } = ctx;
const cacheKey = buildFullSubjectCacheKey({
orgId: org.id,
env,
customerId: fullSubject.customerId,
entityId: fullSubject.entityId,
});
const pathIndexEntries = buildPathIndex({
fullCustomer: {
customer_products: fullSubject.customer_products,
extra_customer_entitlements: fullSubject.extra_customer_entitlements,
} as Pick<
FullCustomer,
"customer_products" | "extra_customer_entitlements"
> as FullCustomer,
});
const pathIndexJson = JSON.stringify(pathIndexEntries);
const result = await tryRedisWrite(async () => {
return await redis.setFullCustomerCache(
cacheKey,
org.id,
env,
fullSubject.customerId,
String(fetchTimeMs),
String(FULL_SUBJECT_CACHE_TTL_SECONDS),
JSON.stringify(fullSubject),
String(overwrite),
pathIndexJson,
);
});
if (result === null) {
logger.warn(
`[setCachedFullSubject] Redis write failed for ${fullSubject.customerId}${fullSubject.entityId ? `:${fullSubject.entityId}` : ""}`,
);
return "FAILED";
}
const subjectLabel = fullSubject.entityId
? `${fullSubject.customerId}:${fullSubject.entityId}`
: fullSubject.customerId;
logger.info(
`[setCachedFullSubject] ${subjectLabel}: ${result}, source: ${source}`,
);
addToExtraLogs({
ctx,
extras: {
setCacheSubject: {
result,
subjectType: fullSubject.subjectType,
},
},
});
return result;
};

View File

@@ -1,5 +1,5 @@
import { getFullCustomerV2 } from "./getFullCustomerV2.js";
import { getFullSubject } from "./getFullSubject.js";
export const customerRepo = {
getFullV2: getFullCustomerV2,
getFullSubject,
} as const;

View File

@@ -1,75 +0,0 @@
import type {
AggregatedCustomerEntitlement,
CusProductStatus,
DbCustomer,
DbCustomerEntitlement,
DbCustomerPrice,
DbCustomerProduct,
DbEntitlement,
DbFeature,
DbFreeTrial,
DbPrice,
DbProduct,
DbRollover,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js";
export { resultToFullCustomer } from "./getFullCustomerV2/resultToFullCustomer.js";
import { getSubjectCoreQuery } from "./sql/getSubjectCoreQuery.js";
type EntitlementWithFeatureRow = DbEntitlement & {
feature: DbFeature;
};
export interface EntityAggregations {
aggregated_customer_products: DbCustomerProduct[];
aggregated_customer_entitlements: AggregatedCustomerEntitlement[];
aggregated_customer_prices: DbCustomerPrice[];
}
/**
* Raw row shape returned by getSubjectCore query.
* Each field is a JSON column from the SQL result.
*/
export interface SubjectCoreRow {
customer: DbCustomer;
customer_products: DbCustomerProduct[];
customer_entitlements: DbCustomerEntitlement[];
customer_prices: DbCustomerPrice[];
extra_customer_entitlements: DbCustomerEntitlement[];
rollovers: DbRollover[];
products: DbProduct[];
entitlements: EntitlementWithFeatureRow[];
prices: DbPrice[];
free_trials: DbFreeTrial[];
entity_aggregations?: EntityAggregations;
}
export async function getFullCustomerV2({
ctx,
customerId,
inStatuses = RELEVANT_STATUSES,
}: {
ctx: AutumnContext;
customerId: string;
inStatuses?: CusProductStatus[];
}): Promise<SubjectCoreRow | null> {
const { db, org, env } = ctx;
const query = getSubjectCoreQuery({
orgId: org.id,
env,
customerId,
inStatuses,
});
const result = await db.execute(query);
if (!result || result.length === 0) return null;
return result[0] as unknown as SubjectCoreRow;
}
export { getSubjectCoreQuery };

View File

@@ -12,7 +12,7 @@ import type {
Replaceable,
Subscription,
} from "@autumn/shared";
import type { SubjectCoreRow } from "../getFullCustomerV2.js";
import type { SubjectCoreRow } from "../getFullSubject.js";
const getRolloverSortValue = ({ rollover }: { rollover: DbRollover }) =>
rollover.expires_at ?? Number.POSITIVE_INFINITY;

View File

@@ -0,0 +1,339 @@
import type {
AggregatedCustomerEntitlement,
CusProductStatus,
Customer,
CustomerPrice,
DbCustomer,
DbCustomerEntitlement,
DbCustomerPrice,
DbCustomerProduct,
DbEntitlement,
DbFeature,
DbFreeTrial,
DbPrice,
DbProduct,
DbRollover,
Entity,
FullAggregatedCustomerEntitlement,
FullCusProduct,
FullCustomerEntitlement,
FullCustomerPrice,
FullSubject,
Invoice,
Replaceable,
Subscription,
SubjectType,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js";
import { getSubjectCoreQuery } from "./sql/getSubjectCoreQuery.js";
type EntitlementWithFeatureRow = DbEntitlement & {
feature: DbFeature;
};
export interface EntityAggregations {
aggregated_customer_products: DbCustomerProduct[];
aggregated_customer_entitlements: AggregatedCustomerEntitlement[];
aggregated_customer_prices: DbCustomerPrice[];
}
/** Raw row shape returned by getSubjectCoreQuery. */
export interface SubjectCoreRow {
customer: DbCustomer;
customer_products: DbCustomerProduct[];
customer_entitlements: DbCustomerEntitlement[];
customer_prices: DbCustomerPrice[];
extra_customer_entitlements: DbCustomerEntitlement[];
rollovers: DbRollover[];
products: DbProduct[];
entitlements: EntitlementWithFeatureRow[];
prices: DbPrice[];
free_trials: DbFreeTrial[];
entity_aggregations?: EntityAggregations;
subscriptions: Subscription[];
invoices?: Invoice[];
entity?: Entity;
}
const getRolloverSortValue = ({ rollover }: { rollover: DbRollover }) =>
rollover.expires_at ?? Number.POSITIVE_INFINITY;
const buildFullCustomerEntitlement = ({
customerEntitlement,
entitlement,
rollovers,
}: {
customerEntitlement: SubjectCoreRow["customer_entitlements"][number];
entitlement: SubjectCoreRow["entitlements"][number] | undefined;
rollovers: DbRollover[];
}): FullCustomerEntitlement | null => {
if (!entitlement) return null;
return {
...customerEntitlement,
entitlement,
replaceables: [] as Replaceable[],
rollovers: [...rollovers].sort(
(left, right) =>
getRolloverSortValue({ rollover: left }) -
getRolloverSortValue({ rollover: right }),
),
} as FullCustomerEntitlement;
};
const buildFullCustomerPrice = ({
customerPrice,
price,
}: {
customerPrice: SubjectCoreRow["customer_prices"][number];
price: DbPrice | undefined;
}): FullCustomerPrice | null => {
if (!price) return null;
return {
...customerPrice,
price,
} as FullCustomerPrice;
};
export const resultToFullSubject = ({
row,
}: {
row: SubjectCoreRow;
}): FullSubject => {
const entity = row.entity as Entity | undefined;
const isEntitySubject = !!entity;
const productsByInternalId = new Map(
row.products.map((product) => [product.internal_id, product] as const),
);
const entitlementsById = new Map(
row.entitlements.map(
(entitlement) => [entitlement.id, entitlement] as const,
),
);
const pricesById = new Map(
row.prices.map((price) => [price.id, price] as const),
);
const freeTrialsById = new Map(
row.free_trials.map((freeTrial) => [freeTrial.id, freeTrial] as const),
);
const rolloversByCustomerEntitlementId = new Map<string, DbRollover[]>();
for (const rollover of row.rollovers) {
const existing =
rolloversByCustomerEntitlementId.get(rollover.cus_ent_id) ?? [];
existing.push(rollover);
rolloversByCustomerEntitlementId.set(rollover.cus_ent_id, existing);
}
const customerPricesByCustomerProductId = new Map<
string,
FullCustomerPrice[]
>();
for (const customerPrice of row.customer_prices) {
if (!customerPrice.customer_product_id) continue;
const fullCustomerPrice = buildFullCustomerPrice({
customerPrice,
price: customerPrice.price_id
? pricesById.get(customerPrice.price_id)
: undefined,
});
if (!fullCustomerPrice) continue;
const existing =
customerPricesByCustomerProductId.get(
customerPrice.customer_product_id,
) ?? [];
existing.push(fullCustomerPrice);
customerPricesByCustomerProductId.set(
customerPrice.customer_product_id,
existing,
);
}
const customerEntitlementsByCustomerProductId = new Map<
string,
FullCustomerEntitlement[]
>();
for (const customerEntitlement of row.customer_entitlements) {
if (!customerEntitlement.customer_product_id) continue;
const fullCustomerEntitlement = buildFullCustomerEntitlement({
customerEntitlement,
entitlement: entitlementsById.get(customerEntitlement.entitlement_id),
rollovers:
rolloversByCustomerEntitlementId.get(customerEntitlement.id) ?? [],
});
if (!fullCustomerEntitlement) continue;
const existing =
customerEntitlementsByCustomerProductId.get(
customerEntitlement.customer_product_id,
) ?? [];
existing.push(fullCustomerEntitlement);
customerEntitlementsByCustomerProductId.set(
customerEntitlement.customer_product_id,
existing,
);
}
const customerProducts: FullCusProduct[] = [];
for (const customerProduct of row.customer_products) {
const product = productsByInternalId.get(
customerProduct.internal_product_id,
);
if (!product) continue;
customerProducts.push({
...customerProduct,
product,
free_trial: customerProduct.free_trial_id
? (freeTrialsById.get(customerProduct.free_trial_id) ?? null)
: null,
customer_prices:
customerPricesByCustomerProductId.get(customerProduct.id) ?? [],
customer_entitlements:
customerEntitlementsByCustomerProductId.get(customerProduct.id) ?? [],
} as FullCusProduct);
}
const extraCustomerEntitlements = row.extra_customer_entitlements
.map((customerEntitlement) =>
buildFullCustomerEntitlement({
customerEntitlement,
entitlement: entitlementsById.get(customerEntitlement.entitlement_id),
rollovers:
rolloversByCustomerEntitlementId.get(customerEntitlement.id) ?? [],
}),
)
.filter(
(customerEntitlement): customerEntitlement is FullCustomerEntitlement =>
customerEntitlement !== null,
);
let aggregatedCustomerProducts: FullCusProduct[] | undefined;
let aggregatedCustomerEntitlements:
| FullAggregatedCustomerEntitlement[]
| undefined;
let aggregatedCustomerPrices: CustomerPrice[] | undefined;
if (row.entity_aggregations) {
const entityAgg = row.entity_aggregations;
const entityCusPricesByProductId = new Map<string, FullCustomerPrice[]>();
for (const entityCusPrice of entityAgg.aggregated_customer_prices) {
if (!entityCusPrice.customer_product_id) continue;
const fullPrice = buildFullCustomerPrice({
customerPrice: entityCusPrice,
price: entityCusPrice.price_id
? pricesById.get(entityCusPrice.price_id)
: undefined,
});
if (!fullPrice) continue;
const existing =
entityCusPricesByProductId.get(entityCusPrice.customer_product_id) ??
[];
existing.push(fullPrice);
entityCusPricesByProductId.set(
entityCusPrice.customer_product_id,
existing,
);
}
aggregatedCustomerProducts = [];
for (const entityCusProduct of entityAgg.aggregated_customer_products) {
const product = productsByInternalId.get(
entityCusProduct.internal_product_id,
);
if (!product) continue;
aggregatedCustomerProducts.push({
...entityCusProduct,
product,
free_trial: entityCusProduct.free_trial_id
? (freeTrialsById.get(entityCusProduct.free_trial_id) ?? null)
: null,
customer_prices:
entityCusPricesByProductId.get(entityCusProduct.id) ?? [],
customer_entitlements: [],
} as FullCusProduct);
}
aggregatedCustomerEntitlements = (
entityAgg.aggregated_customer_entitlements ?? []
)
.map((aggregatedCusEnt) => {
const entitlement = row.entitlements.find(
(e) => e.internal_feature_id === aggregatedCusEnt.internal_feature_id,
);
if (!entitlement) return null;
return {
...aggregatedCusEnt,
entitlement,
} as FullAggregatedCustomerEntitlement;
})
.filter((e): e is FullAggregatedCustomerEntitlement => e !== null);
}
const customer = row.customer as unknown as Customer;
return {
subjectType: (isEntitySubject ? "entity" : "customer") as SubjectType,
customerId: customer.id ?? customer.internal_id,
internalCustomerId: customer.internal_id,
...(entity
? {
entityId: entity.id ?? entity.internal_id,
internalEntityId: entity.internal_id,
entity,
}
: {}),
customer,
customer_products: customerProducts,
extra_customer_entitlements: extraCustomerEntitlements,
subscriptions: row.subscriptions ?? [],
invoices: row.invoices ?? [],
...(aggregatedCustomerProducts
? { aggregated_customer_products: aggregatedCustomerProducts }
: {}),
...(aggregatedCustomerEntitlements
? { aggregated_customer_entitlements: aggregatedCustomerEntitlements }
: {}),
...(aggregatedCustomerPrices
? { aggregated_customer_prices: aggregatedCustomerPrices }
: {}),
} as FullSubject;
};
export async function getFullSubject({
ctx,
customerId,
entityId,
inStatuses = RELEVANT_STATUSES,
}: {
ctx: AutumnContext;
customerId?: string;
entityId?: string;
inStatuses?: CusProductStatus[];
}): Promise<FullSubject | null> {
const { db, org, env } = ctx;
const result = await db.execute(
getSubjectCoreQuery({
orgId: org.id,
env,
customerId,
entityId,
inStatuses,
}),
);
if (!result?.length) return null;
return resultToFullSubject({ row: result[0] as unknown as SubjectCoreRow });
}

View File

@@ -245,6 +245,7 @@ export const getSubjectCoreQuery = ({
}) => {
const page = pagination.page ?? 50;
const offset = pagination.offset ?? 0;
const entityOnlyLookup = !!entityId && !customerId;
const statusFilter =
inStatuses.length > 0
@@ -269,8 +270,32 @@ export const getSubjectCoreQuery = ({
OFFSET ${offset}
`;
const entityRecordCte = entityId
? sql`,
let leadingCtes: SQL;
if (entityOnlyLookup) {
leadingCtes = sql`
WITH entity_record AS (
SELECT e.*
FROM entities e
WHERE e.org_id = ${orgId}
AND e.env = ${env}
AND (e.id = ${entityId} OR e.internal_id = ${entityId})
LIMIT 1
),
subject_customer_records AS (
SELECT c.*
FROM customers c
WHERE c.internal_id = (SELECT internal_customer_id FROM entity_record LIMIT 1)
)`;
} else if (entityId) {
leadingCtes = sql`
WITH subject_customer_records AS (
SELECT *
FROM customers c
WHERE c.org_id = ${orgId}
AND c.env = ${env}
${customerFilter}
${customerPagination}
),
entity_record AS (
SELECT e.*
FROM entities e
@@ -280,12 +305,22 @@ export const getSubjectCoreQuery = ({
)
AND (e.id = ${entityId} OR e.internal_id = ${entityId})
LIMIT 1
)
`
: sql``;
)`;
} else {
leadingCtes = sql`
WITH subject_customer_records AS (
SELECT *
FROM customers c
WHERE c.org_id = ${orgId}
AND c.env = ${env}
${customerFilter}
${customerPagination}
)`;
}
const customerProductEntityFilter = entityId
? sql`AND cp.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1)`
? sql`AND (cp.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1)
OR cp.internal_entity_id IS NULL)`
: sql`AND cp.internal_entity_id IS NULL`;
const entityFragments = getEntityAggregateFragments({
@@ -303,32 +338,59 @@ export const getSubjectCoreQuery = ({
`
: sql``;
/**
* Builds the normalized subject-core query used by both single-customer and
* list-customer reads.
*
* The result shape is always one row per matched customer with flat JSON arrays
* for customer products, customer entitlements, prices, rollovers, products,
* entitlements, and free trials. Callers can then hydrate that payload into a
* `FullCustomer`-style object in TypeScript.
*
* Behavior:
* - `customerId`: narrows to a single customer lookup.
* - `pagination`: applies only when listing customers.
* - `entityId`: switches product selection to the matching entity-scoped
* customer products and limits the final rowset to that entity's customer.
* - `inStatuses`: filters customer products before downstream joins.
*/
return sql`
WITH subject_customer_records AS (
const subscriptionsCte = sql`,
customer_subscriptions AS (
SELECT DISTINCT s.*
FROM cus_products cp
JOIN LATERAL unnest(cp.subscription_ids) AS cp_sub(stripe_id) ON true
JOIN subscriptions s ON s.stripe_id = cp_sub.stripe_id
)`;
const invoicesCte = entityId
? sql``
: sql`,
customer_invoices AS (
SELECT *
FROM customers c
WHERE c.org_id = ${orgId}
AND c.env = ${env}
${customerFilter}
${customerPagination}
)
${entityRecordCte}
FROM invoices i
WHERE i.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
ORDER BY i.created_at DESC, i.id DESC
LIMIT 10
)`;
const subscriptionsSelect = sql`,
COALESCE(
(
SELECT json_agg(row_to_json(cs)) FILTER (WHERE cs.stripe_id IS NOT NULL)
FROM customer_subscriptions cs
),
'[]'::json
) AS subscriptions`;
const invoicesSelect = entityId
? sql``
: sql`,
COALESCE(
(
SELECT json_agg(row_to_json(ci) ORDER BY ci.created_at DESC, ci.id DESC)
FILTER (WHERE ci.id IS NOT NULL)
FROM customer_invoices ci
WHERE ci.internal_customer_id = scr.internal_id
),
'[]'::json
) AS invoices`;
const entitySelect = entityId
? sql`,
(SELECT row_to_json(er) FROM entity_record er LIMIT 1) AS entity`
: sql``;
return sql`
${leadingCtes}
,
cus_products AS (
@@ -375,6 +437,8 @@ export const getSubjectCoreQuery = ({
WHERE cpr.customer_product_id IN (SELECT id FROM cus_products)
)
${subscriptionsCte}
${invoicesCte}
${entityFragments.ctes}
,
@@ -537,6 +601,9 @@ export const getSubjectCoreQuery = ({
'[]'::json
) AS free_trials
${subscriptionsSelect}
${invoicesSelect}
${entitySelect}
${entityFragments.selectColumns}
FROM subject_customer_records scr

View File

@@ -8,6 +8,10 @@ import {
type ApiCustomerV5,
ApiCustomerV5Schema,
} from "@shared/api/customers/apiCustomerV5";
import {
type ApiCustomerV3,
ApiCustomerV3Schema,
} from "@shared/api/customers/previousVersions/apiCustomerV3";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
@@ -16,6 +20,111 @@ import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// Multi-entity comparison tests (V2 cache baseline)
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("get-customer: multi-entity customer returns correct balances across API versions")}`, async () => {
const dashboardItem = items.dashboard();
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const creditsItem = items.monthlyCredits({ includedUsage: 200 });
const cusLevelProd = products.pro({
id: "cus-lvl",
items: [dashboardItem, messagesItem],
});
const entityProd = products.base({
id: "ent-prod",
items: [creditsItem],
});
const customerId = "get-cus-multi-ent-v2";
const { autumnV1, autumnV2_1, autumnV2_2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [cusLevelProd, entityProd] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: cusLevelProd.id }),
s.attach({ productId: entityProd.id, entityIndex: 0 }),
s.attach({ productId: entityProd.id, entityIndex: 1 }),
s.track({ featureId: TestFeature.Messages, value: 10 }),
],
});
// V1 (v1.2) -- products[] + features{} shape (ApiCustomerV3)
const cusV1 = await autumnV1.customers.get<ApiCustomerV3>(customerId);
ApiCustomerV3Schema.parse(cusV1);
expect(cusV1.products.length).toBeGreaterThan(0);
const cusV1Prod = cusV1.products[0];
expect(cusV1Prod.current_period_start).toBeNumber();
expect(cusV1Prod.current_period_end).toBeNumber();
expect(cusV1.features[TestFeature.Messages]).toMatchObject({
id: TestFeature.Messages,
balance: 90,
usage: 10,
included_usage: 100,
});
expect(cusV1.features[TestFeature.Dashboard]).toMatchObject({
id: TestFeature.Dashboard,
});
// V2.1 -- subscriptions (single array), flags split out, balances use V1 shape
const cusV2_1 = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
keepInternalFields: true,
});
ApiCustomerV5Schema.parse(cusV2_1);
expect(cusV2_1.subscriptions.length).toBeGreaterThan(0);
const cusV2_1Sub = cusV2_1.subscriptions[0];
expect(cusV2_1Sub.current_period_start).toBeNumber();
expect(cusV2_1Sub.current_period_end).toBeNumber();
expectFlagCorrect({
customer: cusV2_1,
featureId: TestFeature.Dashboard,
planId: cusLevelProd.id,
expiresAt: null,
});
expectBalanceCorrect({
customer: cusV2_1,
featureId: TestFeature.Messages,
remaining: 90,
usage: 10,
planId: cusLevelProd.id,
});
expect(cusV2_1.balances[TestFeature.Dashboard]).toBeUndefined();
// V2.2 -- same shape as v2.1
const cusV2_2 = await autumnV2_2.customers.get<ApiCustomerV5>(customerId, {
keepInternalFields: true,
});
ApiCustomerV5Schema.parse(cusV2_2);
expect(cusV2_2.subscriptions.length).toBeGreaterThan(0);
const cusV2_2Sub = cusV2_2.subscriptions[0];
expect(cusV2_2Sub.current_period_start).toBeNumber();
expect(cusV2_2Sub.current_period_end).toBeNumber();
expectFlagCorrect({
customer: cusV2_2,
featureId: TestFeature.Dashboard,
planId: cusLevelProd.id,
expiresAt: null,
});
expectBalanceCorrect({
customer: cusV2_2,
featureId: TestFeature.Messages,
remaining: 90,
usage: 10,
planId: cusLevelProd.id,
});
const refDir = `${import.meta.dir}/../../../references`;
await Bun.write(`${refDir}/getCustomerV1Response.json`, JSON.stringify(cusV1, null, 2));
await Bun.write(`${refDir}/getCustomerV2_1Response.json`, JSON.stringify(cusV2_1, null, 2));
await Bun.write(`${refDir}/getCustomerV2_2Response.json`, JSON.stringify(cusV2_2, null, 2));
});
test.concurrent(`${chalk.yellowBright("get-customer: expand empty array returns items")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });

View File

@@ -5,12 +5,144 @@ import {
type ApiEntityV2,
} from "@autumn/shared";
import { ApiEntityV2Schema } from "@shared/api/entities/apiEntityV2";
import {
type ApiEntityV0,
ApiEntityV0Schema,
} from "@shared/api/entities/prevVersions/apiEntityV0";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// Multi-entity inheritance + deduction comparison tests (V2 cache baseline)
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("get-entity: entity inheriting customer-level product returns correct balances across versions")}`, async () => {
const dashboardItem = items.dashboard();
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const creditsItem = items.monthlyCredits({ includedUsage: 200 });
const cusLevelProd = products.pro({
id: "inherit-cus-lvl",
items: [dashboardItem, messagesItem],
});
const entityProd = products.base({
id: "inherit-ent-prod",
items: [creditsItem],
});
const customerId = "get-ent-inherit-v2";
const { autumnV1, autumnV2_1, autumnV2_2, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [cusLevelProd, entityProd] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: cusLevelProd.id }),
s.attach({ productId: entityProd.id, entityIndex: 0 }),
s.attach({ productId: entityProd.id, entityIndex: 1 }),
s.track({ featureId: TestFeature.Credits, value: 25, entityIndex: 0 }),
s.track({ featureId: TestFeature.Messages, value: 15, entityIndex: 1 }),
],
});
const entityId0 = entities[0].id;
const entityId1 = entities[1].id;
// ── Entity 0: V1 (v1.2 / ApiEntityV0 -- products[] + features{}) ──
const ent0V1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entityId0,
);
ApiEntityV0Schema.parse(ent0V1);
expect(ent0V1.products).toBeDefined();
expect(ent0V1.products!.length).toBeGreaterThan(0);
const ent0V1Prod = ent0V1.products![0];
expect(ent0V1Prod.current_period_start).toBeNumber();
expect(ent0V1Prod.current_period_end).toBeNumber();
expect(ent0V1.features).toBeDefined();
expect(ent0V1.features?.[TestFeature.Credits]).toMatchObject({
id: TestFeature.Credits,
balance: 175,
usage: 25,
included_usage: 200,
});
expect(ent0V1.features?.[TestFeature.Dashboard]).toMatchObject({
id: TestFeature.Dashboard,
});
// ── Entity 0: V2.1 (ApiEntityV2) ──
const ent0V2_1 = await autumnV2_1.entities.get<ApiEntityV2>(
customerId,
entityId0,
{ keepInternalFields: true },
);
ApiEntityV2Schema.parse(ent0V2_1);
expect(ent0V2_1.subscriptions.length).toBeGreaterThan(0);
const ent0V2_1Sub = ent0V2_1.subscriptions[0];
expect(ent0V2_1Sub.current_period_start).toBeNumber();
expect(ent0V2_1Sub.current_period_end).toBeNumber();
expect(ent0V2_1.balances[TestFeature.Credits]).toMatchObject({
remaining: 175,
usage: 25,
});
expect(ent0V2_1.balances[TestFeature.Dashboard]).toBeUndefined();
expect(ent0V2_1.flags[TestFeature.Dashboard]).toMatchObject({
feature_id: TestFeature.Dashboard,
plan_id: cusLevelProd.id,
expires_at: null,
});
// ── Entity 0: V2.2 ──
const ent0V2_2 = await autumnV2_2.entities.get<ApiEntityV2>(
customerId,
entityId0,
{ keepInternalFields: true },
);
ApiEntityV2Schema.parse(ent0V2_2);
expect(ent0V2_2.subscriptions.length).toBeGreaterThan(0);
const ent0V2_2Sub = ent0V2_2.subscriptions[0];
expect(ent0V2_2Sub.current_period_start).toBeNumber();
expect(ent0V2_2Sub.current_period_end).toBeNumber();
expect(ent0V2_2.balances[TestFeature.Credits]).toMatchObject({
remaining: 175,
usage: 25,
});
expect(ent0V2_2.flags[TestFeature.Dashboard]).toMatchObject({
feature_id: TestFeature.Dashboard,
});
// ── Entity 1: V2.1 -- verify cross-entity deduction ──
const ent1V2_1 = await autumnV2_1.entities.get<ApiEntityV2>(
customerId,
entityId1,
{ keepInternalFields: true },
);
ApiEntityV2Schema.parse(ent1V2_1);
expect(ent1V2_1.balances[TestFeature.Credits]).toMatchObject({
remaining: 200,
usage: 0,
});
expect(ent1V2_1.balances[TestFeature.Messages]).toMatchObject({
remaining: 85,
usage: 15,
});
expect(ent1V2_1.flags[TestFeature.Dashboard]).toMatchObject({
feature_id: TestFeature.Dashboard,
expires_at: null,
});
const refDir = `${import.meta.dir}/../../../references`;
await Bun.write(`${refDir}/getEntityV1Response.json`, JSON.stringify(ent0V1, null, 2));
await Bun.write(`${refDir}/getEntityV2_1Response.json`, JSON.stringify(ent0V2_1, null, 2));
await Bun.write(`${refDir}/getEntityV2_2Response.json`, JSON.stringify(ent0V2_2, null, 2));
});
test.concurrent(`${chalk.yellowBright("get-entity: v2.1 returns boolean features in flags")}`, async () => {
const dashboardItem = items.dashboard();
const messagesItem = items.monthlyMessages({ includedUsage: 100 });

View File

@@ -0,0 +1,662 @@
import { describe, expect, test } from "bun:test";
import { AppEnv, type FullCustomer } from "@autumn/shared";
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import { customers } from "@tests/utils/fixtures/db/customers";
import { entities as entityFixtures } from "@tests/utils/fixtures/db/entities";
import chalk from "chalk";
import { redis } from "@/external/redis/initRedis.js";
import { buildPathIndex } from "@/internal/customers/cache/pathIndex/buildPathIndex.js";
import { buildPathIndexKey } from "@/internal/customers/cache/pathIndex/pathIndexConfig.js";
import {
buildFullCustomerCacheKey,
FULL_CUSTOMER_CACHE_TTL_SECONDS,
} from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js";
// ═══════════════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════════════
const ORG_ID = "org_v2_perf";
const ENV = AppEnv.Sandbox;
const CUSTOMER_ID = "cus_v2_perf";
const ITERATIONS = 50;
const BOUNDED_CUSTOMER_PRODUCTS = 50;
const BOUNDED_CUSTOMER_CUS_ENTS_PER_PRODUCT = 3;
const BOUNDED_CUSTOMER_ENTITY_COUNT = 10;
const BOUNDED_CUSTOMER_EXTRA_CUS_ENTS = 5;
const ENTITY_SCOPED_PRODUCTS = 11;
const ENTITY_INHERITED_PRODUCTS = 50;
const ENTITY_CUS_ENTS_PER_PRODUCT = 3;
const ENTITY_EXTRA_CUS_ENTS = 5;
// ═══════════════════════════════════════════════════════════════════
// Fixture builders
// ═══════════════════════════════════════════════════════════════════
const buildBoundedFullCustomer = (): FullCustomer => {
const entitiesList = Array.from(
{ length: BOUNDED_CUSTOMER_ENTITY_COUNT },
(_, i) =>
entityFixtures.create({
id: `entity_${i}`,
featureId: `entity_feature_${i}`,
}),
);
const cusProducts = Array.from(
{ length: BOUNDED_CUSTOMER_PRODUCTS },
(_, cpIdx) => {
const cusEnts = Array.from(
{ length: BOUNDED_CUSTOMER_CUS_ENTS_PER_PRODUCT },
(_, ceIdx) =>
customerEntitlements.create({
id: `bounded_ce_${cpIdx}_${ceIdx}`,
featureId: `feature_${ceIdx}`,
featureName: `Feature ${ceIdx}`,
allowance: 1000,
balance: 500,
customerProductId: `bounded_cp_${cpIdx}`,
}),
);
return customerProducts.create({
id: `bounded_cp_${cpIdx}`,
productId: `prod_cus_${cpIdx}`,
customerEntitlements: cusEnts,
});
},
);
const extraCustomerEntitlements = Array.from(
{ length: BOUNDED_CUSTOMER_EXTRA_CUS_ENTS },
(_, i) =>
customerEntitlements.create({
id: `bounded_extra_ce_${i}`,
featureId: `extra_feature_${i}`,
featureName: `Extra Feature ${i}`,
allowance: 500,
balance: 250,
customerProductId: undefined,
}),
);
for (const extraCusEnt of extraCustomerEntitlements) {
extraCusEnt.customer_product_id = null as unknown as string;
}
return {
...customers.create({ customerProducts: cusProducts }),
id: CUSTOMER_ID,
org_id: ORG_ID,
env: ENV,
entities: entitiesList,
extra_customer_entitlements: extraCustomerEntitlements,
};
};
const buildFullEntity = (): FullCustomer => {
const entityScopedProducts = Array.from(
{ length: ENTITY_SCOPED_PRODUCTS },
(_, cpIdx) => {
const cusEnts = Array.from(
{ length: ENTITY_CUS_ENTS_PER_PRODUCT },
(_, ceIdx) =>
customerEntitlements.create({
id: `ent_scope_ce_${cpIdx}_${ceIdx}`,
featureId: `ent_feature_${ceIdx}`,
featureName: `Entity Feature ${ceIdx}`,
allowance: 1000,
balance: 500,
customerProductId: `ent_scope_cp_${cpIdx}`,
entityFeatureId: `entity_feature_0`,
entities: {
entity_0: { id: "entity_0", balance: 500, adjustment: 0 },
},
}),
);
return customerProducts.create({
id: `ent_scope_cp_${cpIdx}`,
productId: `prod_ent_scope_${cpIdx}`,
customerEntitlements: cusEnts,
internalEntityId: "internal_entity_0",
entityId: "entity_0",
});
},
);
const inheritedProducts = Array.from(
{ length: ENTITY_INHERITED_PRODUCTS },
(_, cpIdx) => {
const cusEnts = Array.from(
{ length: ENTITY_CUS_ENTS_PER_PRODUCT },
(_, ceIdx) =>
customerEntitlements.create({
id: `ent_inherit_ce_${cpIdx}_${ceIdx}`,
featureId: `cus_feature_${ceIdx}`,
featureName: `Customer Feature ${ceIdx}`,
allowance: 2000,
balance: 1000,
customerProductId: `ent_inherit_cp_${cpIdx}`,
}),
);
return customerProducts.create({
id: `ent_inherit_cp_${cpIdx}`,
productId: `prod_cus_level_${cpIdx}`,
customerEntitlements: cusEnts,
});
},
);
const allProducts = [...entityScopedProducts, ...inheritedProducts];
const extraCustomerEntitlements = Array.from(
{ length: ENTITY_EXTRA_CUS_ENTS },
(_, i) =>
customerEntitlements.create({
id: `ent_extra_ce_${i}`,
featureId: `extra_feature_${i}`,
featureName: `Extra Feature ${i}`,
allowance: 500,
balance: 250,
customerProductId: undefined,
}),
);
for (const extraCusEnt of extraCustomerEntitlements) {
extraCusEnt.customer_product_id = null as unknown as string;
}
return {
...customers.create({ customerProducts: allProducts }),
id: CUSTOMER_ID,
org_id: ORG_ID,
env: ENV,
entities: [],
extra_customer_entitlements: extraCustomerEntitlements,
};
};
// ═══════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════
type SlowlogEntry = [
id: number,
timestamp: number,
durationMicros: number,
command: string[],
clientIp: string,
clientName: string,
];
const formatStats = (
timings: number[],
): { min: number; avg: number; p95: number; max: number } => {
const sorted = [...timings].sort((a, b) => a - b);
const sum = sorted.reduce((acc, t) => acc + t, 0);
const p95Index = Math.floor(sorted.length * 0.95);
return {
min: sorted[0],
avg: sum / sorted.length,
p95: sorted[p95Index],
max: sorted[sorted.length - 1],
};
};
const fmtMs = (ms: number) => `${ms.toFixed(2)}ms`;
const seedCache = async ({
fixture,
cacheKey,
}: {
fixture: FullCustomer;
cacheKey: string;
}) => {
const pathIndexEntries = buildPathIndex({ fullCustomer: fixture });
await redis.setFullCustomerCache(
cacheKey,
ORG_ID,
ENV,
CUSTOMER_ID,
String(Date.now()),
String(FULL_CUSTOMER_CACHE_TTL_SECONDS),
JSON.stringify(fixture),
"true",
JSON.stringify(pathIndexEntries),
);
return pathIndexEntries;
};
const runSlowlogBatch = async ({
fn,
iterations,
filterCommand,
}: {
fn: () => Promise<void>;
iterations: number;
filterCommand: string;
}): Promise<{ e2eTimings: number[]; serverTimings: number[] }> => {
await redis.call("CONFIG", "SET", "slowlog-log-slower-than", "0");
await redis.call("CONFIG", "SET", "slowlog-max-len", "1024");
await redis.call("SLOWLOG", "RESET");
const e2eTimings: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
await fn();
e2eTimings.push(performance.now() - start);
}
const rawEntries = (await redis.call(
"SLOWLOG",
"GET",
"1024",
)) as SlowlogEntry[];
const matchingEntries = rawEntries.filter(
(entry) =>
entry[3] &&
(entry[3][0] === filterCommand ||
entry[3][0] === filterCommand.toLowerCase()),
);
const serverTimings = matchingEntries.map((entry) => entry[2] / 1000);
await redis.call("CONFIG", "SET", "slowlog-log-slower-than", "10000");
return { e2eTimings, serverTimings };
};
const buildDeductionParams = ({
iteration,
entitlementCount,
fixture,
}: {
iteration: number;
entitlementCount: number;
fixture: FullCustomer;
}) => {
const totalProducts = fixture.customer_products.length;
const cusEntsPerProduct =
fixture.customer_products[0]?.customer_entitlements.length ?? 1;
const sortedEntitlements = Array.from(
{ length: entitlementCount },
(_, idx) => {
const cpIdx = (iteration * entitlementCount + idx) % totalProducts;
const ceIdx = (iteration * entitlementCount + idx) % cusEntsPerProduct;
const cusEnt =
fixture.customer_products[cpIdx]?.customer_entitlements[ceIdx];
const entityFeatureId = cusEnt?.entitlement?.entity_feature_id ?? null;
return {
customer_entitlement_id: cusEnt?.id ?? `fallback_ce_${idx}`,
credit_cost: 1,
feature_id: cusEnt?.feature_id ?? `feature_${ceIdx}`,
entity_feature_id: entityFeatureId,
usage_allowed: true,
min_balance: null,
max_balance: null,
};
},
);
return {
org_id: ORG_ID,
env: ENV,
customer_id: CUSTOMER_ID,
sorted_entitlements: sortedEntitlements,
spend_limit_by_feature_id: null,
usage_based_cus_ent_ids_by_feature_id: null,
amount_to_deduct: 1,
target_balance: null,
target_entity_id: null,
rollovers: null,
skip_additional_balance: false,
alter_granted_balance: false,
overage_behaviour: "allow",
feature_id: sortedEntitlements[0]?.feature_id ?? "feature_0",
lock: null,
unwind_value: null,
lock_receipt_key: null,
};
};
// ═══════════════════════════════════════════════════════════════════
// Results collection
// ═══════════════════════════════════════════════════════════════════
type BenchResult = {
e2e: ReturnType<typeof formatStats>;
server: ReturnType<typeof formatStats> | null;
};
const results: Record<string, { bounded: BenchResult; entity: BenchResult }> =
{};
const CUSTOMER_CACHE_KEY = buildFullCustomerCacheKey({
orgId: ORG_ID,
env: ENV,
customerId: CUSTOMER_ID,
});
const CUSTOMER_PATH_IDX_KEY = buildPathIndexKey({
orgId: ORG_ID,
env: ENV,
customerId: CUSTOMER_ID,
});
const ENTITY_CACHE_KEY_PREFIX = `{${ORG_ID}}:${ENV}:fullentity:1.0.0:${CUSTOMER_ID}:entity_0`;
const ENTITY_PATH_IDX_KEY_PREFIX = `{${ORG_ID}}:${ENV}:fullentity:pathidx:${CUSTOMER_ID}:entity_0`;
// ═══════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════
describe(
chalk.blueBright("V2 Cache Performance: Bounded FullCustomer vs FullEntity"),
() => {
const boundedFixture = buildBoundedFullCustomer();
const entityFixture = buildFullEntity();
test("fixture sizes", () => {
const boundedSize = JSON.stringify(boundedFixture).length;
const entitySize = JSON.stringify(entityFixture).length;
console.log(chalk.bold("\n ─── Fixture Sizes ───"));
console.log(
` Bounded FullCustomer: ${(boundedSize / 1024).toFixed(1)} KB (${BOUNDED_CUSTOMER_PRODUCTS} products, ${BOUNDED_CUSTOMER_PRODUCTS * BOUNDED_CUSTOMER_CUS_ENTS_PER_PRODUCT} cusEnts, ${BOUNDED_CUSTOMER_ENTITY_COUNT} entities)`,
);
console.log(
` FullEntity: ${(entitySize / 1024).toFixed(1)} KB (${ENTITY_SCOPED_PRODUCTS + ENTITY_INHERITED_PRODUCTS} products, ${(ENTITY_SCOPED_PRODUCTS + ENTITY_INHERITED_PRODUCTS) * ENTITY_CUS_ENTS_PER_PRODUCT} cusEnts)`,
);
});
// ── JSON.SET ──
test("JSON.SET (full write) — Bounded FullCustomer", async () => {
const serialized = JSON.stringify(boundedFixture);
const { e2eTimings, serverTimings } = await runSlowlogBatch({
fn: async () => {
await redis.call("JSON.SET", CUSTOMER_CACHE_KEY, ".", serialized);
},
iterations: ITERATIONS,
filterCommand: "JSON.SET",
});
results["JSON.SET"] = {
...results["JSON.SET"],
bounded: {
e2e: formatStats(e2eTimings),
server: serverTimings.length > 0 ? formatStats(serverTimings) : null,
},
} as (typeof results)["JSON.SET"];
});
test("JSON.SET (full write) — FullEntity", async () => {
const serialized = JSON.stringify(entityFixture);
const { e2eTimings, serverTimings } = await runSlowlogBatch({
fn: async () => {
await redis.call(
"JSON.SET",
ENTITY_CACHE_KEY_PREFIX,
".",
serialized,
);
},
iterations: ITERATIONS,
filterCommand: "JSON.SET",
});
results["JSON.SET"] = {
...results["JSON.SET"],
entity: {
e2e: formatStats(e2eTimings),
server: serverTimings.length > 0 ? formatStats(serverTimings) : null,
},
} as (typeof results)["JSON.SET"];
});
// ── JSON.GET ──
test("JSON.GET (full read + parse) — Bounded FullCustomer", async () => {
await redis.call(
"JSON.SET",
CUSTOMER_CACHE_KEY,
".",
JSON.stringify(boundedFixture),
);
const { e2eTimings, serverTimings } = await runSlowlogBatch({
fn: async () => {
const raw = (await redis.call(
"JSON.GET",
CUSTOMER_CACHE_KEY,
)) as string;
expect(raw).toBeTruthy();
JSON.parse(raw);
},
iterations: ITERATIONS,
filterCommand: "JSON.GET",
});
results["JSON.GET"] = {
...results["JSON.GET"],
bounded: {
e2e: formatStats(e2eTimings),
server: serverTimings.length > 0 ? formatStats(serverTimings) : null,
},
} as (typeof results)["JSON.GET"];
});
test("JSON.GET (full read + parse) — FullEntity", async () => {
await redis.call(
"JSON.SET",
ENTITY_CACHE_KEY_PREFIX,
".",
JSON.stringify(entityFixture),
);
const { e2eTimings, serverTimings } = await runSlowlogBatch({
fn: async () => {
const raw = (await redis.call(
"JSON.GET",
ENTITY_CACHE_KEY_PREFIX,
)) as string;
expect(raw).toBeTruthy();
JSON.parse(raw);
},
iterations: ITERATIONS,
filterCommand: "JSON.GET",
});
results["JSON.GET"] = {
...results["JSON.GET"],
entity: {
e2e: formatStats(e2eTimings),
server: serverTimings.length > 0 ? formatStats(serverTimings) : null,
},
} as (typeof results)["JSON.GET"];
});
// ── setFullCustomerCache (Lua) ──
test("setFullCustomerCache (Lua) — Bounded FullCustomer", async () => {
const serialized = JSON.stringify(boundedFixture);
const pathIndexEntries = buildPathIndex({ fullCustomer: boundedFixture });
const pathIndexJson = JSON.stringify(pathIndexEntries);
const { e2eTimings, serverTimings } = await runSlowlogBatch({
fn: async () => {
await redis.setFullCustomerCache(
CUSTOMER_CACHE_KEY,
ORG_ID,
ENV,
CUSTOMER_ID,
String(Date.now()),
String(FULL_CUSTOMER_CACHE_TTL_SECONDS),
serialized,
"true",
pathIndexJson,
);
},
iterations: ITERATIONS,
filterCommand: "EVALSHA",
});
results["setCache (Lua)"] = {
...results["setCache (Lua)"],
bounded: {
e2e: formatStats(e2eTimings),
server: serverTimings.length > 0 ? formatStats(serverTimings) : null,
},
} as (typeof results)["setCache (Lua)"];
});
test("setFullCustomerCache (Lua) — FullEntity", async () => {
const serialized = JSON.stringify(entityFixture);
const pathIndexEntries = buildPathIndex({ fullCustomer: entityFixture });
const pathIndexJson = JSON.stringify(pathIndexEntries);
const { e2eTimings, serverTimings } = await runSlowlogBatch({
fn: async () => {
await redis.setFullCustomerCache(
ENTITY_CACHE_KEY_PREFIX,
ORG_ID,
ENV,
CUSTOMER_ID,
String(Date.now()),
String(FULL_CUSTOMER_CACHE_TTL_SECONDS),
serialized,
"true",
pathIndexJson,
);
},
iterations: ITERATIONS,
filterCommand: "EVALSHA",
});
results["setCache (Lua)"] = {
...results["setCache (Lua)"],
entity: {
e2e: formatStats(e2eTimings),
server: serverTimings.length > 0 ? formatStats(serverTimings) : null,
},
} as (typeof results)["setCache (Lua)"];
});
// ── Deduction benchmarks ──
for (const entCount of [1, 5, 10]) {
const label = `deduct ${entCount} ent`;
test(`deductFromCustomerEntitlements (${entCount} ent) — Bounded FullCustomer`, async () => {
await seedCache({
fixture: boundedFixture,
cacheKey: CUSTOMER_CACHE_KEY,
});
const { e2eTimings, serverTimings } = await runSlowlogBatch({
fn: async () => {
const params = buildDeductionParams({
iteration: Math.floor(Math.random() * 1000),
entitlementCount: entCount,
fixture: boundedFixture,
});
const result = await redis.deductFromCustomerEntitlements(
CUSTOMER_CACHE_KEY,
JSON.stringify(params),
);
const parsed = JSON.parse(result);
expect(parsed.error).toBeNull();
},
iterations: ITERATIONS,
filterCommand: "EVALSHA",
});
results[label] = {
...results[label],
bounded: {
e2e: formatStats(e2eTimings),
server:
serverTimings.length > 0 ? formatStats(serverTimings) : null,
},
} as (typeof results)[typeof label];
});
test(`deductFromCustomerEntitlements (${entCount} ent) — FullEntity`, async () => {
await seedCache({
fixture: entityFixture,
cacheKey: ENTITY_CACHE_KEY_PREFIX,
});
const { e2eTimings, serverTimings } = await runSlowlogBatch({
fn: async () => {
const params = buildDeductionParams({
iteration: Math.floor(Math.random() * 1000),
entitlementCount: entCount,
fixture: entityFixture,
});
const result = await redis.deductFromCustomerEntitlements(
ENTITY_CACHE_KEY_PREFIX,
JSON.stringify(params),
);
const parsed = JSON.parse(result);
expect(parsed.error).toBeNull();
},
iterations: ITERATIONS,
filterCommand: "EVALSHA",
});
results[label] = {
...results[label],
entity: {
e2e: formatStats(e2eTimings),
server:
serverTimings.length > 0 ? formatStats(serverTimings) : null,
},
} as (typeof results)[typeof label];
});
}
// ── Comparison table ──
test("print comparison table", async () => {
const pad = (str: string, len: number) => str.padStart(len);
console.log(
chalk.bold(
"\n ─── V2 Cache Performance: Bounded FullCustomer vs FullEntity ───",
),
);
console.log(
`${"Operation".padEnd(24)}${"FullCustomer (server)".padEnd(22)}${"FullEntity (server)".padEnd(22)}`,
);
console.log(`${"─".repeat(25)}${"─".repeat(23)}${"─".repeat(23)}`);
const operations = [
"JSON.SET",
"JSON.GET",
"setCache (Lua)",
"deduct 1 ent",
"deduct 5 ent",
"deduct 10 ent",
];
for (const op of operations) {
const entry = results[op];
if (!entry) continue;
const boundedVal = entry.bounded?.server
? pad(fmtMs(entry.bounded.server.avg), 12)
: pad("N/A", 12);
const entityVal = entry.entity?.server
? pad(fmtMs(entry.entity.server.avg), 12)
: pad("N/A", 12);
console.log(
`${op.padEnd(24)}${boundedVal.padEnd(22)}${entityVal.padEnd(22)}`,
);
}
// Cleanup test keys
await redis.call("DEL", CUSTOMER_CACHE_KEY);
await redis.call("DEL", CUSTOMER_PATH_IDX_KEY);
await redis.call("DEL", ENTITY_CACHE_KEY_PREFIX);
await redis.call("DEL", ENTITY_PATH_IDX_KEY_PREFIX);
});
},
);

View File

@@ -0,0 +1,237 @@
{
"id": "get-cus-multi-ent-v2",
"created_at": 1774899525971,
"name": "get-cus-multi-ent-v2",
"email": "get-cus-multi-ent-v2@example.com",
"fingerprint": null,
"stripe_id": "cus_UFGA5ZzfGCckTr",
"env": "sandbox",
"metadata": {},
"send_email_receipts": false,
"products": [
{
"id": "cus-lvl_get-cus-multi-ent-v2",
"name": "Cus Lvl get-cus-multi-ent-v2",
"group": "get-cus-multi-ent-v2",
"status": "active",
"canceled_at": null,
"started_at": 1774899525000,
"is_default": false,
"is_add_on": false,
"version": 1,
"current_period_start": 1774899525000,
"current_period_end": 1777577925000,
"items": [
{
"type": "price",
"feature_id": null,
"feature": null,
"interval": "month",
"interval_count": 1,
"price": 20,
"display": {
"primary_text": "$20",
"secondary_text": "per month"
}
},
{
"type": "feature",
"feature_id": "dashboard",
"feature_type": "static",
"feature": {
"id": "dashboard",
"name": "Dashboard",
"type": "boolean",
"display": {
"singular": "Dashboard",
"plural": "Dashboard"
}
},
"included_usage": 0,
"interval": null,
"reset_usage_when_enabled": false,
"display": {
"primary_text": "Dashboard"
}
},
{
"type": "feature",
"feature_id": "messages",
"feature_type": "single_use",
"feature": {
"id": "messages",
"name": "Messages",
"type": "single_use",
"display": {
"singular": "Messages",
"plural": "Messages"
}
},
"included_usage": 100,
"interval": "month",
"reset_usage_when_enabled": true,
"display": {
"primary_text": "100 Messages"
}
}
],
"quantity": 1
},
{
"id": "ent-prod_get-cus-multi-ent-v2",
"name": "Ent Prod get-cus-multi-ent-v2",
"group": "get-cus-multi-ent-v2",
"status": "active",
"canceled_at": null,
"started_at": 1774899538120,
"is_default": false,
"is_add_on": false,
"version": 1,
"current_period_start": null,
"current_period_end": null,
"items": [
{
"type": "feature",
"feature_id": "credits",
"feature_type": "single_use",
"feature": {
"id": "credits",
"name": "Credits",
"type": "credit_system",
"display": {
"singular": "Credits",
"plural": "Credits"
},
"credit_schema": [
{
"metered_feature_id": "action1",
"credit_cost": 0.2
},
{
"metered_feature_id": "action2",
"credit_cost": 0.6
}
]
},
"included_usage": 200,
"interval": "month",
"reset_usage_when_enabled": true,
"display": {
"primary_text": "200 Credits"
}
}
],
"quantity": 2
}
],
"features": {
"messages": {
"id": "messages",
"type": "single_use",
"name": "Messages",
"interval": "month",
"interval_count": 1,
"unlimited": false,
"balance": 90,
"usage": 10,
"included_usage": 100,
"next_reset_at": 1777577925000,
"overage_allowed": false,
"breakdown": [
{
"interval": "month",
"interval_count": 1,
"balance": 90,
"usage": 10,
"included_usage": 100,
"next_reset_at": 1777577925000,
"expires_at": null,
"overage_allowed": false
}
]
},
"credits": {
"id": "credits",
"type": "single_use",
"name": "Credits",
"interval": "month",
"interval_count": 1,
"unlimited": false,
"balance": 400,
"usage": 0,
"included_usage": 400,
"next_reset_at": 1777577925000,
"overage_allowed": false,
"breakdown": [
{
"interval": "month",
"interval_count": 1,
"balance": 200,
"usage": 0,
"included_usage": 200,
"next_reset_at": 1777577925000,
"expires_at": null,
"overage_allowed": false
},
{
"interval": "month",
"interval_count": 1,
"balance": 200,
"usage": 0,
"included_usage": 200,
"next_reset_at": 1777577925000,
"expires_at": null,
"overage_allowed": false
}
],
"credit_schema": [
{
"feature_id": "action1",
"credit_amount": 0.2
},
{
"feature_id": "action2",
"credit_amount": 0.6
}
]
},
"dashboard": {
"id": "dashboard",
"type": "static",
"name": "Dashboard",
"interval": null,
"interval_count": null,
"unlimited": false,
"balance": 0,
"usage": 0,
"included_usage": 0,
"next_reset_at": null,
"overage_allowed": false,
"breakdown": [
{
"interval": null,
"interval_count": null,
"balance": 0,
"usage": 0,
"included_usage": 0,
"next_reset_at": null,
"expires_at": null,
"overage_allowed": false
}
]
}
},
"invoices": [
{
"product_ids": [
"cus-lvl_get-cus-multi-ent-v2"
],
"stripe_id": "in_1TGle86GVhEVh7f8YnHsCA2a",
"status": "paid",
"total": 20,
"currency": "usd",
"created_at": 1774899525000,
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3Bg8p9i1EF5PBHdyGGKiXPif0Fh"
}
]
}

View File

@@ -0,0 +1,161 @@
{
"id": "get-cus-multi-ent-v2",
"name": "get-cus-multi-ent-v2",
"email": "get-cus-multi-ent-v2@example.com",
"created_at": 1774899525971,
"fingerprint": null,
"stripe_id": "cus_UFGA5ZzfGCckTr",
"env": "sandbox",
"metadata": {},
"send_email_receipts": false,
"billing_controls": {},
"subscriptions": [
{
"id": "cus_prod_3Bg8oSenpkLioqPDqivRVwPkZlR",
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899525000,
"current_period_start": 1774899525000,
"current_period_end": 1777577925000,
"quantity": 1
},
{
"id": "cus_prod_3Bg8ptPJoAK4E6yg8e5HSBIq1ua",
"plan_id": "ent-prod_get-cus-multi-ent-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899538120,
"current_period_start": null,
"current_period_end": null,
"quantity": 1
},
{
"id": "cus_prod_3Bg8r3sP3VIWje27a2JXz20qQZl",
"plan_id": "ent-prod_get-cus-multi-ent-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899547029,
"current_period_start": null,
"current_period_end": null,
"quantity": 1
}
],
"purchases": [],
"balances": {
"messages": {
"object": "balance",
"feature_id": "messages",
"granted": 100,
"remaining": 90,
"usage": 10,
"unlimited": false,
"overage_allowed": false,
"max_purchase": null,
"next_reset_at": 1777577925000,
"breakdown": [
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8oV49d9Ha0nM3ZjMyVbUh2P7",
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
"included_grant": 100,
"prepaid_grant": 0,
"remaining": 90,
"usage": 10,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577925000
},
"price": null,
"expires_at": null,
"overage": 0
}
]
},
"credits": {
"object": "balance",
"feature_id": "credits",
"granted": 400,
"remaining": 400,
"usage": 0,
"unlimited": false,
"overage_allowed": false,
"max_purchase": null,
"next_reset_at": 1777577925000,
"breakdown": [
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8pukfXtchhUxe2eSJVIREqGk",
"plan_id": "ent-prod_get-cus-multi-ent-v2",
"included_grant": 200,
"prepaid_grant": 0,
"remaining": 200,
"usage": 0,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577925000
},
"price": null,
"expires_at": null,
"overage": 0
},
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8r46EP6GwjNS61sKgWlZB8PM",
"plan_id": "ent-prod_get-cus-multi-ent-v2",
"included_grant": 200,
"prepaid_grant": 0,
"remaining": 200,
"usage": 0,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577925000
},
"price": null,
"expires_at": null,
"overage": 0
}
]
}
},
"flags": {
"dashboard": {
"object": "flag",
"id": "cus_ent_3Bg8oYXYhJrMlktOfLClNx4hcLW",
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
"expires_at": null,
"feature_id": "dashboard"
}
},
"invoices": [
{
"plan_ids": [
"cus-lvl_get-cus-multi-ent-v2"
],
"stripe_id": "in_1TGle86GVhEVh7f8YnHsCA2a",
"status": "paid",
"total": 20,
"currency": "usd",
"created_at": 1774899525000,
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3Bg8p9i1EF5PBHdyGGKiXPif0Fh"
}
]
}

View File

@@ -0,0 +1,161 @@
{
"id": "get-cus-multi-ent-v2",
"name": "get-cus-multi-ent-v2",
"email": "get-cus-multi-ent-v2@example.com",
"created_at": 1774899525971,
"fingerprint": null,
"stripe_id": "cus_UFGA5ZzfGCckTr",
"env": "sandbox",
"metadata": {},
"send_email_receipts": false,
"billing_controls": {},
"subscriptions": [
{
"id": "cus_prod_3Bg8oSenpkLioqPDqivRVwPkZlR",
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899525000,
"current_period_start": 1774899525000,
"current_period_end": 1777577925000,
"quantity": 1
},
{
"id": "cus_prod_3Bg8ptPJoAK4E6yg8e5HSBIq1ua",
"plan_id": "ent-prod_get-cus-multi-ent-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899538120,
"current_period_start": null,
"current_period_end": null,
"quantity": 1
},
{
"id": "cus_prod_3Bg8r3sP3VIWje27a2JXz20qQZl",
"plan_id": "ent-prod_get-cus-multi-ent-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899547029,
"current_period_start": null,
"current_period_end": null,
"quantity": 1
}
],
"purchases": [],
"balances": {
"messages": {
"object": "balance",
"feature_id": "messages",
"granted": 100,
"remaining": 90,
"usage": 10,
"unlimited": false,
"overage_allowed": false,
"max_purchase": null,
"next_reset_at": 1777577925000,
"breakdown": [
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8oV49d9Ha0nM3ZjMyVbUh2P7",
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
"included_grant": 100,
"prepaid_grant": 0,
"remaining": 90,
"usage": 10,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577925000
},
"price": null,
"expires_at": null,
"overage": 0
}
]
},
"credits": {
"object": "balance",
"feature_id": "credits",
"granted": 400,
"remaining": 400,
"usage": 0,
"unlimited": false,
"overage_allowed": false,
"max_purchase": null,
"next_reset_at": 1777577925000,
"breakdown": [
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8pukfXtchhUxe2eSJVIREqGk",
"plan_id": "ent-prod_get-cus-multi-ent-v2",
"included_grant": 200,
"prepaid_grant": 0,
"remaining": 200,
"usage": 0,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577925000
},
"price": null,
"expires_at": null,
"overage": 0
},
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8r46EP6GwjNS61sKgWlZB8PM",
"plan_id": "ent-prod_get-cus-multi-ent-v2",
"included_grant": 200,
"prepaid_grant": 0,
"remaining": 200,
"usage": 0,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577925000
},
"price": null,
"expires_at": null,
"overage": 0
}
]
}
},
"flags": {
"dashboard": {
"object": "flag",
"id": "cus_ent_3Bg8oYXYhJrMlktOfLClNx4hcLW",
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
"expires_at": null,
"feature_id": "dashboard"
}
},
"invoices": [
{
"plan_ids": [
"cus-lvl_get-cus-multi-ent-v2"
],
"stripe_id": "in_1TGle86GVhEVh7f8YnHsCA2a",
"status": "paid",
"total": 20,
"currency": "usd",
"created_at": 1774899525000,
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3Bg8p9i1EF5PBHdyGGKiXPif0Fh"
}
]
}

View File

@@ -0,0 +1,223 @@
{
"id": "ent-1",
"name": "Entity 1",
"customer_id": "get-ent-inherit-v2",
"created_at": 1774899574222,
"env": "sandbox",
"products": [
{
"id": "inherit-cus-lvl_get-ent-inherit-v2",
"name": "Inherit Cus Lvl get-ent-inherit-v2",
"group": "get-ent-inherit-v2",
"status": "active",
"canceled_at": null,
"started_at": 1774899571000,
"is_default": false,
"is_add_on": false,
"version": 1,
"current_period_start": 1774899571000,
"current_period_end": 1777577971000,
"items": [
{
"type": "price",
"feature_id": null,
"feature": null,
"interval": "month",
"interval_count": 1,
"price": 20,
"display": {
"primary_text": "$20",
"secondary_text": "per month"
}
},
{
"type": "feature",
"feature_id": "dashboard",
"feature_type": "static",
"feature": {
"id": "dashboard",
"name": "Dashboard",
"type": "boolean",
"display": {
"singular": "Dashboard",
"plural": "Dashboard"
}
},
"included_usage": 0,
"interval": null,
"reset_usage_when_enabled": false,
"display": {
"primary_text": "Dashboard"
}
},
{
"type": "feature",
"feature_id": "messages",
"feature_type": "single_use",
"feature": {
"id": "messages",
"name": "Messages",
"type": "single_use",
"display": {
"singular": "Messages",
"plural": "Messages"
}
},
"included_usage": 100,
"interval": "month",
"reset_usage_when_enabled": true,
"display": {
"primary_text": "100 Messages"
}
}
],
"quantity": 1
},
{
"id": "inherit-ent-prod_get-ent-inherit-v2",
"name": "Inherit Ent Prod get-ent-inherit-v2",
"group": "get-ent-inherit-v2",
"status": "active",
"canceled_at": null,
"started_at": 1774899584964,
"is_default": false,
"is_add_on": false,
"version": 1,
"current_period_start": null,
"current_period_end": null,
"items": [
{
"type": "feature",
"feature_id": "credits",
"feature_type": "single_use",
"feature": {
"id": "credits",
"name": "Credits",
"type": "credit_system",
"display": {
"singular": "Credits",
"plural": "Credits"
},
"credit_schema": [
{
"metered_feature_id": "action1",
"credit_cost": 0.2
},
{
"metered_feature_id": "action2",
"credit_cost": 0.6
}
]
},
"included_usage": 200,
"interval": "month",
"reset_usage_when_enabled": true,
"display": {
"primary_text": "200 Credits"
}
}
],
"quantity": 1
}
],
"features": {
"credits": {
"id": "credits",
"type": "single_use",
"name": "Credits",
"interval": "month",
"interval_count": 1,
"unlimited": false,
"balance": 175,
"usage": 25,
"included_usage": 200,
"next_reset_at": 1777577971000,
"overage_allowed": false,
"breakdown": [
{
"interval": "month",
"interval_count": 1,
"balance": 175,
"usage": 25,
"included_usage": 200,
"next_reset_at": 1777577971000,
"expires_at": null,
"overage_allowed": false
}
],
"credit_schema": [
{
"feature_id": "action1",
"credit_amount": 0.2
},
{
"feature_id": "action2",
"credit_amount": 0.6
}
]
},
"messages": {
"id": "messages",
"type": "single_use",
"name": "Messages",
"interval": "month",
"interval_count": 1,
"unlimited": false,
"balance": 85,
"usage": 15,
"included_usage": 100,
"next_reset_at": 1777577971000,
"overage_allowed": false,
"breakdown": [
{
"interval": "month",
"interval_count": 1,
"balance": 85,
"usage": 15,
"included_usage": 100,
"next_reset_at": 1777577971000,
"expires_at": null,
"overage_allowed": false
}
]
},
"dashboard": {
"id": "dashboard",
"type": "static",
"name": "Dashboard",
"interval": null,
"interval_count": null,
"unlimited": false,
"balance": 0,
"usage": 0,
"included_usage": 0,
"next_reset_at": null,
"overage_allowed": false,
"breakdown": [
{
"interval": null,
"interval_count": null,
"balance": 0,
"usage": 0,
"included_usage": 0,
"next_reset_at": null,
"expires_at": null,
"overage_allowed": false
}
]
}
},
"invoices": [
{
"product_ids": [
"inherit-cus-lvl_get-ent-inherit-v2"
],
"stripe_id": "in_1TGlet6GVhEVh7f8nGL4bX2Q",
"status": "paid",
"total": 20,
"currency": "usd",
"created_at": 1774899571000,
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3Bg8uuukNBNJ5f2HoXK3zzz4Hq7"
}
]
}

View File

@@ -0,0 +1,125 @@
{
"id": "ent-1",
"name": "Entity 1",
"customer_id": "get-ent-inherit-v2",
"created_at": 1774899574222,
"env": "sandbox",
"subscriptions": [
{
"id": "cus_prod_3Bg8uMStCPvyLm8Z2MObn4SK8NE",
"plan_id": "inherit-cus-lvl_get-ent-inherit-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899571000,
"current_period_start": 1774899571000,
"current_period_end": 1777577971000,
"quantity": 1
},
{
"id": "cus_prod_3Bg8vfg3tSAoyYFvhaZ57YabLMT",
"plan_id": "inherit-ent-prod_get-ent-inherit-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899584964,
"current_period_start": null,
"current_period_end": null,
"quantity": 1
}
],
"purchases": [],
"balances": {
"credits": {
"object": "balance",
"feature_id": "credits",
"granted": 200,
"remaining": 175,
"usage": 25,
"unlimited": false,
"overage_allowed": false,
"max_purchase": null,
"next_reset_at": 1777577971000,
"breakdown": [
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8vfdwFiK5d0jbX7bl1rdHfUh",
"plan_id": "inherit-ent-prod_get-ent-inherit-v2",
"included_grant": 200,
"prepaid_grant": 0,
"remaining": 175,
"usage": 25,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577971000
},
"price": null,
"expires_at": null,
"overage": 0
}
]
},
"messages": {
"object": "balance",
"feature_id": "messages",
"granted": 100,
"remaining": 85,
"usage": 15,
"unlimited": false,
"overage_allowed": false,
"max_purchase": null,
"next_reset_at": 1777577971000,
"breakdown": [
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8uMhC7dJHCiQnJeJ4kwz4m9E",
"plan_id": "inherit-cus-lvl_get-ent-inherit-v2",
"included_grant": 100,
"prepaid_grant": 0,
"remaining": 85,
"usage": 15,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577971000
},
"price": null,
"expires_at": null,
"overage": 0
}
]
}
},
"flags": {
"dashboard": {
"object": "flag",
"id": "cus_ent_3Bg8uOEwgICbCrdv6CWa6HJdk4i",
"plan_id": "inherit-cus-lvl_get-ent-inherit-v2",
"expires_at": null,
"feature_id": "dashboard"
}
},
"billing_controls": {},
"invoices": [
{
"plan_ids": [
"inherit-cus-lvl_get-ent-inherit-v2"
],
"stripe_id": "in_1TGlet6GVhEVh7f8nGL4bX2Q",
"status": "paid",
"total": 20,
"currency": "usd",
"created_at": 1774899571000,
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3Bg8uuukNBNJ5f2HoXK3zzz4Hq7"
}
]
}

View File

@@ -0,0 +1,125 @@
{
"id": "ent-1",
"name": "Entity 1",
"customer_id": "get-ent-inherit-v2",
"created_at": 1774899574222,
"env": "sandbox",
"subscriptions": [
{
"id": "cus_prod_3Bg8uMStCPvyLm8Z2MObn4SK8NE",
"plan_id": "inherit-cus-lvl_get-ent-inherit-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899571000,
"current_period_start": 1774899571000,
"current_period_end": 1777577971000,
"quantity": 1
},
{
"id": "cus_prod_3Bg8vfg3tSAoyYFvhaZ57YabLMT",
"plan_id": "inherit-ent-prod_get-ent-inherit-v2",
"auto_enable": false,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1774899584964,
"current_period_start": null,
"current_period_end": null,
"quantity": 1
}
],
"purchases": [],
"balances": {
"credits": {
"object": "balance",
"feature_id": "credits",
"granted": 200,
"remaining": 175,
"usage": 25,
"unlimited": false,
"overage_allowed": false,
"max_purchase": null,
"next_reset_at": 1777577971000,
"breakdown": [
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8vfdwFiK5d0jbX7bl1rdHfUh",
"plan_id": "inherit-ent-prod_get-ent-inherit-v2",
"included_grant": 200,
"prepaid_grant": 0,
"remaining": 175,
"usage": 25,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577971000
},
"price": null,
"expires_at": null,
"overage": 0
}
]
},
"messages": {
"object": "balance",
"feature_id": "messages",
"granted": 100,
"remaining": 85,
"usage": 15,
"unlimited": false,
"overage_allowed": false,
"max_purchase": null,
"next_reset_at": 1777577971000,
"breakdown": [
{
"object": "balance_breakdown",
"id": "cus_ent_3Bg8uMhC7dJHCiQnJeJ4kwz4m9E",
"plan_id": "inherit-cus-lvl_get-ent-inherit-v2",
"included_grant": 100,
"prepaid_grant": 0,
"remaining": 85,
"usage": 15,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1777577971000
},
"price": null,
"expires_at": null,
"overage": 0
}
]
}
},
"flags": {
"dashboard": {
"object": "flag",
"id": "cus_ent_3Bg8uOEwgICbCrdv6CWa6HJdk4i",
"plan_id": "inherit-cus-lvl_get-ent-inherit-v2",
"expires_at": null,
"feature_id": "dashboard"
}
},
"billing_controls": {},
"invoices": [
{
"plan_ids": [
"inherit-cus-lvl_get-ent-inherit-v2"
],
"stripe_id": "in_1TGlet6GVhEVh7f8nGL4bX2Q",
"status": "paid",
"total": 20,
"currency": "usd",
"created_at": 1774899571000,
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3Bg8uuukNBNJ5f2HoXK3zzz4Hq7"
}
]
}

View File

@@ -63,6 +63,7 @@ export * from "./models/cusModels/entityModels/entityExpand";
export * from "./models/cusModels/entityModels/entityModels";
export * from "./models/cusModels/entityModels/entityTable";
export * from "./models/cusModels/fullCusModel";
export * from "./models/cusModels/fullSubjectModel";
export * from "./models/cusModels/invoiceModels/invoiceLineItemModels";
export * from "./models/cusModels/invoiceModels/invoiceLineItemTable";
export * from "./models/cusModels/invoiceModels/invoiceModels";

View File

@@ -65,5 +65,10 @@ export const entities = pgTable(
table.internal_customer_id,
sql`${table.internal_id} DESC`,
),
index("idx_entities_org_env_id").on(
table.org_id,
table.env,
table.id,
),
],
);

View File

@@ -1,11 +1,6 @@
import { ProductSchema } from "@models/productModels/productModels.js";
import { z } from "zod/v4";
import {
type AggregatedCustomerEntitlement,
FullAggregatedCustomerEntitlementSchema,
} from "../cusProductModels/cusEntModels/aggregatedCusEnt.js";
import type { FullCustomerEntitlement } from "../cusProductModels/cusEntModels/cusEntModels.js";
import type { CustomerPrice } from "../cusProductModels/cusPriceModels/cusPriceModels.js";
import {
CusProductSchema,
type FullCusProduct,
@@ -36,11 +31,6 @@ export const FullCustomerSchema = CustomerSchema.extend({
)
.optional(),
invoices: z.array(InvoiceSchema).optional(),
aggregated_customer_products: z.array(FullCusProductSchema).optional(),
aggregated_customer_entitlements: z
.array(FullAggregatedCustomerEntitlementSchema)
.optional(),
});
export type FullCustomer = Customer & {
@@ -56,10 +46,6 @@ export type FullCustomer = Customer & {
subscriptions?: Subscription[];
events?: Event[];
extra_customer_entitlements: FullCustomerEntitlement[];
aggregated_customer_products?: FullCusProduct[];
aggregated_customer_entitlements?: AggregatedCustomerEntitlement[];
aggregated_customer_prices?: CustomerPrice[];
};
export const CustomerWithProductsSchema = CustomerSchema.extend({

View File

@@ -0,0 +1,54 @@
import { z } from "zod/v4";
import { FullAggregatedCustomerEntitlementSchema } from "../cusProductModels/cusEntModels/aggregatedCusEnt.js";
import {
type FullCustomerEntitlement,
FullCustomerEntitlementSchema,
} from "../cusProductModels/cusEntModels/cusEntModels.js";
import { CustomerPriceSchema } from "../cusProductModels/cusPriceModels/cusPriceModels.js";
import {
type FullCusProduct,
FullCusProductSchema,
} from "../cusProductModels/cusProductModels.js";
import { SubscriptionSchema } from "../subModels/subModels.js";
import { type Customer, CustomerSchema } from "./cusModels.js";
import { type Entity, EntitySchema } from "./entityModels/entityModels.js";
import { InvoiceSchema } from "./invoiceModels/invoiceModels.js";
export const SubjectType = {
Customer: "customer",
Entity: "entity",
} as const;
export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType];
export const FullSubjectSchema = z.object({
subjectType: z.enum(["customer", "entity"]),
customerId: z.string(),
internalCustomerId: z.string(),
entityId: z.string().optional(),
internalEntityId: z.string().optional(),
customer: CustomerSchema,
entity: EntitySchema.optional(),
customer_products: z.array(FullCusProductSchema),
extra_customer_entitlements: z.array(FullCustomerEntitlementSchema),
subscriptions: z.array(SubscriptionSchema).optional(),
invoices: z.array(InvoiceSchema),
aggregated_customer_products: z.array(FullCusProductSchema).optional(),
aggregated_customer_entitlements: z
.array(FullAggregatedCustomerEntitlementSchema)
.optional(),
aggregated_customer_prices: z.array(CustomerPriceSchema).optional(),
});
export type FullSubject = z.infer<typeof FullSubjectSchema>;
/** Backward-compat type for entity DB layer files. */
export type FullEntity = Entity & {
customer: Customer;
customer_products: FullCusProduct[];
extra_customer_entitlements: FullCustomerEntitlement[];
};

View File

@@ -8,6 +8,15 @@ export const ms = {
months: (n: number) => n * 30 * 24 * 60 * 60 * 1000,
};
/** Converts time units to seconds. */
export const seconds = {
minutes: (n: number) => n * 60,
hours: (n: number) => n * 60 * 60,
days: (n: number) => n * 24 * 60 * 60,
weeks: (n: number) => n * 7 * 24 * 60 * 60,
months: (n: number) => n * 30 * 24 * 60 * 60,
};
/**
* Validates that a timestamp is in milliseconds (not seconds).
* Returns true if valid, false otherwise.

View File

@@ -1,10 +1,19 @@
import type { FullCustomer } from "../../../models/cusModels/fullCusModel.js";
import type { FullAggregatedCustomerEntitlement } from "../../../models/cusProductModels/cusEntModels/aggregatedCusEnt.js";
import type { CustomerPrice } from "../../../models/cusProductModels/cusPriceModels/cusPriceModels.js";
import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels.js";
type FullCustomerWithAggregations = FullCustomer & {
aggregated_customer_products?: FullCusProduct[];
aggregated_customer_entitlements?: FullAggregatedCustomerEntitlement[];
aggregated_customer_prices?: CustomerPrice[];
};
/** Logs a compact summary of a FullCustomer without flooding the terminal. */
export const logFullCustomer = ({
fullCustomer,
}: {
fullCustomer: FullCustomer;
fullCustomer: FullCustomerWithAggregations;
}) => {
const summarizeProduct = (cp: FullCustomer["customer_products"][number]) => ({
id: cp.id,
@@ -25,7 +34,9 @@ export const logFullCustomer = ({
});
const summarizeAggregatedEnt = (
ae: NonNullable<FullCustomer["aggregated_customer_entitlements"]>[number],
ae: NonNullable<
FullCustomerWithAggregations["aggregated_customer_entitlements"]
>[number],
) => ({
feature_id: ae.feature_id,
balance: ae.balance,