@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.
Files changed (44) hide show
  1. package/package.json +31 -6
  2. package/src/bunfig.ts +1 -1
  3. package/src/e2e/define-app-e2e-config.ts +2 -2
  4. package/src/e2e/index.ts +3 -3
  5. package/src/e2e/poll.ts +1 -1
  6. package/src/e2e/screenshots.ts +2 -2
  7. package/src/e2e/seed-route.ts +5 -4
  8. package/src/helpers/access-assertions.ts +15 -0
  9. package/src/helpers/assertions.ts +35 -0
  10. package/src/helpers/boot-validator-fixture.ts +127 -0
  11. package/src/helpers/closed-connection-error.ts +59 -0
  12. package/src/helpers/db-cleanup.ts +41 -0
  13. package/src/helpers/e2e-generator.ts +496 -0
  14. package/src/helpers/expect-error.ts +25 -0
  15. package/src/helpers/file-provider-contract.ts +123 -0
  16. package/src/helpers/http-cookies.ts +52 -0
  17. package/src/helpers/index.ts +67 -0
  18. package/src/helpers/late-bound.ts +41 -0
  19. package/src/helpers/manual-clock.ts +28 -0
  20. package/src/helpers/multipart-helper.ts +96 -0
  21. package/src/helpers/mutable-master-key-provider.ts +57 -0
  22. package/src/helpers/observability-recorder.ts +54 -0
  23. package/src/helpers/real-providers.ts +30 -0
  24. package/src/helpers/seed.ts +49 -0
  25. package/src/helpers/shared-entities.ts +65 -0
  26. package/src/helpers/utils.ts +1 -0
  27. package/src/helpers/wait-for.ts +50 -0
  28. package/src/helpers/without-ambient-temporal.ts +14 -0
  29. package/src/helpers/zip-fixture.ts +41 -0
  30. package/src/preload/real.ts +1 -1
  31. package/src/scaffold.ts +2 -2
  32. package/src/seed-tenant.ts +1 -1
  33. package/src/setup-app-test-stack.ts +1 -1
  34. package/src/stack/db.ts +94 -0
  35. package/src/stack/drain-event-consumers.ts +81 -0
  36. package/src/stack/drain-jobs.ts +188 -0
  37. package/src/stack/event-collector.ts +24 -0
  38. package/src/stack/index.ts +28 -0
  39. package/src/stack/push-entity-projection-tables.ts +102 -0
  40. package/src/stack/redis.ts +78 -0
  41. package/src/stack/request-helper.ts +298 -0
  42. package/src/stack/table-helpers.ts +47 -0
  43. package/src/stack/test-stack.ts +715 -0
  44. /package/src/{e2e/timeouts.ts → e2e-timeouts.ts} +0 -0
@@ -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
+ }