From dad56a86e4b1aa2ab3af291c621d6e37b65bace0 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 22:44:13 +0800 Subject: [PATCH 1/5] test(quota): pin the D1 semantics the inspection cap depends on Probes the primitive the cap's concurrency guarantee rests on, before any code relies on it: a conditional upsert whose WHERE holds a correlated subquery over another table. Settles three things: - `excluded.tenant_id` IS in scope inside `DO UPDATE ... WHERE`, and resolves to the calling tenant (a sibling tenant's rows do not cap it). No bound-parameter fallback is needed. - `changes === 0` is reported when that subquery is false. - The plain `INSERT ... VALUES` form leaves the INSERT branch UNGATED: a conflict-free insert never reaches DO UPDATE, so a tenant already over the cap but with no counter row yet gets a free pass. The `INSERT ... SELECT ... WHERE` form gates both branches with the same predicate and is what the guard will use. --- .../quota/inspection-cap-counts-rows.spec.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/unit/quota/inspection-cap-counts-rows.spec.ts diff --git a/tests/unit/quota/inspection-cap-counts-rows.spec.ts b/tests/unit/quota/inspection-cap-counts-rows.spec.ts new file mode 100644 index 000000000..166366918 --- /dev/null +++ b/tests/unit/quota/inspection-cap-counts-rows.spec.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; + +/** + * The inspection cap is enforced by a single conditional upsert whose WHERE + * counts rows in another table. `meta.changes === 0` is what the guard reads to + * mean "at cap", so this asserts the primitive itself rather than trusting it. + * + * Specifically: does a conditional upsert whose `DO UPDATE ... WHERE` holds a + * correlated subquery report `changes === 0` when that subquery is false, and + * is `excluded.tenant_id` in scope there at all? Both are assumptions about + * SQLite/D1, not about our code, and the concurrency guarantee rests on them. + */ +describe('D1/SQLite: conditional upsert with a correlated subquery', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let sqlite: any; + + beforeEach(async () => { + const t = createTestDb(); + await setupSchema(t.sqlite); + sqlite = t.sqlite; + }); + + /** `inspections.tenant_id` carries a legacy FK, and better-sqlite3 enforces + * foreign keys by default — so the parent row has to exist first. */ + const seedTenant = (tenantId: string) => sqlite.prepare( + `INSERT INTO tenants (id, name, slug, created_at) VALUES (?, ?, ?, ?)`, + ).run(tenantId, `Tenant ${tenantId}`, tenantId, Date.now()); + + /** Minimal inspection rows — only the NOT NULL columns are supplied. */ + const seedInspections = (tenantId: string, n: number) => { + const stmt = sqlite.prepare( + `INSERT INTO inspections (id, tenant_id, property_address, date, created_at) + VALUES (?, ?, '1 Main St', '2026-08-05', ?)`, + ); + const base = countRows(tenantId); // so repeated calls do not collide on id + for (let i = 0; i < n; i++) stmt.run(`${tenantId}-insp-${base + i}`, tenantId, Date.now()); + }; + + const deleteInspections = (tenantId: string, n: number) => + sqlite.prepare( + `DELETE FROM inspections WHERE rowid IN + (SELECT rowid FROM inspections WHERE tenant_id = ? LIMIT ?)`, + ).run(tenantId, n); + + const countRows = (tenantId: string) => + sqlite.prepare('SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = ?').get(tenantId).n; + + const readCounter = (tenantId: string) => + sqlite.prepare( + `SELECT value FROM usage_counters + WHERE tenant_id = ? AND metric = 'inspections' AND period_key = 'lifetime'`, + ).get(tenantId)?.value; + + /** The exact statement shape the guard will use, `excluded.` references and all. */ + const upsert = (tenantId: string, cap: number) => sqlite.prepare( + `INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) + VALUES (?, 'inspections', 'lifetime', + (SELECT COUNT(*) FROM inspections WHERE tenant_id = ?) + 1, ?) + ON CONFLICT(tenant_id, metric, period_key) + DO UPDATE SET value = (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + 1, + updated_at = excluded.updated_at + WHERE (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) < ?`, + ).run(tenantId, tenantId, Date.now(), cap); + + it('reports changes > 0 while under the cap', () => { + seedTenant('t1'); + seedInspections('t1', 2); + expect(upsert('t1', 5).changes).toBeGreaterThan(0); + }); + + it('reports changes === 0 once the row count reaches the cap', () => { + seedTenant('t1'); + seedInspections('t1', 5); + upsert('t1', 5); // creates the row (INSERT branch — no WHERE applies) + expect(upsert('t1', 5).changes).toBe(0); + }); + + it('the plain VALUES form leaves the INSERT branch UNGATED', () => { + // A conflict-free INSERT never reaches DO UPDATE, so its WHERE cannot + // block it: a tenant already over the cap but with no counter row yet + // gets a free pass. Documented here because it is why the guard uses + // the INSERT...SELECT form below instead. + seedTenant('t1'); + seedInspections('t1', 9); + expect(upsert('t1', 5).changes).toBeGreaterThan(0); + }); + + /** The form the guard actually uses: `INSERT ... SELECT ... WHERE` so the + * same row-count predicate gates the INSERT branch as well as the UPDATE. */ + const gatedUpsert = (tenantId: string, cap: number) => sqlite.prepare( + `INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) + SELECT ?, 'inspections', 'lifetime', cnt.n + 1, ? + FROM (SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = ?) AS cnt + WHERE cnt.n < ? + ON CONFLICT(tenant_id, metric, period_key) + DO UPDATE SET value = (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + 1, + updated_at = excluded.updated_at + WHERE (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) < ?`, + ).run(tenantId, Date.now(), tenantId, cap, cap); + + it('the gated INSERT...SELECT form refuses a tenant already over the cap with no counter row', () => { + seedTenant('t1'); + seedInspections('t1', 9); + expect(gatedUpsert('t1', 5).changes).toBe(0); + expect(readCounter('t1')).toBeUndefined(); // no row was written at all + }); + + it('the gated form still admits a tenant under the cap with no counter row', () => { + seedTenant('t1'); + seedInspections('t1', 2); + expect(gatedUpsert('t1', 5).changes).toBeGreaterThan(0); + expect(readCounter('t1')).toBe(3); + }); + + it('the gated form behaves identically to the plain form on the UPDATE branch', () => { + seedTenant('t1'); + seedInspections('t1', 4); + expect(gatedUpsert('t1', 5).changes).toBeGreaterThan(0); // 4 rows < 5 → allowed + seedInspections('t1', 1); // the create lands: 5 rows + expect(gatedUpsert('t1', 5).changes).toBe(0); // now at cap + }); + + it('excluded.tenant_id IS in scope inside DO UPDATE ... WHERE', () => { + // If it were not, SQLite would raise "no such column" rather than run. + seedTenant('t1'); + seedTenant('t2'); + seedInspections('t1', 1); + seedInspections('t2', 9); + upsert('t1', 5); + expect(() => upsert('t1', 5)).not.toThrow(); + // And it resolves to THIS tenant: t2's 9 rows must not cap t1. + expect(upsert('t1', 5).changes).toBeGreaterThan(0); + }); + + it('lets the count FALL again when rows are deleted — the whole point', () => { + seedTenant('t1'); + seedInspections('t1', 5); + upsert('t1', 5); + expect(upsert('t1', 5).changes).toBe(0); // at cap + + deleteInspections('t1', 3); + expect(countRows('t1')).toBe(2); + expect(upsert('t1', 5).changes).toBeGreaterThan(0); // allowance returned + expect(readCounter('t1')).toBe(3); // cache heals to rows + this create + }); +}); From 3fc73777390206bf5cd8cde8d093cfc0a9607225 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 23:00:30 +0800 Subject: [PATCH 2/5] fix(quota): count inspections that exist, not creates that happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The free-tier cap read a monotonic counter, so a tenant who deleted an inspection never got the allowance back. Found in production 2026-08-05: one tenant had 1 inspection and 4 of 5 consumed; another was capped at 5 with 3 rows. Five counters were corrected by hand that day — this makes that correction unnecessary rather than repeatable. The gate now counts inspection rows. `usage_counters.value` for this metric degrades from source-of-truth to a self-healing display cache. Still one statement, so `meta.changes === 0` stays the authoritative "at cap" answer with no read-then-write window inside the guard. Two things the probe (previous commit) forced: - `INSERT ... SELECT ... WHERE` rather than VALUES, so the INSERT branch is gated too. A conflict-free INSERT never reaches DO UPDATE, so the VALUES form waves through a tenant who has rows but no counter row. - `consumeInspection(tenantId, count)`. Because the gate counts rows the caller inserts only afterwards, N looped calls all read the same count and all pass — a 3-sub inspection request took a tenant from 3 inspections to 6, deterministically. InspectionRequestService.create now consumes the batch as one unit; it is admitted whole or not at all. What counting rows gives up, unavoidably: two creates that overlap before either row lands both pass. The cap becomes a steady-state invariant rather than a serialized claim — the overshoot is bounded by in-flight concurrency and self-corrects, since the next create counts what exists. That is the better trade: the old design bought serialization with a counter that could only climb, which is what cost real tenants their allowance permanently. sms/email are untouched — consumed events with no rows to count. Converting them to a row count would silently uncap them. --- server/features/plan-quota/guard.ts | 57 +++++-- server/services/inspection-request.service.ts | 10 +- .../unit/inspections/inspection-quota.spec.ts | 20 ++- .../quota/inspection-cap-counts-rows.spec.ts | 155 +++++++++++++++++- .../usage/plan-quota-guarded-services.spec.ts | 10 +- tests/unit/usage/plan-quota.spec.ts | 34 +++- 6 files changed, 252 insertions(+), 34 deletions(-) diff --git a/server/features/plan-quota/guard.ts b/server/features/plan-quota/guard.ts index c4ad24f4c..c449ea113 100644 --- a/server/features/plan-quota/guard.ts +++ b/server/features/plan-quota/guard.ts @@ -43,30 +43,59 @@ export class PlanQuotaGuard { }, ) {} - /** Atomic consume for inspection creation. Free+enforced: increment-if-below-cap - * (throws QuotaExhausted at the cap). Other tiers / standalone: plain increment - * (lifetime analytics). Counter is monotonic — deletes never refund. */ - async consumeInspection(tenantId: string): Promise { + /** Atomic consume for inspection creation. Free+enforced: allow-if-the-rows-a- + * tenant-has-plus-`count`-fit-under-the-cap (throws QuotaExhausted otherwise). + * Other tiers / standalone: plain increment (lifetime analytics). The cap + * counts the inspections a tenant HAS, so deleting one returns the allowance. + * + * `count` is the number of inspections the caller is about to create in one + * go. It must be passed for a batch rather than looping: because the gate + * counts rows and the caller inserts them only after this returns, N looped + * calls all read the same count and all pass — a 3-sub request would take a + * tenant from 3 inspections to 6. One call with `count: 3` is the same single + * statement and admits the batch only if the whole batch fits. */ + async consumeInspection(tenantId: string, count = 1): Promise { const tier = await readTenantTier(this.db, tenantId); if (!this.opts.enforced || tier !== 'free') { - await new MeteringService(this.db).record(tenantId, 'inspections', STOCK_PERIOD); + await new MeteringService(this.db).record(tenantId, 'inspections', STOCK_PERIOD, count); return; } const cap = FREE_TIER_CAPS.inspections; - // Single-statement conditional increment: the guarded UPDATE only fires - // while value < cap, so D1's `meta.changes === 0` is the authoritative - // "already at cap" signal even under concurrent callers — SQLite/D1 - // serialize writes to a given row, so there is no read-then-write window - // for two callers to both observe "below cap" and both increment past it. + // THE GATE COUNTS ROWS. `usage_counters.value` is a self-healing display + // cache for this metric, never the thing enforced — a stale value can no + // longer refuse a tenant who is genuinely under the cap, and a delete + // returns the allowance without anything having to write the counter back. + // (Do not "fix" a stale value by hand; the next create heals it. See #105.) + // + // Still a single statement, and still atomic: D1/SQLite serialize writes to + // a given row, so `meta.changes === 0` remains the authoritative "at cap" + // answer with no read-then-write window inside the guard. The INSERT branch + // is gated too — hence `INSERT ... SELECT ... WHERE` rather than VALUES, + // because a conflict-free INSERT never reaches DO UPDATE and would wave + // through a tenant who has rows but no counter row yet. + // + // What counting rows DOES give up, unavoidably: the caller inserts the + // inspection row AFTER this returns, so two creates that overlap before + // either row lands both see the same count and both pass. The cap is + // therefore a steady-state invariant, not a serialized claim — an overshoot + // is bounded by in-flight concurrency and self-corrects, because the next + // create counts the rows that actually exist and refuses. That trade is + // deliberate: the alternative (a counter that can only climb) is what cost + // real tenants their allowance permanently. The one case that would NOT + // have been a rare race — a caller creating N rows in a loop — is why + // `count` exists rather than being left to the caller to iterate. const res = await this.db.prepare( `INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) - VALUES (?1, 'inspections', 'lifetime', 1, ?2) + SELECT ?1, 'inspections', 'lifetime', cnt.n + ?4, ?2 + FROM (SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = ?1) AS cnt + WHERE cnt.n + ?4 <= ?3 ON CONFLICT(tenant_id, metric, period_key) - DO UPDATE SET value = value + 1, updated_at = ?2 - WHERE usage_counters.value < ?3`, - ).bind(tenantId, Date.now(), cap).run(); + DO UPDATE SET value = (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + ?4, + updated_at = excluded.updated_at + WHERE (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + ?4 <= ?3`, + ).bind(tenantId, Date.now(), cap, count).run(); if (res.meta.changes === 0) { throw Errors.QuotaExhausted({ metric: 'inspections', used: cap, cap, billingPortalUrl: this.opts.billingPortalUrl }); diff --git a/server/services/inspection-request.service.ts b/server/services/inspection-request.service.ts index 4e53fa399..aea9d63fa 100644 --- a/server/services/inspection-request.service.ts +++ b/server/services/inspection-request.service.ts @@ -254,14 +254,14 @@ export class InspectionRequestService { const totalAmount = subs.reduce((sum, s) => sum + (s.price ?? 0), 0); - // Quota is consumed once per sub-inspection, after every precondition + // Quota is consumed for the whole batch at once, after every precondition // check above (bounds, template ownership) and BEFORE the parent // request row is inserted — a request row must never be orphaned // (created with zero children) because the tenant hit the cap - // partway through. - for (let i = 0; i < subs.length; i++) { - await this.planQuota?.consumeInspection(tenantId); - } + // partway through. One call, not a loop: the guard counts existing + // inspection rows and these are inserted below, so N looped calls would + // all read the same count and all pass. See consumeInspection's `count`. + await this.planQuota?.consumeInspection(tenantId, subs.length); await db.insert(inspectionRequests).values({ id: requestId, diff --git a/tests/unit/inspections/inspection-quota.spec.ts b/tests/unit/inspections/inspection-quota.spec.ts index 9d4d80fcf..1c23a20a5 100644 --- a/tests/unit/inspections/inspection-quota.spec.ts +++ b/tests/unit/inspections/inspection-quota.spec.ts @@ -103,14 +103,26 @@ describe('Inspection creation consumes the free-tier quota (Task 3)', () => { expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(3); }); - it('a delete does not refund quota', async () => { + it('a delete DOES return the allowance, and the counter heals on the next create', async () => { const guard = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null }); const svc = makeService(guard); - const a = await svc.createInspection(TENANT, minimalCreateData()); - await deleteInspectionCascade(testDb as unknown as DrizzleD1Database, makeR2(), TENANT, a.id); + // Fill the cap, then delete two — the tenant is under it again. + const created = []; + for (let i = 0; i < 5; i++) created.push(await svc.createInspection(TENANT, minimalCreateData())); + await expect(svc.createInspection(TENANT, minimalCreateData())).rejects.toMatchObject({ + status: 402, code: 'QUOTA_EXHAUSTED', + }); + for (const insp of created.slice(0, 2)) { + await deleteInspectionCascade(testDb as unknown as DrizzleD1Database, makeR2(), TENANT, insp.id); + } - expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(1); + // The counter is momentarily stale-high (nothing writes it on delete)... + expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(5); + // ...but it is a display cache, not the gate: the create is admitted, + // and the cache heals to the truth as it lands. + await expect(svc.createInspection(TENANT, minimalCreateData())).resolves.toBeDefined(); + expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(4); }); it('cloneInspection of a nonexistent id rejects and does not consume quota', async () => { diff --git a/tests/unit/quota/inspection-cap-counts-rows.spec.ts b/tests/unit/quota/inspection-cap-counts-rows.spec.ts index 166366918..8e15e1ceb 100644 --- a/tests/unit/quota/inspection-cap-counts-rows.spec.ts +++ b/tests/unit/quota/inspection-cap-counts-rows.spec.ts @@ -1,5 +1,15 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createTestDb, setupSchema } from '../db'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createTestDb, setupSchema, toRawD1 } from '../db'; + +// Same mocking pattern as tests/unit/usage/plan-quota.spec.ts: the guard's +// `drizzle(d1)` calls (tier lookup, MeteringService) resolve to the in-memory +// SQLite Drizzle instance, while its raw `db.prepare(...).bind(...).run()` path +// runs against `toRawD1` over the same underlying sqlite. +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import { PlanQuotaGuard } from '../../../server/features/plan-quota/guard'; +import { MeteringService } from '../../../server/services/metering.service'; +import { FREE_TIER_CAPS } from '../../../server/features/plan-quota/policy'; /** * The inspection cap is enforced by a single conditional upsert whose WHERE @@ -145,3 +155,144 @@ describe('D1/SQLite: conditional upsert with a correlated subquery', () => { expect(readCounter('t1')).toBe(3); // cache heals to rows + this create }); }); + +/** + * The behaviour that primitive buys: the free-tier inspection cap gates on the + * inspections a tenant HAS, not on how many they have ever created. Deleting an + * inspection returns the allowance, and a stale `usage_counters.value` cannot + * block a tenant who is genuinely under the cap. + * + * The 2026-08-05 production defect is reproduced verbatim in the stale-counter + * case: one tenant had a single inspection row and a counter reading 4 of 5. + */ +describe('PlanQuotaGuard.consumeInspection counts rows, not creates', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let sqlite: any; + let testD1: D1Database; + let guard: PlanQuotaGuard; + + const CAP = FREE_TIER_CAPS.inspections; + + beforeEach(async () => { + const fixture = createTestDb(); + await setupSchema(fixture.sqlite); + sqlite = fixture.sqlite; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(fixture.db); + testD1 = toRawD1(sqlite); + sqlite.prepare( + `INSERT INTO tenants (id, name, slug, tier, created_at) VALUES (?, ?, ?, 'free', ?)`, + ).run('t1', 'Acme', 'acme', Date.now()); + guard = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null }); + }); + + const countRows = () => + sqlite.prepare("SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = 't1'").get().n; + + const seedInspections = (n: number) => { + const stmt = sqlite.prepare( + `INSERT INTO inspections (id, tenant_id, property_address, date, created_at) + VALUES (?, 't1', '1 Main St', '2026-08-05', ?)`, + ); + const base = countRows(); + for (let i = 0; i < n; i++) stmt.run(`insp-${base + i}`, Date.now()); + }; + + const deleteInspections = (n: number) => sqlite.prepare( + `DELETE FROM inspections WHERE rowid IN + (SELECT rowid FROM inspections WHERE tenant_id = 't1' LIMIT ?)`, + ).run(n); + + const setCounter = (value: number) => sqlite.prepare( + `INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) + VALUES ('t1', 'inspections', 'lifetime', ?, ?) + ON CONFLICT(tenant_id, metric, period_key) DO UPDATE SET value = excluded.value`, + ).run(value, Date.now()); + + it('a tenant with 5 inspections is at cap', async () => { + seedInspections(CAP); + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ + status: 402, code: 'QUOTA_EXHAUSTED', + }); + }); + + it('deleting an inspection returns the allowance — the defect this fixes', async () => { + seedInspections(CAP); + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + deleteInspections(2); // user cleans up duplicates + await expect(guard.consumeInspection('t1')).resolves.toBeUndefined(); + }); + + it('a stale counter does not block a tenant who is genuinely under the cap', async () => { + // The exact production state on 2026-08-05: counter said 4, one row existed. + seedInspections(1); + setCounter(4); + await expect(guard.consumeInspection('t1')).resolves.toBeUndefined(); + await expect(guard.consumeInspection('t1')).resolves.toBeUndefined(); + // ...and the counter heals to the truth rather than climbing from 4. + expect(await new MeteringService(testD1).lifetimeTotal('t1', 'inspections')).toBe(2); + }); + + it('a tenant over the cap with NO counter row is still refused', async () => { + // The INSERT branch has to be gated too — see the probe above. Reachable + // whenever rows exist without a counter (imports, a counter row deleted + // by hand to "fix" a stale value). + seedInspections(9); + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + expect(await new MeteringService(testD1).lifetimeTotal('t1', 'inspections')).toBe(0); + }); + + it('the counter tracks the row count as a cache, one create ahead of the insert', async () => { + // consumeInspection runs immediately BEFORE the row is inserted, so the + // cached value is "rows now + this create" — which equals the row count + // once the caller's insert lands. + seedInspections(2); + await guard.consumeInspection('t1'); + expect(await new MeteringService(testD1).lifetimeTotal('t1', 'inspections')).toBe(3); + }); + + it('sms and email still TALLY — they have no rows to count', async () => { + const metering = new MeteringService(testD1); + await metering.record('t1', 'email', '2026-08'); + await metering.record('t1', 'email', '2026-08'); + expect(await metering.lifetimeTotal('t1', 'email')).toBe(2); + + // And the messaging gate still reads that tally, with zero rows anywhere + // to count — converting it to a row count would silently uncap it. + await metering.record('t1', 'sms', '2026-08', FREE_TIER_CAPS.sms); + await expect(guard.checkMessagingQuota('t1', 'free', 'sms')).rejects.toMatchObject({ + code: 'QUOTA_EXHAUSTED', + }); + }); + + it('a second consume is refused once the first create has landed', async () => { + // HONEST LIMITATION: better-sqlite3 is synchronous, so nothing in this + // file can overlap two calls; this asserts the statement's LOGIC, not + // concurrency. Real overlap is exercised under workerd in + // tests/workers/quota-cap-concurrency.spec.ts. + seedInspections(4); + await expect(guard.consumeInspection('t1')).resolves.toBeUndefined(); + seedInspections(1); // the caller's insert lands + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + }); + + it('two consumes that overlap BEFORE either row lands both pass — the accepted bound', async () => { + // Counting rows moves the cap from "serialized claim" to "steady-state + // invariant": the caller inserts its row after consumeInspection returns, + // so overlapping creates see the same count. The overshoot is bounded by + // in-flight concurrency and self-corrects — asserted below. This is the + // deliberate trade recorded in guard.ts, pinned so a future change that + // silently alters it has to come here and say so. + seedInspections(4); + const results = await Promise.allSettled([ + guard.consumeInspection('t1'), guard.consumeInspection('t1'), + ]); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(2); + + // ...and it self-corrects: with both rows landed the tenant is over the + // cap and cannot grow further. + seedInspections(2); + expect(countRows()).toBe(6); + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + }); +}); diff --git a/tests/unit/usage/plan-quota-guarded-services.spec.ts b/tests/unit/usage/plan-quota-guarded-services.spec.ts index 8cd878161..e46ab3749 100644 --- a/tests/unit/usage/plan-quota-guarded-services.spec.ts +++ b/tests/unit/usage/plan-quota-guarded-services.spec.ts @@ -193,11 +193,11 @@ describe('InspectionRequestService consumes the free-tier quota', () => { expect(reqRows).toHaveLength(1); // only the first (successful) request exists const inspRows = await testDb.select().from(schema.inspections).all(); expect(inspRows).toHaveLength(3); // only the first batch's 3 children exist - // The cap (5) was reached mid-loop — 2 of the 3 attempted consumes for - // the rejected batch succeeded before the 3rd hit the cap. The counter - // is monotonic (no refund), matching PlanQuotaGuard's documented - // semantics elsewhere (deletes/aborted batches never refund). - expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(5); + // The batch is consumed as one unit (`consumeInspection(tenantId, 3)`), + // so the rejected batch consumed NOTHING — the counter still reflects + // the 3 inspections that exist rather than being left inflated by two + // partial consumes. + expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(3); }); it('a rejected validation (unknown template) does not consume quota', async () => { diff --git a/tests/unit/usage/plan-quota.spec.ts b/tests/unit/usage/plan-quota.spec.ts index 5b2113be9..420beaa3d 100644 --- a/tests/unit/usage/plan-quota.spec.ts +++ b/tests/unit/usage/plan-quota.spec.ts @@ -30,6 +30,16 @@ describe('PlanQuotaGuard', () => { testD1 = toRawD1(sqlite); }); + /** The row the caller inserts right after a successful consume. The gate + * counts these, so a test that consumes without inserting is not modelling + * anything a real create path does. */ + function seedInspection(tenantId: string, i: number) { + sqlite.prepare( + `INSERT INTO inspections (id, tenant_id, property_address, date, created_at) + VALUES (?, ?, '1 Main St', '2026-08-05', ?)`, + ).run(`${tenantId}-insp-${i}`, tenantId, Date.now()); + } + async function seedTenant(id: string, opts: { tier: 'free' | 'pro' | 'enterprise' }) { await testDb.insert(tenants).values({ id, @@ -44,7 +54,10 @@ describe('PlanQuotaGuard', () => { it('allows and counts the first 5 creates for a free tenant, blocks the 6th', async () => { await seedTenant('t1', { tier: 'free' }); const g = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: 'https://x/billing' }); - for (let i = 0; i < 5; i++) await g.consumeInspection('t1'); + // The gate counts inspection ROWS, so each consume has to be paired + // with the insert its caller performs — see inspection-quota.spec.ts + // for the same thing through the real service. + for (let i = 0; i < 5; i++) { await g.consumeInspection('t1'); seedInspection('t1', i); } await expect(g.consumeInspection('t1')).rejects.toMatchObject({ status: 402, code: 'QUOTA_EXHAUSTED', @@ -67,11 +80,24 @@ describe('PlanQuotaGuard', () => { expect(await new MeteringService(testD1).lifetimeTotal('t3', 'inspections')).toBe(6); }); - it('is race-safe: the conditional increment never exceeds the cap', async () => { + it('a batch consume admits the whole batch or none of it', async () => { + // Since the gate counts rows the caller has not inserted yet, a + // caller creating N at once passes `count: N` instead of looping — + // otherwise all N calls read the same count and all pass. See + // consumeInspection's `count` parameter. await seedTenant('t4', { tier: 'free' }); const g = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null }); - const results = await Promise.allSettled(Array.from({ length: 8 }, () => g.consumeInspection('t4'))); - expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(5); + + await g.consumeInspection('t4', 3); + for (let i = 0; i < 3; i++) seedInspection('t4', i); + expect(await new MeteringService(testD1).lifetimeTotal('t4', 'inspections')).toBe(3); + + // 3 + 3 > 5 — refused outright, with nothing partially consumed. + await expect(g.consumeInspection('t4', 3)).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + expect(await new MeteringService(testD1).lifetimeTotal('t4', 'inspections')).toBe(3); + + // 3 + 2 fits exactly. + await expect(g.consumeInspection('t4', 2)).resolves.toBeUndefined(); expect(await new MeteringService(testD1).lifetimeTotal('t4', 'inspections')).toBe(5); }); }); From 95cb50963bc77539f5d78719ad104cc92308d950 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 23:07:28 +0800 Subject: [PATCH 3/5] test(quota): verify the inspection cap on real D1 under genuine concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit suite runs the guard against better-sqlite3, which is synchronous — Promise.all there overlaps nothing, so it can only assert the statement's logic. Two things are testable only under workerd: - That D1 accepts the statement at all. `INSERT ... SELECT ... WHERE` with an `ON CONFLICT ... DO UPDATE ... WHERE` whose predicate is a correlated subquery over another table, plus `excluded.` inside that predicate, is not a shape D1 code usually reaches for, and `meta.changes === 0` on the no-rows-selected path is the signal the whole gate reads. Confirmed. - What concurrent callers actually observe. At the cap, six genuinely overlapping consumes ALL fail — no phantom pass. Below the cap, two that overlap before either row lands both pass, which pins the bound recorded in guard.ts as real-engine behaviour rather than a test-driver artefact. --- tests/workers/quota-cap-concurrency.spec.ts | 166 ++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/workers/quota-cap-concurrency.spec.ts diff --git a/tests/workers/quota-cap-concurrency.spec.ts b/tests/workers/quota-cap-concurrency.spec.ts new file mode 100644 index 000000000..ae457d8dd --- /dev/null +++ b/tests/workers/quota-cap-concurrency.spec.ts @@ -0,0 +1,166 @@ +// Free-tier inspection cap — real-D1 (workerd/miniflare) coverage. +// +// The unit suite runs the guard against better-sqlite3, which is SYNCHRONOUS: +// `Promise.all` there does not overlap anything, so it can only ever assert the +// statement's logic. Two things are testable only here: +// +// 1. That D1 accepts and runs the statement at all. It is not a shape D1 code +// usually reaches for — `INSERT ... SELECT ... WHERE` with an `ON CONFLICT +// ... DO UPDATE ... WHERE` whose predicate is a correlated subquery over +// ANOTHER table, plus `excluded.` references inside that predicate — and +// `meta.changes === 0` on the no-rows-selected path is exactly the signal +// the whole gate reads. +// 2. What genuinely concurrent callers observe, rather than what a synchronous +// driver lets us pretend they observe. +import { env } from 'cloudflare:test'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { PlanQuotaGuard } from '../../server/features/plan-quota/guard'; +import { FREE_TIER_CAPS } from '../../server/features/plan-quota/policy'; + +interface TestBindings { DB: D1Database } +const b = env as unknown as TestBindings; + +const TENANT = 'tenant-quota'; +const OTHER = 'tenant-quota-other'; +const CAP = FREE_TIER_CAPS.inspections; + +const guard = () => new PlanQuotaGuard(b.DB, { enforced: true, billingPortalUrl: null }); + +async function seedSchema(): Promise { + await b.DB.exec( + "CREATE TABLE IF NOT EXISTS tenants (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL, tier TEXT NOT NULL DEFAULT 'free', created_at INTEGER NOT NULL);", + ); + await b.DB.exec( + 'CREATE TABLE IF NOT EXISTS inspections (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, property_address TEXT, date TEXT, created_at INTEGER NOT NULL);', + ); + await b.DB.exec( + 'CREATE TABLE IF NOT EXISTS usage_counters (tenant_id TEXT NOT NULL, metric TEXT NOT NULL, period_key TEXT NOT NULL, value INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (tenant_id, metric, period_key));', + ); +} + +async function reset(): Promise { + await b.DB.exec('DELETE FROM usage_counters;'); + await b.DB.exec('DELETE FROM inspections;'); + await b.DB.exec('DELETE FROM tenants;'); + await b.DB.prepare('INSERT INTO tenants (id, name, slug, tier, created_at) VALUES (?, ?, ?, ?, ?)') + .bind(TENANT, 'Acme', 'acme', 'free', Date.now()).run(); + await b.DB.prepare('INSERT INTO tenants (id, name, slug, tier, created_at) VALUES (?, ?, ?, ?, ?)') + .bind(OTHER, 'Other', 'other', 'free', Date.now()).run(); +} + +/** The row a caller inserts right after a successful consume. */ +async function addInspections(tenantId: string, n: number): Promise { + const existing = await rowCount(tenantId); + for (let i = 0; i < n; i++) { + await b.DB.prepare( + "INSERT INTO inspections (id, tenant_id, property_address, date, created_at) VALUES (?, ?, '1 Main St', '2026-08-05', ?)", + ).bind(`${tenantId}-insp-${existing + i}`, tenantId, Date.now()).run(); + } +} + +async function rowCount(tenantId: string): Promise { + const r = await b.DB.prepare('SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = ?') + .bind(tenantId).first<{ n: number }>(); + return r?.n ?? 0; +} + +async function counter(tenantId: string): Promise { + const r = await b.DB.prepare( + "SELECT value FROM usage_counters WHERE tenant_id = ? AND metric = 'inspections' AND period_key = 'lifetime'", + ).bind(tenantId).first<{ value: number }>(); + return r?.value ?? null; +} + +describe('free-tier inspection cap on real D1', () => { + beforeAll(seedSchema); + beforeEach(reset); + + it('runs the statement and gates on the row count', async () => { + await addInspections(TENANT, CAP - 1); + await expect(guard().consumeInspection(TENANT)).resolves.toBeUndefined(); + expect(await counter(TENANT)).toBe(CAP); + + await addInspections(TENANT, 1); // the caller's insert lands + await expect(guard().consumeInspection(TENANT)).rejects.toMatchObject({ + status: 402, code: 'QUOTA_EXHAUSTED', + }); + }); + + it('gates the INSERT branch too — over cap with no counter row is still refused', async () => { + // The reason the statement is `INSERT ... SELECT ... WHERE` and not + // `INSERT ... VALUES`: a conflict-free INSERT never reaches DO UPDATE, + // so its WHERE cannot refuse anyone. + await addInspections(TENANT, CAP + 4); + expect(await counter(TENANT)).toBeNull(); + + await expect(guard().consumeInspection(TENANT)).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + expect(await counter(TENANT)).toBeNull(); // nothing was written at all + }); + + it('excluded.tenant_id resolves per tenant inside DO UPDATE ... WHERE', async () => { + // A sibling tenant's rows must not count toward this one's cap. If + // `excluded.` were out of scope here D1 would raise, not miscount. + await addInspections(OTHER, CAP + 10); + await addInspections(TENANT, 1); + await guard().consumeInspection(TENANT); // creates the counter row + await addInspections(TENANT, 1); + await expect(guard().consumeInspection(TENANT)).resolves.toBeUndefined(); // UPDATE branch + expect(await counter(TENANT)).toBe(3); + }); + + it('a stale counter cannot block a tenant under the cap — the production defect', async () => { + // 2026-08-05: one tenant had a single inspection row and a counter of 4. + await addInspections(TENANT, 1); + await b.DB.prepare( + "INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) VALUES (?, 'inspections', 'lifetime', ?, ?)", + ).bind(TENANT, CAP - 1, Date.now()).run(); + + await expect(guard().consumeInspection(TENANT)).resolves.toBeUndefined(); + expect(await counter(TENANT)).toBe(2); // healed down to the truth + }); + + it('AT the cap, genuinely concurrent consumes ALL fail — no phantom pass', async () => { + // Real overlap: workerd runs these D1 calls as genuine concurrent async + // I/O, unlike the synchronous better-sqlite3 unit harness. + await addInspections(TENANT, CAP); + const results = await Promise.allSettled( + Array.from({ length: 6 }, () => guard().consumeInspection(TENANT)), + ); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(0); + expect(await counter(TENANT)).toBeNull(); + }); + + it('a batch is admitted whole or not at all under real overlap', async () => { + await addInspections(TENANT, 3); + // 3 + 3 > 5 — both concurrent batch attempts must be refused outright; + // neither may consume part of its batch. + const results = await Promise.allSettled([ + guard().consumeInspection(TENANT, 3), guard().consumeInspection(TENANT, 3), + ]); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(0); + expect(await counter(TENANT)).toBeNull(); + + // 3 + 2 fits exactly. + await expect(guard().consumeInspection(TENANT, 2)).resolves.toBeUndefined(); + expect(await counter(TENANT)).toBe(CAP); + }); + + it('the documented bound: consumes overlapping BEFORE their rows land both pass', async () => { + // Pinned deliberately rather than left to be discovered. Counting rows + // makes the cap a steady-state invariant: the caller inserts its row + // after consumeInspection returns, so overlapping creates read the same + // count. Real workerd concurrency, so this is the true behaviour and not + // an artefact of a synchronous test driver. + await addInspections(TENANT, CAP - 1); + const results = await Promise.allSettled([ + guard().consumeInspection(TENANT), guard().consumeInspection(TENANT), + ]); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(2); + + // ...and it self-corrects: once both rows exist the tenant is over the + // cap and cannot grow further. + await addInspections(TENANT, 2); + expect(await rowCount(TENANT)).toBe(CAP + 1); + await expect(guard().consumeInspection(TENANT)).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + }); +}); From c14c4207dcacedeeb8638e4a70947df25930f103 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 23:11:59 +0800 Subject: [PATCH 4/5] docs(quota)+fix(usage): the counter is a cache, and /api/usage says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retires the 2026-08-05 manual production correction rather than leaving it to be repeated. guard.ts header now records that `usage_counters.value` for `inspections` is a cache and not the gate, so the next person who finds a value higher than the row count does not "fix" it. Deleting the row is worse than leaving it, and the header says why. sms/email are called out as the opposite case: consumed events whose counters ARE the source of truth. /api/usage decision (plan Task 4 Step 2), taken rather than left open: a tenant whose inspections are capped now gets the live row count — the number shown against a cap has to be the number the cap is enforced against, or a tenant who deletes three inspections is told "5 of 5 used" while creating works, which is the visible half of the defect. Uncapped tenants keep the cumulative lifetime counter: with `caps: null` it is measured against nothing, and silently redefining it from "ever created" to "currently have" would change an analytics figure nobody asked to change. --- server/api/usage.ts | 21 ++++++++++- server/features/plan-quota/guard.ts | 10 ++++++ tests/unit/usage/usage-summary-api.spec.ts | 42 ++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/server/api/usage.ts b/server/api/usage.ts index f3ba3b5f6..1bb7d0981 100644 --- a/server/api/usage.ts +++ b/server/api/usage.ts @@ -17,10 +17,13 @@ * directly rather than read off the context. */ import { createRoute } from '@hono/zod-openapi'; +import { drizzle } from 'drizzle-orm/d1'; +import { count, eq } from 'drizzle-orm'; import { createApiRouter } from '../lib/openapi-router'; import { Errors } from '../lib/errors'; import { withMcpMetadata } from '../lib/route-metadata-standards'; import { MeteringService } from '../services/metering.service'; +import { inspections as inspectionsTable } from '../lib/db/schema'; import { getSeatUsage } from '../features/seat-quota'; import { readTenantTier } from '../features/plan-quota/guard'; import { FREE_TIER_CAPS } from '../features/plan-quota/policy'; @@ -68,13 +71,29 @@ const usageRoutes = createApiRouter() const isFreeTierQuota = tier === 'free' && c.var.profile.hasUsageQuota; + // The number shown against a cap must be the number the cap is enforced + // against. `PlanQuotaGuard.consumeInspection` gates on the inspection + // ROWS a tenant has, and `usage_counters.value` is only a self-healing + // cache of that — it reads stale-high between a delete and the next + // create, which would tell a tenant "5 of 5 used" while creating still + // works. So capped tenants get the live count. + // + // Everyone else keeps the cumulative lifetime counter: with `caps: null` + // it is measured against nothing, and quietly redefining it from "how + // many they have ever created" to "how many they have now" would change + // an analytics figure nobody asked to change. + const inspectionsUsed = isFreeTierQuota + ? (await drizzle(c.env.DB).select({ n: count() }).from(inspectionsTable) + .where(eq(inspectionsTable.tenantId, tenantId)).get())?.n ?? 0 + : inspections; + return c.json({ success: true as const, data: { tier, caps: isFreeTierQuota ? FREE_TIER_CAPS : null, usage: { - inspections, sms, email, + inspections: inspectionsUsed, sms, email, smsByo, emailByo, aiTranslate, aiTranslateByo, aiAssist, aiAssistByo, diff --git a/server/features/plan-quota/guard.ts b/server/features/plan-quota/guard.ts index c449ea113..1582c65f0 100644 --- a/server/features/plan-quota/guard.ts +++ b/server/features/plan-quota/guard.ts @@ -16,6 +16,16 @@ import { FREE_TIER_CAPS, type AiCappedMetric, type AiTierCaps } from './policy'; * The actual meter increment stays at the existing send-site call (see * MeteringService.record in the sms/email pipelines) so a provider failure * never consumes quota it didn't actually spend. + * + * IMPORTANT — `usage_counters.value` for the `inspections` metric is a CACHE, + * not the gate. The cap counts the inspection rows a tenant has; the counter is + * written alongside it and heals itself on the next create. A value that looks + * wrong (higher than the row count, e.g. after a delete) is therefore not a + * defect and must NOT be "corrected" by hand — that is what the 2026-08-05 + * production hand-fix did, and it is exactly what this design makes + * unnecessary. Deleting the row is worse than leaving it: see the INSERT-branch + * gating below. `sms`/`email` are the opposite — consumed events with nothing + * to count, so their counters ARE the source of truth. */ /** * One-line tenant-tier lookup, defaulting to 'free' when the row is missing diff --git a/tests/unit/usage/usage-summary-api.spec.ts b/tests/unit/usage/usage-summary-api.spec.ts index 4d7240c57..33de42efb 100644 --- a/tests/unit/usage/usage-summary-api.spec.ts +++ b/tests/unit/usage/usage-summary-api.spec.ts @@ -72,6 +72,17 @@ describe('GET /api/usage/summary', () => { }); } + /** The `inspections` figure a capped tenant sees is the live row count, not + * the counter — see the comment in server/api/usage.ts. */ + async function seedInspections(tenantId: string, n: number) { + for (let i = 0; i < n; i++) { + await testDb.insert(schema.inspections).values({ + id: `${tenantId}-insp-${i}`, tenantId, propertyAddress: '1 Main St', + date: '2026-08-05', createdAt: new Date(), + }); + } + } + async function seedUser(tenantId: string, id: string) { await testDb.insert(users).values({ id, tenantId, email: `${id}@example.test`, passwordHash: 'x', createdAt: new Date(), @@ -82,6 +93,7 @@ describe('GET /api/usage/summary', () => { await seedTenant(TENANT, { tier: 'free', maxUsers: 5 }); await seedUser(TENANT, 'u1'); await seedUser(TENANT, 'u2'); + await seedInspections(TENANT, 3); const m = new MeteringService(testD1); await m.record(TENANT, 'inspections', 'lifetime', 3); await m.record(TENANT, 'sms', '2026-06', 10); @@ -115,6 +127,36 @@ describe('GET /api/usage/summary', () => { }); }); + it('a capped tenant sees the ROWS they have, not a stale counter', async () => { + // The production state on 2026-08-05: a counter of 4 against a single + // inspection. Reporting the counter would tell a tenant who deleted + // three inspections that they are still at 4 of 5 while creating works. + await seedTenant(TENANT, { tier: 'free' }); + await seedInspections(TENANT, 1); + await new MeteringService(testD1).record(TENANT, 'inspections', 'lifetime', 4); + + const app = buildApp(testDb, SAAS_PROFILE); + const env = { DB: testD1 } as unknown as HonoConfig['Bindings']; + const res = await app.request('/api/usage/summary', {}, env, makeExecCtx()); + const body = await res.json() as { data: { usage: { inspections: number } } }; + expect(body.data.usage.inspections).toBe(1); + }); + + it('an UNcapped tenant keeps the cumulative lifetime counter', async () => { + // With `caps: null` the figure is measured against nothing, so it stays + // "how many they have ever created" — redefining it to a row count would + // silently change an analytics number for paid tiers. + await seedTenant(TENANT, { tier: 'pro' }); + await seedInspections(TENANT, 1); + await new MeteringService(testD1).record(TENANT, 'inspections', 'lifetime', 9); + + const app = buildApp(testDb, SAAS_PROFILE); + const env = { DB: testD1 } as unknown as HonoConfig['Bindings']; + const res = await app.request('/api/usage/summary', {}, env, makeExecCtx()); + const body = await res.json() as { data: { usage: { inspections: number } } }; + expect(body.data.usage.inspections).toBe(9); + }); + it('caps is null for a pro tenant even on a hasUsageQuota profile', async () => { await seedTenant(TENANT, { tier: 'pro' }); const app = buildApp(testDb, SAAS_PROFILE); From 97635a6a05357c5a2e39c8a408e5507687399d79 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 23:29:56 +0800 Subject: [PATCH 5/5] fix(usage): route the inspection count through getDrizzle lint:provider-helpers hard-fails on `drizzle(c.env.DB)` in an API route; route handlers go through the helper. --- server/api/usage.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/usage.ts b/server/api/usage.ts index 1bb7d0981..fa939ec01 100644 --- a/server/api/usage.ts +++ b/server/api/usage.ts @@ -17,9 +17,9 @@ * directly rather than read off the context. */ import { createRoute } from '@hono/zod-openapi'; -import { drizzle } from 'drizzle-orm/d1'; import { count, eq } from 'drizzle-orm'; import { createApiRouter } from '../lib/openapi-router'; +import { getDrizzle } from '../lib/route-helpers'; import { Errors } from '../lib/errors'; import { withMcpMetadata } from '../lib/route-metadata-standards'; import { MeteringService } from '../services/metering.service'; @@ -83,7 +83,7 @@ const usageRoutes = createApiRouter() // many they have ever created" to "how many they have now" would change // an analytics figure nobody asked to change. const inspectionsUsed = isFreeTierQuota - ? (await drizzle(c.env.DB).select({ n: count() }).from(inspectionsTable) + ? (await getDrizzle(c).select({ n: count() }).from(inspectionsTable) .where(eq(inspectionsTable.tenantId, tenantId)).get())?.n ?? 0 : inspections;