@cosmicdrift/kumiko-testing 0.367.0 → 0.369.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/package.json +31 -6
- package/src/bunfig.ts +1 -1
- package/src/e2e/define-app-e2e-config.ts +2 -2
- package/src/e2e/index.ts +3 -3
- package/src/e2e/poll.ts +1 -1
- package/src/e2e/screenshots.ts +2 -2
- package/src/e2e/seed-route.ts +5 -4
- package/src/helpers/access-assertions.ts +15 -0
- package/src/helpers/assertions.ts +35 -0
- package/src/helpers/boot-validator-fixture.ts +127 -0
- package/src/helpers/closed-connection-error.ts +59 -0
- package/src/helpers/db-cleanup.ts +41 -0
- package/src/helpers/e2e-generator.ts +496 -0
- package/src/helpers/expect-error.ts +25 -0
- package/src/helpers/file-provider-contract.ts +123 -0
- package/src/helpers/http-cookies.ts +52 -0
- package/src/helpers/index.ts +67 -0
- package/src/helpers/late-bound.ts +41 -0
- package/src/helpers/manual-clock.ts +28 -0
- package/src/helpers/multipart-helper.ts +96 -0
- package/src/helpers/mutable-master-key-provider.ts +57 -0
- package/src/helpers/observability-recorder.ts +54 -0
- package/src/helpers/real-providers.ts +30 -0
- package/src/helpers/seed.ts +49 -0
- package/src/helpers/shared-entities.ts +65 -0
- package/src/helpers/utils.ts +1 -0
- package/src/helpers/wait-for.ts +50 -0
- package/src/helpers/without-ambient-temporal.ts +14 -0
- package/src/helpers/zip-fixture.ts +41 -0
- package/src/preload/real.ts +1 -1
- package/src/scaffold.ts +2 -2
- package/src/seed-tenant.ts +1 -1
- package/src/setup-app-test-stack.ts +1 -1
- package/src/stack/db.ts +94 -0
- package/src/stack/drain-event-consumers.ts +81 -0
- package/src/stack/drain-jobs.ts +188 -0
- package/src/stack/event-collector.ts +24 -0
- package/src/stack/index.ts +28 -0
- package/src/stack/push-entity-projection-tables.ts +102 -0
- package/src/stack/redis.ts +78 -0
- package/src/stack/request-helper.ts +298 -0
- package/src/stack/table-helpers.ts +47 -0
- package/src/stack/test-stack.ts +715 -0
- /package/src/{e2e/timeouts.ts → e2e-timeouts.ts} +0 -0
package/src/stack/db.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @runtime dev
|
|
2
|
+
// Test-DB Factory: CREATE DATABASE → connect → createEventsTable.
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
+
import {
|
|
6
|
+
createDatabase,
|
|
7
|
+
createDbConnection,
|
|
8
|
+
databaseExists,
|
|
9
|
+
dropDatabaseIfExists,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
11
|
+
import { ensureTemporalPolyfill } from "@cosmicdrift/kumiko-framework/time";
|
|
12
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
13
|
+
import type { DbConnection, PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
|
|
14
|
+
|
|
15
|
+
function requireEnv(name: string): string {
|
|
16
|
+
const value = process.env[name];
|
|
17
|
+
if (!value) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Missing required env var: ${name}. Copy .env.example to .env and fill in values. Tests also need the test services (Postgres, Redis) running, e.g. "docker compose up -d".`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type TestDb = {
|
|
26
|
+
db: DbConnection;
|
|
27
|
+
outsideTransactionDb: DbConnection;
|
|
28
|
+
client: PgListenClient;
|
|
29
|
+
dbName: string;
|
|
30
|
+
cleanup: () => Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type CreateTestDbOptions = {
|
|
34
|
+
readonly baseUrl?: string;
|
|
35
|
+
readonly dbName?: string;
|
|
36
|
+
readonly persistent?: boolean;
|
|
37
|
+
readonly poolMax?: number;
|
|
38
|
+
readonly outsideTransactionPoolMax?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<TestDb> {
|
|
42
|
+
await ensureTemporalPolyfill();
|
|
43
|
+
const opts: CreateTestDbOptions = typeof arg === "string" ? { baseUrl: arg } : (arg ?? {});
|
|
44
|
+
const url = opts.baseUrl ?? requireEnv("TEST_DATABASE_URL");
|
|
45
|
+
const dbName = opts.dbName ?? `kumiko_test_${generateId().slice(-8)}`;
|
|
46
|
+
const adminUrl = url.replace(/\/[^/]+$/, "/postgres");
|
|
47
|
+
|
|
48
|
+
const admin = createDbConnection({ url: adminUrl, maxConnections: 1 });
|
|
49
|
+
try {
|
|
50
|
+
if (opts.persistent) {
|
|
51
|
+
if (!(await databaseExists(admin.db, dbName))) {
|
|
52
|
+
await createDatabase(admin.db, dbName);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
await createDatabase(admin.db, dbName);
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
await admin.close();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const testUrl = url.replace(/\/[^/]+$/, `/${dbName}`);
|
|
62
|
+
const conn = createDbConnection({
|
|
63
|
+
url: testUrl,
|
|
64
|
+
...(opts.poolMax !== undefined ? { maxConnections: opts.poolMax } : {}),
|
|
65
|
+
});
|
|
66
|
+
const outsideConn = createDbConnection({
|
|
67
|
+
url: testUrl,
|
|
68
|
+
maxConnections: opts.outsideTransactionPoolMax ?? DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const { createEventsTable } = await import("@cosmicdrift/kumiko-framework/event-store");
|
|
72
|
+
await createEventsTable(conn.db);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
db: conn.db,
|
|
76
|
+
outsideTransactionDb: outsideConn.db,
|
|
77
|
+
client: conn.client,
|
|
78
|
+
dbName,
|
|
79
|
+
cleanup: async () => {
|
|
80
|
+
await conn.close();
|
|
81
|
+
await outsideConn.close();
|
|
82
|
+
if (!opts.persistent) {
|
|
83
|
+
const admin2 = createDbConnection({ url: adminUrl, maxConnections: 1 });
|
|
84
|
+
try {
|
|
85
|
+
await dropDatabaseIfExists(admin2.db, dbName);
|
|
86
|
+
} finally {
|
|
87
|
+
await admin2.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { requireEnv };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @runtime dev
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { getEventsHighWaterMark } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
|
+
import {
|
|
5
|
+
type ConsumerRecoveryState,
|
|
6
|
+
getConsumerState,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
8
|
+
import type { TestStack } from "./test-stack.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_PASSES = 25;
|
|
11
|
+
|
|
12
|
+
// Drives eventDispatcher.runOnce() until every named consumer has caught up
|
|
13
|
+
// to the events high-water mark captured at call time. No "no progress ⇒
|
|
14
|
+
// throw" shortcut: a pass can legitimately advance zero cursors even though
|
|
15
|
+
// work remains — runOnce's FOR UPDATE SKIP LOCKED lets a concurrent pass
|
|
16
|
+
// (the test stack's own 50ms poll timer) hold a consumer's row, and a
|
|
17
|
+
// consumer coming off a thrown-pass backoff sits idle until its retryAtMs
|
|
18
|
+
// elapses. A bounded pass budget tolerates both without pretending to detect
|
|
19
|
+
// "stuck" from a single empty pass. The target is a snapshot: events a
|
|
20
|
+
// consumer's own handler writes afterward (cascades) are not waited on.
|
|
21
|
+
export async function drainEventConsumers(
|
|
22
|
+
stack: Pick<TestStack, "db" | "eventDispatcher">,
|
|
23
|
+
consumerNames: readonly [string, ...string[]],
|
|
24
|
+
options?: { readonly maxPasses?: number },
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const { db, eventDispatcher } = stack;
|
|
27
|
+
if (!eventDispatcher) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"drainEventConsumers: stack.eventDispatcher is undefined — no system consumer or " +
|
|
30
|
+
"multiStreamProjection is wired for this stack.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const maxPasses = options?.maxPasses ?? DEFAULT_MAX_PASSES;
|
|
34
|
+
if (!Number.isInteger(maxPasses) || maxPasses < 1) {
|
|
35
|
+
throw new Error("drainEventConsumers: maxPasses must be a positive integer");
|
|
36
|
+
}
|
|
37
|
+
const target = await getEventsHighWaterMark(db);
|
|
38
|
+
|
|
39
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
40
|
+
await eventDispatcher.runOnce();
|
|
41
|
+
const states = await loadConsumerStates(db, consumerNames);
|
|
42
|
+
// skip: every named consumer has already caught up to the target snapshot — the
|
|
43
|
+
// remaining budgeted passes are unnecessary, not a swallowed error.
|
|
44
|
+
if (states.every((state) => state.lastProcessedEventId >= target)) return;
|
|
45
|
+
if (pass === maxPasses - 1) {
|
|
46
|
+
throw budgetExhaustedError(maxPasses, target, states);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function loadConsumerStates(
|
|
52
|
+
db: DbConnection,
|
|
53
|
+
consumerNames: readonly [string, ...string[]],
|
|
54
|
+
): Promise<readonly ConsumerRecoveryState[]> {
|
|
55
|
+
return Promise.all(
|
|
56
|
+
consumerNames.map(async (name) => {
|
|
57
|
+
const state = await getConsumerState(db, name);
|
|
58
|
+
if (!state) {
|
|
59
|
+
throw new Error(`drainEventConsumers: consumer "${name}" not registered or per-instance`);
|
|
60
|
+
}
|
|
61
|
+
return state;
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function budgetExhaustedError(
|
|
67
|
+
maxPasses: number,
|
|
68
|
+
target: bigint,
|
|
69
|
+
states: readonly ConsumerRecoveryState[],
|
|
70
|
+
): Error {
|
|
71
|
+
const unfinished = states.filter((state) => state.lastProcessedEventId < target);
|
|
72
|
+
const lines = unfinished.map(
|
|
73
|
+
(state) =>
|
|
74
|
+
` ${state.name}: lastProcessedEventId=${state.lastProcessedEventId} target=${target} ` +
|
|
75
|
+
`status=${state.status} attempts=${state.attempts} lastError=${state.lastError ?? "null"}`,
|
|
76
|
+
);
|
|
77
|
+
return new Error(
|
|
78
|
+
`drainEventConsumers: budget of ${maxPasses} passes exhausted, consumers still behind target:\n` +
|
|
79
|
+
lines.join("\n"),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// @runtime dev
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { getEventsHighWaterMark } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
|
+
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
5
|
+
import { type EventDispatcher, getConsumerState } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
6
|
+
|
|
7
|
+
// Gap between idle-checks while waiting for a job hook to fire. Short enough
|
|
8
|
+
// that a test never feels it, long enough that consecutive idle passes don't
|
|
9
|
+
// hammer Postgres/Redis with pointless polling between hook-driven wakeups.
|
|
10
|
+
const DRAIN_IDLE_POLL_MS = 25;
|
|
11
|
+
// Two consecutive idle passes (not one) before declaring the stack drained —
|
|
12
|
+
// a single "pending===0 and consumers caught up" pass can be a race: a job
|
|
13
|
+
// hook fires and enqueues a follow-up between the countPendingJobs() read
|
|
14
|
+
// and the consumer check of the SAME pass. A DRAIN_IDLE_POLL_MS wait sits
|
|
15
|
+
// between every pass, including the first and second idle one, so an
|
|
16
|
+
// enqueue that lands just after the first idle read still gets picked up
|
|
17
|
+
// by the second pass instead of racing straight past it.
|
|
18
|
+
const REQUIRED_IDLE_PASSES = 2;
|
|
19
|
+
// Below the 15s per-test budget so a stuck drain reports what is open rather
|
|
20
|
+
// than surfacing as a bare test timeout.
|
|
21
|
+
const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
function sleep(ms: number): Promise<void> {
|
|
24
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type JobFailureTracker = {
|
|
28
|
+
readonly onJobComplete: (jobName: string, jobId: string) => void;
|
|
29
|
+
readonly onJobFailed: (jobName: string, jobId: string, error: string) => void;
|
|
30
|
+
waitForActivity(): Promise<void>;
|
|
31
|
+
readonly failures: ReadonlyMap<string, { readonly jobName: string; readonly error: string }>;
|
|
32
|
+
clear(): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createJobFailureTracker(): JobFailureTracker {
|
|
36
|
+
const failures = new Map<string, { jobName: string; error: string }>();
|
|
37
|
+
let wake: (() => void) | undefined;
|
|
38
|
+
let armed: Promise<void> | undefined;
|
|
39
|
+
|
|
40
|
+
function fire(): void {
|
|
41
|
+
wake?.();
|
|
42
|
+
armed = undefined;
|
|
43
|
+
wake = undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
onJobComplete(_jobName, jobId) {
|
|
48
|
+
// A retry that eventually succeeds must not leave a stale failure
|
|
49
|
+
// behind from its earlier attempt(s).
|
|
50
|
+
failures.delete(jobId);
|
|
51
|
+
fire();
|
|
52
|
+
},
|
|
53
|
+
onJobFailed(jobName, jobId, error) {
|
|
54
|
+
failures.set(jobId, { jobName, error });
|
|
55
|
+
fire();
|
|
56
|
+
},
|
|
57
|
+
waitForActivity() {
|
|
58
|
+
armed ??= new Promise<void>((resolve) => {
|
|
59
|
+
wake = resolve;
|
|
60
|
+
});
|
|
61
|
+
return armed;
|
|
62
|
+
},
|
|
63
|
+
failures,
|
|
64
|
+
clear() {
|
|
65
|
+
failures.clear();
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type DrainJobsStack = {
|
|
71
|
+
readonly db: DbConnection;
|
|
72
|
+
readonly eventDispatcher?: EventDispatcher;
|
|
73
|
+
readonly jobRunner?: JobRunner;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Diagnostic only: lets the deadline error name what was still open.
|
|
77
|
+
async function describeLaggingConsumers(
|
|
78
|
+
db: DbConnection,
|
|
79
|
+
consumerNames: readonly string[],
|
|
80
|
+
): Promise<readonly string[]> {
|
|
81
|
+
// skip: no shared consumers wired — nothing to catch up on.
|
|
82
|
+
if (consumerNames.length === 0) return [];
|
|
83
|
+
const target = await getEventsHighWaterMark(db);
|
|
84
|
+
const lagging: string[] = [];
|
|
85
|
+
for (const name of consumerNames) {
|
|
86
|
+
const state = await getConsumerState(db, name);
|
|
87
|
+
if (state && state.lastProcessedEventId < target) {
|
|
88
|
+
lagging.push(
|
|
89
|
+
`${name} (status=${state.status}, lastProcessedEventId=${state.lastProcessedEventId}, target=${target})`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return lagging;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function drainTimeoutError(
|
|
97
|
+
db: DbConnection,
|
|
98
|
+
consumerNames: readonly string[],
|
|
99
|
+
timeoutMs: number,
|
|
100
|
+
pending: number,
|
|
101
|
+
): Promise<Error> {
|
|
102
|
+
const lagging = await describeLaggingConsumers(db, consumerNames);
|
|
103
|
+
return new Error(
|
|
104
|
+
`drainJobs: not drained after ${timeoutMs}ms — pending jobs: ${pending}, ` +
|
|
105
|
+
`consumers behind: ${lagging.length === 0 ? "none" : lagging.join("; ")}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function sharedConsumersCaughtUp(
|
|
110
|
+
db: DbConnection,
|
|
111
|
+
consumerNames: readonly string[],
|
|
112
|
+
): Promise<boolean> {
|
|
113
|
+
// skip: no shared consumers wired — nothing to catch up on.
|
|
114
|
+
if (consumerNames.length === 0) return true;
|
|
115
|
+
const target = await getEventsHighWaterMark(db);
|
|
116
|
+
let allCaughtUp = true;
|
|
117
|
+
for (const name of consumerNames) {
|
|
118
|
+
const state = await getConsumerState(db, name);
|
|
119
|
+
if (!state) {
|
|
120
|
+
throw new Error(`drainJobs: consumer "${name}" not registered or per-instance`);
|
|
121
|
+
}
|
|
122
|
+
if (state.status === "dead") {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`drainJobs: consumer "${state.name}" is dead — lastError: ${state.lastError ?? "unknown"}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (state.lastProcessedEventId < target) allCaughtUp = false;
|
|
128
|
+
}
|
|
129
|
+
return allCaughtUp;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Fails a stuck drain with a diagnosis instead of a generic test-runner timeout.
|
|
133
|
+
// It only bounds the loop; it never replaces waiting for real work.
|
|
134
|
+
export async function drainJobs(
|
|
135
|
+
stack: DrainJobsStack,
|
|
136
|
+
tracker: JobFailureTracker,
|
|
137
|
+
options?: { readonly timeoutMs?: number },
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const { db, eventDispatcher, jobRunner } = stack;
|
|
140
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
141
|
+
const deadline = Date.now() + timeoutMs;
|
|
142
|
+
if (!jobRunner) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
"drainJobs: stack.jobRunner is undefined — pass `jobs: {...}` to setupTestStack and " +
|
|
145
|
+
"register at least one r.job(...) so a runner is built.",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sharedConsumerNames = (eventDispatcher?.consumers ?? [])
|
|
150
|
+
.filter((consumer) => (consumer.delivery ?? "shared") === "shared")
|
|
151
|
+
.map((consumer) => consumer.name);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
try {
|
|
155
|
+
let consecutiveIdlePasses = 0;
|
|
156
|
+
while (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
|
|
157
|
+
await eventDispatcher?.runOnce();
|
|
158
|
+
const pending = await jobRunner.countPendingJobs();
|
|
159
|
+
const caughtUp = await sharedConsumersCaughtUp(db, sharedConsumerNames);
|
|
160
|
+
consecutiveIdlePasses = pending === 0 && caughtUp ? consecutiveIdlePasses + 1 : 0;
|
|
161
|
+
if (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
|
|
162
|
+
if (Date.now() > deadline) {
|
|
163
|
+
throw await drainTimeoutError(db, sharedConsumerNames, timeoutMs, pending);
|
|
164
|
+
}
|
|
165
|
+
await Promise.race([tracker.waitForActivity(), sleep(DRAIN_IDLE_POLL_MS)]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
// A failed job often kills its consumer, so the drain error is only the follow-up symptom.
|
|
170
|
+
if (tracker.failures.size === 0) throw err;
|
|
171
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
172
|
+
throw new Error(`${message}; ${describeJobFailures(tracker)}`, { cause: err });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (tracker.failures.size > 0) {
|
|
176
|
+
throw new Error(`drainJobs: ${describeJobFailures(tracker)}`);
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
tracker.clear();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function describeJobFailures(tracker: JobFailureTracker): string {
|
|
184
|
+
const details = [...tracker.failures.values()]
|
|
185
|
+
.map((failure) => `${failure.jobName}: ${failure.error}`)
|
|
186
|
+
.join("; ");
|
|
187
|
+
return `job(s) failed: ${details}`;
|
|
188
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @runtime dev
|
|
2
|
+
import type { SseEvent } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
|
|
5
|
+
export type EventCollector = {
|
|
6
|
+
readonly sse: SseEvent[];
|
|
7
|
+
readonly postSave: SaveContext[];
|
|
8
|
+
/** Clears all collected events — call in beforeEach for per-test isolation */
|
|
9
|
+
reset(): void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function createEventCollector(): EventCollector {
|
|
13
|
+
const sse: SseEvent[] = [];
|
|
14
|
+
const postSave: SaveContext[] = [];
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
sse,
|
|
18
|
+
postSave,
|
|
19
|
+
reset() {
|
|
20
|
+
sse.length = 0;
|
|
21
|
+
postSave.length = 0;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @runtime dev
|
|
2
|
+
// Runtime-safe stack builder. The dev-server uses what lives here to boot a
|
|
3
|
+
// complete Kumiko instance: DB, Redis, Hono app, dispatcher, SSE broker. The
|
|
4
|
+
// files are historically named `test*` (createTestDb, setupTestStack,
|
|
5
|
+
// TestUsers, …) but serve dev AND test code today: it is the same boot path,
|
|
6
|
+
// once with an ephemeral DB (test) and once with a persistent DB (dev).
|
|
7
|
+
//
|
|
8
|
+
// Important: this module must contain NO vitest imports and must not pull
|
|
9
|
+
// vitest-only helpers in transitively, or every tool that loads the dev-server
|
|
10
|
+
// under Node (drizzle-kit, build scripts) crashes.
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
unsafeCreateEntityTable,
|
|
14
|
+
unsafeEnsureEntityTable,
|
|
15
|
+
unsafePushTables,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
17
|
+
export {
|
|
18
|
+
type CreateTestDbOptions,
|
|
19
|
+
createTestDb,
|
|
20
|
+
type TestDb,
|
|
21
|
+
} from "./db.js";
|
|
22
|
+
export { drainEventConsumers } from "./drain-event-consumers.js";
|
|
23
|
+
export { createEventCollector, type EventCollector } from "./event-collector.js";
|
|
24
|
+
export { pushEntityProjectionTables } from "./push-entity-projection-tables.js";
|
|
25
|
+
export { createTestRedis, type TestRedis } from "./redis.js";
|
|
26
|
+
export { createRequestHelper, type RequestHelper } from "./request-helper.js";
|
|
27
|
+
export { resetEventStore } from "./table-helpers.js";
|
|
28
|
+
export { setupTestStack, type TestStack, type TestStackOptions } from "./test-stack.js";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @runtime dev
|
|
2
|
+
import type { ColumnMeta, DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { tableExists } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import {
|
|
6
|
+
addMissingColumns,
|
|
7
|
+
addMissingIndexes,
|
|
8
|
+
columnNamesOf,
|
|
9
|
+
schemaTableName,
|
|
10
|
+
tableToMeta,
|
|
11
|
+
unsafePushTables,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
13
|
+
|
|
14
|
+
type DbHolder = { readonly db: DbConnection };
|
|
15
|
+
|
|
16
|
+
// biome-ignore lint/suspicious/noConsole: stack-internal status logging
|
|
17
|
+
const logInfo = (msg: string): void => console.log(msg);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Push all implicit-projection tables — one per `r.entity()` — that the
|
|
21
|
+
* registry knows about. setupTestStack already handles explicit
|
|
22
|
+
* projections, MSPs, and `r.storeTable()` declarations in its own loop;
|
|
23
|
+
* implicit projections are the missing piece for a fresh boot.
|
|
24
|
+
*
|
|
25
|
+
* Idempotent via `tableExists` so a persistent dev DB
|
|
26
|
+
* (`KUMIKO_DEV_DB_NAME`) reuses existing tables on reboot. One batched
|
|
27
|
+
* push at the end so drizzle-kit's `generateMigration` runs once over
|
|
28
|
+
* the whole missing set.
|
|
29
|
+
*
|
|
30
|
+
* Lives next to `setupTestStack` because both are stack-bootstrap
|
|
31
|
+
* helpers that legitimately speak the `unsafe*`-DDL layer; the
|
|
32
|
+
* Table-DDL Guard's stack/** allowlist is the single shared exemption
|
|
33
|
+
* site. Apps still declare data via `r.entity()` / `r.storeTable()` and
|
|
34
|
+
* never call this directly.
|
|
35
|
+
*/
|
|
36
|
+
export async function pushEntityProjectionTables(
|
|
37
|
+
stack: DbHolder,
|
|
38
|
+
registry: Registry,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const seen = new Set<unknown>();
|
|
41
|
+
const missing: Record<string, unknown> = {};
|
|
42
|
+
|
|
43
|
+
for (const [projName, proj] of registry.getAllProjections()) {
|
|
44
|
+
if (!proj.isImplicit) continue;
|
|
45
|
+
if (seen.has(proj.table)) continue;
|
|
46
|
+
seen.add(proj.table);
|
|
47
|
+
const physical = schemaTableName(proj.table) ?? tableToMeta(proj.table).tableName;
|
|
48
|
+
if (!(await tableExists(stack.db, `public.${physical}`))) {
|
|
49
|
+
missing[projName] = proj.table;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
await syncExistingProjectionTable(stack, physical, proj.table);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Object.keys(missing).length > 0) {
|
|
56
|
+
await unsafePushTables(stack.db, missing);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isUnsafeToAutoAdd(col: ColumnMeta): boolean {
|
|
61
|
+
return col.notNull && !col.primaryKey && col.defaultSql === undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Backfills columns and indexes a persistent dev DB predates; a required column with no
|
|
65
|
+
// default can't be added safely on a table that may hold rows, so that
|
|
66
|
+
// stays a boot error instead.
|
|
67
|
+
async function syncExistingProjectionTable(
|
|
68
|
+
stack: DbHolder,
|
|
69
|
+
physical: string,
|
|
70
|
+
table: unknown,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const meta = tableToMeta(table);
|
|
73
|
+
const liveColumns = await columnNamesOf(stack.db, physical);
|
|
74
|
+
const missingColumns = meta.columns.filter((c) => !liveColumns.has(c.name));
|
|
75
|
+
if (missingColumns.length === 0) {
|
|
76
|
+
logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
|
|
77
|
+
await addMissingIndexes(stack.db, physical, meta.indexes);
|
|
78
|
+
// skip: table is already in sync, only indexes needed backfilling
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const unsafeColumns = missingColumns.filter(isUnsafeToAutoAdd);
|
|
83
|
+
if (unsafeColumns.length > 0) {
|
|
84
|
+
const devDbName = process.env["KUMIKO_DEV_DB_NAME"] ?? "<KUMIKO_DEV_DB_NAME>";
|
|
85
|
+
throw new Error(
|
|
86
|
+
`[kumiko-stack] table "${physical}" is missing required column(s) ` +
|
|
87
|
+
`${unsafeColumns.map((c) => `"${c.name}"`).join(", ")} with no default. ` +
|
|
88
|
+
"Auto-sync only backfills nullable columns or columns with a default on an " +
|
|
89
|
+
"existing dev DB — a required column with no default can't be added safely " +
|
|
90
|
+
`once the table may hold rows. Drop the persistent dev database "${devDbName}" ` +
|
|
91
|
+
`(e.g. \`dropdb ${devDbName}\`, adjusted for your local Postgres host/port/user) ` +
|
|
92
|
+
"so the next boot recreates it from scratch.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
logInfo(
|
|
97
|
+
`[kumiko-stack] table ${physical} is missing column(s) ` +
|
|
98
|
+
`${missingColumns.map((c) => `"${c.name}"`).join(", ")} — adding`,
|
|
99
|
+
);
|
|
100
|
+
await addMissingColumns(stack.db, physical, missingColumns);
|
|
101
|
+
await addMissingIndexes(stack.db, physical, meta.indexes);
|
|
102
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @runtime dev
|
|
2
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
|
+
import { requireEnv } from "./db.js";
|
|
4
|
+
|
|
5
|
+
export type TestRedis = {
|
|
6
|
+
redis: import("ioredis").Redis;
|
|
7
|
+
// The exact REDIS_URL used to build `redis` above — for a second, unrelated
|
|
8
|
+
// connection (e.g. the test-stack's JobRunner) that needs its own client
|
|
9
|
+
// rather than sharing this one's keyPrefix. Reconstructing a URL from
|
|
10
|
+
// `redis.options` loses password/username/tls/path (pr-review
|
|
11
|
+
// kumiko-framework #1036/2) — callers needing a fresh connection should use
|
|
12
|
+
// this raw string, not rebuild one from parsed options.
|
|
13
|
+
redisUrl: string;
|
|
14
|
+
/** The per-test `keyPrefix` on `redis`, e.g. `kt:1a2b3c4d:`. */
|
|
15
|
+
keyPrefix: string;
|
|
16
|
+
/** Delete every key this test created (prefix-scoped). Replaces the old
|
|
17
|
+
* `redis.flushdb()` — that wiped other parallel tests' BullMQ state. */
|
|
18
|
+
flushNamespace: () => Promise<void>;
|
|
19
|
+
cleanup: () => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// BullMQ queues live on the raw redisUrl, outside the test Redis keyPrefix, so
|
|
23
|
+
// parallel stacks sharing the prod default queue name would consume each
|
|
24
|
+
// other's jobs. BullMQ rejects `:` in queue names.
|
|
25
|
+
export function queueNamePrefixForTestRedis(keyPrefix: string): string {
|
|
26
|
+
return keyPrefix.split(":").filter(Boolean).join("-");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CreateTestRedisOptions = {
|
|
30
|
+
/** Reuse another stack's keyPrefix on a fresh connection. cleanup() then only
|
|
31
|
+
* disconnects — the owning stack flushes the namespace and its queues. */
|
|
32
|
+
readonly borrowKeyPrefix?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function createTestRedis(opts: CreateTestRedisOptions = {}): Promise<TestRedis> {
|
|
36
|
+
const Redis = (await import("ioredis")).Redis;
|
|
37
|
+
const redisUrl = requireEnv("REDIS_URL");
|
|
38
|
+
// Every test gets a per-file key prefix on a shared DB (no DB-pool-of-15
|
|
39
|
+
// round-robin). Collisions at birthday-paradox rates are gone — the
|
|
40
|
+
// prefix space is unbounded. See Track B.3 in docs/plans/tests-refactor.
|
|
41
|
+
const prefix = opts.borrowKeyPrefix ?? `kt:${generateId().slice(-8)}:`;
|
|
42
|
+
const redis = new Redis(redisUrl, { keyPrefix: prefix });
|
|
43
|
+
|
|
44
|
+
async function deleteKeysMatching(pattern: string): Promise<void> {
|
|
45
|
+
// Open a prefix-less client for the scan — ioredis' keyPrefix is applied
|
|
46
|
+
// per-command but SCAN's returned keys are full names, so managing the
|
|
47
|
+
// del set with the prefix already on the connection is error-prone.
|
|
48
|
+
const raw = new Redis(redisUrl);
|
|
49
|
+
try {
|
|
50
|
+
const stream = raw.scanStream({ match: pattern, count: 500 });
|
|
51
|
+
const keys: string[] = [];
|
|
52
|
+
for await (const batch of stream) keys.push(...batch);
|
|
53
|
+
if (keys.length > 0) await raw.del(...keys);
|
|
54
|
+
} finally {
|
|
55
|
+
raw.disconnect();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const flushNamespace = (): Promise<void> => deleteKeysMatching(`${prefix}*`);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
redis,
|
|
63
|
+
redisUrl,
|
|
64
|
+
keyPrefix: prefix,
|
|
65
|
+
flushNamespace,
|
|
66
|
+
cleanup: async () => {
|
|
67
|
+
if (opts.borrowKeyPrefix !== undefined) {
|
|
68
|
+
redis.disconnect();
|
|
69
|
+
// skip: the owning stack flushes the shared namespace and its queues
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await flushNamespace();
|
|
73
|
+
// The derived per-stack JobRunner queues sit outside the keyPrefix.
|
|
74
|
+
await deleteKeysMatching(`bull:${queueNamePrefixForTestRedis(prefix)}-*`);
|
|
75
|
+
redis.disconnect();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|