Skip to content

Commit fe26d6a

Browse files
committed
feat: add schema isolation infrastructure for parallel Jest workers
Add infrastructure for PostgreSQL schema isolation to enable parallel Jest workers within CI jobs. Each worker gets its own schema to prevent data conflicts between tests. Changes: - Add TYPEORM_SCHEMA env var support and auto-schema selection based on JEST_WORKER_ID when ENABLE_SCHEMA_ISOLATION=true - Set PostgreSQL search_path at connection level for raw SQL queries - Add createWorkerSchema() to copy table structures, views, and migrations data from public schema to worker schemas - Use pg_get_serial_sequence() for sequence resets to handle different sequence naming conventions Known limitation: Database triggers are not copied as they reference functions in the public schema. Schema isolation is opt-in via ENABLE_SCHEMA_ISOLATION=true environment variable. Addresses ENG-283
1 parent 3832b36 commit fe26d6a

File tree

2 files changed

+136
-6
lines changed

2 files changed

+136
-6
lines changed

__tests__/setup.ts

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as matchers from 'jest-extended';
2+
import { DataSource } from 'typeorm';
23
import '../src/config';
34
import createOrGetConnection from '../src/db';
5+
import { testSchema } from '../src/data-source';
46
import { remoteConfig } from '../src/remoteConfig';
57
import { loadAuthKeys } from '../src/auth';
68

