From 50aa88b91f519b4758e5816ea094cfd55c89c24a Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:12:36 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20max=20entities=20edge=20?= =?UTF-8?q?config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/internal/customers/CusService.ts | 10 +- .../src/internal/customers/getFullCusQuery.ts | 14 +- .../misc/edgeConfig/orgLimitsSchemas.ts | 1 + .../misc/edgeConfig/orgLimitsStore.ts | 29 +++- .../get-entities-max-entities-limit.test.ts | 145 ++++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 server/tests/integration/crud/entities/get-entities-max-entities-limit.test.ts diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 4a980b301..5ec8ab44d 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -34,7 +34,10 @@ import { executeWithHealthTracking } from "@/db/pgHealthMonitor.js"; import type { RepoContext } from "@/db/repoContext.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { withSpan } from "../analytics/tracer/spanUtils.js"; -import { getOrgCusProductLimit } from "../misc/edgeConfig/orgLimitsStore.js"; +import { + getOrgCusProductLimit, + getOrgEntitiesLimit, +} from "../misc/edgeConfig/orgLimitsStore.js"; import { resetCustomerEntitlements } from "./actions/resetCustomerEntitlements/resetCustomerEntitlements.js"; import { ACTIVE_STATUSES, @@ -90,6 +93,10 @@ export class CusService { orgId, orgSlug: org.slug, }); + const entitiesLimit = getOrgEntitiesLimit({ + orgId, + orgSlug: org.slug, + }); const query = getFullCusQuery({ idOrInternalId, @@ -103,6 +110,7 @@ export class CusService { withEvents, entityId, cusProductLimit, + entitiesLimit, }); if (explain) { diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 159a0feca..2bcec666a 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -98,7 +98,13 @@ const buildOptimizedCusProductsCTE = ({ `; }; -const buildEntitiesCTE = (withEntities: boolean) => { +const buildEntitiesCTE = ({ + withEntities, + entitiesLimit, +}: { + withEntities: boolean; + entitiesLimit: number; +}) => { if (!withEntities) { return sql``; } @@ -114,7 +120,7 @@ const buildEntitiesCTE = (withEntities: boolean) => { SELECT * FROM entities e WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record) ORDER BY e.internal_id DESC - LIMIT 300 + LIMIT ${entitiesLimit} ) e ) `; @@ -284,6 +290,7 @@ export const getFullCusQuery = ({ withEvents, entityId, cusProductLimit, + entitiesLimit = 300, }: { idOrInternalId: string; orgId: string; @@ -296,6 +303,7 @@ export const getFullCusQuery = ({ withEvents: boolean; entityId?: string; cusProductLimit: number; + entitiesLimit?: number; }) => { const sqlChunks: SQL[] = []; @@ -316,7 +324,7 @@ export const getFullCusQuery = ({ // Step 2: Get entities if (withEntities) { sqlChunks.push(sql`, `); - sqlChunks.push(buildEntitiesCTE(withEntities)); + sqlChunks.push(buildEntitiesCTE({ withEntities, entitiesLimit })); } // Step 3: Get entity diff --git a/server/src/internal/misc/edgeConfig/orgLimitsSchemas.ts b/server/src/internal/misc/edgeConfig/orgLimitsSchemas.ts index d69ab51a8..a9fef4dab 100644 --- a/server/src/internal/misc/edgeConfig/orgLimitsSchemas.ts +++ b/server/src/internal/misc/edgeConfig/orgLimitsSchemas.ts @@ -6,6 +6,7 @@ export const OrgLimitsConfigSchema = z.object({ z.string(), z.object({ maxCusProducts: z.number().min(1).optional(), + maxEntities: z.number().min(1).optional(), }), ) .default({}), diff --git a/server/src/internal/misc/edgeConfig/orgLimitsStore.ts b/server/src/internal/misc/edgeConfig/orgLimitsStore.ts index 399a90d8a..cf605a91b 100644 --- a/server/src/internal/misc/edgeConfig/orgLimitsStore.ts +++ b/server/src/internal/misc/edgeConfig/orgLimitsStore.ts @@ -7,6 +7,7 @@ import { } from "./orgLimitsSchemas.js"; export const DEFAULT_CUS_PRODUCT_LIMIT = 15; +export const DEFAULT_ENTITIES_LIMIT = 300; const store = createEdgeConfigStore({ s3Key: ADMIN_ORG_LIMITS_CONFIG_KEY, @@ -28,11 +29,23 @@ export const getOrgCusProductLimit = ({ }): number => { const orgs = store.get().orgs; const orgConfig = - (orgId ? orgs[orgId] : undefined) ?? - (orgSlug ? orgs[orgSlug] : undefined); + (orgId ? orgs[orgId] : undefined) ?? (orgSlug ? orgs[orgSlug] : undefined); return orgConfig?.maxCusProducts ?? DEFAULT_CUS_PRODUCT_LIMIT; }; +export const getOrgEntitiesLimit = ({ + orgId, + orgSlug, +}: { + orgId?: string; + orgSlug?: string; +}): number => { + const orgs = store.get().orgs; + const orgConfig = + (orgId ? orgs[orgId] : undefined) ?? (orgSlug ? orgs[orgSlug] : undefined); + return orgConfig?.maxEntities ?? DEFAULT_ENTITIES_LIMIT; +}; + export const getOrgLimitsConfigFromSource = async () => { return await store.readFromSource(); }; @@ -63,3 +76,15 @@ export const updateFullOrgLimitsConfig = async ({ }) => { await store.writeToSource({ config }); }; + +/** + * Test-only helper: override the in-memory org limits config without touching S3. + * Use inside tests to simulate admin-configured org limits deterministically. + */ +export const _setOrgLimitsConfigForTesting = ({ + config, +}: { + config: OrgLimitsConfig; +}) => { + store._setRuntimeConfigForTesting(config); +}; diff --git a/server/tests/integration/crud/entities/get-entities-max-entities-limit.test.ts b/server/tests/integration/crud/entities/get-entities-max-entities-limit.test.ts new file mode 100644 index 000000000..3072f595c --- /dev/null +++ b/server/tests/integration/crud/entities/get-entities-max-entities-limit.test.ts @@ -0,0 +1,145 @@ +import { afterAll, expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; +import type { OrgLimitsConfig } from "@/internal/misc/edgeConfig/orgLimitsSchemas.js"; +import { + _setOrgLimitsConfigForTesting, + DEFAULT_ENTITIES_LIMIT, + getOrgEntitiesLimit, +} from "@/internal/misc/edgeConfig/orgLimitsStore.js"; + +/** + * These tests mutate the in-memory org limits store (a module-level singleton) + * so they must run serially, not concurrently, to avoid leaking state into + * unrelated tests. + * + * They call CusService.getFull directly (in-process) rather than going through + * the HTTP server, because the running server has its own copy of the + * orgLimitsStore that only refreshes from S3 every 30s. Calling in-process + * exercises the same SQL path (getFullCusQuery -> buildEntitiesCTE) while + * picking up the in-memory override immediately. + */ + +const resetOrgLimits = () => { + _setOrgLimitsConfigForTesting({ config: { orgs: {} } }); +}; + +afterAll(() => { + resetOrgLimits(); +}); + +test(`${chalk.yellowBright("maxEntities: caps entities returned by CusService.getFull to configured limit")}`, async () => { + const customerId = "max-entities-cap"; + const entityCount = 5; + const maxEntities = 3; + + const { ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({}), + s.entities({ count: entityCount, featureId: TestFeature.Users }), + ], + actions: [], + }); + + expect(entities.length).toBe(entityCount); + + try { + const config: OrgLimitsConfig = { + orgs: { [ctx.org.id]: { maxEntities } }, + }; + _setOrgLimitsConfigForTesting({ config }); + + // Verify the accessor resolves to the override + expect(getOrgEntitiesLimit({ orgId: ctx.org.id })).toBe(maxEntities); + + const fullCus = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + }); + + expect(fullCus.entities).toBeDefined(); + expect(fullCus.entities.length).toBe(maxEntities); + } finally { + resetOrgLimits(); + } +}); + +test(`${chalk.yellowBright("maxEntities: returns all entities when override is absent (falls back to default)")}`, async () => { + const customerId = "max-entities-default"; + const entityCount = 4; + + const { ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({}), + s.entities({ count: entityCount, featureId: TestFeature.Users }), + ], + actions: [], + }); + + expect(entities.length).toBe(entityCount); + + resetOrgLimits(); + + // No override for this org -> default (300) applies + expect(getOrgEntitiesLimit({ orgId: ctx.org.id })).toBe( + DEFAULT_ENTITIES_LIMIT, + ); + + const fullCus = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + }); + + expect(fullCus.entities).toBeDefined(); + expect(fullCus.entities.length).toBe(entityCount); +}); + +test(`${chalk.yellowBright("maxEntities: raising the limit exposes previously-hidden entities")}`, async () => { + const customerId = "max-entities-raise"; + const entityCount = 4; + + const { ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({}), + s.entities({ count: entityCount, featureId: TestFeature.Users }), + ], + actions: [], + }); + + expect(entities.length).toBe(entityCount); + + try { + // Start with a tight cap + _setOrgLimitsConfigForTesting({ + config: { orgs: { [ctx.org.id]: { maxEntities: 2 } } }, + }); + + const capped = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + }); + expect(capped.entities.length).toBe(2); + + // Raise the cap above the entity count + _setOrgLimitsConfigForTesting({ + config: { orgs: { [ctx.org.id]: { maxEntities: 50 } } }, + }); + + const uncapped = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + }); + expect(uncapped.entities.length).toBe(entityCount); + } finally { + resetOrgLimits(); + } +});