12 KiB
12 KiB
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
{
"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 bycusProductId, NOT an array. Contains the product definition, prices, and free_trial — but NOT customer_entitlements (those are inentitlements).entitlements: keyed bycusEntId. Each entitlement has acustomer_product_idfield to link back to its product. Contains the full entitlement definition, rollovers, and replaceables inline.extraEntitlements: keyed bycusEntId, for loose entitlements wherecustomer_product_idis 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
{
"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
entitiesarray,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 loosefind_entitlementfallback (O(n) scan) →JSON.GET cache_key $.entitlements["{cusEntId}"](O(1))- Path index Hash is eliminated entirely — no
HGET, noHSET, 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, viaentitlement.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)
// 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
// 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
// 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
// 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/FullEntityin-memory types getApiBalances,getApiSubscriptions,fullCustomerToCustomerEntitlements— all unchangedsyncItemV3— reads from cache, but the Lua deduction already writescusEntIdinto 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— deletedfullCustomerCacheConfig.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:
- Move each product group (
products[cpId]+ its entitlements fromentitlementswherecustomer_product_id === cpId) to a separate Redis key - The Lua deduction script just receives a different cache key — the entitlement path
$.entitlements["{cusEntId}"]stays identical within each product sub-doc - Full-read endpoints pipeline
JSON.GETacross product keys
No structural changes needed — just key routing.