@cosmicdrift/kumiko-testing 0.388.0 → 0.390.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.
@@ -3,7 +3,6 @@ import type {
3
3
  APIRequestContext,
4
4
  BrowserContext,
5
5
  Page,
6
- Request as PlaywrightRequest,
7
6
  PlaywrightTestArgs,
8
7
  PlaywrightTestOptions,
9
8
  PlaywrightWorkerArgs,
@@ -19,6 +18,11 @@ import {
19
18
  type SeedTenantOptions,
20
19
  withSession,
21
20
  } from "../seed-types";
21
+ import {
22
+ appBootBudgetViolation,
23
+ DEFAULT_MAX_APP_BOOTS_PER_TEST,
24
+ observeAppBoots,
25
+ } from "./app-boot-budget";
22
26
  import {
23
27
  CLIENT_IP_HEADER,
24
28
  clearSession,
@@ -69,6 +73,7 @@ export type E2eSeededTenant = Omit<SeededTenant, "addUser"> & {
69
73
  export type SeedTenantFixture = (opts?: E2eSeedTenantOptions) => Promise<E2eSeededTenant>;
70
74
 
71
75
  type E2eFixtures = {
76
+ maxAppBootsPerTest: number;
72
77
  seedTenant: SeedTenantFixture;
73
78
  };
74
79
 
@@ -230,52 +235,35 @@ export function perTestClientIpKey(
230
235
  return `${runToken ?? ""}:${testInfo.testId}:${testInfo.repeatEachIndex}:${testInfo.retry}`;
231
236
  }
232
237
 
233
- // Same-origin only: on a cross-origin fetch the extra header would force a
234
- // CORS preflight the target never agreed to (why extraHTTPHeaders is no option
235
- // here). An explicit X-Forwarded-For set by the test wins.
236
- export function headersWithClientIp(
237
- request: { readonly url: string; readonly frameUrl: string | undefined },
238
- headers: Readonly<Record<string, string>>,
239
- clientIp: string,
240
- ): Record<string, string> | undefined {
241
- if (request.frameUrl === undefined) return undefined;
242
- let sameOrigin: boolean;
243
- try {
244
- sameOrigin = new URL(request.frameUrl).origin === new URL(request.url).origin;
245
- } catch {
246
- return undefined;
247
- }
248
- return sameOrigin ? { [CLIENT_IP_HEADER]: clientIp, ...headers } : undefined;
249
- }
250
-
251
- function frameUrlOf(request: PlaywrightRequest): string | undefined {
252
- try {
253
- return request.frame().url();
254
- } catch {
255
- // Service-worker requests have no frame.
256
- return undefined;
257
- }
258
- }
259
-
238
+ // Chromium turns its HTTP cache off for a context as soon as it has a route,
239
+ // so the header goes through setExtraHTTPHeaders and the cache stays on.
260
240
  export async function providePerTestClientIpContext(
261
- { context }: Pick<PlaywrightTestArgs, "context">,
241
+ {
242
+ context,
243
+ maxAppBootsPerTest,
244
+ }: Pick<PlaywrightTestArgs, "context"> & Pick<E2eFixtures, "maxAppBootsPerTest">,
262
245
  use: (context: BrowserContext) => Promise<void>,
263
- testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry">,
246
+ testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry" | "status" | "expectedStatus">,
264
247
  ): Promise<void> {
265
- const clientIp = syntheticClientIpFor(perTestClientIpKey(testInfo));
266
- await context.route("**/api/**", (route) => {
267
- const request = route.request();
268
- const headers = headersWithClientIp(
269
- { url: request.url(), frameUrl: frameUrlOf(request) },
270
- request.headers(),
271
- clientIp,
272
- );
273
- return headers === undefined ? route.fallback() : route.fallback({ headers });
248
+ await context.setExtraHTTPHeaders({
249
+ [CLIENT_IP_HEADER]: syntheticClientIpFor(perTestClientIpKey(testInfo)),
274
250
  });
251
+ const appBoots = observeAppBoots(context);
275
252
  await use(context);
253
+
254
+ // A window.open tab left open stalls the context close by seconds.
255
+ await Promise.all(context.pages().map((page) => (page.isClosed() ? undefined : page.close())));
256
+
257
+ // Only after a passing body, so the budget never hides the original failure.
258
+ const violation =
259
+ testInfo.status === testInfo.expectedStatus
260
+ ? appBootBudgetViolation(appBoots.count(), maxAppBootsPerTest)
261
+ : undefined;
262
+ if (violation !== undefined) throw new Error(violation);
276
263
  }
277
264
 
278
265
  export const test = base.extend<E2eFixtures>({
266
+ maxAppBootsPerTest: [DEFAULT_MAX_APP_BOOTS_PER_TEST, { option: true }],
279
267
  context: providePerTestClientIpContext,
280
268
  seedTenant: provideSeedTenant,
281
269
  });
@@ -2,7 +2,6 @@
2
2
  // the production matcher checks driver-specific codes a fake can't reproduce.
3
3
 
4
4
  import { isClosedConnectionError } from "@cosmicdrift/kumiko-framework/internal/stack-support";
5
- import postgres from "postgres";
6
5
  import { requireEnv } from "../stack/db.ts";
7
6
 
8
7
  export function testDatabaseUrl(): string {
@@ -14,8 +13,6 @@ const READS_PER_ROUND = 5;
14
13
 
15
14
  type AdminClient = { unsafe(sql: string, params?: readonly unknown[]): Promise<unknown> };
16
15
 
17
- // A per-round admin client hits a postgres-js reconnect timing bug (the first
18
- // retry after terminate hangs); a single long-lived admin client avoids it.
19
16
  export async function terminateBackendsByApplicationName(
20
17
  admin: AdminClient,
21
18
  applicationName: string,
@@ -31,11 +28,11 @@ export async function terminateBackendsByApplicationName(
31
28
  export async function captureClosedConnectionError(
32
29
  url: string = testDatabaseUrl(),
33
30
  ): Promise<unknown> {
34
- const admin = postgres(url, { max: 1 });
31
+ const admin = new Bun.SQL(url, { max: 1 });
35
32
  try {
36
33
  for (let round = 0; round < MAX_WARMUP_ROUNDS; round++) {
37
34
  const applicationName = `kumiko-closed-conn-test-${crypto.randomUUID()}`;
38
- const pool = postgres(url, { max: 1, connection: { application_name: applicationName } });
35
+ const pool = new Bun.SQL(url, { max: 1, connection: { application_name: applicationName } });
39
36
  try {
40
37
  await pool.unsafe("select 1");
41
38
  await terminateBackendsByApplicationName(admin, applicationName);
@@ -47,13 +44,13 @@ export async function captureClosedConnectionError(
47
44
  }
48
45
  }
49
46
  } finally {
50
- await pool.end({ timeout: 0 });
47
+ await pool.close({ timeout: 0 });
51
48
  }
52
49
  }
53
50
  throw new Error(
54
51
  `captureClosedConnectionError: no closed-connection error observed after ${MAX_WARMUP_ROUNDS} rounds.`,
55
52
  );
56
53
  } finally {
57
- await admin.end({ timeout: 0 });
54
+ await admin.close({ timeout: 0 });
58
55
  }
59
56
  }
@@ -10,3 +10,12 @@ export function consoleCallsMentioning(
10
10
  ): ReadonlyArray<ReadonlyArray<unknown>> {
11
11
  return spy.mock.calls.filter((args) => args.some((arg) => String(arg).includes(fragment)));
12
12
  }
13
+
14
+ // For "stays silent" assertions: counts every call from the given logger namespace instead of one
15
+ // message text, so a new or reworded warning from the module under test still fails the test.
16
+ export function consoleCallsFromNamespace(
17
+ spy: ConsoleSpyLike,
18
+ namespace: string,
19
+ ): ReadonlyArray<ReadonlyArray<unknown>> {
20
+ return spy.mock.calls.filter((args) => String(args[0]).startsWith(`[${namespace}]`));
21
+ }
@@ -0,0 +1,160 @@
1
+ // Test-side writes on event-sourced tables that go through the event store, so
2
+ // the event log and the projection stay consistent. Unlike seed.ts these survive
3
+ // a projection rebuild and fire the same events a handler would.
4
+
5
+ import {
6
+ createEventStoreExecutor,
7
+ createTenantDb,
8
+ type DbConnection,
9
+ fetchOne,
10
+ selectMany,
11
+ type WhereObject,
12
+ } from "@cosmicdrift/kumiko-framework/db";
13
+ import {
14
+ createSystemUser,
15
+ SYSTEM_TENANT_ID,
16
+ type TenantId,
17
+ } from "@cosmicdrift/kumiko-framework/engine";
18
+ import { executorManagedEntitySourceOf } from "@cosmicdrift/kumiko-framework/internal/testing";
19
+
20
+ type ExecutorArgs = Parameters<typeof createEventStoreExecutor>;
21
+
22
+ export type EventStoreWriteTarget = {
23
+ readonly table: ExecutorArgs[0];
24
+ readonly entity: ExecutorArgs[1];
25
+ readonly entityName: string;
26
+ };
27
+
28
+ export type EventStoreWriteScope = {
29
+ /** Tenant of the acting system user and of the tenant-bound db; defaults to the system tenant. */
30
+ readonly tenantId?: TenantId;
31
+ /** Address a stream that lives on another tenant than the actor's. */
32
+ readonly streamTenantId?: TenantId;
33
+ };
34
+
35
+ type EntityId = string | number;
36
+
37
+ function isEntityId(value: unknown): value is EntityId {
38
+ return typeof value === "string" || typeof value === "number";
39
+ }
40
+
41
+ async function loadTarget(
42
+ db: DbConnection,
43
+ target: EventStoreWriteTarget,
44
+ where: WhereObject,
45
+ verb: string,
46
+ ): Promise<{ id: EntityId; version: number }> {
47
+ const row = await fetchOne<Record<string, unknown>>(db, target.table, where);
48
+ if (!row) {
49
+ throw new Error(`${verb} ${target.entityName}: no row matches ${JSON.stringify(where)}`);
50
+ }
51
+ const { id, version } = row;
52
+ if (!isEntityId(id)) throw new Error(`${verb} ${target.entityName}: row has no id`);
53
+ if (typeof version !== "number") {
54
+ throw new Error(`${verb} ${target.entityName}: row has no numeric version`);
55
+ }
56
+ return { id, version };
57
+ }
58
+
59
+ function writeContext(scope: EventStoreWriteScope | undefined, db: DbConnection) {
60
+ const tenantId = scope?.tenantId ?? SYSTEM_TENANT_ID;
61
+ return {
62
+ user: createSystemUser(tenantId),
63
+ tenantDb: createTenantDb({
64
+ db,
65
+ tenantId,
66
+ mode: "system",
67
+ reason: "test helper seeds event-store rows for any tenant",
68
+ }),
69
+ options: scope?.streamTenantId ? { streamTenantId: scope.streamTenantId } : undefined,
70
+ };
71
+ }
72
+
73
+ function failedWrite(
74
+ verb: string,
75
+ entityName: string,
76
+ error: { readonly code: string; readonly details?: unknown },
77
+ ): Error {
78
+ return new Error(
79
+ `${verb} ${entityName} failed: ${error.code} ${JSON.stringify(error.details ?? {})}`,
80
+ );
81
+ }
82
+
83
+ // Same arguments as seedRow, so a seed on an entity table switches over by rename. The row's
84
+ // tenantId picks the acting tenant (and so the stream tenant); seed global entities without one,
85
+ // the executor rejects a tenantId on them.
86
+ export async function seedRowViaEventStore(
87
+ db: DbConnection,
88
+ table: EventStoreWriteTarget["table"],
89
+ values: Readonly<Record<string, unknown>>,
90
+ ): Promise<void> {
91
+ const source = executorManagedEntitySourceOf(table);
92
+ if (!source) {
93
+ throw new Error(
94
+ "seedRowViaEventStore: table is not an event-sourced entity table (buildEntityTable or r.entity); use seedRow",
95
+ );
96
+ }
97
+ const { entity, entityName } = source;
98
+ const { tenantId, ...payload } = values;
99
+ if (payload["id"] !== undefined && typeof payload["id"] !== "string") {
100
+ throw new Error(
101
+ `seed ${entityName}: id must be a string, the event store would mint a new one`,
102
+ );
103
+ }
104
+ if (tenantId !== undefined && typeof tenantId !== "string") {
105
+ throw new Error(`seed ${entityName}: tenantId must be a string`);
106
+ }
107
+ const { user, tenantDb } = writeContext({ tenantId }, db);
108
+ const executor = createEventStoreExecutor(table, entity, { entityName });
109
+ const result = await executor.create(payload, user, tenantDb);
110
+ if (!result.isSuccess) throw failedWrite("seed", entityName, result.error);
111
+ }
112
+
113
+ export async function updateRowViaEventStore(
114
+ db: DbConnection,
115
+ target: EventStoreWriteTarget,
116
+ where: WhereObject,
117
+ changes: Readonly<Record<string, unknown>>,
118
+ scope?: EventStoreWriteScope,
119
+ ): Promise<void> {
120
+ const { id, version } = await loadTarget(db, target, where, "update");
121
+ const { user, tenantDb, options } = writeContext(scope, db);
122
+ const executor = createEventStoreExecutor(target.table, target.entity, {
123
+ entityName: target.entityName,
124
+ });
125
+ const result = await executor.update(
126
+ { id, version, changes: { ...changes } },
127
+ user,
128
+ tenantDb,
129
+ options,
130
+ );
131
+ if (!result.isSuccess) throw failedWrite("update", target.entityName, result.error);
132
+ }
133
+
134
+ export async function deleteRowViaEventStore(
135
+ db: DbConnection,
136
+ target: EventStoreWriteTarget,
137
+ where: WhereObject,
138
+ scope?: EventStoreWriteScope,
139
+ ): Promise<void> {
140
+ const { id } = await loadTarget(db, target, where, "delete");
141
+ const { user, tenantDb, options } = writeContext(scope, db);
142
+ const executor = createEventStoreExecutor(target.table, target.entity, {
143
+ entityName: target.entityName,
144
+ });
145
+ const result = await executor.delete({ id }, user, tenantDb, options);
146
+ if (!result.isSuccess) throw failedWrite("delete", target.entityName, result.error);
147
+ }
148
+
149
+ /** Deletes every row matching `where`, each through its own event-store delete. */
150
+ export async function deleteRowsViaEventStore(
151
+ db: DbConnection,
152
+ target: EventStoreWriteTarget,
153
+ where: WhereObject,
154
+ scope?: EventStoreWriteScope,
155
+ ): Promise<void> {
156
+ const rows = await selectMany<{ id: EntityId }>(db, target.table, where);
157
+ for (const { id } of rows) {
158
+ await deleteRowViaEventStore(db, target, { id }, scope);
159
+ }
160
+ }
@@ -2,6 +2,12 @@
2
2
  // must import nothing from this subpath — the stack builders live in
3
3
  // `@cosmicdrift/kumiko-testing/stack`.
4
4
 
5
+ // Scope setters for tests that call cross-tenant or tenant functions directly, outside dispatch.
6
+ export {
7
+ runInRowLevelSecurityScope,
8
+ runInSystemRowLevelSecurityScope,
9
+ tenantRowLevelSecurityScope,
10
+ } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
5
11
  // The four cache/injection resets stay in their own modules (they close over
6
12
  // module-private state) and are only re-exported here — they are out of /crypto
7
13
  // and /db. A production call to resetPiiSubjectKmsForTests() silently switches
@@ -23,7 +29,11 @@ export {
23
29
  terminateBackendsByApplicationName,
24
30
  testDatabaseUrl,
25
31
  } from "./closed-connection-error.ts";
26
- export { type ConsoleSpyLike, consoleCallsMentioning } from "./console-calls.ts";
32
+ export {
33
+ type ConsoleSpyLike,
34
+ consoleCallsFromNamespace,
35
+ consoleCallsMentioning,
36
+ } from "./console-calls.ts";
27
37
  export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup.ts";
28
38
  export {
29
39
  type E2EGeneratorOptions,
@@ -32,6 +42,14 @@ export {
32
42
  generateE2ESpec,
33
43
  generateZodFixture,
34
44
  } from "./e2e-generator.ts";
45
+ export {
46
+ deleteRowsViaEventStore,
47
+ deleteRowViaEventStore,
48
+ type EventStoreWriteScope,
49
+ type EventStoreWriteTarget,
50
+ seedRowViaEventStore,
51
+ updateRowViaEventStore,
52
+ } from "./event-store-writes.ts";
35
53
  export { expectErrorIncludes } from "./expect-error.ts";
36
54
  export { describeFileProviderContract } from "./file-provider-contract.ts";
37
55
  export {
@@ -0,0 +1,24 @@
1
+ import { afterAll, mock } from "bun:test";
2
+
3
+ /**
4
+ * Mocks `specifier` with the real exports plus `overrides` and restores the real module in
5
+ * `afterAll`: bun's mock.module is process-global, so a leaked mock breaks later files of a
6
+ * shared DOM run. Pass the namespace imported before mocking; it is copied here because
7
+ * mock.module patches an already imported namespace in place. A relative specifier must be
8
+ * absolute (resolve it against the test file), since mock.module resolves it from this file.
9
+ *
10
+ * `restoreOverrides` fixes up modules that re-export a mocked binding: their copy was taken
11
+ * under the mock and holds the spy, so it must be pointed back at the real export.
12
+ */
13
+ export function mockModuleForFile(
14
+ specifier: string,
15
+ realModule: object,
16
+ overrides: Readonly<Record<string, unknown>>,
17
+ options: { readonly restoreOverrides?: Readonly<Record<string, unknown>> } = {},
18
+ ): void {
19
+ const real = { ...realModule };
20
+ mock.module(specifier, () => ({ ...real, ...overrides }));
21
+ afterAll(() => {
22
+ mock.module(specifier, () => ({ ...real, ...options.restoreOverrides }));
23
+ });
24
+ }
package/src/scaffold.ts CHANGED
@@ -181,7 +181,7 @@ function renderIntegrationTest(): string {
181
181
  "});",
182
182
  "",
183
183
  "afterAll(async () => {",
184
- " await stack.cleanup();",
184
+ " await stack?.cleanup();",
185
185
  "});",
186
186
  "",
187
187
  'describe("tasks", () => {',
package/src/stack/db.ts CHANGED
@@ -4,12 +4,15 @@
4
4
  import { DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX } from "@cosmicdrift/kumiko-framework/db";
5
5
  import {
6
6
  acquireSessionAdvisoryLock,
7
+ createApplicationRole,
7
8
  createDatabase,
8
9
  createDbConnection,
9
10
  databaseExists,
10
11
  dropDatabaseIfExists,
11
12
  listDatabaseNamesWithPrefix,
12
13
  renameDatabase,
14
+ transferDatabaseOwnership,
15
+ transferPublicSchemaOwnership,
13
16
  } from "@cosmicdrift/kumiko-framework/internal/stack-support";
14
17
  import { ensureTemporalPolyfill } from "@cosmicdrift/kumiko-framework/time";
15
18
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
@@ -27,6 +30,9 @@ function requireEnv(name: string): string {
27
30
 
28
31
  export const DEFAULT_TEST_DB_POOL_MAX = 10;
29
32
 
33
+ // Non-superuser without BYPASSRLS: the only login that makes row-level-security policies bite in tests.
34
+ export const TEST_APPLICATION_ROLE = "kumiko_app";
35
+
30
36
  export type TestDb = {
31
37
  db: DbConnection;
32
38
  poolMax: number;
@@ -44,6 +50,8 @@ export type CreateTestDbOptions = {
44
50
  readonly outsideTransactionPoolMax?: number;
45
51
  /** Clone this already-migrated database instead of starting empty. */
46
52
  readonly templateDbName?: string;
53
+ /** Own the database as the non-bypass application role and enforce row-level security on the pools. */
54
+ readonly applicationRole?: boolean;
47
55
  };
48
56
 
49
57
  // Set by the integration runner for one run; templates named after it are dropped when the run ends.
@@ -62,6 +70,67 @@ function databaseUrlOf(url: string, dbName: string): string {
62
70
  return url.replace(/\/[^/]+$/, `/${dbName}`);
63
71
  }
64
72
 
73
+ /**
74
+ * URL of database `dbName` on the host of `url`, logged in as TEST_APPLICATION_ROLE with the password
75
+ * of `url` (the caller's TEST_DATABASE_URL). Pair it with `createTestDb({ applicationRole: true })` or
76
+ * a database owned by that role, so boots with row level security on connect without BYPASSRLS.
77
+ */
78
+ // DDL takes no bind params, so the password is read from the configured URL instead of a literal.
79
+ export function applicationRoleUrlOf(url: string, dbName: string): string {
80
+ const parsed = new URL(url);
81
+ parsed.pathname = `/${dbName}`;
82
+ parsed.username = TEST_APPLICATION_ROLE;
83
+ parsed.password = new URL(url).password;
84
+ return parsed.toString();
85
+ }
86
+
87
+ async function ensureApplicationRole(admin: DbConnection, url: string): Promise<void> {
88
+ await createApplicationRole(
89
+ admin,
90
+ TEST_APPLICATION_ROLE,
91
+ decodeURIComponent(new URL(url).password),
92
+ );
93
+ }
94
+
95
+ // Admin connections below run as superuser for CREATE/DROP DATABASE and never open a tenant scope: "off" is explicit.
96
+ async function adoptPersistentDatabase(
97
+ admin: DbConnection,
98
+ url: string,
99
+ dbName: string,
100
+ ): Promise<void> {
101
+ await transferDatabaseOwnership(admin, dbName, TEST_APPLICATION_ROLE);
102
+ const owner = createDbConnection({
103
+ url: databaseUrlOf(url, dbName),
104
+ maxConnections: 1,
105
+ rowLevelSecurity: "off", // superuser ownership transfer, no tenant scope
106
+ });
107
+ try {
108
+ await transferPublicSchemaOwnership(owner.db, TEST_APPLICATION_ROLE);
109
+ } finally {
110
+ await owner.close();
111
+ }
112
+ }
113
+
114
+ type ProvisionArgs = {
115
+ readonly url: string;
116
+ readonly dbName: string;
117
+ readonly opts: CreateTestDbOptions;
118
+ readonly applicationRole: boolean;
119
+ readonly owner: string | undefined;
120
+ };
121
+
122
+ async function provisionDatabase(
123
+ admin: DbConnection,
124
+ { url, dbName, opts, applicationRole, owner }: ProvisionArgs,
125
+ ): Promise<void> {
126
+ const existing = opts.persistent === true && (await databaseExists(admin, dbName));
127
+ if (existing && applicationRole) await adoptPersistentDatabase(admin, url, dbName);
128
+ if (!existing) {
129
+ const template = opts.persistent === true ? undefined : opts.templateDbName;
130
+ await createDatabase(admin, dbName, template, owner);
131
+ }
132
+ }
133
+
65
134
  export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<TestDb> {
66
135
  await ensureTemporalPolyfill();
67
136
  const opts: CreateTestDbOptions = typeof arg === "string" ? { baseUrl: arg } : (arg ?? {});
@@ -69,24 +138,23 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
69
138
  const dbName = opts.dbName ?? `kumiko_test_${generateId().slice(-8)}`;
70
139
  const adminUrl = adminUrlOf(url);
71
140
 
72
- const admin = createDbConnection({ url: adminUrl, maxConnections: 1 });
141
+ const applicationRole = opts.applicationRole === true;
142
+ const owner = applicationRole ? TEST_APPLICATION_ROLE : undefined;
143
+ const admin = createDbConnection({ url: adminUrl, maxConnections: 1, rowLevelSecurity: "off" });
73
144
  try {
74
- if (opts.persistent) {
75
- if (!(await databaseExists(admin.db, dbName))) {
76
- await createDatabase(admin.db, dbName);
77
- }
78
- } else {
79
- await createDatabase(admin.db, dbName, opts.templateDbName);
80
- }
145
+ if (applicationRole) await ensureApplicationRole(admin.db, url);
146
+ await provisionDatabase(admin.db, { url, dbName, opts, applicationRole, owner });
81
147
  } finally {
82
148
  await admin.close();
83
149
  }
84
150
 
85
- const testUrl = databaseUrlOf(url, dbName);
151
+ const testUrl = applicationRole ? applicationRoleUrlOf(url, dbName) : databaseUrlOf(url, dbName);
152
+ const rowLevelSecurity = applicationRole ? "on" : "off";
86
153
  const poolMax = opts.poolMax ?? DEFAULT_TEST_DB_POOL_MAX;
87
- const conn = createDbConnection({ url: testUrl, maxConnections: poolMax });
154
+ const conn = createDbConnection({ url: testUrl, maxConnections: poolMax, rowLevelSecurity });
88
155
  const outsideConn = createDbConnection({
89
156
  url: testUrl,
157
+ rowLevelSecurity,
90
158
  maxConnections: opts.outsideTransactionPoolMax ?? DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX,
91
159
  });
92
160
 
@@ -99,13 +167,17 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
99
167
  db: conn.db,
100
168
  poolMax,
101
169
  outsideTransactionDb: outsideConn.db,
102
- client: conn.client,
170
+ client: conn.listenClient,
103
171
  dbName,
104
172
  cleanup: async () => {
105
173
  await conn.close();
106
174
  await outsideConn.close();
107
175
  if (!opts.persistent) {
108
- const admin2 = createDbConnection({ url: adminUrl, maxConnections: 1 });
176
+ const admin2 = createDbConnection({
177
+ url: adminUrl,
178
+ maxConnections: 1,
179
+ rowLevelSecurity: "off",
180
+ });
109
181
  try {
110
182
  await dropDatabaseIfExists(admin2.db, dbName);
111
183
  } finally {
@@ -123,10 +195,11 @@ const testDbTemplates = new Map<string, Promise<void>>();
123
195
  export function ensureTestDbTemplate(
124
196
  templateDbName: string,
125
197
  buildSchema: (db: DbConnection) => Promise<void>,
198
+ options: { readonly applicationRole?: boolean } = {},
126
199
  ): Promise<void> {
127
200
  const known = testDbTemplates.get(templateDbName);
128
201
  if (known) return known;
129
- const pending = buildTestDbTemplate(templateDbName, buildSchema);
202
+ const pending = buildTestDbTemplate(templateDbName, buildSchema, options);
130
203
  testDbTemplates.set(templateDbName, pending);
131
204
  pending.catch(() => testDbTemplates.delete(templateDbName));
132
205
  return pending;
@@ -135,10 +208,16 @@ export function ensureTestDbTemplate(
135
208
  export async function buildTestDbTemplate(
136
209
  templateDbName: string,
137
210
  buildSchema: (db: DbConnection) => Promise<void>,
211
+ options: { readonly applicationRole?: boolean } = {},
138
212
  ): Promise<void> {
213
+ const applicationRole = options.applicationRole === true;
139
214
  const url = requireEnv("TEST_DATABASE_URL");
140
215
  // One connection, so the session that takes the advisory lock is the one closed below.
141
- const admin = createDbConnection({ url: adminUrlOf(url), maxConnections: 1 });
216
+ const admin = createDbConnection({
217
+ url: adminUrlOf(url),
218
+ maxConnections: 1,
219
+ rowLevelSecurity: "off",
220
+ });
142
221
  try {
143
222
  await acquireSessionAdvisoryLock(admin.db, templateDbName);
144
223
  // skip: another worker of this run already built it while we waited on the lock
@@ -146,8 +225,18 @@ export async function buildTestDbTemplate(
146
225
  // Built under a side name and renamed once complete, so a clone never sees a half-built schema.
147
226
  const buildDbName = `${templateDbName}_build`;
148
227
  await dropDatabaseIfExists(admin.db, buildDbName);
149
- await createDatabase(admin.db, buildDbName);
150
- const conn = createDbConnection({ url: databaseUrlOf(url, buildDbName), maxConnections: 1 });
228
+ if (applicationRole) await ensureApplicationRole(admin.db, url);
229
+ await createDatabase(
230
+ admin.db,
231
+ buildDbName,
232
+ undefined,
233
+ applicationRole ? TEST_APPLICATION_ROLE : undefined,
234
+ );
235
+ const conn = createDbConnection(
236
+ applicationRole
237
+ ? { url: applicationRoleUrlOf(url, buildDbName), maxConnections: 1, rowLevelSecurity: "on" }
238
+ : { url: databaseUrlOf(url, buildDbName), maxConnections: 1, rowLevelSecurity: "off" },
239
+ );
151
240
  try {
152
241
  await buildSchema(conn.db);
153
242
  } finally {
@@ -160,7 +249,11 @@ export async function buildTestDbTemplate(
160
249
  }
161
250
 
162
251
  export async function dropTestDbTemplates(baseUrl: string, runId: string): Promise<void> {
163
- const admin = createDbConnection({ url: adminUrlOf(baseUrl), maxConnections: 1 });
252
+ const admin = createDbConnection({
253
+ url: adminUrlOf(baseUrl),
254
+ maxConnections: 1,
255
+ rowLevelSecurity: "off",
256
+ });
164
257
  try {
165
258
  for (const name of await listDatabaseNamesWithPrefix(admin.db, testDbTemplatePrefix(runId))) {
166
259
  await dropDatabaseIfExists(admin.db, name);
@@ -15,9 +15,11 @@ export {
15
15
  unsafePushTables,
16
16
  } from "@cosmicdrift/kumiko-framework/internal/stack-support";
17
17
  export {
18
+ applicationRoleUrlOf,
18
19
  type CreateTestDbOptions,
19
20
  createTestDb,
20
21
  DEFAULT_TEST_DB_POOL_MAX,
22
+ TEST_APPLICATION_ROLE,
21
23
  type TestDb,
22
24
  } from "./db.ts";
23
25
  export { drainEventConsumers } from "./drain-event-consumers.ts";
@@ -11,7 +11,12 @@ import {
11
11
  unsafePushTables,
12
12
  } from "@cosmicdrift/kumiko-framework/internal/stack-support";
13
13
 
14
- type DbHolder = { readonly db: DbConnection };
14
+ // DDL must not run through the system-scoped `db` of a stack: each scoped statement is its own transaction, which CREATE INDEX CONCURRENTLY rejects.
15
+ type DbHolder = { readonly db: DbConnection; readonly unscopedDb?: DbConnection };
16
+
17
+ function ddlDbOf(stack: DbHolder): DbConnection {
18
+ return stack.unscopedDb ?? stack.db;
19
+ }
15
20
 
16
21
  // biome-ignore lint/suspicious/noConsole: stack-internal status logging
17
22
  const logInfo = (msg: string): void => console.log(msg);
@@ -45,7 +50,7 @@ export async function pushEntityProjectionTables(
45
50
  if (seen.has(proj.table)) continue;
46
51
  seen.add(proj.table);
47
52
  const physical = schemaTableName(proj.table) ?? tableToMeta(proj.table).tableName;
48
- if (!(await tableExists(stack.db, `public.${physical}`))) {
53
+ if (!(await tableExists(ddlDbOf(stack), `public.${physical}`))) {
49
54
  missing[projName] = proj.table;
50
55
  continue;
51
56
  }
@@ -53,7 +58,7 @@ export async function pushEntityProjectionTables(
53
58
  }
54
59
 
55
60
  if (Object.keys(missing).length > 0) {
56
- await unsafePushTables(stack.db, missing);
61
+ await unsafePushTables(ddlDbOf(stack), missing);
57
62
  }
58
63
  }
59
64
 
@@ -70,11 +75,11 @@ async function syncExistingProjectionTable(
70
75
  table: unknown,
71
76
  ): Promise<void> {
72
77
  const meta = tableToMeta(table);
73
- const liveColumns = await columnNamesOf(stack.db, physical);
78
+ const liveColumns = await columnNamesOf(ddlDbOf(stack), physical);
74
79
  const missingColumns = meta.columns.filter((c) => !liveColumns.has(c.name));
75
80
  if (missingColumns.length === 0) {
76
81
  logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
77
- await addMissingIndexes(stack.db, physical, meta.indexes);
82
+ await addMissingIndexes(ddlDbOf(stack), physical, meta.indexes);
78
83
  // skip: table is already in sync, only indexes needed backfilling
79
84
  return;
80
85
  }
@@ -97,6 +102,6 @@ async function syncExistingProjectionTable(
97
102
  `[kumiko-stack] table ${physical} is missing column(s) ` +
98
103
  `${missingColumns.map((c) => `"${c.name}"`).join(", ")} — adding`,
99
104
  );
100
- await addMissingColumns(stack.db, physical, missingColumns);
101
- await addMissingIndexes(stack.db, physical, meta.indexes);
105
+ await addMissingColumns(ddlDbOf(stack), physical, missingColumns);
106
+ await addMissingIndexes(ddlDbOf(stack), physical, meta.indexes);
102
107
  }