@happyvertical/smrt-vitest 0.30.0

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.
@@ -0,0 +1,887 @@
1
+ /**
2
+ * Test database utilities for SMRT tests
3
+ *
4
+ * Automatically uses PostgreSQL when DATABASE_URL is set (CI environment),
5
+ * otherwise creates unique SQLite temp files to avoid concurrency issues.
6
+ *
7
+ * Supports transaction-based test isolation: each test runs in a transaction
8
+ * that gets rolled back, ensuring clean state between tests.
9
+ *
10
+ * @example Basic usage
11
+ * ```typescript
12
+ * import { getTestDbConfig, createTestDb } from '@happyvertical/smrt-vitest';
13
+ *
14
+ * const { config, cleanup } = await createTestDb();
15
+ * // Use config...
16
+ * await cleanup();
17
+ * ```
18
+ *
19
+ * @example Transaction isolation (recommended for parallel tests)
20
+ * ```typescript
21
+ * import { createIsolatedTestDb } from '@happyvertical/smrt-vitest';
22
+ *
23
+ * const { db, cleanup } = await createIsolatedTestDb({ schema: mySchema });
24
+ * // All operations run in a transaction
25
+ * await db.insert('users', { id: '1', name: 'Test' });
26
+ * // cleanup() rolls back the transaction - no data persists
27
+ * await cleanup();
28
+ * ```
29
+ *
30
+ * @packageDocumentation
31
+ */
32
+ import { randomUUID } from 'node:crypto';
33
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
34
+ import { tmpdir } from 'node:os';
35
+ import { join } from 'node:path';
36
+ // Share the canonical collection-base detector with core instead of keeping a
37
+ // local copy. A second copy is exactly how the #1342 junction schema-drop bug
38
+ // half-survived: the core fix taught `collection-resolution.ts` to recognize
39
+ // `SmrtJunction`, but this manifest-based test-db builder kept an older copy
40
+ // that only knew `SmrtCollection`, so junction Collections filtered through
41
+ // `createIsolatedTestDbFromManifest({ includeObjects: [...] })` were still
42
+ // misclassified as table-bearing and lost their FK/junction columns.
43
+ import { isSmrtCollectionExtendsName } from '@happyvertical/smrt-core';
44
+ /**
45
+ * Detect the database adapter to use based on the current environment.
46
+ *
47
+ * Resolution order:
48
+ * 1. `TEST_DB_ADAPTER` env var — explicit override (`'sqlite'` or `'postgres'`)
49
+ * 2. `DATABASE_URL` env var set → `'postgres'`
50
+ * 3. Default → `'sqlite'`
51
+ *
52
+ * @returns The adapter identifier for the current environment.
53
+ * @see {@link getTestDbConfig} to obtain a full {@link TestDbConfig}.
54
+ */
55
+ export function getTestAdapter() {
56
+ // Explicit adapter override
57
+ if (process.env.TEST_DB_ADAPTER) {
58
+ return process.env.TEST_DB_ADAPTER;
59
+ }
60
+ // Use Postgres if DATABASE_URL is set (CI environment)
61
+ if (process.env.DATABASE_URL) {
62
+ return 'postgres';
63
+ }
64
+ // Default to SQLite for local development
65
+ return 'sqlite';
66
+ }
67
+ /**
68
+ * Check whether PostgreSQL is available for the current test run.
69
+ *
70
+ * Returns `true` when the `DATABASE_URL` environment variable is set,
71
+ * which is the signal used by CI environments to opt into PostgreSQL.
72
+ *
73
+ * @returns `true` if `DATABASE_URL` is set, `false` otherwise.
74
+ * @see {@link getTestAdapter} for full adapter resolution logic.
75
+ */
76
+ export function isPostgresAvailable() {
77
+ return Boolean(process.env.DATABASE_URL);
78
+ }
79
+ /**
80
+ * Get a {@link TestDbConfig} appropriate for the current environment.
81
+ *
82
+ * Uses PostgreSQL when `DATABASE_URL` is set (CI), otherwise generates
83
+ * a unique SQLite temp-file path to prevent concurrency conflicts between
84
+ * parallel test workers.
85
+ *
86
+ * @param prefix - Optional prefix used in the SQLite temp-file name.
87
+ * Ignored when using PostgreSQL. Defaults to `'smrt-test'`.
88
+ * @returns A {@link TestDbConfig} ready to pass to `getDatabase()`.
89
+ * @see {@link getInMemoryDbConfig} for a non-persistent SQLite alternative.
90
+ * @see {@link createTestDb} to obtain the config alongside a cleanup function.
91
+ */
92
+ export function getTestDbConfig(prefix = 'smrt-test') {
93
+ const adapter = getTestAdapter();
94
+ const testId = randomUUID().slice(0, 8);
95
+ switch (adapter) {
96
+ case 'postgres':
97
+ return {
98
+ type: 'postgres',
99
+ url: process.env.DATABASE_URL ||
100
+ `postgresql://postgres:postgres@${process.env.POSTGRES_HOST || 'localhost'}:${process.env.POSTGRES_PORT || '5432'}/test_db`,
101
+ };
102
+ default:
103
+ // Use unique temp file to avoid concurrency issues
104
+ return {
105
+ type: 'sqlite',
106
+ url: join(tmpdir(), `${prefix}-${testId}.db`),
107
+ };
108
+ }
109
+ }
110
+ /**
111
+ * Get a {@link TestDbConfig} backed by an in-memory SQLite database.
112
+ *
113
+ * In-memory databases are isolated per connection — safe for concurrent
114
+ * tests within the same process, but the database cannot be shared across
115
+ * connections or workers. No temp files are created or cleaned up.
116
+ *
117
+ * Prefer {@link getTestDbConfig} (file-based SQLite or PostgreSQL) when
118
+ * tests need to be shared or inspected after the run.
119
+ *
120
+ * @returns A `TestDbConfig` with `url: ':memory:'`.
121
+ */
122
+ export function getInMemoryDbConfig() {
123
+ return {
124
+ type: 'sqlite',
125
+ url: ':memory:',
126
+ };
127
+ }
128
+ /**
129
+ * Create a test database and return a cleanup function.
130
+ *
131
+ * Determines the adapter automatically via {@link getTestDbConfig}. For
132
+ * SQLite, a unique temp file is created; `cleanup()` removes it along with
133
+ * any WAL/SHM sidecar files. For PostgreSQL, `cleanup()` is a no-op
134
+ * (table isolation must be handled by the test itself).
135
+ *
136
+ * Unlike {@link createIsolatedTestDb}, this function does **not** wrap
137
+ * operations in a transaction — mutations made during the test persist until
138
+ * the temp file is deleted. Prefer {@link createIsolatedTestDb} for
139
+ * isolated, parallel-safe tests.
140
+ *
141
+ * @param prefix - Prefix for the SQLite temp-file name. Ignored for
142
+ * PostgreSQL. Defaults to `'smrt-test'`.
143
+ * @returns An object containing the resolved {@link TestDbConfig} and an
144
+ * async `cleanup()` function that removes the temp file on SQLite.
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * import { createTestDb } from '@happyvertical/smrt-vitest';
149
+ *
150
+ * const { config, cleanup } = await createTestDb();
151
+ * const db = await getDatabase(config);
152
+ * // ... run tests ...
153
+ * await cleanup();
154
+ * ```
155
+ *
156
+ * @see {@link createIsolatedTestDb} for transaction-isolated test databases.
157
+ */
158
+ export async function createTestDb(prefix = 'smrt-test') {
159
+ const config = getTestDbConfig(prefix);
160
+ const cleanup = async () => {
161
+ // Clean up SQLite file-based databases
162
+ if (config.type === 'sqlite' &&
163
+ config.url !== ':memory:' &&
164
+ existsSync(config.url)) {
165
+ // Small delay to allow any pending writes
166
+ await new Promise((resolve) => setTimeout(resolve, 50));
167
+ try {
168
+ rmSync(config.url, { force: true });
169
+ // Also remove -wal and -shm files if they exist
170
+ rmSync(`${config.url}-wal`, { force: true });
171
+ rmSync(`${config.url}-shm`, { force: true });
172
+ }
173
+ catch {
174
+ // Ignore cleanup errors
175
+ }
176
+ }
177
+ // For Postgres, we might want to clean up test tables
178
+ // but for now we'll rely on the test isolation
179
+ };
180
+ return { config, cleanup };
181
+ }
182
+ /**
183
+ * Get a human-readable display name for the current test database adapter.
184
+ *
185
+ * Useful for labelling `describe` blocks or test output so logs make clear
186
+ * which backend is under test.
187
+ *
188
+ * @returns `'PostgreSQL'` when the adapter is `'postgres'`, otherwise `'SQLite'`.
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * import { getAdapterDisplayName } from '@happyvertical/smrt-vitest';
193
+ *
194
+ * describe(`Product (${getAdapterDisplayName()})`, () => {
195
+ * // ...
196
+ * });
197
+ * ```
198
+ *
199
+ * @see {@link getTestAdapter} to obtain the raw adapter identifier.
200
+ */
201
+ export function getAdapterDisplayName() {
202
+ const adapter = getTestAdapter();
203
+ switch (adapter) {
204
+ case 'postgres':
205
+ return 'PostgreSQL';
206
+ default:
207
+ return 'SQLite';
208
+ }
209
+ }
210
+ /**
211
+ * Create a test database with transaction isolation.
212
+ *
213
+ * Each test runs in a transaction that gets rolled back on `cleanup()`,
214
+ * ensuring complete isolation between tests without the overhead of
215
+ * creating or dropping tables between runs. Parallel test workers each
216
+ * receive their own temp database file (SQLite) or an independent
217
+ * transaction (PostgreSQL).
218
+ *
219
+ * Requires `@happyvertical/sql` with `beginTransaction()` support
220
+ * (SDK PR #722). Throws if the adapter does not implement it.
221
+ *
222
+ * @param options - Optional schema DDL and SQLite prefix.
223
+ * Pass `schema` to have the DDL applied before the transaction begins.
224
+ * @returns An {@link IsolatedTestDbResult} containing the transaction handle,
225
+ * base connection, resolved config, and a `cleanup()` function.
226
+ *
227
+ * @see {@link createIsolatedTestDbFromManifest} to derive the schema
228
+ * automatically from the generated manifest file.
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * import { createIsolatedTestDb } from '@happyvertical/smrt-vitest';
233
+ * import { beforeEach, afterEach, it } from 'vitest';
234
+ *
235
+ * let db: TransactionHandle;
236
+ * let cleanup: () => Promise<void>;
237
+ *
238
+ * beforeEach(async () => {
239
+ * const result = await createIsolatedTestDb({
240
+ * schema: `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT)`
241
+ * });
242
+ * db = result.db;
243
+ * cleanup = result.cleanup;
244
+ * });
245
+ *
246
+ * afterEach(async () => {
247
+ * await cleanup(); // Rolls back - no data persists
248
+ * });
249
+ *
250
+ * it('should insert and query', async () => {
251
+ * await db.insert('users', { id: '1', name: 'Alice' });
252
+ * const user = await db.get('users', { id: '1' });
253
+ * expect(user?.name).toBe('Alice');
254
+ * // After this test, cleanup() rolls back - Alice doesn't exist
255
+ * });
256
+ *
257
+ * it('should start with clean state', async () => {
258
+ * // This test runs with a fresh transaction
259
+ * const users = await db.list('users', {});
260
+ * expect(users).toHaveLength(0); // Clean!
261
+ * });
262
+ * ```
263
+ */
264
+ export async function createIsolatedTestDb(options = {}) {
265
+ const { schema, prefix = 'smrt-isolated' } = options;
266
+ // Dynamically import getDatabase to avoid circular dependencies
267
+ const { getDatabase, syncSchema } = await import('@happyvertical/sql');
268
+ const config = getTestDbConfig(prefix);
269
+ // Create the base database connection
270
+ // Cast to extended interface that includes beginTransaction (from SDK #722)
271
+ const baseDb = (await getDatabase({
272
+ ...config,
273
+ __smrtSkipVitestSchemaPreparation: true,
274
+ }));
275
+ // Sync schema if provided (must be done before transaction for DDL)
276
+ if (schema) {
277
+ await syncSchema({ db: baseDb, schema });
278
+ }
279
+ // Begin transaction for isolation
280
+ // Note: beginTransaction requires @happyvertical/sql with SDK PR #722 merged
281
+ if (!baseDb.beginTransaction) {
282
+ throw new Error(`Database adapter '${config.type}' does not support beginTransaction(). ` +
283
+ `This requires @happyvertical/sql with SDK PR #722 merged. ` +
284
+ `See: https://github.com/happyvertical/sdk/pull/722`);
285
+ }
286
+ const db = await baseDb.beginTransaction();
287
+ // Cleanup function rolls back transaction and closes connection
288
+ const cleanup = async () => {
289
+ try {
290
+ if (db.isActive()) {
291
+ await db.rollback();
292
+ }
293
+ }
294
+ catch {
295
+ // Ignore rollback errors (connection may be closed)
296
+ }
297
+ // Close base database connection to prevent connection leaks (Issue #858)
298
+ try {
299
+ if (baseDb &&
300
+ typeof baseDb.close ===
301
+ 'function') {
302
+ await baseDb
303
+ .close();
304
+ }
305
+ }
306
+ catch {
307
+ // Ignore close errors
308
+ }
309
+ // Clean up SQLite temp files
310
+ if (config.type === 'sqlite' &&
311
+ config.url !== ':memory:' &&
312
+ existsSync(config.url)) {
313
+ await new Promise((resolve) => setTimeout(resolve, 50));
314
+ try {
315
+ rmSync(config.url, { force: true });
316
+ rmSync(`${config.url}-wal`, { force: true });
317
+ rmSync(`${config.url}-shm`, { force: true });
318
+ }
319
+ catch {
320
+ // Ignore cleanup errors
321
+ }
322
+ }
323
+ };
324
+ return { db, baseDb, config, cleanup };
325
+ }
326
+ // ============================================================================
327
+ // Manifest-based Test Database Helpers
328
+ // ============================================================================
329
+ /**
330
+ * Load a manifest file from common locations
331
+ *
332
+ * @param manifestPath - Optional explicit path to manifest
333
+ * @returns Parsed manifest or null if not found
334
+ */
335
+ function loadManifest(manifestPath) {
336
+ const searchPaths = manifestPath
337
+ ? [manifestPath]
338
+ : [
339
+ join(process.cwd(), '.smrt', 'manifest.json'),
340
+ join(process.cwd(), 'dist', 'manifest.json'),
341
+ join(process.cwd(), 'src', 'manifest', 'manifest.json'),
342
+ ];
343
+ for (const path of searchPaths) {
344
+ if (existsSync(path)) {
345
+ try {
346
+ const content = readFileSync(path, 'utf-8');
347
+ return JSON.parse(content);
348
+ }
349
+ catch (error) {
350
+ // Log parse error but continue to next path
351
+ console.warn(`[smrt-vitest] Failed to parse manifest at "${path}":`, error);
352
+ }
353
+ }
354
+ }
355
+ return null;
356
+ }
357
+ /**
358
+ * Generate CREATE INDEX statements from manifest indexes
359
+ *
360
+ * Generates both regular and UNIQUE indexes.
361
+ * UNIQUE indexes on conflict columns (like slug, context) are required
362
+ * for UPSERT/ON CONFLICT to work in SQLite.
363
+ */
364
+ function generateIndexDDL(tableName, indexes) {
365
+ if (!indexes || indexes.length === 0)
366
+ return '';
367
+ const statements = [];
368
+ for (const index of indexes) {
369
+ if (!index.columns || index.columns.length === 0)
370
+ continue;
371
+ const indexType = index.unique ? 'UNIQUE INDEX' : 'INDEX';
372
+ const columns = index.columns.map((c) => `"${c}"`).join(', ');
373
+ statements.push(`CREATE ${indexType} IF NOT EXISTS "${index.name}" ON "${tableName}" (${columns});`);
374
+ }
375
+ return statements.join('\n');
376
+ }
377
+ /**
378
+ * Extract foreign key dependencies from DDL
379
+ *
380
+ * Parses REFERENCES tableName( patterns from CREATE TABLE statements
381
+ */
382
+ function extractForeignKeyDependencies(ddl) {
383
+ const dependencies = [];
384
+ // Match REFERENCES "tablename"( or REFERENCES tablename(
385
+ const regex = /REFERENCES\s+"?([a-zA-Z_][a-zA-Z0-9_]*)"?\s*\(/gi;
386
+ for (const match of ddl.matchAll(regex)) {
387
+ const tableName = match[1];
388
+ if (!dependencies.includes(tableName)) {
389
+ dependencies.push(tableName);
390
+ }
391
+ }
392
+ return dependencies;
393
+ }
394
+ /**
395
+ * Tokenize CREATE TABLE body into individual column/constraint definitions.
396
+ *
397
+ * Splits on top-level commas (not inside parentheses or quotes) so it works
398
+ * for both multi-line and single-line DDL strings.
399
+ */
400
+ function tokenizeDDLBody(body) {
401
+ const segments = [];
402
+ let current = '';
403
+ let parenDepth = 0;
404
+ let inSingleQuote = false;
405
+ let inDoubleQuote = false;
406
+ let prevChar = '';
407
+ for (const ch of body) {
408
+ if (ch === "'" && !inDoubleQuote && prevChar !== '\\') {
409
+ inSingleQuote = !inSingleQuote;
410
+ }
411
+ else if (ch === '"' && !inSingleQuote && prevChar !== '\\') {
412
+ inDoubleQuote = !inDoubleQuote;
413
+ }
414
+ else if (!inSingleQuote && !inDoubleQuote) {
415
+ if (ch === '(')
416
+ parenDepth += 1;
417
+ else if (ch === ')' && parenDepth > 0)
418
+ parenDepth -= 1;
419
+ }
420
+ if (ch === ',' && parenDepth === 0 && !inSingleQuote && !inDoubleQuote) {
421
+ const trimmed = current.trim();
422
+ if (trimmed)
423
+ segments.push(trimmed);
424
+ current = '';
425
+ }
426
+ else {
427
+ current += ch;
428
+ }
429
+ prevChar = ch;
430
+ }
431
+ const last = current.trim();
432
+ if (last)
433
+ segments.push(last);
434
+ return segments;
435
+ }
436
+ /** Keywords that indicate a table-level constraint, not a column definition */
437
+ const CONSTRAINT_KEYWORDS = new Set([
438
+ 'CONSTRAINT',
439
+ 'PRIMARY',
440
+ 'FOREIGN',
441
+ 'UNIQUE',
442
+ 'CHECK',
443
+ ]);
444
+ /**
445
+ * Parse column definitions from a CREATE TABLE DDL statement
446
+ *
447
+ * Returns a Map of column name → full column definition line.
448
+ * Skips table-level constraints (FOREIGN KEY, PRIMARY KEY, etc.).
449
+ */
450
+ function parseColumnsFromDDL(ddl) {
451
+ const columns = new Map();
452
+ // Match the body between CREATE TABLE ... ( ... )
453
+ const bodyMatch = ddl.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?\w+"?\s*\(([\s\S]+)\)/i);
454
+ if (!bodyMatch)
455
+ return columns;
456
+ const segments = tokenizeDDLBody(bodyMatch[1]);
457
+ for (const segment of segments) {
458
+ // Extract first identifier and check if it's a constraint keyword
459
+ const colMatch = segment.match(/^"?([a-zA-Z_][a-zA-Z0-9_]*)"?\s+/);
460
+ if (!colMatch)
461
+ continue;
462
+ const firstToken = colMatch[1].toUpperCase();
463
+ if (CONSTRAINT_KEYWORDS.has(firstToken))
464
+ continue;
465
+ columns.set(colMatch[1], segment);
466
+ }
467
+ return columns;
468
+ }
469
+ /**
470
+ * Merge two DDL statements for the same table (STI subclasses)
471
+ *
472
+ * Takes the union of all columns from both DDLs.
473
+ * When a column exists in both, the existing definition is kept.
474
+ */
475
+ function mergeDDL(existingDDL, newDDL) {
476
+ const existingCols = parseColumnsFromDDL(existingDDL);
477
+ const newCols = parseColumnsFromDDL(newDDL);
478
+ // Find columns in newDDL that are missing from existingDDL
479
+ const missingCols = [];
480
+ for (const [name, def] of newCols) {
481
+ if (!existingCols.has(name)) {
482
+ missingCols.push(def);
483
+ }
484
+ }
485
+ if (missingCols.length === 0)
486
+ return existingDDL;
487
+ // Insert missing columns before the closing paren
488
+ const closingIdx = existingDDL.lastIndexOf(')');
489
+ if (closingIdx === -1)
490
+ return existingDDL;
491
+ const before = existingDDL.substring(0, closingIdx).trimEnd();
492
+ const after = existingDDL.substring(closingIdx);
493
+ const additions = missingCols.map((col) => ` ${col}`).join(',\n');
494
+ return `${before},\n${additions}\n${after}`;
495
+ }
496
+ /**
497
+ * Merge indexes from multiple STI subclasses sharing the same table
498
+ *
499
+ * Deduplicates by index name, keeping the first occurrence.
500
+ */
501
+ function mergeIndexes(existing, incoming) {
502
+ const names = new Set(existing.map((i) => i.name));
503
+ const merged = [...existing];
504
+ for (const idx of incoming) {
505
+ if (!names.has(idx.name)) {
506
+ names.add(idx.name);
507
+ merged.push(idx);
508
+ }
509
+ }
510
+ return merged;
511
+ }
512
+ function getPackageNameFromKey(key) {
513
+ const separatorIndex = key.lastIndexOf(':');
514
+ if (separatorIndex <= 0) {
515
+ return undefined;
516
+ }
517
+ return key.slice(0, separatorIndex);
518
+ }
519
+ function getObjectPackageName(manifest, key, objectDef) {
520
+ return (objectDef.packageName || getPackageNameFromKey(key) || manifest.packageName);
521
+ }
522
+ function addManifestObjectIdentifiers(target, manifest, key, objectDef, options) {
523
+ target.add(key);
524
+ if (options.includeSimpleName && objectDef.className) {
525
+ target.add(objectDef.className);
526
+ }
527
+ const packageName = getObjectPackageName(manifest, key, objectDef);
528
+ if (packageName && objectDef.className) {
529
+ target.add(`${packageName}:${objectDef.className}`);
530
+ }
531
+ }
532
+ function findManifestObject(manifest, lookupName, packageName) {
533
+ const direct = manifest.objects[lookupName];
534
+ if (direct) {
535
+ return { key: lookupName, objectDef: direct };
536
+ }
537
+ const separatorIndex = lookupName.lastIndexOf(':');
538
+ const lookupPackage = separatorIndex > 0 ? lookupName.slice(0, separatorIndex) : packageName;
539
+ const simpleName = separatorIndex > 0 ? lookupName.slice(separatorIndex + 1) : lookupName;
540
+ const lowerSimpleName = simpleName.toLowerCase();
541
+ for (const [key, objectDef] of Object.entries(manifest.objects)) {
542
+ const objectPackageName = getObjectPackageName(manifest, key, objectDef);
543
+ if (lookupPackage &&
544
+ objectPackageName &&
545
+ objectPackageName !== lookupPackage) {
546
+ continue;
547
+ }
548
+ if (key === lookupName ||
549
+ objectDef.className === simpleName ||
550
+ objectDef.className?.toLowerCase() === lowerSimpleName) {
551
+ return { key, objectDef };
552
+ }
553
+ }
554
+ return undefined;
555
+ }
556
+ function getManifestCollectionAncestors(manifest, key, objectDef) {
557
+ const ancestors = [];
558
+ const visited = new Set();
559
+ let currentKey = key;
560
+ let currentDef = objectDef;
561
+ while (currentDef?.extends) {
562
+ if (isSmrtCollectionExtendsName(currentDef.extends)) {
563
+ break;
564
+ }
565
+ const currentPackage = getObjectPackageName(manifest, currentKey, currentDef);
566
+ const parent = findManifestObject(manifest, currentDef.extends, currentPackage);
567
+ if (!parent || visited.has(parent.key)) {
568
+ break;
569
+ }
570
+ visited.add(parent.key);
571
+ ancestors.push(parent);
572
+ currentKey = parent.key;
573
+ currentDef = parent.objectDef;
574
+ }
575
+ return ancestors;
576
+ }
577
+ function isManifestCollectionObject(manifest, key, objectDef) {
578
+ if (isSmrtCollectionExtendsName(objectDef.extends)) {
579
+ return true;
580
+ }
581
+ return getManifestCollectionAncestors(manifest, key, objectDef).some((ancestor) => isSmrtCollectionExtendsName(ancestor.objectDef.extends));
582
+ }
583
+ function resolveManifestCollectionItemClassName(manifest, key, objectDef) {
584
+ if (objectDef.extendsTypeArg) {
585
+ return objectDef.extendsTypeArg;
586
+ }
587
+ const inferredItemClassName = inferManifestCollectionItemClassName(manifest, key, objectDef);
588
+ if (inferredItemClassName) {
589
+ return inferredItemClassName;
590
+ }
591
+ for (const ancestor of getManifestCollectionAncestors(manifest, key, objectDef)) {
592
+ if (ancestor.objectDef.extendsTypeArg) {
593
+ return ancestor.objectDef.extendsTypeArg;
594
+ }
595
+ }
596
+ return undefined;
597
+ }
598
+ function getCollectionItemNameCandidates(className) {
599
+ const candidates = [];
600
+ const addCandidate = (candidate) => {
601
+ if (candidate &&
602
+ candidate !== className &&
603
+ !candidates.includes(candidate)) {
604
+ candidates.push(candidate);
605
+ }
606
+ };
607
+ if (className.endsWith('Collection')) {
608
+ addCandidate(className.slice(0, -'Collection'.length));
609
+ }
610
+ if (className.endsWith('ies')) {
611
+ addCandidate(`${className.slice(0, -3)}y`);
612
+ }
613
+ else if (className.endsWith('s')) {
614
+ addCandidate(className.slice(0, -1));
615
+ }
616
+ return candidates;
617
+ }
618
+ function inferManifestCollectionItemClassName(manifest, key, objectDef) {
619
+ const objectPackageName = getObjectPackageName(manifest, key, objectDef);
620
+ for (const candidate of getCollectionItemNameCandidates(objectDef.className)) {
621
+ const candidateEntry = findManifestObject(manifest, candidate, objectPackageName);
622
+ if (candidateEntry) {
623
+ return candidate;
624
+ }
625
+ }
626
+ return undefined;
627
+ }
628
+ function buildEffectiveIncludeObjects(manifest, includeObjects) {
629
+ if (!includeObjects) {
630
+ return undefined;
631
+ }
632
+ const effectiveIncludes = new Set();
633
+ for (const includeObject of includeObjects) {
634
+ const includeSimpleName = !includeObject.includes(':');
635
+ const entry = findManifestObject(manifest, includeObject);
636
+ if (!entry) {
637
+ effectiveIncludes.add(includeObject);
638
+ continue;
639
+ }
640
+ const objectPackageName = getObjectPackageName(manifest, entry.key, entry.objectDef);
641
+ const itemClassName = isManifestCollectionObject(manifest, entry.key, entry.objectDef)
642
+ ? resolveManifestCollectionItemClassName(manifest, entry.key, entry.objectDef)
643
+ : undefined;
644
+ const itemEntry = itemClassName
645
+ ? findManifestObject(manifest, itemClassName, objectPackageName)
646
+ : undefined;
647
+ addManifestObjectIdentifiers(effectiveIncludes, manifest, itemEntry?.key || entry.key, itemEntry?.objectDef || entry.objectDef, { includeSimpleName });
648
+ }
649
+ return effectiveIncludes;
650
+ }
651
+ /**
652
+ * Extract DDL from manifest objects with STI deduplication
653
+ *
654
+ * Multiple classes may share the same table (STI), so we deduplicate by tableName.
655
+ * When STI subclasses add columns, DDLs are merged to include all columns.
656
+ * Also extracts indexes for generating CREATE INDEX statements.
657
+ */
658
+ function extractDDLFromManifest(manifest, includeObjects) {
659
+ const tableMap = new Map();
660
+ const effectiveIncludes = buildEffectiveIncludeObjects(manifest, includeObjects);
661
+ for (const [key, objectDef] of Object.entries(manifest.objects)) {
662
+ // Skip if filter is specified and class not included
663
+ // Compare against both the key (e.g., '@dumm/models:Product') and className (e.g., 'Product')
664
+ // to support both namespaced and simple class names (Issue #860)
665
+ if (effectiveIncludes) {
666
+ const className = objectDef.className || key;
667
+ const matchesKey = effectiveIncludes.has(key);
668
+ const matchesClassName = effectiveIncludes.has(className);
669
+ if (!matchesKey && !matchesClassName) {
670
+ continue;
671
+ }
672
+ }
673
+ // Skip objects without schema (abstract classes, etc.)
674
+ if (!objectDef.schema?.ddl || !objectDef.schema?.tableName) {
675
+ continue;
676
+ }
677
+ const { tableName, ddl, indexes = [] } = objectDef.schema;
678
+ // Merge by tableName — STI subclasses may add columns to the same table
679
+ const existing = tableMap.get(tableName);
680
+ if (!existing) {
681
+ tableMap.set(tableName, {
682
+ tableName,
683
+ ddl,
684
+ indexes,
685
+ dependencies: extractForeignKeyDependencies(ddl),
686
+ });
687
+ }
688
+ else {
689
+ // Merge DDL columns and indexes from this STI subclass
690
+ existing.ddl = mergeDDL(existing.ddl, ddl);
691
+ existing.indexes = mergeIndexes(existing.indexes, indexes);
692
+ // Merge FK dependencies
693
+ const newDeps = extractForeignKeyDependencies(ddl);
694
+ for (const dep of newDeps) {
695
+ if (!existing.dependencies.includes(dep)) {
696
+ existing.dependencies.push(dep);
697
+ }
698
+ }
699
+ }
700
+ }
701
+ return Array.from(tableMap.values());
702
+ }
703
+ /**
704
+ * Sort tables by foreign key dependencies using topological sort (Kahn's algorithm)
705
+ *
706
+ * Ensures referenced tables are created before tables that reference them
707
+ */
708
+ function sortByDependencies(tables) {
709
+ const tableNames = new Set(tables.map((t) => t.tableName));
710
+ const inDegree = new Map();
711
+ const graph = new Map();
712
+ // Initialize
713
+ for (const table of tables) {
714
+ inDegree.set(table.tableName, 0);
715
+ graph.set(table.tableName, []);
716
+ }
717
+ // Build graph - only count dependencies that are in our table set
718
+ for (const table of tables) {
719
+ for (const dep of table.dependencies) {
720
+ if (tableNames.has(dep) && dep !== table.tableName) {
721
+ inDegree.set(table.tableName, (inDegree.get(table.tableName) || 0) + 1);
722
+ const edges = graph.get(dep) || [];
723
+ edges.push(table.tableName);
724
+ graph.set(dep, edges);
725
+ }
726
+ }
727
+ }
728
+ // Find all nodes with no incoming edges
729
+ const queue = [];
730
+ for (const [table, degree] of inDegree) {
731
+ if (degree === 0) {
732
+ queue.push(table);
733
+ }
734
+ }
735
+ // Process queue
736
+ const sorted = [];
737
+ while (queue.length > 0) {
738
+ const current = queue.shift();
739
+ if (current === undefined)
740
+ break;
741
+ sorted.push(current);
742
+ const neighbors = graph.get(current) || [];
743
+ for (const neighbor of neighbors) {
744
+ const newDegree = (inDegree.get(neighbor) ?? 0) - 1;
745
+ inDegree.set(neighbor, newDegree);
746
+ if (newDegree === 0) {
747
+ queue.push(neighbor);
748
+ }
749
+ }
750
+ }
751
+ // Handle any remaining tables (circular dependencies)
752
+ // Use Set for O(1) lookups instead of O(n) Array.includes()
753
+ const sortedSet = new Set(sorted);
754
+ for (const table of tables) {
755
+ if (!sortedSet.has(table.tableName)) {
756
+ sorted.push(table.tableName);
757
+ sortedSet.add(table.tableName);
758
+ }
759
+ }
760
+ return sorted;
761
+ }
762
+ /**
763
+ * Create an isolated test database with schema derived from a manifest file.
764
+ *
765
+ * Eliminates the need to manually write or maintain DDL in test files by
766
+ * reading table definitions directly from the generated manifest. Handles:
767
+ *
768
+ * - **STI deduplication** — multiple classes that share the same table are
769
+ * merged into a single `CREATE TABLE` statement that includes all columns.
770
+ * - **FK dependency ordering** — tables are created in topological order so
771
+ * `REFERENCES` constraints are always satisfied.
772
+ * - **Auto-detection** — searches `.smrt/manifest.json`, `dist/manifest.json`,
773
+ * and `src/manifest/manifest.json` when no `manifestPath` is given.
774
+ *
775
+ * @param options - Optional manifest path, class filter, and SQLite prefix.
776
+ * @returns An {@link IsolatedTestDbResult} — same shape as
777
+ * {@link createIsolatedTestDb}, with a transaction-scoped `db` handle and
778
+ * a `cleanup()` that rolls back and removes temp files.
779
+ *
780
+ * @throws When no manifest is found at any of the checked locations.
781
+ * @throws When the manifest contains no objects with a database schema (or
782
+ * none of the filtered `includeObjects` have a schema).
783
+ *
784
+ * @see {@link createIsolatedTestDb} if you prefer to supply raw DDL directly.
785
+ * @see {@link ManifestTestDbOptions} for all available options.
786
+ *
787
+ * @example Basic usage
788
+ * ```typescript
789
+ * import { createIsolatedTestDbFromManifest } from '@happyvertical/smrt-vitest';
790
+ *
791
+ * let db, cleanup;
792
+ *
793
+ * beforeEach(async () => {
794
+ * ({ db, cleanup } = await createIsolatedTestDbFromManifest());
795
+ * });
796
+ *
797
+ * afterEach(async () => {
798
+ * await cleanup();
799
+ * });
800
+ * ```
801
+ *
802
+ * @example With tenant scoping
803
+ * ```typescript
804
+ * import { createIsolatedTestDbFromManifest } from '@happyvertical/smrt-vitest';
805
+ * import { withTenant, resetTenancy, setupTestTenancy } from '@happyvertical/smrt-tenancy';
806
+ *
807
+ * // In setup file
808
+ * setupTestTenancy({ enableInterceptors: true, rawQueryPolicy: 'allow' });
809
+ *
810
+ * // In test file
811
+ * let db, cleanup;
812
+ *
813
+ * beforeEach(async () => {
814
+ * ({ db, cleanup } = await createIsolatedTestDbFromManifest());
815
+ * });
816
+ *
817
+ * afterEach(async () => {
818
+ * resetTenancy();
819
+ * await cleanup();
820
+ * });
821
+ *
822
+ * it('should auto-populate tenantId', async () => {
823
+ * await withTenant({ tenantId: 'test-tenant' }, async () => {
824
+ * const product = await collection.create({ name: 'Widget' });
825
+ * expect(product.tenantId).toBe('test-tenant');
826
+ * });
827
+ * });
828
+ * ```
829
+ *
830
+ * @example Filter to specific objects
831
+ * ```typescript
832
+ * const { db, cleanup } = await createIsolatedTestDbFromManifest({
833
+ * includeObjects: ['Product', 'Order', 'OrderItem'],
834
+ * });
835
+ * ```
836
+ */
837
+ export async function createIsolatedTestDbFromManifest(options = {}) {
838
+ const { manifestPath, includeObjects, prefix = 'smrt-manifest' } = options;
839
+ // 1. Load manifest
840
+ const manifest = loadManifest(manifestPath);
841
+ if (!manifest) {
842
+ const checkedPaths = manifestPath
843
+ ? [manifestPath]
844
+ : [
845
+ '.smrt/manifest.json',
846
+ 'dist/manifest.json',
847
+ 'src/manifest/manifest.json',
848
+ ];
849
+ throw new Error('No manifest found. Ensure smrtVitestPlugin() is configured in vitest.config.ts ' +
850
+ 'or specify manifestPath. Checked: ' +
851
+ checkedPaths.join(', '));
852
+ }
853
+ // 2. Extract DDL with STI deduplication
854
+ const tables = extractDDLFromManifest(manifest, includeObjects);
855
+ if (tables.length === 0) {
856
+ throw new Error(includeObjects
857
+ ? `No objects with schema found matching: ${includeObjects.join(', ')}`
858
+ : 'No objects with schema found in manifest.');
859
+ }
860
+ // 3. Sort by FK dependencies and join DDL
861
+ // Use Map for O(1) lookups instead of O(n) Array.find()
862
+ const tableMap = new Map(tables.map((t) => [t.tableName, t]));
863
+ const sortedTableNames = sortByDependencies(tables);
864
+ // Generate CREATE TABLE statements first
865
+ const createTableDDL = sortedTableNames
866
+ .map((name) => tableMap.get(name)?.ddl)
867
+ .filter(Boolean)
868
+ .join('\n\n');
869
+ // Generate CREATE INDEX statements after tables exist
870
+ // This includes UNIQUE indexes required for UPSERT/ON CONFLICT to work
871
+ const createIndexDDL = sortedTableNames
872
+ .map((name) => {
873
+ const table = tableMap.get(name);
874
+ if (!table)
875
+ return '';
876
+ return generateIndexDDL(table.tableName, table.indexes);
877
+ })
878
+ .filter(Boolean)
879
+ .join('\n');
880
+ // Combine table DDL and index DDL
881
+ const sortedDDL = createIndexDDL
882
+ ? `${createTableDDL}\n\n${createIndexDDL}`
883
+ : createTableDDL;
884
+ // 4. Delegate to existing function
885
+ return createIsolatedTestDb({ schema: sortedDDL, prefix });
886
+ }
887
+ //# sourceMappingURL=test-db.js.map