Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Add ordered composite indexes for dashboard list and summary queries.

-- Campaign dashboard/export filters by org and status, then orders/paginates by createdAt.
CREATE INDEX "Campaign_orgId_status_createdAt_idx" ON "Campaign"("orgId", "status", "createdAt");

-- Aid package dashboard filters packages within a campaign by status, then orders by createdAt.
CREATE INDEX "AidPackage_campaignId_status_createdAt_idx" ON "AidPackage"("campaignId", "status", "createdAt");
6 changes: 4 additions & 2 deletions app/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ model AidPackage {
remainingAmount Float @default(0)

@@index([campaignId])
@@index([campaignId, status])
@@index([campaignId, status])
@@index([campaignId, status, createdAt])
}

/// Tracks every balance event (lock / unlock / disburse) for a campaign.
Expand Down Expand Up @@ -243,7 +244,7 @@ model Claim {

@@index([status])
@@index([campaignId])
@@index([campaignId, status])
@@index([campaignId, status])
@@index([createdAt])
@@index([deletedAt])
@@index([reissuedFromId])
Expand Down Expand Up @@ -369,6 +370,7 @@ model Campaign {
@@index([archivedAt])
@@index([ngoId])
@@index([orgId])
@@index([orgId, status, createdAt])
@@index([deletedAt])
}

Expand Down
3 changes: 3 additions & 0 deletions app/backend/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,8 @@ export * from './decorators/deprecated.decorator';
// Interceptors
export * from './interceptors/deprecation.interceptor';

// Streaming
export * from './streaming';

// Budget
export * from './budget/budget.service';
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
HTTP_CACHE_METADATA,
HTTP_CACHE_SKIP,
} from '../../decorators/http-cache.decorator';
import { HTTP_STREAMING_CACHE } from '../../streaming';

interface FakeResponse {
headers: Record<string, string>;
Expand Down Expand Up @@ -46,6 +47,8 @@ const decorated = (key: string, value: unknown) => {
return fn;
};

const nextTick = () => new Promise<void>(resolve => setImmediate(resolve));

const createContext = ({
method = 'GET',
path = '/api/v1/campaigns',
Expand Down Expand Up @@ -203,9 +206,7 @@ describe('HttpCacheInterceptor', () => {
expect(response.getHeader('Cache-Control')).toBe(
'private, must-revalidate',
);
expect(response.getHeader('Vary')).toBe(
'Authorization, Accept-Encoding',
);
expect(response.getHeader('Vary')).toBe('Authorization, Accept-Encoding');
expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/);
});

Expand Down Expand Up @@ -242,9 +243,7 @@ describe('HttpCacheInterceptor', () => {
expect(response.getHeader('Cache-Control')).toBe(
'private, must-revalidate',
);
expect(response.getHeader('Vary')).toBe(
'Authorization, Accept-Encoding',
);
expect(response.getHeader('Vary')).toBe('Authorization, Accept-Encoding');
expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/);
expect(response.getHeader('X-Http-Cache')).toBe('miss');
});
Expand Down Expand Up @@ -355,7 +354,10 @@ describe('HttpCacheInterceptor', () => {
}),
);
expect(result).toBeUndefined();
return { status: next.response.statusCode, etag: next.response.getHeader('ETag') };
return {
status: next.response.statusCode,
etag: next.response.getHeader('ETag'),
};
};

it('returns 304 on exact strong ETag match', async () => {
Expand Down Expand Up @@ -485,5 +487,101 @@ describe('HttpCacheInterceptor', () => {
);
}
});

it('defers ETag hashing for @UseStreamingCache responses', async () => {
configGet.mockReturnValue(undefined);
const interceptor = buildInterceptor();
const handler = decorated(HTTP_STREAMING_CACHE, true);
const payload = {
rows: Array.from({ length: 4_000 }, (_, id) => ({
id,
value: `row-${id}`,
})),
};
const { context, response } = createContext({ handler });

const result = await firstValueFrom(
interceptor.intercept(context, { handle: () => of(payload) }),
);

expect(result).toBe(payload);
expect(response.getHeader('ETag')).toBe('W/"pending"');
expect(response.getHeader('Link')).toBe(
'</etag>; rel=etag; status=pending',
);
expect(response.getHeader('X-Http-Cache')).toBe('pending');

await nextTick();

expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/);
expect(response.getHeader('Link')).toMatch(
/^<\/etag>; rel=etag; etag="[a-f0-9]{64}"$/,
);
expect(response.getHeader('X-Http-Cache')).toBe('miss');
});

it('computes identical deferred ETags for identical streaming-cache bodies', async () => {
configGet.mockReturnValue(undefined);
const interceptor = buildInterceptor();
const handler = decorated(HTTP_STREAMING_CACHE, true);

const firstCtx = createContext({ handler });
await firstValueFrom(
interceptor.intercept(firstCtx.context, {
handle: () => of({ z: 1, a: 2, m: { y: 1, x: 2 } }),
}),
);

const secondCtx = createContext({ handler, path: '/api/v1/y' });
await firstValueFrom(
interceptor.intercept(secondCtx.context, {
handle: () => of({ a: 2, m: { x: 2, y: 1 }, z: 1 }),
}),
);

await nextTick();

expect(firstCtx.response.getHeader('ETag')).toBeTruthy();
expect(firstCtx.response.getHeader('ETag')).toBe(
secondCtx.response.getHeader('ETag'),
);
});

it('completes a 200 KB streaming-cache JSON response under the latency budget', async () => {
configGet.mockReturnValue(undefined);
const interceptor = buildInterceptor();
const handler = decorated(HTTP_STREAMING_CACHE, true);
const payload = {
rows: Array.from({ length: 2_500 }, (_, id) => ({
id,
label: `recipient-${id}`,
status: 'pending',
notes: 'x'.repeat(48),
})),
};
expect(Buffer.byteLength(JSON.stringify(payload))).toBeGreaterThan(
200 * 1024,
);

const timings: number[] = [];
for (let i = 0; i < 100; i += 1) {
const { context } = createContext({
handler,
path: `/api/v1/large-${i}`,
});
const started = process.hrtime.bigint();
await firstValueFrom(
interceptor.intercept(context, { handle: () => of(payload) }),
);
const elapsedMs = Number(process.hrtime.bigint() - started) / 1_000_000;
timings.push(elapsedMs);
}

timings.sort((a, b) => a - b);
const p99 = timings[98];
expect(p99).toBeLessThan(8);

await nextTick();
});
});
});
Loading
Loading