@cosmicdrift/kumiko-testing 0.385.0 → 0.386.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.
- package/bin/kumiko-testing.ts +10 -2
- package/package.json +6 -6
- package/src/changes.json +12 -0
- package/src/integration-runner.ts +21 -0
- package/src/stack/db.ts +82 -5
- package/src/stack/redis.ts +3 -1
- package/src/stack/test-stack.ts +111 -63
package/bin/kumiko-testing.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
buildIntegrationTestArgs,
|
|
9
9
|
listIntegrationTestFiles,
|
|
10
10
|
resolveRequestedIntegrationFiles,
|
|
11
|
+
runWithTestDbTemplates,
|
|
11
12
|
selectIntegrationFiles,
|
|
12
13
|
} from "../src/integration-runner";
|
|
13
14
|
|
|
@@ -99,8 +100,15 @@ async function runIntegration(args: readonly string[]): Promise<number> {
|
|
|
99
100
|
...(values.timings !== undefined && { timings: values.timings }),
|
|
100
101
|
...(values["update-timings"] === true && { updateTimings: true }),
|
|
101
102
|
});
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
return runWithTestDbTemplates(
|
|
104
|
+
// Bun.spawn defaults to the env from process start; pass the live one so the
|
|
105
|
+
// template run id set by runWithTestDbTemplates reaches the test process.
|
|
106
|
+
() =>
|
|
107
|
+
Bun.spawn(["bun", ...testArgs], {
|
|
108
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
109
|
+
env: process.env,
|
|
110
|
+
}).exited,
|
|
111
|
+
);
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
const [command, ...rest] = process.argv.slice(2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.386.0",
|
|
4
4
|
"description": "Test template for Kumiko apps: per-flow seedTenant, app test stack, canonical test preloads, bunfig generator and integration runner.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -99,9 +99,9 @@
|
|
|
99
99
|
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
100
100
|
},
|
|
101
101
|
"dependencies": {
|
|
102
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
103
|
-
"@cosmicdrift/kumiko-guards": "0.
|
|
104
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
102
|
+
"@cosmicdrift/kumiko-framework": "0.386.0",
|
|
103
|
+
"@cosmicdrift/kumiko-guards": "0.386.0",
|
|
104
|
+
"@cosmicdrift/kumiko-types": "0.386.0",
|
|
105
105
|
"zod": "^4.4.3"
|
|
106
106
|
},
|
|
107
107
|
"peerDependencies": {
|
|
@@ -125,8 +125,8 @@
|
|
|
125
125
|
}
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-bundled-features": "0.386.0",
|
|
129
|
+
"@cosmicdrift/kumiko-dev-server": "0.386.0",
|
|
130
130
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
131
131
|
"@playwright/test": "^1.62.1",
|
|
132
132
|
"@testing-library/dom": "^10.4.1",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.386.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Integration test stacks clone a migrated template database",
|
|
6
|
+
"detail": "kumiko-testing integration and the framework integration runner set a run id; setupTestStack then builds one template per schema fingerprint under an advisory lock and clones it, and the runner drops the templates at the end. Plain bun test runs keep the in-place DDL path. setupAppTestStack skips pushEntityProjectionTables on fresh databases."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.386.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "createTestRedis waits for a ready connection",
|
|
12
|
+
"detail": "The test Redis client connected in the background, so the first rate-limit check in a test file could see status \"connect\" and fail with \"rate-limit redis connection not ready\". createTestRedis now connects explicitly and returns after the connection is ready."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.385.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
3
|
import { join, resolve } from "node:path";
|
|
3
4
|
import { BUNFIG_FILES, TEST_TIMEOUT_MS } from "./bunfig";
|
|
5
|
+
import { withEnv } from "./helpers/with-env";
|
|
6
|
+
import { SERVICE_ENV_DEFAULTS } from "./preload/service-env-defaults-values";
|
|
4
7
|
|
|
5
8
|
export type IntegrationRunOptions = {
|
|
6
9
|
readonly files: readonly string[];
|
|
@@ -85,3 +88,21 @@ export function buildIntegrationTestArgs(opts: IntegrationRunOptions): string[]
|
|
|
85
88
|
...opts.files,
|
|
86
89
|
];
|
|
87
90
|
}
|
|
91
|
+
|
|
92
|
+
// Test stacks of this run clone migrated template databases instead of repeating
|
|
93
|
+
// the DDL per stack; the templates live exactly as long as the run.
|
|
94
|
+
export async function runWithTestDbTemplates(run: () => Promise<number>): Promise<number> {
|
|
95
|
+
const { TEST_DB_TEMPLATE_RUN_ENV, dropTestDbTemplates } = await import("./stack/db.ts");
|
|
96
|
+
const runId = randomBytes(4).toString("hex");
|
|
97
|
+
try {
|
|
98
|
+
return await withEnv({ [TEST_DB_TEMPLATE_RUN_ENV]: runId }, run);
|
|
99
|
+
} finally {
|
|
100
|
+
const baseUrl = process.env["TEST_DATABASE_URL"] ?? SERVICE_ENV_DEFAULTS.TEST_DATABASE_URL;
|
|
101
|
+
try {
|
|
102
|
+
await dropTestDbTemplates(baseUrl, runId);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// biome-ignore lint/suspicious/noConsole: runner output is the interface
|
|
105
|
+
console.warn(`kumiko-testing: could not drop the test DB templates of run ${runId}:`, error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/stack/db.ts
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
import { DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import {
|
|
6
|
+
acquireSessionAdvisoryLock,
|
|
6
7
|
createDatabase,
|
|
7
8
|
createDbConnection,
|
|
8
9
|
databaseExists,
|
|
9
10
|
dropDatabaseIfExists,
|
|
11
|
+
listDatabaseNamesWithPrefix,
|
|
12
|
+
renameDatabase,
|
|
10
13
|
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
11
14
|
import { ensureTemporalPolyfill } from "@cosmicdrift/kumiko-framework/time";
|
|
12
15
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
@@ -39,14 +42,32 @@ export type CreateTestDbOptions = {
|
|
|
39
42
|
readonly persistent?: boolean;
|
|
40
43
|
readonly poolMax?: number;
|
|
41
44
|
readonly outsideTransactionPoolMax?: number;
|
|
45
|
+
/** Clone this already-migrated database instead of starting empty. */
|
|
46
|
+
readonly templateDbName?: string;
|
|
42
47
|
};
|
|
43
48
|
|
|
49
|
+
// Set by the integration runner for one run; templates named after it are dropped when the run ends.
|
|
50
|
+
export const TEST_DB_TEMPLATE_RUN_ENV = "KUMIKO_TEST_DB_TEMPLATE_RUN";
|
|
51
|
+
|
|
52
|
+
// Inside the kumiko_test_ namespace, so clean-test-dbs also sweeps templates of killed runs.
|
|
53
|
+
export function testDbTemplatePrefix(runId: string): string {
|
|
54
|
+
return `kumiko_test_tpl_${runId}_`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function adminUrlOf(url: string): string {
|
|
58
|
+
return url.replace(/\/[^/]+$/, "/postgres");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function databaseUrlOf(url: string, dbName: string): string {
|
|
62
|
+
return url.replace(/\/[^/]+$/, `/${dbName}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<TestDb> {
|
|
45
66
|
await ensureTemporalPolyfill();
|
|
46
67
|
const opts: CreateTestDbOptions = typeof arg === "string" ? { baseUrl: arg } : (arg ?? {});
|
|
47
68
|
const url = opts.baseUrl ?? requireEnv("TEST_DATABASE_URL");
|
|
48
69
|
const dbName = opts.dbName ?? `kumiko_test_${generateId().slice(-8)}`;
|
|
49
|
-
const adminUrl = url
|
|
70
|
+
const adminUrl = adminUrlOf(url);
|
|
50
71
|
|
|
51
72
|
const admin = createDbConnection({ url: adminUrl, maxConnections: 1 });
|
|
52
73
|
try {
|
|
@@ -55,13 +76,13 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
|
|
|
55
76
|
await createDatabase(admin.db, dbName);
|
|
56
77
|
}
|
|
57
78
|
} else {
|
|
58
|
-
await createDatabase(admin.db, dbName);
|
|
79
|
+
await createDatabase(admin.db, dbName, opts.templateDbName);
|
|
59
80
|
}
|
|
60
81
|
} finally {
|
|
61
82
|
await admin.close();
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
const testUrl = url
|
|
85
|
+
const testUrl = databaseUrlOf(url, dbName);
|
|
65
86
|
const poolMax = opts.poolMax ?? DEFAULT_TEST_DB_POOL_MAX;
|
|
66
87
|
const conn = createDbConnection({ url: testUrl, maxConnections: poolMax });
|
|
67
88
|
const outsideConn = createDbConnection({
|
|
@@ -69,8 +90,10 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
|
|
|
69
90
|
maxConnections: opts.outsideTransactionPoolMax ?? DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX,
|
|
70
91
|
});
|
|
71
92
|
|
|
72
|
-
|
|
73
|
-
|
|
93
|
+
if (opts.templateDbName === undefined) {
|
|
94
|
+
const { createEventsTable } = await import("@cosmicdrift/kumiko-framework/event-store");
|
|
95
|
+
await createEventsTable(conn.db);
|
|
96
|
+
}
|
|
74
97
|
|
|
75
98
|
return {
|
|
76
99
|
db: conn.db,
|
|
@@ -93,4 +116,58 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
|
|
|
93
116
|
};
|
|
94
117
|
}
|
|
95
118
|
|
|
119
|
+
const testDbTemplates = new Map<string, Promise<void>>();
|
|
120
|
+
|
|
121
|
+
// One build per template and process; other workers of the run wait on the
|
|
122
|
+
// advisory lock and reuse the finished database.
|
|
123
|
+
export function ensureTestDbTemplate(
|
|
124
|
+
templateDbName: string,
|
|
125
|
+
buildSchema: (db: DbConnection) => Promise<void>,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const known = testDbTemplates.get(templateDbName);
|
|
128
|
+
if (known) return known;
|
|
129
|
+
const pending = buildTestDbTemplate(templateDbName, buildSchema);
|
|
130
|
+
testDbTemplates.set(templateDbName, pending);
|
|
131
|
+
pending.catch(() => testDbTemplates.delete(templateDbName));
|
|
132
|
+
return pending;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function buildTestDbTemplate(
|
|
136
|
+
templateDbName: string,
|
|
137
|
+
buildSchema: (db: DbConnection) => Promise<void>,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const url = requireEnv("TEST_DATABASE_URL");
|
|
140
|
+
// One connection, so the session that takes the advisory lock is the one closed below.
|
|
141
|
+
const admin = createDbConnection({ url: adminUrlOf(url), maxConnections: 1 });
|
|
142
|
+
try {
|
|
143
|
+
await acquireSessionAdvisoryLock(admin.db, templateDbName);
|
|
144
|
+
// skip: another worker of this run already built it while we waited on the lock
|
|
145
|
+
if (await databaseExists(admin.db, templateDbName)) return;
|
|
146
|
+
// Built under a side name and renamed once complete, so a clone never sees a half-built schema.
|
|
147
|
+
const buildDbName = `${templateDbName}_build`;
|
|
148
|
+
await dropDatabaseIfExists(admin.db, buildDbName);
|
|
149
|
+
await createDatabase(admin.db, buildDbName);
|
|
150
|
+
const conn = createDbConnection({ url: databaseUrlOf(url, buildDbName), maxConnections: 1 });
|
|
151
|
+
try {
|
|
152
|
+
await buildSchema(conn.db);
|
|
153
|
+
} finally {
|
|
154
|
+
await conn.close();
|
|
155
|
+
}
|
|
156
|
+
await renameDatabase(admin.db, buildDbName, templateDbName);
|
|
157
|
+
} finally {
|
|
158
|
+
await admin.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function dropTestDbTemplates(baseUrl: string, runId: string): Promise<void> {
|
|
163
|
+
const admin = createDbConnection({ url: adminUrlOf(baseUrl), maxConnections: 1 });
|
|
164
|
+
try {
|
|
165
|
+
for (const name of await listDatabaseNamesWithPrefix(admin.db, testDbTemplatePrefix(runId))) {
|
|
166
|
+
await dropDatabaseIfExists(admin.db, name);
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
await admin.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
96
173
|
export { requireEnv };
|
package/src/stack/redis.ts
CHANGED
|
@@ -39,7 +39,9 @@ export async function createTestRedis(opts: CreateTestRedisOptions = {}): Promis
|
|
|
39
39
|
// round-robin). Collisions at birthday-paradox rates are gone — the
|
|
40
40
|
// prefix space is unbounded. See Track B.3 in docs/plans/tests-refactor.
|
|
41
41
|
const prefix = opts.borrowKeyPrefix ?? `kt:${generateId().slice(-8)}:`;
|
|
42
|
-
|
|
42
|
+
// Callers that check redis.status (rate-limit resolver) must not see "connect" on their first call.
|
|
43
|
+
const redis = new Redis(redisUrl, { keyPrefix: prefix, lazyConnect: true });
|
|
44
|
+
await redis.connect();
|
|
43
45
|
|
|
44
46
|
async function deleteKeysMatching(pattern: string): Promise<void> {
|
|
45
47
|
// Open a prefix-less client for the scan — ioredis' keyPrefix is applied
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @runtime dev
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import type { AuthRoutesConfig, JwtHelper } from "@cosmicdrift/kumiko-framework/api";
|
|
3
4
|
import { buildServer, createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
5
|
+
import type { DbConnection, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
4
6
|
import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
7
|
import type {
|
|
6
8
|
AppContext,
|
|
@@ -50,7 +52,12 @@ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
|
50
52
|
import { createInMemorySearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
51
53
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
52
54
|
import type { Hono } from "hono";
|
|
53
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
createTestDb,
|
|
57
|
+
ensureTestDbTemplate,
|
|
58
|
+
TEST_DB_TEMPLATE_RUN_ENV,
|
|
59
|
+
testDbTemplatePrefix,
|
|
60
|
+
} from "./db.ts";
|
|
54
61
|
import { createJobFailureTracker, drainJobs } from "./drain-jobs.ts";
|
|
55
62
|
import { createEventCollector, type EventCollector } from "./event-collector.ts";
|
|
56
63
|
import { createTestRedis, queueNamePrefixForTestRedis, type TestRedis } from "./redis.ts";
|
|
@@ -304,6 +311,87 @@ function bindMfaTokenSecrets(features: readonly FeatureDefinition[], jwtSecret:
|
|
|
304
311
|
}
|
|
305
312
|
}
|
|
306
313
|
|
|
314
|
+
// Same table list as `kumiko schema generate` (collectTableMetas) — divergence
|
|
315
|
+
// was the #255 prod-crash and the #3102 missing r.entity() tables.
|
|
316
|
+
async function collectStackTableMetas(
|
|
317
|
+
options: TestStackOptions,
|
|
318
|
+
): Promise<readonly EntityTableMeta[]> {
|
|
319
|
+
const { collectTableMetas, enumerateFeatureTableSources, extractTableInfo } = await import(
|
|
320
|
+
"@cosmicdrift/kumiko-framework/db"
|
|
321
|
+
);
|
|
322
|
+
const metas = collectTableMetas(options.features);
|
|
323
|
+
if (options.entityTables !== false) return metas;
|
|
324
|
+
const nonEntityNames = new Set(
|
|
325
|
+
options.features.flatMap((f) =>
|
|
326
|
+
enumerateFeatureTableSources(f).map((s) => extractTableInfo(s.table).name),
|
|
327
|
+
),
|
|
328
|
+
);
|
|
329
|
+
return metas.filter((m) => nonEntityNames.has(m.tableName));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function pushStackSchema(
|
|
333
|
+
db: DbConnection,
|
|
334
|
+
metas: readonly EntityTableMeta[],
|
|
335
|
+
withFileTables: boolean,
|
|
336
|
+
): Promise<void> {
|
|
337
|
+
// Every ES-entity writes events via createEventStoreExecutor in the
|
|
338
|
+
// feature's write handlers. Auto-create the events table so every
|
|
339
|
+
// setupTestStack call is ready for writes without needing a manual
|
|
340
|
+
// createEventsTable().
|
|
341
|
+
await createEventsTable(db);
|
|
342
|
+
// Archive-stream metadata — needed by ctx.appendEvent's archive guard and
|
|
343
|
+
// loadAggregate's default-skip. Idempotent, so production boot running
|
|
344
|
+
// the same call is fine.
|
|
345
|
+
await createArchivedStreamsTable(db);
|
|
346
|
+
|
|
347
|
+
// Framework state for projection rebuild/status + event-consumer cursors.
|
|
348
|
+
// Idempotent — production boot flows run the same calls.
|
|
349
|
+
const { createProjectionStateTable, createEventConsumerStateTable } = await import(
|
|
350
|
+
"@cosmicdrift/kumiko-framework/pipeline"
|
|
351
|
+
);
|
|
352
|
+
await createProjectionStateTable(db);
|
|
353
|
+
await createEventConsumerStateTable(db);
|
|
354
|
+
|
|
355
|
+
// Files support: when a provider is registered, the fileRefs table must
|
|
356
|
+
// exist before the first upload. Skipped when no provider — the table
|
|
357
|
+
// stays off tenant test DBs that never touch files.
|
|
358
|
+
if (withFileTables) {
|
|
359
|
+
const { fileRefsTable, fileUploadLedgerTable } = await import(
|
|
360
|
+
"@cosmicdrift/kumiko-framework/files"
|
|
361
|
+
);
|
|
362
|
+
await unsafePushTables(db, { fileRefsTable, fileUploadLedgerTable });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const { tableExists } = await import("@cosmicdrift/kumiko-framework/db");
|
|
366
|
+
const missing: Record<string, unknown> = {};
|
|
367
|
+
for (const meta of metas) {
|
|
368
|
+
if (await tableExists(db, `public.${meta.tableName}`)) continue;
|
|
369
|
+
missing[meta.tableName] = meta;
|
|
370
|
+
}
|
|
371
|
+
if (Object.keys(missing).length > 0) {
|
|
372
|
+
await unsafePushTables(db, missing);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Only inside an integration-runner run: the framework tables above are not part
|
|
377
|
+
// of the fingerprint, which holds because one run never sees two framework versions.
|
|
378
|
+
async function resolveTestDbTemplateName(
|
|
379
|
+
options: TestStackOptions,
|
|
380
|
+
metas: readonly EntityTableMeta[],
|
|
381
|
+
withFileTables: boolean,
|
|
382
|
+
): Promise<string | undefined> {
|
|
383
|
+
const runId = process.env[TEST_DB_TEMPLATE_RUN_ENV];
|
|
384
|
+
if (runId === undefined || options.persistentDb === true || options.dbName !== undefined) {
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
const { renderTablesDdl } = await import("@cosmicdrift/kumiko-framework/db");
|
|
388
|
+
const fingerprint = createHash("sha256")
|
|
389
|
+
.update(JSON.stringify({ withFileTables, ddl: renderTablesDdl(metas) }))
|
|
390
|
+
.digest("hex")
|
|
391
|
+
.slice(0, 16);
|
|
392
|
+
return `${testDbTemplatePrefix(runId)}${fingerprint}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
307
395
|
export async function setupTestStack(options: TestStackOptions): Promise<TestStack> {
|
|
308
396
|
const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
|
|
309
397
|
const enabledHooks = options.systemHooks ?? ["sse", "search"];
|
|
@@ -325,6 +413,13 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
325
413
|
if (options.validateBoot === "full") validateBoot(options.features);
|
|
326
414
|
bindMfaTokenSecrets(options.features, jwtSecret);
|
|
327
415
|
|
|
416
|
+
const stackTableMetas = await collectStackTableMetas(options);
|
|
417
|
+
const withFileTables = options.files !== undefined;
|
|
418
|
+
const buildStackSchema = (db: DbConnection): Promise<void> =>
|
|
419
|
+
pushStackSchema(db, stackTableMetas, withFileTables);
|
|
420
|
+
const templateDbName = await resolveTestDbTemplateName(options, stackTableMetas, withFileTables);
|
|
421
|
+
if (templateDbName !== undefined) await ensureTestDbTemplate(templateDbName, buildStackSchema);
|
|
422
|
+
|
|
328
423
|
// Forward db-name/persistent-flag through to createTestDb. The
|
|
329
424
|
// defaults (undefined dbName, persistent:false) keep the legacy
|
|
330
425
|
// test contract: fresh kumiko_test_<random> DB per setup, dropped
|
|
@@ -337,6 +432,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
337
432
|
...(options.outsideTransactionPoolMax !== undefined && {
|
|
338
433
|
outsideTransactionPoolMax: options.outsideTransactionPoolMax,
|
|
339
434
|
}),
|
|
435
|
+
...(templateDbName !== undefined && { templateDbName }),
|
|
340
436
|
}),
|
|
341
437
|
createTestRedis(
|
|
342
438
|
options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix },
|
|
@@ -347,70 +443,22 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
347
443
|
// DB/Redis — the caller never gets the stack object back to call
|
|
348
444
|
// cleanup() itself (same "leaked ephemeral DB" failure class as the later
|
|
349
445
|
// try/catch below, but for setup that runs before that try starts).
|
|
350
|
-
|
|
351
|
-
// Every ES-entity writes events via createEventStoreExecutor in the
|
|
352
|
-
// feature's write handlers. Auto-create the events table so every
|
|
353
|
-
// setupTestStack call is ready for writes without needing a manual
|
|
354
|
-
// createEventsTable().
|
|
355
|
-
await createEventsTable(testDb.db);
|
|
356
|
-
// Archive-stream metadata — needed by ctx.appendEvent's archive guard and
|
|
357
|
-
// loadAggregate's default-skip. Idempotent, so production boot running
|
|
358
|
-
// the same call is fine.
|
|
359
|
-
await createArchivedStreamsTable(testDb.db);
|
|
360
|
-
|
|
361
|
-
// Framework state for projection rebuild/status + event-consumer cursors.
|
|
362
|
-
// Idempotent — production boot flows run the same calls.
|
|
363
|
-
const { createProjectionStateTable, createEventConsumerStateTable } = await import(
|
|
364
|
-
"@cosmicdrift/kumiko-framework/pipeline"
|
|
365
|
-
);
|
|
366
|
-
await createProjectionStateTable(testDb.db);
|
|
367
|
-
await createEventConsumerStateTable(testDb.db);
|
|
368
|
-
|
|
369
|
-
// Files support: when a provider is registered, the fileRefs table must
|
|
370
|
-
// exist before the first upload. Skipped when no provider — the table
|
|
371
|
-
// stays off tenant test DBs that never touch files.
|
|
372
|
-
if (options.files) {
|
|
373
|
-
const { fileRefsTable, fileUploadLedgerTable } = await import(
|
|
374
|
-
"@cosmicdrift/kumiko-framework/files"
|
|
375
|
-
);
|
|
376
|
-
await unsafePushTables(testDb.db, { fileRefsTable, fileUploadLedgerTable });
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// Same table list as `kumiko schema generate` (collectTableMetas) — divergence
|
|
380
|
-
// was the #255 prod-crash and the #3102 missing r.entity() tables.
|
|
381
|
-
const { collectTableMetas } = await import("@cosmicdrift/kumiko-framework/db");
|
|
382
|
-
const { tableExists } = await import("@cosmicdrift/kumiko-framework/db");
|
|
383
|
-
let metas = collectTableMetas(options.features);
|
|
384
|
-
if (options.entityTables === false) {
|
|
385
|
-
const { enumerateFeatureTableSources } = await import("@cosmicdrift/kumiko-framework/db");
|
|
386
|
-
const { extractTableInfo } = await import("@cosmicdrift/kumiko-framework/db");
|
|
387
|
-
const nonEntityNames = new Set(
|
|
388
|
-
options.features.flatMap((f) =>
|
|
389
|
-
enumerateFeatureTableSources(f).map((s) => extractTableInfo(s.table).name),
|
|
390
|
-
),
|
|
391
|
-
);
|
|
392
|
-
metas = metas.filter((m) => nonEntityNames.has(m.tableName));
|
|
393
|
-
}
|
|
394
|
-
const missing: Record<string, unknown> = {};
|
|
395
|
-
for (const meta of metas) {
|
|
396
|
-
if (await tableExists(testDb.db, `public.${meta.tableName}`)) continue;
|
|
397
|
-
missing[meta.tableName] = meta;
|
|
398
|
-
}
|
|
399
|
-
if (Object.keys(missing).length > 0) {
|
|
400
|
-
await unsafePushTables(testDb.db, missing);
|
|
401
|
-
}
|
|
402
|
-
} catch (error) {
|
|
446
|
+
if (templateDbName === undefined) {
|
|
403
447
|
try {
|
|
404
|
-
await testDb.
|
|
405
|
-
} catch {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
448
|
+
await buildStackSchema(testDb.db);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
try {
|
|
451
|
+
await testDb.cleanup();
|
|
452
|
+
} catch {
|
|
453
|
+
// ignore — `error` is the one that matters
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
await testRedis.cleanup();
|
|
457
|
+
} catch {
|
|
458
|
+
// ignore — `error` is the one that matters
|
|
459
|
+
}
|
|
460
|
+
throw error;
|
|
412
461
|
}
|
|
413
|
-
throw error;
|
|
414
462
|
}
|
|
415
463
|
|
|
416
464
|
// Redis only where two stacks can actually disagree (avoids two extra
|