@@ -64,14 +66,24 @@ const cleanDatabase = async (): Promise<void> => {
6466
for (const entity of con.entityMetadatas) {
6567
const repository = con.getRepository(entity.name);
6668
if (repository.metadata.tableType === 'view') continue;
67-
await repository.query(`DELETE
68-
FROM "${entity.tableName}";`);
69+
await repository.query(`DELETE FROM "${entity.tableName}";`);
6970

7071
for (const column of entity.primaryColumns) {
7172
if (column.generationStrategy === 'increment') {
72-
await repository.query(
73-
`ALTER SEQUENCE ${entity.tableName}_${column.databaseName}_seq RESTART WITH 1`,
74-
);
73+
// Use pg_get_serial_sequence to find the actual sequence name
74+
// This handles both original and copied tables with different sequence naming
75+
try {
76+
const seqResult = await repository.query(
77+
`SELECT pg_get_serial_sequence('"${entity.tableName}"', '${column.databaseName}') as seq_name`,
78+
);
79+
if (seqResult[0]?.seq_name) {
80+
await repository.query(
81+
`ALTER SEQUENCE ${seqResult[0].seq_name} RESTART WITH 1`,
82+
);
83+
}
84+
} catch {
85+
// Sequence might not exist, ignore
86+
}
7587
}
7688
}
7789
}
@@ -82,6 +94,96 @@ jest.mock('file-type', () => ({
8294
fileTypeFromBuffer: () => fileTypeFromBuffer(),
8395
}));
8496

97+
/**
98+
* Create the worker schema for test isolation.
99+
* Creates a new schema and copies all table structures from public schema.
100+
* This is used when ENABLE_SCHEMA_ISOLATION=true for parallel Jest workers.
101+
*/
102+
const createWorkerSchema = async (): Promise<void> => {
103+
// Only create non-public schemas (when running with multiple Jest workers)
104+
if (testSchema === 'public') {
105+
return;
106+
}
107+
108+
// Bootstrap connection using public schema
109+
const bootstrapDataSource = new DataSource({
110+
type: 'postgres',
111+
host: process.env.TYPEORM_HOST || 'localhost',
112+
port: 5432,
113+
username: process.env.TYPEORM_USERNAME || 'postgres',
114+
password: process.env.TYPEORM_PASSWORD || '12345',
115+
database:
116+
process.env.TYPEORM_DATABASE ||
117+
(process.env.NODE_ENV === 'test' ? 'api_test' : 'api'),
118+
schema: 'public',
119+
});
120+
121+
await bootstrapDataSource.initialize();
122+
123+
// Drop and create the worker schema
124+
await bootstrapDataSource.query(
125+
`DROP SCHEMA IF EXISTS "${testSchema}" CASCADE`,
126+
);
127+
await bootstrapDataSource.query(`CREATE SCHEMA "${testSchema}"`);
128+
129+
// Get all tables from public schema (excluding views and TypeORM metadata)
130+
const tables = await bootstrapDataSource.query(`
131+
SELECT tablename FROM pg_tables
132+
WHERE schemaname = 'public'
133+
AND tablename NOT LIKE 'pg_%'
134+
AND tablename != 'typeorm_metadata'
135+
`);
136+
137+
// Copy table structure from public to worker schema
138+
for (const { tablename } of tables) {
139+
await bootstrapDataSource.query(`
140+
CREATE TABLE "${testSchema}"."${tablename}"
141+
(LIKE "public"."${tablename}" INCLUDING ALL)
142+
`);
143+
}
144+
145+
// Copy migrations table so TypeORM knows migrations are already applied
146+
await bootstrapDataSource.query(`
147+
INSERT INTO "${testSchema}"."migrations" SELECT * FROM "public"."migrations"
148+
`);
149+
150+
// Get all views from public schema and recreate them in worker schema
151+
const views = await bootstrapDataSource.query(`
152+
SELECT viewname, definition FROM pg_views
153+
WHERE schemaname = 'public'
154+
`);
155+
156+
for (const { viewname, definition } of views) {
157+
// Replace public schema references with worker schema in view definition
158+
const modifiedDefinition = definition.replace(
159+
/public\./g,
160+
`${testSchema}.`,
161+
);
162+
await bootstrapDataSource.query(`
163+
CREATE OR REPLACE VIEW "${testSchema}"."${viewname}" AS ${modifiedDefinition}
164+
`);
165+
}
166+
167+
// Note: Triggers are NOT copied because they reference functions in public schema
168+
// which would insert data into public schema tables instead of worker schema tables.
169+
// This is a known limitation of schema isolation.
170+
171+
await bootstrapDataSource.destroy();
172+
};
173+
174+
let schemaInitialized = false;
175+
176+
beforeAll(async () => {
177+
if (!schemaInitialized) {
178+
// Create worker schema for parallel test isolation
179+
// Public schema is set up by the pretest script
180+
if (testSchema !== 'public') {
181+
await createWorkerSchema();
182+
}
183+
schemaInitialized = true;
184+
}
185+
});
186+
85187
beforeEach(async () => {
86188
loadAuthKeys();
87189

src/data-source.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,41 @@
11
import 'reflect-metadata';
22
import { DataSource } from 'typeorm';
33

4+
/**
5+
* Determine schema for test isolation.
6+
* Each Jest worker gets its own schema to enable parallel test execution.
7+
* Schema isolation is enabled in CI when ENABLE_SCHEMA_ISOLATION=true,
8+
* which allows parallel Jest workers to run without conflicts.
9+
*/
10+
const getSchema = (): string => {
11+
if (process.env.TYPEORM_SCHEMA) {
12+
return process.env.TYPEORM_SCHEMA;
13+
}
14+
// Enable schema isolation for parallel Jest workers in CI
15+
if (
16+
process.env.ENABLE_SCHEMA_ISOLATION === 'true' &&
17+
process.env.JEST_WORKER_ID
18+
) {
19+
return `test_worker_${process.env.JEST_WORKER_ID}`;
20+
}
21+
return 'public';
22+
};
23+
24+
export const testSchema = getSchema();
25+
26+
// PostgreSQL connection options to set search_path for raw SQL queries
27+
const pgOptions =
28+
testSchema !== 'public' ? `-c search_path=${testSchema}` : undefined;
29+
430
export const AppDataSource = new DataSource({
531
type: 'postgres',
6-
schema: 'public',
32+
schema: testSchema,
733
synchronize: false,
834
extra: {
935
max: 30,
1036
idleTimeoutMillis: 0,
37+
// Set search_path at connection level so raw SQL uses the correct schema
38+
options: pgOptions,
1139
},
1240
logging: false,
1341
entities: ['src/entity/**/*.{js,ts}'],

0 commit comments

Comments
 (0)