Implement pagination response handling and related enhancements

- Introduced `@Pagination()` decorator to mark list endpoints for pagination.
- Added `TransformInterceptor` to wrap responses in a standardized format `{ data, meta }`.
- Created pagination-related utility functions and constants for managing pagination logic.
- Defined `PaginationQueryDto` for handling pagination query parameters.
- Established `PaginationMetaDto` for OpenAPI documentation of pagination metadata.
- Updated existing controller and service structures to support pagination in responses.
- Added unit tests for pagination utilities and interceptor to ensure correct functionality.
This commit is contained in:
shancheas
2026-08-21 15:56:54 +07:00
parent d01fd6e2ef
commit 0550cbe764
28 changed files with 1177 additions and 19 deletions
+8 -1
View File
@@ -3,11 +3,13 @@ import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from '../app.controller';
import { AppService } from '../app.service';
import { configureApp } from './configure-app';
import { TransformInterceptor } from './http/response';
import * as setupSwaggerModule from './swagger/setup-swagger';
describe('configureApp', () => {
let app: INestApplication;
let useGlobalPipesSpy: jest.SpyInstance;
let useGlobalInterceptorsSpy: jest.SpyInstance;
let setupSwaggerSpy: jest.SpyInstance;
beforeAll(async () => {
@@ -18,6 +20,7 @@ describe('configureApp', () => {
app = moduleRef.createNestApplication();
useGlobalPipesSpy = jest.spyOn(app, 'useGlobalPipes');
useGlobalInterceptorsSpy = jest.spyOn(app, 'useGlobalInterceptors');
setupSwaggerSpy = jest
.spyOn(setupSwaggerModule, 'setupSwagger')
.mockImplementation(() => undefined);
@@ -29,14 +32,18 @@ describe('configureApp', () => {
afterEach(() => {
useGlobalPipesSpy.mockClear();
useGlobalInterceptorsSpy.mockClear();
setupSwaggerSpy.mockClear();
});
it('registers ValidationPipe and setupSwagger', () => {
it('registers ValidationPipe, TransformInterceptor, and setupSwagger', () => {
const env = { NODE_ENV: 'test' } as NodeJS.ProcessEnv;
configureApp(app, env);
expect(useGlobalPipesSpy).toHaveBeenCalledWith(expect.any(ValidationPipe));
expect(useGlobalInterceptorsSpy).toHaveBeenCalledWith(
expect.any(TransformInterceptor),
);
expect(setupSwaggerSpy).toHaveBeenCalledWith(app, env);
});
});
+3
View File
@@ -1,4 +1,6 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { TransformInterceptor } from './http/response';
import { setupSwagger } from './swagger/setup-swagger';
/** Shared Nest app configuration for bootstrap and E2E. */
@@ -13,5 +15,6 @@ export function configureApp(
transform: true,
}),
);
app.useGlobalInterceptors(new TransformInterceptor(new Reflector()));
setupSwagger(app, env);
}
+8
View File
@@ -0,0 +1,8 @@
/** Reflector metadata key set by `@Pagination()`. */
export const PAGINATION_RESPONSE = 'http.paginationResponse';
/**
* Reflector metadata key set by `@RawResponse()`.
* When present, the transform interceptor leaves the handler result untouched.
*/
export const RAW_RESPONSE = 'http.rawResponse';
+24
View File
@@ -0,0 +1,24 @@
export { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
export { Pagination, RawResponse } from './pagination.decorator';
export type {
PaginationMeta,
PaginationResponse,
SuccessResponse,
} from './ok-response.interface';
export { PaginationMetaDto } from './pagination-meta.dto';
export { TransformInterceptor } from './transform.interceptor';
export {
createPaginationMeta,
createPaginationResponse,
resolvePaginationQuery,
} from './pagination-meta.helper';
export { PaginationQueryDto } from './pagination-query.dto';
export {
toListPage,
type ListPage,
type PaginationQueryInput,
} from './list-page';
export {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} from './pagination.constants';
@@ -0,0 +1,44 @@
import {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} from './pagination.constants';
import { toListPage } from './list-page';
describe('list-page', () => {
describe('toListPage', () => {
it('defaults to page 1 with default limit', () => {
expect(toListPage({})).toEqual({
limit: PAGINATION_DEFAULT_LIMIT,
offset: 0,
});
});
it('derives offset from page and limit', () => {
expect(toListPage({ page: 3, limit: 10 })).toEqual({
limit: 10,
offset: 20,
});
});
it('uses explicit offset when page is omitted', () => {
expect(toListPage({ offset: 15, limit: 5 })).toEqual({
limit: 5,
offset: 15,
});
});
it('prefers page over offset when both are present', () => {
expect(toListPage({ page: 2, offset: 50, limit: 10 })).toEqual({
limit: 10,
offset: 10,
});
});
it('clamps limit to max', () => {
expect(toListPage({ limit: PAGINATION_MAX_LIMIT + 50 })).toEqual({
limit: PAGINATION_MAX_LIMIT,
offset: 0,
});
});
});
});
+43
View File
@@ -0,0 +1,43 @@
import {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} from './pagination.constants';
export type PaginationQueryInput = {
page?: number;
limit?: number;
offset?: number;
};
export type ListPage = {
limit: number;
offset: number;
};
/**
* Resolves repository page args from list query params.
* When `page` is present it wins over `offset` (aligned with `resolvePaginationQuery`).
* Otherwise derives from `offset`, or from page 1 when both are omitted.
*/
export function toListPage(
query: PaginationQueryInput,
defaults?: { limit?: number; maxLimit?: number },
): ListPage {
const maxLimit = defaults?.maxLimit ?? PAGINATION_MAX_LIMIT;
const limit = clampLimit(
query.limit ?? defaults?.limit ?? PAGINATION_DEFAULT_LIMIT,
maxLimit,
);
if (query.page != null && query.page >= 1) {
const page = Math.trunc(query.page);
return { limit, offset: (page - 1) * limit };
}
if (query.offset != null) {
return { limit, offset: Math.max(0, Math.trunc(query.offset)) };
}
return { limit, offset: 0 };
}
function clampLimit(limit: number, maxLimit: number): number {
return Math.max(1, Math.min(Math.trunc(limit), maxLimit));
}
@@ -0,0 +1,21 @@
export interface PaginationMeta {
currentPage: number;
itemCount: number;
itemsPerPage: number;
totalItems: number;
totalPages: number;
}
export interface SuccessResponse<T> {
data: T;
meta?: PaginationMeta;
}
/**
* Shape a list handler must return when the route is marked with `@Pagination()`.
* The transform interceptor turns this into `{ data, meta }`.
*/
export interface PaginationResponse<T> {
data: T[];
total: number;
}
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import type { PaginationMeta } from './ok-response.interface';
/** OpenAPI-visible pagination meta (DTO class, not an interface). */
export class PaginationMetaDto implements PaginationMeta {
@ApiProperty({ example: 1 })
currentPage!: number;
@ApiProperty({ example: 10 })
itemCount!: number;
@ApiProperty({ example: 10 })
itemsPerPage!: number;
@ApiProperty({ example: 42 })
totalItems!: number;
@ApiProperty({ example: 5 })
totalPages!: number;
}
@@ -0,0 +1,113 @@
import {
createPaginationMeta,
createPaginationResponse,
resolvePaginationQuery,
} from './pagination-meta.helper';
import {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} from './pagination.constants';
import { toListPage } from './list-page';
describe('pagination-meta.helper', () => {
describe('createPaginationMeta', () => {
it('builds meta from page, limit, data count, and total', () => {
expect(createPaginationMeta(2, 10, 10, 25)).toEqual({
currentPage: 2,
itemCount: 10,
itemsPerPage: 10,
totalItems: 25,
totalPages: 3,
});
});
it('clamps page and limit to at least 1', () => {
expect(createPaginationMeta(0, 0, 0, 0)).toEqual({
currentPage: 1,
itemCount: 0,
itemsPerPage: 1,
totalItems: 0,
totalPages: 0,
});
});
});
describe('createPaginationResponse', () => {
it('wraps handler payload as { data, meta }', () => {
const items = [{ id: '1' }, { id: '2' }];
expect(
createPaginationResponse({ data: items, total: 12 }, 1, 2),
).toEqual({
data: items,
meta: {
currentPage: 1,
itemCount: 2,
itemsPerPage: 2,
totalItems: 12,
totalPages: 6,
},
});
});
it('passes through payloads that are not PaginationResponse shapes', () => {
const payload = { id: 'not-a-list' };
expect(createPaginationResponse(payload as never, 1, 10)).toEqual(
payload,
);
});
});
describe('resolvePaginationQuery', () => {
it('defaults to page 1 and default limit', () => {
expect(resolvePaginationQuery({})).toEqual({
page: 1,
limit: PAGINATION_DEFAULT_LIMIT,
});
});
it('reads page and limit from query', () => {
expect(resolvePaginationQuery({ page: '3', limit: '20' })).toEqual({
page: 3,
limit: 20,
});
});
it('derives page from offset and limit', () => {
expect(resolvePaginationQuery({ offset: '20', limit: '10' })).toEqual({
page: 3,
limit: 10,
});
});
it('prefers page over offset when both are present', () => {
expect(
resolvePaginationQuery({ page: '2', offset: '50', limit: '10' }),
).toEqual({ page: 2, limit: 10 });
});
it('falls back on invalid page or limit', () => {
expect(resolvePaginationQuery({ page: 'abc', limit: '-1' })).toEqual({
page: 1,
limit: PAGINATION_DEFAULT_LIMIT,
});
});
it('clamps limit to max', () => {
expect(
resolvePaginationQuery({
limit: String(PAGINATION_MAX_LIMIT + 50),
}),
).toEqual({ page: 1, limit: PAGINATION_MAX_LIMIT });
});
it('stays aligned with toListPage for page and offset', () => {
const query = { page: 2, offset: 50, limit: 10 };
const resolved = resolvePaginationQuery(query);
const listPage = toListPage(query);
expect(resolved.limit).toBe(listPage.limit);
expect((resolved.page - 1) * resolved.limit).toBe(listPage.offset);
});
});
});
@@ -0,0 +1,97 @@
import {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} from './pagination.constants';
import type {
PaginationMeta,
PaginationResponse,
} from './ok-response.interface';
export function createPaginationMeta(
page: number,
limit: number,
dataCount: number,
total: number,
): PaginationMeta {
const safeLimit = Math.max(1, limit);
return {
currentPage: Math.max(1, page),
itemCount: dataCount,
itemsPerPage: safeLimit,
totalItems: total,
totalPages: Math.ceil(total / safeLimit) || 0,
};
}
/**
* Builds the public paginated envelope:
* `{ data, meta: { currentPage, itemsPerPage, totalItems, totalPages, itemCount } }`.
* Returns the original payload when it is not a PaginationResponse shape.
*/
export function createPaginationResponse(
response: unknown,
page: number,
limit: number,
): unknown {
if (!isPaginationResponse(response)) {
return response;
}
const { data, total } = response;
return {
data,
meta: createPaginationMeta(page, limit, data.length, total),
};
}
function isPaginationResponse(
value: unknown,
): value is PaginationResponse<unknown> {
if (value === null || typeof value !== 'object') {
return false;
}
const record = value as Record<string, unknown>;
return Array.isArray(record.data) && typeof record.total === 'number';
}
/**
* Resolves page/limit from query params.
* Supports `?page=&limit=` and `?offset=&limit=`
* (page is derived as `floor(offset / limit) + 1`).
* When both `page` and `offset` are present, `page` wins (same as `toListPage`).
*/
export function resolvePaginationQuery(query: Record<string, unknown>): {
page: number;
limit: number;
} {
const limit = clampLimit(
toPositiveInt(query.limit, PAGINATION_DEFAULT_LIMIT),
PAGINATION_MAX_LIMIT,
);
if (query.page != null && query.page !== '') {
return { page: toPositiveInt(query.page, 1), limit };
}
const offset = toNonNegativeInt(query.offset, 0);
return { page: Math.floor(offset / limit) + 1, limit };
}
function clampLimit(limit: number, maxLimit: number): number {
return Math.max(1, Math.min(limit, maxLimit));
}
function toPositiveInt(value: unknown, fallback: number): number {
const n = Number(value);
if (!Number.isFinite(n) || n < 1) {
return fallback;
}
return Math.trunc(n);
}
function toNonNegativeInt(value: unknown, fallback: number): number {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) {
return fallback;
}
return Math.trunc(n);
}
@@ -0,0 +1,24 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { PAGINATION_MAX_LIMIT } from './pagination.constants';
export class PaginationQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(PAGINATION_MAX_LIMIT)
limit?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
}
@@ -0,0 +1,3 @@
/** Default max page size for collection list endpoints. */
export const PAGINATION_DEFAULT_LIMIT = 10;
export const PAGINATION_MAX_LIMIT = 200;
@@ -0,0 +1,20 @@
import { SetMetadata } from '@nestjs/common';
import { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
/**
* Marks a handler as a paginated list endpoint.
*
* The handler must return `{ data: T[]; total: number }`. The global
* transform interceptor then wraps it as `{ data, meta }` using `page` /
* `limit` (or `offset` / `limit`) from the query string.
*/
export const Pagination = (
isPagination = true,
): MethodDecorator & ClassDecorator =>
SetMetadata(PAGINATION_RESPONSE, isPagination);
/**
* Skips response wrapping for the handler (e.g. file downloads, health probes).
*/
export const RawResponse = (): MethodDecorator & ClassDecorator =>
SetMetadata(RAW_RESPONSE, true);
@@ -0,0 +1,116 @@
import { CallHandler, ExecutionContext, StreamableFile } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { of, firstValueFrom } from 'rxjs';
import { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
import { TransformInterceptor } from './transform.interceptor';
describe('TransformInterceptor', () => {
const reflector = {
getAllAndOverride: jest.fn(),
};
const interceptor = new TransformInterceptor(
reflector as unknown as Reflector,
);
function createContext(
query: Record<string, unknown> = {},
): ExecutionContext {
return {
getHandler: () => jest.fn(),
getClass: () => class TestController {},
switchToHttp: () => ({
getRequest: () => ({ query }),
}),
} as unknown as ExecutionContext;
}
function createHandler(payload: unknown): CallHandler {
return { handle: () => of(payload) };
}
beforeEach(() => {
reflector.getAllAndOverride.mockReset();
});
it('passes through when not marked as pagination', async () => {
reflector.getAllAndOverride.mockReturnValue(false);
const payload = { id: '1' };
const result = await firstValueFrom(
interceptor.intercept(createContext(), createHandler(payload)),
);
expect(result).toEqual(payload);
});
it('passes through when marked @RawResponse()', async () => {
reflector.getAllAndOverride.mockImplementation((key: string) => {
if (key === RAW_RESPONSE) {
return true;
}
return false;
});
const payload = { data: [], total: 0 };
const result = await firstValueFrom(
interceptor.intercept(createContext(), createHandler(payload)),
);
expect(result).toEqual(payload);
});
it('wraps @Pagination() handler return as { data, meta }', async () => {
reflector.getAllAndOverride.mockImplementation((key: string) => {
if (key === RAW_RESPONSE) {
return false;
}
if (key === PAGINATION_RESPONSE) {
return true;
}
return false;
});
const result = await firstValueFrom(
interceptor.intercept(
createContext({ page: '2', limit: '5' }),
createHandler({ data: [{ id: 'a' }], total: 11 }),
),
);
expect(result).toEqual({
data: [{ id: 'a' }],
meta: {
currentPage: 2,
itemCount: 1,
itemsPerPage: 5,
totalItems: 11,
totalPages: 3,
},
});
});
it('does not wrap null payloads', async () => {
reflector.getAllAndOverride.mockImplementation((key: string) => {
return key === PAGINATION_RESPONSE;
});
const result = await firstValueFrom(
interceptor.intercept(createContext(), createHandler(null)),
);
expect(result).toBeNull();
});
it('does not wrap StreamableFile payloads', async () => {
reflector.getAllAndOverride.mockImplementation((key: string) => {
return key === PAGINATION_RESPONSE;
});
const file = new StreamableFile(Buffer.from('x'));
const result = await firstValueFrom(
interceptor.intercept(createContext(), createHandler(file)),
);
expect(result).toBe(file);
});
});
@@ -0,0 +1,58 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
StreamableFile,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
import {
createPaginationResponse,
resolvePaginationQuery,
} from './pagination-meta.helper';
/**
* Applies pagination enveloping when a handler is marked with `@Pagination()`.
*
* Non-paginated handlers pass through unchanged. `@RawResponse()`, empty
* bodies, and `StreamableFile` downloads are never wrapped.
*/
@Injectable()
export class TransformInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE, [
context.getHandler(),
context.getClass(),
]);
if (isRaw) {
return next.handle();
}
const isPagination = this.reflector.getAllAndOverride<boolean>(
PAGINATION_RESPONSE,
[context.getHandler(), context.getClass()],
);
if (!isPagination) {
return next.handle();
}
const request = context.switchToHttp().getRequest<{
query: Record<string, unknown>;
}>();
const { page, limit } = resolvePaginationQuery(request.query);
return next.handle().pipe(
map((payload: unknown): unknown => {
if (payload == null || payload instanceof StreamableFile) {
return payload;
}
return createPaginationResponse(payload, page, limit);
}),
);
}
}
@@ -0,0 +1,6 @@
export class InvalidStatusError extends Error {
constructor() {
super('Invalid status');
this.name = 'InvalidStatusError';
}
}
@@ -0,0 +1,134 @@
import { InvalidStatusError } from './invalid-status.error';
import { CORE_STATUSES, Status } from './status';
describe('Status', () => {
describe('CORE_STATUSES and DEFAULT', () => {
it('exposes draft, active, and archived as core statuses', () => {
expect(CORE_STATUSES).toEqual(['draft', 'active', 'archived']);
});
it('defaults to draft', () => {
expect(Status.DEFAULT).toBe('draft');
});
});
describe('create', () => {
it.each(CORE_STATUSES)('accepts core status %s', (raw) => {
const status = Status.create(raw);
expect(status.value).toBe(raw);
});
it('trims surrounding whitespace', () => {
const status = Status.create(' draft ');
expect(status.value).toBe('draft');
});
it('rejects empty string', () => {
expect(() => Status.create('')).toThrow(InvalidStatusError);
});
it('rejects whitespace-only input', () => {
expect(() => Status.create(' ')).toThrow(InvalidStatusError);
});
it('rejects non-string input', () => {
expect(() => Status.create(null as unknown as string)).toThrow(
InvalidStatusError,
);
expect(() => Status.create(123 as unknown as string)).toThrow(
InvalidStatusError,
);
});
it('rejects unknown status', () => {
expect(() => Status.create('in_transit')).toThrow(InvalidStatusError);
});
it('rejects casing variants of core statuses', () => {
expect(() => Status.create('Draft')).toThrow(InvalidStatusError);
expect(() => Status.create('ACTIVE')).toThrow(InvalidStatusError);
});
it('does not echo raw input in the error message', () => {
expect(() => Status.create('secret-status')).toThrow('Invalid status');
try {
Status.create('secret-status');
} catch (error) {
expect((error as Error).message).not.toContain('secret-status');
}
});
it('accepts an extended allowed list', () => {
const status = Status.create('in_transit', [
...CORE_STATUSES,
'in_transit',
]);
expect(status.value).toBe('in_transit');
});
it('still accepts core statuses when allowed is extended', () => {
const allowed = [...CORE_STATUSES, 'in_transit'];
expect(Status.create('draft', allowed).value).toBe('draft');
expect(Status.create('active', allowed).value).toBe('active');
});
it('rejects values outside the extended allowed list', () => {
expect(() =>
Status.create('cancelled', [...CORE_STATUSES, 'in_transit']),
).toThrow(InvalidStatusError);
});
it('rejects empty allowed list', () => {
expect(() => Status.create('draft', [])).toThrow(InvalidStatusError);
});
});
describe('equals', () => {
it('returns true for the same status value', () => {
const a = Status.create('draft');
const b = Status.create('draft');
expect(a.equals(b)).toBe(true);
});
it('returns false for different status values', () => {
const a = Status.create('draft');
const b = Status.create('active');
expect(a.equals(b)).toBe(false);
});
it('returns false for non-Status values', () => {
const status = Status.create('draft');
expect(status.equals({ value: 'draft' } as unknown as Status)).toBe(
false,
);
});
});
describe('serialization', () => {
it('toString returns the canonical status', () => {
expect(Status.create('archived').toString()).toBe('archived');
});
it('toJSON returns the canonical status', () => {
expect(Status.create('active').toJSON()).toBe('active');
expect(JSON.stringify({ status: Status.create('active') })).toBe(
'{"status":"active"}',
);
});
});
describe('construction', () => {
it('cannot be constructed with new Status()', () => {
expect(
() => new (Status as unknown as new (...args: unknown[]) => Status)(),
).toThrow(TypeError);
});
});
});
+62
View File
@@ -0,0 +1,62 @@
import { InvalidStatusError } from './invalid-status.error';
export const CORE_STATUSES = ['draft', 'active', 'archived'] as const;
export type CoreStatus = (typeof CORE_STATUSES)[number];
export class Status {
static readonly DEFAULT: CoreStatus = 'draft';
private static readonly createToken = Symbol('Status.create');
private constructor(
private readonly status: string,
token: symbol,
) {
if (token !== Status.createToken) {
throw new TypeError('Status can only be created via Status.create()');
}
Object.freeze(this);
}
/**
* Creates a Status from a raw string.
* @param raw - status string (trimmed)
* @param allowed - optional allow-list; defaults to CORE_STATUSES
*/
static create(
raw: string,
allowed: readonly string[] = CORE_STATUSES,
): Status {
if (typeof raw !== 'string') {
throw new InvalidStatusError();
}
if (!Array.isArray(allowed) || allowed.length === 0) {
throw new InvalidStatusError();
}
const trimmed = raw.trim();
if (trimmed === '' || !allowed.includes(trimmed)) {
throw new InvalidStatusError();
}
return new Status(trimmed, Status.createToken);
}
get value(): string {
return this.status;
}
equals(other: Status): boolean {
return other instanceof Status && this.status === other.status;
}
toString(): string {
return this.status;
}
toJSON(): string {
return this.status;
}
}
@@ -0,0 +1,69 @@
import { type AnyPgColumn } from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
describe('primaryEntityColumns', () => {
const columns = primaryEntityColumns(users);
it('returns status, createdAt, updatedAt, createdBy, and updatedBy', () => {
expect(Object.keys(columns).sort()).toEqual(
['createdAt', 'createdBy', 'status', 'updatedAt', 'updatedBy'].sort(),
);
});
it('maps status to text column status with default draft', () => {
const config = getConfig(columns.status);
expect(config.name).toBe('status');
expect(config.dataType).toBe('string');
expect(config.notNull).toBe(true);
expect(config.hasDefault).toBe(true);
expect(config.default).toBe('draft');
});
it('maps createdAt and updatedAt to bigint unix millisecond columns', () => {
const createdAt = getConfig(columns.createdAt);
const updatedAt = getConfig(columns.updatedAt);
expect(createdAt.name).toBe('created_at');
expect(updatedAt.name).toBe('updated_at');
expect(createdAt.dataType).toBe('number');
expect(updatedAt.dataType).toBe('number');
expect(createdAt.notNull).toBe(true);
expect(updatedAt.notNull).toBe(true);
});
it('maps createdBy and updatedBy to uuid columns referencing users.id', () => {
const createdBy = getConfig(columns.createdBy);
const updatedBy = getConfig(columns.updatedBy);
expect(createdBy.name).toBe('created_by');
expect(updatedBy.name).toBe('updated_by');
expect(createdBy.notNull).toBe(true);
expect(updatedBy.notNull).toBe(true);
});
it('accepts a minimal user table shape without importing circular schema deps', () => {
const stub = { id: users.id as AnyPgColumn };
const cols = primaryEntityColumns(stub);
expect(getConfig(cols.status).name).toBe('status');
expect(getConfig(cols.createdBy).name).toBe('created_by');
});
});
function getConfig(column: { config: Record<string, unknown> }): {
name: string;
dataType: string;
notNull: boolean;
hasDefault?: boolean;
default?: unknown;
} {
return column.config as {
name: string;
dataType: string;
notNull: boolean;
hasDefault?: boolean;
default?: unknown;
};
}
+20
View File
@@ -0,0 +1,20 @@
import { bigint, text, uuid, type AnyPgColumn } from 'drizzle-orm/pg-core';
import { Status } from '../common/value-objects/status/status';
/**
* Standard audit columns for primary / aggregate tables.
* Pass the users table (or `{ id }`) so FKs do not circular-import schema.ts.
*/
export function primaryEntityColumns(userTable: { id: AnyPgColumn }) {
return {
status: text('status').notNull().default(Status.DEFAULT),
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
createdBy: uuid('created_by')
.notNull()
.references(() => userTable.id),
updatedBy: uuid('updated_by')
.notNull()
.references(() => userTable.id),
};
}