@rawsql-ts/ztd-cli 0.13.3 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -2
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +262 -14
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/ztdConfig.d.ts +2 -1
- package/dist/commands/ztdConfig.js +12 -0
- package/dist/commands/ztdConfig.js.map +1 -1
- package/dist/commands/ztdConfigCommand.js +17 -6
- package/dist/commands/ztdConfigCommand.js.map +1 -1
- package/dist/utils/normalizePulledSchema.js +12 -0
- package/dist/utils/normalizePulledSchema.js.map +1 -1
- package/dist/utils/ztdProjectConfig.d.ts +3 -0
- package/dist/utils/ztdProjectConfig.js +7 -1
- package/dist/utils/ztdProjectConfig.js.map +1 -1
- package/package.json +6 -3
- package/templates/AGENTS.md +91 -0
- package/templates/README.md +32 -0
- package/templates/src/db/sql-client.ts +24 -0
- package/templates/tests/support/testkit-client.ts +636 -6
- package/templates/tsconfig.json +9 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// ztd-cli emits this file during project bootstrapping to wire pg-testkit.
|
|
3
3
|
// Regenerate via npx ztd init (choose overwrite when prompted); avoid manual edits.
|
|
4
4
|
|
|
5
|
+
import { existsSync, promises as fsPromises } from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { Client, types } from 'pg';
|
|
7
8
|
import type { ClientConfig, QueryResultRow } from 'pg';
|
|
@@ -14,6 +15,72 @@ const ddlDirectories = [path.resolve(__dirname, '../../ztd/ddl')];
|
|
|
14
15
|
let sharedPgClient: Client | undefined;
|
|
15
16
|
let sharedQueryable: PgQueryable | undefined;
|
|
16
17
|
|
|
18
|
+
type ZtdSqlLogPhase = 'original' | 'rewritten';
|
|
19
|
+
|
|
20
|
+
type ZtdSqlLogEvent = {
|
|
21
|
+
kind: 'ztd-sql';
|
|
22
|
+
phase: ZtdSqlLogPhase;
|
|
23
|
+
queryId: number;
|
|
24
|
+
sql: string;
|
|
25
|
+
params?: unknown[];
|
|
26
|
+
fixturesApplied?: string[];
|
|
27
|
+
timestamp: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ZtdExecutionMode = 'ztd' | 'traditional';
|
|
31
|
+
|
|
32
|
+
export type TraditionalIsolationMode = 'schema' | 'none';
|
|
33
|
+
export type TraditionalCleanupStrategy = 'drop_schema' | 'custom_sql' | 'none';
|
|
34
|
+
|
|
35
|
+
export interface TraditionalExecutionConfig {
|
|
36
|
+
isolation?: TraditionalIsolationMode;
|
|
37
|
+
setupSql?: string[];
|
|
38
|
+
cleanup?: TraditionalCleanupStrategy;
|
|
39
|
+
cleanupSql?: string[];
|
|
40
|
+
schemaName?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ZtdSqlLogOptions = {
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
includeParams?: boolean;
|
|
46
|
+
logger?: (event: ZtdSqlLogEvent) => void;
|
|
47
|
+
profile?: ZtdProfileOptions;
|
|
48
|
+
mode?: ZtdExecutionMode;
|
|
49
|
+
traditional?: TraditionalExecutionConfig;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type ZtdProfilePhase = 'connection' | 'setup' | 'query' | 'teardown';
|
|
53
|
+
|
|
54
|
+
type ZtdProfileEvent = {
|
|
55
|
+
kind: 'ztd-profile';
|
|
56
|
+
phase: ZtdProfilePhase;
|
|
57
|
+
testName?: string;
|
|
58
|
+
workerId?: string;
|
|
59
|
+
processId: number;
|
|
60
|
+
executionMode?: 'serial' | 'parallel' | 'unknown';
|
|
61
|
+
connectionReused?: boolean;
|
|
62
|
+
queryId?: number;
|
|
63
|
+
queryCount?: number;
|
|
64
|
+
durationMs?: number;
|
|
65
|
+
totalQueryMs?: number;
|
|
66
|
+
sql?: string;
|
|
67
|
+
params?: unknown[];
|
|
68
|
+
fixturesApplied?: string[];
|
|
69
|
+
sampleSql?: string[];
|
|
70
|
+
timestamp: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ZtdProfileOptions = {
|
|
74
|
+
enabled?: boolean;
|
|
75
|
+
perQuery?: boolean;
|
|
76
|
+
includeParams?: boolean;
|
|
77
|
+
includeSql?: boolean;
|
|
78
|
+
sampleLimit?: number;
|
|
79
|
+
testName?: string;
|
|
80
|
+
executionMode?: 'serial' | 'parallel' | 'unknown';
|
|
81
|
+
logger?: (event: ZtdProfileEvent) => void;
|
|
82
|
+
};
|
|
83
|
+
|
|
17
84
|
const { INT2, INT4, INT8, NUMERIC, DATE } = types.builtins;
|
|
18
85
|
const parseInteger = (value: string | null) => (value === null ? null : Number(value));
|
|
19
86
|
const parseNumeric = (value: string | null) => (value === null ? null : Number(value));
|
|
@@ -25,6 +92,42 @@ types.setTypeParser(INT8, parseInteger);
|
|
|
25
92
|
types.setTypeParser(NUMERIC, parseNumeric);
|
|
26
93
|
types.setTypeParser(DATE, (value) => value);
|
|
27
94
|
|
|
95
|
+
function isTruthyEnv(value: string | undefined): boolean {
|
|
96
|
+
if (!value) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return value === '1' || value.toLowerCase() === 'true' || value.toLowerCase() === 'yes';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveNumberEnv(value: string | undefined): number | undefined {
|
|
104
|
+
if (!value) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const parsed = Number(value);
|
|
109
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function safeJsonStringify(value: unknown): string {
|
|
113
|
+
const seen = new WeakSet<object>();
|
|
114
|
+
return JSON.stringify(value, (_key, item) => {
|
|
115
|
+
// Avoid JSON.stringify throwing on BigInt params when logging is enabled.
|
|
116
|
+
if (typeof item === 'bigint') {
|
|
117
|
+
return item.toString();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Avoid JSON.stringify throwing on circular references when logging is enabled.
|
|
121
|
+
if (typeof item === 'object' && item !== null) {
|
|
122
|
+
if (seen.has(item)) {
|
|
123
|
+
return '[Circular]';
|
|
124
|
+
}
|
|
125
|
+
seen.add(item);
|
|
126
|
+
}
|
|
127
|
+
return item;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
28
131
|
async function resolveDatabaseUrl(): Promise<string> {
|
|
29
132
|
const configuredUrl = process.env.DATABASE_URL;
|
|
30
133
|
if (configuredUrl) {
|
|
@@ -90,23 +193,550 @@ export type ZtdPlaygroundClient = {
|
|
|
90
193
|
close(): Promise<void>;
|
|
91
194
|
};
|
|
92
195
|
|
|
93
|
-
export async function createTestkitClient(
|
|
196
|
+
export async function createTestkitClient(
|
|
197
|
+
fixtures: TableFixture[],
|
|
198
|
+
options: ZtdSqlLogOptions = {}
|
|
199
|
+
): Promise<ZtdPlaygroundClient> {
|
|
200
|
+
const mode = resolveExecutionMode(options.mode);
|
|
201
|
+
if (mode === 'traditional') {
|
|
202
|
+
return createTraditionalPlaygroundClient(fixtures, options);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return createZtdPlaygroundClient(fixtures, options);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function createZtdPlaygroundClient(
|
|
209
|
+
fixtures: TableFixture[],
|
|
210
|
+
options: ZtdSqlLogOptions
|
|
211
|
+
): Promise<ZtdPlaygroundClient> {
|
|
212
|
+
const {
|
|
213
|
+
logEnabled,
|
|
214
|
+
logParams,
|
|
215
|
+
logSink,
|
|
216
|
+
profileEnabled,
|
|
217
|
+
profilePerQuery,
|
|
218
|
+
profileParams,
|
|
219
|
+
profileIncludeSql,
|
|
220
|
+
profileSampleLimit,
|
|
221
|
+
profileTestName,
|
|
222
|
+
profileExecutionMode,
|
|
223
|
+
profileWorkerId,
|
|
224
|
+
profileSink,
|
|
225
|
+
} = buildLoggingState(options);
|
|
226
|
+
|
|
227
|
+
let nextQueryId = 1;
|
|
228
|
+
const queryIdStack: number[] = [];
|
|
229
|
+
// Keep fixture info aligned with the active query so profiling includes applied fixtures.
|
|
230
|
+
const queryFixtureMap = new Map<number, string[] | undefined>();
|
|
231
|
+
// Track aggregate timings so teardown logs can summarize the run.
|
|
232
|
+
const profileStats = {
|
|
233
|
+
queryCount: 0,
|
|
234
|
+
totalQueryMs: 0,
|
|
235
|
+
sampleSql: [] as string[],
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Capture a consistent timestamp baseline for the setup phase.
|
|
239
|
+
const setupStartedAt = profileEnabled ? Date.now() : 0;
|
|
240
|
+
|
|
241
|
+
const hadSharedConnection = Boolean(sharedPgClient);
|
|
242
|
+
const connectionStartedAt = profileEnabled ? Date.now() : 0;
|
|
94
243
|
const queryable = await getPgQueryable();
|
|
244
|
+
if (profileEnabled) {
|
|
245
|
+
profileSink({
|
|
246
|
+
kind: 'ztd-profile',
|
|
247
|
+
phase: 'connection',
|
|
248
|
+
testName: profileTestName,
|
|
249
|
+
workerId: profileWorkerId,
|
|
250
|
+
processId: process.pid,
|
|
251
|
+
executionMode: profileExecutionMode,
|
|
252
|
+
connectionReused: hadSharedConnection,
|
|
253
|
+
durationMs: Date.now() - connectionStartedAt,
|
|
254
|
+
timestamp: new Date().toISOString(),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
95
258
|
// TableNameResolver keeps DDL and fixtures aligned on canonical schema-qualified identifiers like 'public.table_name'.
|
|
96
259
|
const driver = createPgTestkitClient({
|
|
97
260
|
connectionFactory: () => queryable,
|
|
98
261
|
tableRows: fixtures,
|
|
99
|
-
ddl: { directories: ddlDirectories }
|
|
262
|
+
ddl: { directories: ddlDirectories },
|
|
263
|
+
onExecute: (rewrittenSql: string, params: unknown[] | undefined, fixturesApplied: string[]) => {
|
|
264
|
+
if (profileEnabled) {
|
|
265
|
+
// Capture fixture metadata while the original query is still on the stack.
|
|
266
|
+
const activeQueryId = queryIdStack.at(-1);
|
|
267
|
+
if (typeof activeQueryId === 'number') {
|
|
268
|
+
queryFixtureMap.set(activeQueryId, fixturesApplied);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!logEnabled) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Use a stack so concurrent async queries can still correlate "original" and "rewritten" logs.
|
|
277
|
+
const queryId = queryIdStack.at(-1) ?? -1;
|
|
278
|
+
logSink({
|
|
279
|
+
kind: 'ztd-sql',
|
|
280
|
+
phase: 'rewritten',
|
|
281
|
+
queryId,
|
|
282
|
+
sql: rewrittenSql,
|
|
283
|
+
params: logParams ? (params as unknown[] | undefined) : undefined,
|
|
284
|
+
fixturesApplied,
|
|
285
|
+
timestamp: new Date().toISOString(),
|
|
286
|
+
});
|
|
287
|
+
},
|
|
100
288
|
});
|
|
101
289
|
|
|
290
|
+
if (profileEnabled) {
|
|
291
|
+
profileSink({
|
|
292
|
+
kind: 'ztd-profile',
|
|
293
|
+
phase: 'setup',
|
|
294
|
+
testName: profileTestName,
|
|
295
|
+
workerId: profileWorkerId,
|
|
296
|
+
processId: process.pid,
|
|
297
|
+
executionMode: profileExecutionMode,
|
|
298
|
+
durationMs: Date.now() - setupStartedAt,
|
|
299
|
+
timestamp: new Date().toISOString(),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
102
303
|
// Expose a simplified query API so tests can assert on plain row arrays.
|
|
103
304
|
return {
|
|
104
305
|
async query<T extends QueryResultRow>(text: string, values?: unknown[]) {
|
|
105
|
-
const
|
|
106
|
-
|
|
306
|
+
const queryId = nextQueryId++;
|
|
307
|
+
|
|
308
|
+
if (logEnabled) {
|
|
309
|
+
logSink({
|
|
310
|
+
kind: 'ztd-sql',
|
|
311
|
+
phase: 'original',
|
|
312
|
+
queryId,
|
|
313
|
+
sql: text,
|
|
314
|
+
params: logParams ? values : undefined,
|
|
315
|
+
timestamp: new Date().toISOString(),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const queryStartedAt = profileEnabled ? Date.now() : 0;
|
|
320
|
+
queryIdStack.push(queryId);
|
|
321
|
+
try {
|
|
322
|
+
const result = await driver.query<T>(text, values);
|
|
323
|
+
return result.rows;
|
|
324
|
+
} finally {
|
|
325
|
+
queryIdStack.pop();
|
|
326
|
+
|
|
327
|
+
if (profileEnabled) {
|
|
328
|
+
// Record per-query timing and keep a small SQL sample for teardown summaries.
|
|
329
|
+
const durationMs = Date.now() - queryStartedAt;
|
|
330
|
+
profileStats.queryCount += 1;
|
|
331
|
+
profileStats.totalQueryMs += durationMs;
|
|
332
|
+
|
|
333
|
+
if (profileIncludeSql && profileSampleLimit > 0 && profileStats.sampleSql.length < profileSampleLimit) {
|
|
334
|
+
profileStats.sampleSql.push(text);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const fixturesApplied = queryFixtureMap.get(queryId);
|
|
338
|
+
queryFixtureMap.delete(queryId);
|
|
339
|
+
|
|
340
|
+
if (profilePerQuery) {
|
|
341
|
+
profileSink({
|
|
342
|
+
kind: 'ztd-profile',
|
|
343
|
+
phase: 'query',
|
|
344
|
+
testName: profileTestName,
|
|
345
|
+
workerId: profileWorkerId,
|
|
346
|
+
processId: process.pid,
|
|
347
|
+
executionMode: profileExecutionMode,
|
|
348
|
+
queryId,
|
|
349
|
+
durationMs,
|
|
350
|
+
sql: profileIncludeSql ? text : undefined,
|
|
351
|
+
params: profileParams ? values : undefined,
|
|
352
|
+
fixturesApplied,
|
|
353
|
+
timestamp: new Date().toISOString(),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
107
358
|
},
|
|
108
|
-
close() {
|
|
109
|
-
|
|
359
|
+
async close() {
|
|
360
|
+
const teardownStartedAt = profileEnabled ? Date.now() : 0;
|
|
361
|
+
try {
|
|
362
|
+
await driver.close();
|
|
363
|
+
} finally {
|
|
364
|
+
if (profileEnabled) {
|
|
365
|
+
// Always emit the teardown summary, even if close fails mid-flight.
|
|
366
|
+
profileSink({
|
|
367
|
+
kind: 'ztd-profile',
|
|
368
|
+
phase: 'teardown',
|
|
369
|
+
testName: profileTestName,
|
|
370
|
+
workerId: profileWorkerId,
|
|
371
|
+
processId: process.pid,
|
|
372
|
+
executionMode: profileExecutionMode,
|
|
373
|
+
durationMs: Date.now() - teardownStartedAt,
|
|
374
|
+
queryCount: profileStats.queryCount,
|
|
375
|
+
totalQueryMs: profileStats.totalQueryMs,
|
|
376
|
+
sampleSql: profileIncludeSql ? profileStats.sampleSql : undefined,
|
|
377
|
+
timestamp: new Date().toISOString(),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
110
381
|
}
|
|
111
382
|
};
|
|
112
383
|
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async function createTraditionalPlaygroundClient(
|
|
387
|
+
fixtures: TableFixture[],
|
|
388
|
+
options: ZtdSqlLogOptions
|
|
389
|
+
): Promise<ZtdPlaygroundClient> {
|
|
390
|
+
const {
|
|
391
|
+
logEnabled,
|
|
392
|
+
logParams,
|
|
393
|
+
logSink,
|
|
394
|
+
profileEnabled,
|
|
395
|
+
profilePerQuery,
|
|
396
|
+
profileParams,
|
|
397
|
+
profileIncludeSql,
|
|
398
|
+
profileSampleLimit,
|
|
399
|
+
profileTestName,
|
|
400
|
+
profileExecutionMode,
|
|
401
|
+
profileWorkerId,
|
|
402
|
+
profileSink,
|
|
403
|
+
} = buildLoggingState(options);
|
|
404
|
+
|
|
405
|
+
const databaseUrl = await resolveDatabaseUrl();
|
|
406
|
+
const clientConfig: ClientConfig = { connectionString: databaseUrl };
|
|
407
|
+
const client = new Client(clientConfig);
|
|
408
|
+
await client.connect();
|
|
409
|
+
|
|
410
|
+
const traditional = options.traditional;
|
|
411
|
+
const isolation = traditional?.isolation ?? 'schema';
|
|
412
|
+
const schemaName = isolation === 'schema' ? traditional?.schemaName ?? generateSchemaName() : undefined;
|
|
413
|
+
const setupSql = traditional?.setupSql ?? [];
|
|
414
|
+
const cleanupSql = traditional?.cleanupSql ?? [];
|
|
415
|
+
const cleanupStrategy: TraditionalCleanupStrategy =
|
|
416
|
+
traditional?.cleanup ?? (schemaName ? 'drop_schema' : 'none');
|
|
417
|
+
|
|
418
|
+
let initializationPromise: Promise<void> | null = null;
|
|
419
|
+
const setupStartedAt = profileEnabled ? Date.now() : 0;
|
|
420
|
+
let setupLogged = false;
|
|
421
|
+
|
|
422
|
+
const ensureInitialized = (): Promise<void> => {
|
|
423
|
+
if (!initializationPromise) {
|
|
424
|
+
initializationPromise = (async () => {
|
|
425
|
+
if (schemaName) {
|
|
426
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)}`);
|
|
427
|
+
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}, public`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Build the schema objects before seeding or running custom setup SQL.
|
|
431
|
+
await applySqlFiles(client, ddlDirectories);
|
|
432
|
+
|
|
433
|
+
// Allow callers to run additional setup steps before the fixtures load.
|
|
434
|
+
for (const sql of setupSql) {
|
|
435
|
+
if (!sql.trim()) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
await client.query(sql);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Materialize the fixture rows into the isolated schema.
|
|
442
|
+
await seedFixtureRows(client, fixtures, schemaName);
|
|
443
|
+
})().then(() => {
|
|
444
|
+
if (profileEnabled && !setupLogged) {
|
|
445
|
+
profileSink({
|
|
446
|
+
kind: 'ztd-profile',
|
|
447
|
+
phase: 'setup',
|
|
448
|
+
testName: profileTestName,
|
|
449
|
+
workerId: profileWorkerId,
|
|
450
|
+
processId: process.pid,
|
|
451
|
+
executionMode: profileExecutionMode,
|
|
452
|
+
durationMs: Date.now() - setupStartedAt,
|
|
453
|
+
timestamp: new Date().toISOString(),
|
|
454
|
+
});
|
|
455
|
+
setupLogged = true;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return initializationPromise;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
let cleanupRun = false;
|
|
463
|
+
const runCleanup = async () => {
|
|
464
|
+
if (cleanupRun) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
cleanupRun = true;
|
|
468
|
+
|
|
469
|
+
// Execute any caller-supplied cleanup statements before further teardown.
|
|
470
|
+
if (cleanupStrategy === 'custom_sql') {
|
|
471
|
+
for (const sql of cleanupSql) {
|
|
472
|
+
if (!sql.trim()) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
await client.query(sql);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Tear down the isolated schema when requested.
|
|
480
|
+
if (cleanupStrategy === 'drop_schema' && schemaName) {
|
|
481
|
+
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const connectionStartedAt = profileEnabled ? Date.now() : 0;
|
|
486
|
+
if (profileEnabled) {
|
|
487
|
+
profileSink({
|
|
488
|
+
kind: 'ztd-profile',
|
|
489
|
+
phase: 'connection',
|
|
490
|
+
testName: profileTestName,
|
|
491
|
+
workerId: profileWorkerId,
|
|
492
|
+
processId: process.pid,
|
|
493
|
+
executionMode: profileExecutionMode,
|
|
494
|
+
connectionReused: false,
|
|
495
|
+
durationMs: Date.now() - connectionStartedAt,
|
|
496
|
+
timestamp: new Date().toISOString(),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
let nextQueryId = 1;
|
|
501
|
+
const queryIdStack: number[] = [];
|
|
502
|
+
const profileStats = {
|
|
503
|
+
queryCount: 0,
|
|
504
|
+
totalQueryMs: 0,
|
|
505
|
+
sampleSql: [] as string[],
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
async query<T extends QueryResultRow>(text: string, values?: unknown[]) {
|
|
510
|
+
const queryId = nextQueryId++;
|
|
511
|
+
|
|
512
|
+
// Emit the original SQL so the tracing/logging pipeline stays consistent.
|
|
513
|
+
if (logEnabled) {
|
|
514
|
+
logSink({
|
|
515
|
+
kind: 'ztd-sql',
|
|
516
|
+
phase: 'original',
|
|
517
|
+
queryId,
|
|
518
|
+
sql: text,
|
|
519
|
+
params: logParams ? values : undefined,
|
|
520
|
+
timestamp: new Date().toISOString(),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const queryStartedAt = profileEnabled ? Date.now() : 0;
|
|
525
|
+
queryIdStack.push(queryId);
|
|
526
|
+
try {
|
|
527
|
+
await ensureInitialized();
|
|
528
|
+
const result = await client.query<T>(text, values);
|
|
529
|
+
return result.rows;
|
|
530
|
+
} finally {
|
|
531
|
+
queryIdStack.pop();
|
|
532
|
+
|
|
533
|
+
if (profileEnabled) {
|
|
534
|
+
const durationMs = Date.now() - queryStartedAt;
|
|
535
|
+
profileStats.queryCount += 1;
|
|
536
|
+
profileStats.totalQueryMs += durationMs;
|
|
537
|
+
|
|
538
|
+
if (profileIncludeSql && profileSampleLimit > 0 && profileStats.sampleSql.length < profileSampleLimit) {
|
|
539
|
+
profileStats.sampleSql.push(text);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (profilePerQuery) {
|
|
543
|
+
profileSink({
|
|
544
|
+
kind: 'ztd-profile',
|
|
545
|
+
phase: 'query',
|
|
546
|
+
testName: profileTestName,
|
|
547
|
+
workerId: profileWorkerId,
|
|
548
|
+
processId: process.pid,
|
|
549
|
+
executionMode: profileExecutionMode,
|
|
550
|
+
queryId,
|
|
551
|
+
durationMs,
|
|
552
|
+
sql: profileIncludeSql ? text : undefined,
|
|
553
|
+
params: profileParams ? values : undefined,
|
|
554
|
+
timestamp: new Date().toISOString(),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
async close() {
|
|
561
|
+
const teardownStartedAt = profileEnabled ? Date.now() : 0;
|
|
562
|
+
// Preserve the first teardown error so we can rethrow it after ensuring the client closes.
|
|
563
|
+
let teardownError: unknown;
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
if (initializationPromise) {
|
|
567
|
+
await initializationPromise.catch(() => undefined);
|
|
568
|
+
}
|
|
569
|
+
await runCleanup();
|
|
570
|
+
} catch (error) {
|
|
571
|
+
teardownError = error;
|
|
572
|
+
} finally {
|
|
573
|
+
if (profileEnabled) {
|
|
574
|
+
profileSink({
|
|
575
|
+
kind: 'ztd-profile',
|
|
576
|
+
phase: 'teardown',
|
|
577
|
+
testName: profileTestName,
|
|
578
|
+
workerId: profileWorkerId,
|
|
579
|
+
processId: process.pid,
|
|
580
|
+
executionMode: profileExecutionMode,
|
|
581
|
+
durationMs: Date.now() - teardownStartedAt,
|
|
582
|
+
queryCount: profileStats.queryCount,
|
|
583
|
+
totalQueryMs: profileStats.totalQueryMs,
|
|
584
|
+
sampleSql: profileIncludeSql ? profileStats.sampleSql : undefined,
|
|
585
|
+
timestamp: new Date().toISOString(),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Always attempt to close the client and record any failure without overriding earlier errors.
|
|
590
|
+
try {
|
|
591
|
+
await client.end();
|
|
592
|
+
} catch (clientError) {
|
|
593
|
+
if (!teardownError) {
|
|
594
|
+
teardownError = clientError;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (teardownError) {
|
|
600
|
+
throw teardownError;
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function buildLoggingState(options: ZtdSqlLogOptions) {
|
|
607
|
+
const logEnabled = options.enabled ?? isTruthyEnv(process.env.ZTD_SQL_LOG);
|
|
608
|
+
const logParams = options.includeParams ?? isTruthyEnv(process.env.ZTD_SQL_LOG_PARAMS);
|
|
609
|
+
const logSink =
|
|
610
|
+
options.logger ??
|
|
611
|
+
((event: ZtdSqlLogEvent) => {
|
|
612
|
+
console.log(safeJsonStringify(event));
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const profileOptions = options.profile ?? {};
|
|
616
|
+
const profileEnabled = profileOptions.enabled ?? isTruthyEnv(process.env.ZTD_PROFILE);
|
|
617
|
+
const profilePerQuery = profileOptions.perQuery ?? isTruthyEnv(process.env.ZTD_PROFILE_PER_QUERY);
|
|
618
|
+
const profileParams = profileOptions.includeParams ?? isTruthyEnv(process.env.ZTD_PROFILE_PARAMS);
|
|
619
|
+
const profileIncludeSql = profileOptions.includeSql ?? isTruthyEnv(process.env.ZTD_PROFILE_SQL);
|
|
620
|
+
const profileSampleLimit =
|
|
621
|
+
profileOptions.sampleLimit ?? resolveNumberEnv(process.env.ZTD_PROFILE_SAMPLE_LIMIT) ?? 5;
|
|
622
|
+
const profileTestName = profileOptions.testName ?? process.env.ZTD_PROFILE_TEST_NAME;
|
|
623
|
+
const profileExecutionMode =
|
|
624
|
+
profileOptions.executionMode ??
|
|
625
|
+
(process.env.ZTD_PROFILE_EXECUTION as 'serial' | 'parallel' | 'unknown' | undefined) ??
|
|
626
|
+
(process.env.VITEST_WORKER_ID ? 'parallel' : 'serial');
|
|
627
|
+
const profileWorkerId = process.env.VITEST_WORKER_ID ?? process.env.JEST_WORKER_ID;
|
|
628
|
+
const profileSink =
|
|
629
|
+
profileOptions.logger ??
|
|
630
|
+
((event: ZtdProfileEvent) => {
|
|
631
|
+
console.log(safeJsonStringify(event));
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
logEnabled,
|
|
636
|
+
logParams,
|
|
637
|
+
logSink,
|
|
638
|
+
profileEnabled,
|
|
639
|
+
profilePerQuery,
|
|
640
|
+
profileParams,
|
|
641
|
+
profileIncludeSql,
|
|
642
|
+
profileSampleLimit,
|
|
643
|
+
profileTestName,
|
|
644
|
+
profileExecutionMode,
|
|
645
|
+
profileWorkerId,
|
|
646
|
+
profileSink,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function resolveExecutionMode(mode?: ZtdExecutionMode): ZtdExecutionMode {
|
|
651
|
+
if (mode === 'traditional') {
|
|
652
|
+
return 'traditional';
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const envMode = process.env.ZTD_EXECUTION_MODE as ZtdExecutionMode | undefined;
|
|
656
|
+
return envMode === 'traditional' ? 'traditional' : 'ztd';
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function generateSchemaName(): string {
|
|
660
|
+
const timestamp = Date.now().toString(36);
|
|
661
|
+
const random = Math.random().toString(36).slice(2, 7);
|
|
662
|
+
return `ztd_traditional_${timestamp}_${random}`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function applySqlFiles(client: Client, directories: string[]): Promise<void> {
|
|
666
|
+
// Execute each .sql file so the physical schema matches the ZTD definitions.
|
|
667
|
+
for (const directory of directories) {
|
|
668
|
+
if (!existsSync(directory)) {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
|
|
673
|
+
for (const entry of entries) {
|
|
674
|
+
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.sql')) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const filePath = path.join(directory, entry.name);
|
|
679
|
+
const sql = await fsPromises.readFile(filePath, 'utf8');
|
|
680
|
+
if (!sql.trim()) {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
await client.query(sql);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function seedFixtureRows(client: Client, fixtures: TableFixture[], isolationSchema?: string): Promise<void> {
|
|
690
|
+
for (const fixture of fixtures) {
|
|
691
|
+
if (fixture.rows.length === 0) {
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const columnNames = getColumnNamesFromFixture(fixture);
|
|
696
|
+
if (columnNames.length === 0) {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const tableIdentifier = buildTableIdentifier(fixture.tableName, isolationSchema);
|
|
701
|
+
const columnsSql = columnNames.map(quoteIdentifier).join(', ');
|
|
702
|
+
|
|
703
|
+
for (const row of fixture.rows) {
|
|
704
|
+
const values = columnNames.map((column) =>
|
|
705
|
+
Object.prototype.hasOwnProperty.call(row, column) ? row[column] : null,
|
|
706
|
+
);
|
|
707
|
+
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
|
708
|
+
await client.query(`INSERT INTO ${tableIdentifier} (${columnsSql}) VALUES (${placeholders})`, values);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function getColumnNamesFromFixture(fixture: TableFixture): string[] {
|
|
714
|
+
if (fixture.schema && Array.isArray((fixture.schema as { columns?: unknown }).columns)) {
|
|
715
|
+
return (fixture.schema as { columns: { name: string }[] }).columns.map((column) => column.name);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (fixture.schema && 'columns' in fixture.schema && typeof fixture.schema.columns === 'object') {
|
|
719
|
+
return Object.keys(fixture.schema.columns);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (fixture.rows.length > 0) {
|
|
723
|
+
return Object.keys(fixture.rows[0]);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return [];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function buildTableIdentifier(tableName: string, isolationSchema?: string): string {
|
|
730
|
+
const segments = tableName.split('.');
|
|
731
|
+
const baseTable = segments.length > 1 ? segments[segments.length - 1] : tableName;
|
|
732
|
+
const schema = isolationSchema ?? (segments.length > 1 ? segments.slice(0, -1).join('.') : undefined);
|
|
733
|
+
if (schema) {
|
|
734
|
+
return `${quoteIdentifier(schema)}.${quoteIdentifier(baseTable)}`;
|
|
735
|
+
}
|
|
736
|
+
return quoteIdentifier(baseTable);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function quoteIdentifier(value: string): string {
|
|
740
|
+
const escaped = value.replace(/"/g, '""');
|
|
741
|
+
return `"${escaped}"`;
|
|
742
|
+
}
|