initial full customer queries
This commit is contained in:
@@ -2,6 +2,12 @@
|
||||
"plugins": {
|
||||
"linear": {
|
||||
"enabled": true
|
||||
},
|
||||
"redis-development": {
|
||||
"enabled": true
|
||||
},
|
||||
"planetscale": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
70
.plans/v2-cache-invalidation-indexes.md
Normal file
70
.plans/v2-cache-invalidation-indexes.md
Normal 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
|
||||
359
.plans/v2-cache-object-design.md
Normal file
359
.plans/v2-cache-object-design.md
Normal 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.
|
||||
120
.plans/v2-full-customer-cache.md
Normal file
120
.plans/v2-full-customer-cache.md
Normal 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
155
.plans/v2-handoff-prompt.md
Normal 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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
246
server/experiments/getFullEntityV2Experiment.ts
Normal file
246
server/experiments/getFullEntityV2Experiment.ts
Normal 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);
|
||||
134
server/experiments/getFullSubjectExperiment.ts
Normal file
134
server/experiments/getFullSubjectExperiment.ts
Normal 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);
|
||||
73
server/src/internal/customers/cache/fullSubject/deleteCachedFullSubject.ts
vendored
Normal file
73
server/src/internal/customers/cache/fullSubject/deleteCachedFullSubject.ts
vendored
Normal 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);
|
||||
};
|
||||
37
server/src/internal/customers/cache/fullSubject/fullSubjectCacheConfig.ts
vendored
Normal file
37
server/src/internal/customers/cache/fullSubject/fullSubjectCacheConfig.ts
vendored
Normal 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}`;
|
||||
151
server/src/internal/customers/cache/fullSubject/getCachedFullSubject.ts
vendored
Normal file
151
server/src/internal/customers/cache/fullSubject/getCachedFullSubject.ts
vendored
Normal 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);
|
||||
};
|
||||
70
server/src/internal/customers/cache/fullSubject/getOrSetCachedFullSubject.ts
vendored
Normal file
70
server/src/internal/customers/cache/fullSubject/getOrSetCachedFullSubject.ts
vendored
Normal 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;
|
||||
};
|
||||
90
server/src/internal/customers/cache/fullSubject/setCachedFullSubject.ts
vendored
Normal file
90
server/src/internal/customers/cache/fullSubject/setCachedFullSubject.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getFullCustomerV2 } from "./getFullCustomerV2.js";
|
||||
import { getFullSubject } from "./getFullSubject.js";
|
||||
|
||||
export const customerRepo = {
|
||||
getFullV2: getFullCustomerV2,
|
||||
getFullSubject,
|
||||
} as const;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
339
server/src/internal/customers/repos/getFullSubject.ts
Normal file
339
server/src/internal/customers/repos/getFullSubject.ts
Normal 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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
662
server/tests/perf/v2CachePerf.test.ts
Normal file
662
server/tests/perf/v2CachePerf.test.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
237
server/tests/references/getCustomerV1Response.json
Normal file
237
server/tests/references/getCustomerV1Response.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
161
server/tests/references/getCustomerV2_1Response.json
Normal file
161
server/tests/references/getCustomerV2_1Response.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
161
server/tests/references/getCustomerV2_2Response.json
Normal file
161
server/tests/references/getCustomerV2_2Response.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
223
server/tests/references/getEntityV1Response.json
Normal file
223
server/tests/references/getEntityV1Response.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
125
server/tests/references/getEntityV2_1Response.json
Normal file
125
server/tests/references/getEntityV2_1Response.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
125
server/tests/references/getEntityV2_2Response.json
Normal file
125
server/tests/references/getEntityV2_2Response.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
54
shared/models/cusModels/fullSubjectModel.ts
Normal file
54
shared/models/cusModels/fullSubjectModel.ts
Normal 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[];
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user