@cosmicdrift/kumiko-testing 0.389.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.
package/README.md CHANGED
@@ -47,8 +47,11 @@ test("member sees the note", async ({ seedTenant, page }) => {
47
47
  });
48
48
  ```
49
49
 
50
- - `defineAppE2eConfig({ port, env?, locale?, projects?, serverEntry?, testDir?, testMatch? })`: parallelism, retries, timeouts and
50
+ - `defineAppE2eConfig({ port, env?, locale?, projects?, serverEntry?, testDir?, testMatch?, isolatedSpecs? })`: parallelism, retries, timeouts and
51
51
  workers (`KUMIKO_E2E_WORKERS` overrides) are fixed by the template; projects cannot set them.
52
+ - `isolatedSpecs: ["**/composer.spec.ts"]` moves specs that change app-wide state (catalog entries, global
53
+ settings) into the `chromium-isolated` project, which runs after all parallel projects on one worker. It cannot
54
+ be combined with `--shard`; details in `docs/guides/testing-standard.md`.
52
55
  - `KUMIKO_E2E_REMOTE_BASE_URL=https://staging.example.com` runs the same specs against an already running
53
56
  instance: no web server is started and `baseURL` is that URL (http or https only). Add
54
57
  `KUMIKO_E2E_STORAGE_STATE=<file>` for a session recorded with `playwright codegen --save-storage`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.389.0",
3
+ "version": "0.390.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": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -103,13 +103,13 @@
103
103
  "kumiko-testing": "./bin/kumiko-testing.ts"
104
104
  },
105
105
  "dependencies": {
106
- "@cosmicdrift/kumiko-framework": "0.389.0",
107
- "@cosmicdrift/kumiko-guards": "0.389.0",
108
- "@cosmicdrift/kumiko-types": "0.389.0",
106
+ "@cosmicdrift/kumiko-framework": "0.390.0",
107
+ "@cosmicdrift/kumiko-guards": "0.390.0",
108
+ "@cosmicdrift/kumiko-types": "0.390.0",
109
109
  "zod": "^4.4.3"
110
110
  },
111
111
  "peerDependencies": {
112
- "@happy-dom/global-registrator": "^20.11.2",
112
+ "@happy-dom/global-registrator": "^21.0.4",
113
113
  "@playwright/test": "^1.62.1",
114
114
  "@testing-library/dom": "^10.4.1",
115
115
  "@testing-library/react": "^16.3.2"
@@ -129,9 +129,9 @@
129
129
  }
130
130
  },
131
131
  "devDependencies": {
132
- "@cosmicdrift/kumiko-bundled-features": "0.389.0",
133
- "@cosmicdrift/kumiko-dev-server": "0.389.0",
134
- "@happy-dom/global-registrator": "^20.11.2",
132
+ "@cosmicdrift/kumiko-bundled-features": "0.390.0",
133
+ "@cosmicdrift/kumiko-dev-server": "0.390.0",
134
+ "@happy-dom/global-registrator": "^21.0.4",
135
135
  "@playwright/test": "^1.62.1",
136
136
  "@testing-library/dom": "^10.4.1",
137
137
  "@testing-library/react": "^16.3.2",
package/src/changes.json CHANGED
@@ -1,4 +1,67 @@
1
1
  [
2
+ {
3
+ "version": "0.390.0",
4
+ "type": "breaking",
5
+ "title": "happy-dom and undici updated for security advisories",
6
+ "detail": "The happy-dom range for the global registrator and the DOM test setup moves to ^21.0.4 (fixes GHSA-834V-8H66-3H79, incomplete fix for an unsanitized import alias leading to host code execution), undici is pinned to 7.30.1 (fixes GHSA-XJJ6-F33Q-929C, denial of service through an unhandled error on excessive content-encodings).",
7
+ "migration": "Bump @happy-dom/global-registrator (and happy-dom, if listed) to ^21.0.4 in the app's devDependencies; the kumiko-testing peer range is now ^21.0.4. happy-dom 21 is a major release: run the app's DOM test suite after the bump."
8
+ },
9
+ {
10
+ "version": "0.390.0",
11
+ "type": "improvement",
12
+ "title": "Run e2e specs that change global app state in an isolated project",
13
+ "detail": "defineAppE2eConfig takes an isolatedSpecs option with the same pattern form as testMatch. Matching specs are removed from chromium and from every app project and run in a separate chromium-isolated project with one worker, only after all other projects have finished. Apps use it for specs that change state shared across the app, such as creating catalog channels. With isolatedSpecs set, every app project needs a name, and a run with --shard throws because Playwright does not shard dependency projects. Without the option the generated config is unchanged."
14
+ },
15
+ {
16
+ "version": "0.390.0",
17
+ "type": "improvement",
18
+ "title": "The e2e suites run in two parallel CI shards",
19
+ "detail": "The framework CI splits the Playwright e2e suites across two runners instead of running them one after another on a single runner. The required e2e check passes only when both shards succeed. Nothing changes for apps; the package is only republished."
20
+ },
21
+ {
22
+ "version": "0.390.0",
23
+ "type": "improvement",
24
+ "title": "The puppeteer render tests, including the egress guard, run in CI",
25
+ "detail": "The integration job installs Chrome through the trusted puppeteer postinstall and caches it between runs, so the real render tests no longer skip in CI. Nothing changes for apps; the package is only republished."
26
+ },
27
+ {
28
+ "version": "0.390.0",
29
+ "type": "breaking",
30
+ "title": "extraContext factories get the raw db in the test stack and dev",
31
+ "detail": "The test stack and dev now pass the enforcing pool to extraContext factories, as runProdApp does, so request-time services keep the ambient request scope and a tenant leak shows up in tests instead of only in production.",
32
+ "migration": "Services built in extraContext that read across tenants at request time, or that a test calls directly, must scope themselves: createTenantDb({ db, tenantId, mode: \"system\", reason: \"<why>\" }). Test code reads through stack.db."
33
+ },
34
+ {
35
+ "version": "0.390.0",
36
+ "type": "improvement",
37
+ "title": "Export applicationRoleUrlOf from kumiko-testing/stack",
38
+ "detail": "Use it to boot runProdApp in tests against the app role. It returns the URL of a database as the non-bypass application role with the password of TEST_DATABASE_URL; pair it with createTestDb({ applicationRole: true })."
39
+ },
40
+ {
41
+ "version": "0.390.0",
42
+ "type": "improvement",
43
+ "title": "The test application role is realigned to the configured password",
44
+ "detail": "createApplicationRole used to return early when the role existed, so a kumiko_app role with another password made every integration test fail with 28P01. It now runs ALTER ROLE with the configured password and the NOSUPERUSER NOBYPASSRLS attributes."
45
+ },
46
+ {
47
+ "version": "0.390.0",
48
+ "type": "breaking",
49
+ "title": "The test stack runs with row level security on as role kumiko_app",
50
+ "detail": "setupTestStack creates the role kumiko_app (LOGIN NOSUPERUSER NOBYPASSRLS, password taken from TEST_DATABASE_URL) once, makes it owner of the test database and opens every pool as that role with rowLevelSecurity \"on\"; template databases are built the same way. stack.db runs in system scope and sees every tenant's rows as before. The new stack.unscopedDb is the enforced pool the services use, on which a statement without a scope sees no tenant rows. rowLevelSecurity: \"off\" in the stack options restores the superuser stack without policies. createTestDb takes applicationRole: true for the same setup.",
51
+ "migration": "App code that reads or writes tenant tables through a raw connection outside a request, a job or a TenantDb now sees no rows in integration tests. Test code that calls such a path directly runs it inside runInSystemRowLevelSecurityScope or runInRowLevelSecurityScope(tenantRowLevelSecurityScope(tenantId), ...) from @cosmicdrift/kumiko-testing/helpers. Production code works through a TenantDb for the tenant, a job, a system-scoped handler or a granted unsafeRaw. Tests that need superuser rights on stack.db, such as CREATE EXTENSION, CREATE ROLE or event triggers, open their own connection from TEST_DATABASE_URL or use createTestDb(). Manual migration, there is no codemod."
52
+ },
53
+ {
54
+ "version": "0.390.0",
55
+ "type": "improvement",
56
+ "title": "Test helpers for event-store writes",
57
+ "detail": "The testing helpers gain updateRowViaEventStore, deleteRowViaEventStore and deleteRowsViaEventStore. They update or delete rows of an event-sourced table through the event-store executor, so the event log and the projection stay in step, instead of writing the projection table directly."
58
+ },
59
+ {
60
+ "version": "0.390.0",
61
+ "type": "improvement",
62
+ "title": "Seed event-sourced rows through the event store",
63
+ "detail": "The testing helpers gain seedRowViaEventStore(db, table, values). It takes the same arguments as seedRow, appends a created event through the event-store executor and projects the row, so a rebuild keeps the seeded row. The row's tenantId selects the acting tenant; global entities such as user are seeded without a tenantId, because the executor rejects one on them. It throws for tables that are not event-sourced entity tables, for a non-string id and when the executor rejects the row. To support this, buildEntityTable and r.entity now remember which entity a managed table belongs to; nothing changes for apps."
64
+ },
2
65
  {
3
66
  "version": "0.389.0",
4
67
  "type": "breaking",
@@ -77,6 +77,8 @@ const TEMPLATE_OWNED_PROJECT_KEYS = [
77
77
  ] as const;
78
78
  const TEMPLATE_OWNED_USE_KEYS = ["actionTimeout", "navigationTimeout"] as const;
79
79
  const REAL_SPEC_GLOB = "**/*.real.spec.ts";
80
+ export const ISOLATED_E2E_PROJECT_NAME = "chromium-isolated";
81
+ export const NON_REAL_SPEC_PATTERN = /(?<!\.real)\.spec\.ts$/;
80
82
  const RESERVED_ENV_KEYS = [
81
83
  "PORT",
82
84
  SEED_ENABLE_ENV,
@@ -111,6 +113,7 @@ export type AppE2eConfigInput = {
111
113
  readonly projects?: readonly E2eProject[];
112
114
  readonly testDir?: string;
113
115
  readonly testMatch?: string | RegExp | readonly (string | RegExp)[];
116
+ readonly isolatedSpecs?: string | RegExp | readonly (string | RegExp)[];
114
117
  readonly permissions?: readonly string[];
115
118
  readonly launchOptions?: LaunchOptions;
116
119
  } & ServerStart;
@@ -140,6 +143,13 @@ function mutableTestMatch(
140
143
  return typeof match === "string" || match instanceof RegExp ? match : [...match];
141
144
  }
142
145
 
146
+ function patternList(
147
+ patterns: NonNullable<AppE2eConfigInput["testMatch"]> | undefined,
148
+ ): (string | RegExp)[] {
149
+ if (patterns === undefined) return [];
150
+ return typeof patterns === "string" || patterns instanceof RegExp ? [patterns] : [...patterns];
151
+ }
152
+
143
153
  export function resolveE2eWorkers(
144
154
  env: Readonly<Record<string, string | undefined>> = process.env,
145
155
  // cpus() reports every host core inside a CPU-limited container (1.5 CPU CI
@@ -233,6 +243,58 @@ function failureRecording(
233
243
  return { trace: "off", screenshot: "off", video: "off" };
234
244
  }
235
245
 
246
+ // Worker processes do not carry --shard in argv, so only the runner trips this.
247
+ function isShardedRun(argv: readonly string[] = process.argv): boolean {
248
+ return argv.some((arg) => arg === "--shard" || arg.startsWith("--shard="));
249
+ }
250
+
251
+ type ProjectsInput = {
252
+ readonly chromiumUse: Project["use"];
253
+ readonly appProjects: readonly E2eProject[];
254
+ readonly isolated: readonly (string | RegExp)[];
255
+ readonly baseIgnore: readonly string[];
256
+ readonly realRun: boolean;
257
+ };
258
+
259
+ function assertIsolatedSpecsUsable(appProjects: readonly E2eProject[]): void {
260
+ if (appProjects.some((project) => project.name === undefined)) {
261
+ throw new Error(
262
+ "defineAppE2eConfig: isolatedSpecs needs named projects, because the isolated project waits for them via dependencies",
263
+ );
264
+ }
265
+ if (isShardedRun()) {
266
+ throw new Error(
267
+ "defineAppE2eConfig: isolatedSpecs cannot be combined with --shard; Playwright does not shard dependency projects, so one shard would run every spec.",
268
+ );
269
+ }
270
+ }
271
+
272
+ function buildE2eProjects(input: ProjectsInput): Project[] {
273
+ const { chromiumUse, appProjects, isolated, baseIgnore, realRun } = input;
274
+ const baseProjects: Project[] = [{ name: "chromium", use: chromiumUse }, ...appProjects];
275
+ if (isolated.length === 0) return baseProjects;
276
+ assertIsolatedSpecsUsable(appProjects);
277
+ // A project-level testIgnore replaces the top-level one instead of merging with it.
278
+ const parallelProjects = baseProjects.map((project) => ({
279
+ ...project,
280
+ testIgnore: [...baseIgnore, ...patternList(project.testIgnore), ...isolated],
281
+ }));
282
+ return [
283
+ ...parallelProjects,
284
+ {
285
+ name: ISOLATED_E2E_PROJECT_NAME,
286
+ testMatch: [...isolated],
287
+ testIgnore: [...baseIgnore, ...(realRun ? [NON_REAL_SPEC_PATTERN] : [])],
288
+ dependencies: parallelProjects.flatMap((project) =>
289
+ project.name === undefined ? [] : [project.name],
290
+ ),
291
+ fullyParallel: false,
292
+ workers: 1,
293
+ use: chromiumUse,
294
+ },
295
+ ];
296
+ }
297
+
236
298
  export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConfig {
237
299
  const {
238
300
  port,
@@ -241,6 +303,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
241
303
  projects = [],
242
304
  testDir = "./e2e",
243
305
  testMatch,
306
+ isolatedSpecs,
244
307
  permissions,
245
308
  launchOptions,
246
309
  } = input;
@@ -257,13 +320,27 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
257
320
  const remote = remoteTargetFromEnv(process.env);
258
321
  const baseURL = remote?.baseURL ?? `http://localhost:${port}`;
259
322
  const realRun = isRealProviderRun();
323
+ const baseIgnore = [...screenshotSpecsIgnore(), ...(realRun ? [] : [REAL_SPEC_GLOB])];
324
+ const isolated = patternList(isolatedSpecs);
325
+ const chromiumUse = {
326
+ ...devices["Desktop Chrome"],
327
+ viewport: DESKTOP_VIEWPORT,
328
+ deviceScaleFactor: isScreenshotRun() ? SCREENSHOT_DEVICE_SCALE_FACTOR : 1,
329
+ };
330
+ const e2eProjects = buildE2eProjects({
331
+ chromiumUse,
332
+ appProjects: projects,
333
+ isolated,
334
+ baseIgnore,
335
+ realRun,
336
+ });
260
337
 
261
338
  return defineConfig({
262
339
  testDir,
263
340
  ...(realRun
264
341
  ? { testMatch: REAL_SPEC_GLOB }
265
342
  : testMatch !== undefined && { testMatch: mutableTestMatch(testMatch) }),
266
- testIgnore: [...screenshotSpecsIgnore(), ...(realRun ? [] : [REAL_SPEC_GLOB])],
343
+ testIgnore: baseIgnore,
267
344
  fullyParallel: true,
268
345
  forbidOnly: !!process.env["CI"],
269
346
  retries: 0,
@@ -281,17 +358,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
281
358
  ...(permissions !== undefined && { permissions: [...permissions] }),
282
359
  ...(launchOptions !== undefined && { launchOptions }),
283
360
  },
284
- projects: [
285
- {
286
- name: "chromium",
287
- use: {
288
- ...devices["Desktop Chrome"],
289
- viewport: DESKTOP_VIEWPORT,
290
- deviceScaleFactor: isScreenshotRun() ? SCREENSHOT_DEVICE_SCALE_FACTOR : 1,
291
- },
292
- },
293
- ...projects,
294
- ],
361
+ projects: e2eProjects,
295
362
  ...(remote === undefined && {
296
363
  webServer: {
297
364
  command: serverCommand,
package/src/e2e/index.ts CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  type AppE2eConfigInput,
41
41
  defineAppE2eConfig,
42
42
  type E2eProject,
43
+ ISOLATED_E2E_PROJECT_NAME,
43
44
  resolveE2eWorkers,
44
45
  } from "./define-app-e2e-config";
45
46
  export { type MailCaptureOptions, mailCapture } from "./mail-capture";
@@ -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
@@ -36,6 +42,14 @@ export {
36
42
  generateE2ESpec,
37
43
  generateZodFixture,
38
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";
39
53
  export { expectErrorIncludes } from "./expect-error.ts";
40
54
  export { describeFileProviderContract } from "./file-provider-contract.ts";
41
55
  export {
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
 
@@ -105,7 +173,11 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
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
  }
@@ -1,13 +1,24 @@
1
1
  // @runtime dev
2
2
  import { createHash } from "node:crypto";
3
3
  import { buildServer, createSseBroker } from "@cosmicdrift/kumiko-framework/api";
4
- import type { DbConnection, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ assertRowLevelSecurityBootState,
6
+ type DbConnection,
7
+ type EntityTableMeta,
8
+ knownTablesOf,
9
+ type RowLevelSecurityMode,
10
+ } from "@cosmicdrift/kumiko-framework/db";
5
11
  import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
6
12
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine/types";
7
13
  import {
8
14
  createArchivedStreamsTable,
9
15
  createEventsTable,
10
16
  } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ appHookScopedRunner,
19
+ DEFAULT_ROW_LEVEL_SECURITY_MODE,
20
+ systemScopedRunner,
21
+ } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
11
22
  import {
12
23
  createRateLimitResolver,
13
24
  unsafePushTables,
@@ -35,6 +46,7 @@ import type { Clock } from "@cosmicdrift/kumiko-types/tz-context";
35
46
  import {
36
47
  createTestDb,
37
48
  ensureTestDbTemplate,
49
+ TEST_APPLICATION_ROLE,
38
50
  TEST_DB_TEMPLATE_RUN_ENV,
39
51
  testDbTemplatePrefix,
40
52
  } from "./db.ts";
@@ -95,7 +107,7 @@ async function pushStackSchema(
95
107
  // setupTestStack call is ready for writes without needing a manual
96
108
  // createEventsTable().
97
109
  await createEventsTable(db);
98
- // Archive-stream metadata — needed by ctx.appendEvent's archive guard and
110
+ // Archive-stream metadata — needed by ctx.events.append's archive guard and
99
111
  // loadAggregate's default-skip. Idempotent, so production boot running
100
112
  // the same call is fine.
101
113
  await createArchivedStreamsTable(db);
@@ -135,6 +147,7 @@ async function resolveTestDbTemplateName(
135
147
  options: TestStackOptions,
136
148
  metas: readonly EntityTableMeta[],
137
149
  withFileTables: boolean,
150
+ rowLevelSecurity: RowLevelSecurityMode,
138
151
  ): Promise<string | undefined> {
139
152
  const runId = process.env[TEST_DB_TEMPLATE_RUN_ENV];
140
153
  if (runId === undefined || options.persistentDb === true || options.dbName !== undefined) {
@@ -142,7 +155,14 @@ async function resolveTestDbTemplateName(
142
155
  }
143
156
  const { renderTablesDdl } = await import("@cosmicdrift/kumiko-framework/db");
144
157
  const fingerprint = createHash("sha256")
145
- .update(JSON.stringify({ withFileTables, ddl: renderTablesDdl(metas) }))
158
+ .update(
159
+ JSON.stringify({
160
+ withFileTables,
161
+ rowLevelSecurity,
162
+ role: rowLevelSecurity === "on" ? TEST_APPLICATION_ROLE : "superuser",
163
+ ddl: renderTablesDdl(metas, { rowLevelSecurity }),
164
+ }),
165
+ )
146
166
  .digest("hex")
147
167
  .slice(0, 16);
148
168
  return `${testDbTemplatePrefix(runId)}${fingerprint}`;
@@ -193,8 +213,17 @@ export async function provisionStackStorage(options: TestStackOptions): Promise<
193
213
  const withFileTables = options.files !== undefined;
194
214
  const buildStackSchema = (db: DbConnection): Promise<void> =>
195
215
  pushStackSchema(db, stackTableMetas, withFileTables);
196
- const templateDbName = await resolveTestDbTemplateName(options, stackTableMetas, withFileTables);
197
- if (templateDbName !== undefined) await ensureTestDbTemplate(templateDbName, buildStackSchema);
216
+ const rowLevelSecurity = options.rowLevelSecurity ?? "on";
217
+ const applicationRole = rowLevelSecurity === "on";
218
+ const templateDbName = await resolveTestDbTemplateName(
219
+ options,
220
+ stackTableMetas,
221
+ withFileTables,
222
+ rowLevelSecurity,
223
+ );
224
+ if (templateDbName !== undefined) {
225
+ await ensureTestDbTemplate(templateDbName, buildStackSchema, { applicationRole });
226
+ }
198
227
 
199
228
  // Forward db-name/persistent-flag through to createTestDb. The
200
229
  // defaults (undefined dbName, persistent:false) keep the legacy
@@ -209,6 +238,7 @@ export async function provisionStackStorage(options: TestStackOptions): Promise<
209
238
  outsideTransactionPoolMax: options.outsideTransactionPoolMax,
210
239
  }),
211
240
  ...(templateDbName !== undefined && { templateDbName }),
241
+ applicationRole,
212
242
  }),
213
243
  createTestRedis(
214
244
  options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix },
@@ -219,13 +249,16 @@ export async function provisionStackStorage(options: TestStackOptions): Promise<
219
249
  // DB/Redis — the caller never gets the stack object back to call
220
250
  // cleanup() itself (same "leaked ephemeral DB" failure class as the later
221
251
  // try/catch in setupTestStack, but for setup that runs before that try starts).
222
- if (templateDbName === undefined) {
223
- try {
224
- await buildStackSchema(testDb.db);
225
- } catch (error) {
226
- await runCleanupsIgnoringErrors([() => testDb.cleanup(), () => testRedis.cleanup()]);
227
- throw error;
228
- }
252
+ try {
253
+ if (templateDbName === undefined) await buildStackSchema(testDb.db);
254
+ await assertRowLevelSecurityBootState(
255
+ testDb.db,
256
+ rowLevelSecurity,
257
+ knownTablesOf(options.features),
258
+ );
259
+ } catch (error) {
260
+ await runCleanupsIgnoringErrors([() => testDb.cleanup(), () => testRedis.cleanup()]);
261
+ throw error;
229
262
  }
230
263
  return { testDb, testRedis };
231
264
  }
@@ -308,11 +341,17 @@ export async function buildStackServices(
308
341
  // it there either (only dispatcherOptions.effectiveFeatures, consumed by
309
342
  // the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
310
343
  // index.ts). Putting it here would reintroduce the exact test-vs-prod
311
- // drift fixed: a job/handler reading `ctx.effectiveFeatures` would pass in
344
+ // drift fixed: a job/handler reading `ctx.services.effectiveFeatures` would pass in
312
345
  // tests and break in prod.
313
346
  const extraContext =
314
347
  typeof options.extraContext === "function"
315
- ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
348
+ ? options.extraContext({
349
+ registry,
350
+ // The raw pool, as in prod: request-time services keep the ambient request scope, so a tenant leak shows in tests.
351
+ db: testDb.db,
352
+ sseBroker,
353
+ redis: testRedis.redis,
354
+ })
316
355
  : options.extraContext;
317
356
  const appContext = {
318
357
  db: testDb.db,
@@ -334,7 +373,7 @@ export type StackServices = Awaited<ReturnType<typeof buildStackServices>>;
334
373
  type JobFailureTracker = ReturnType<typeof createJobFailureTracker>;
335
374
 
336
375
  // Built before buildServer() so it can be merged into dispatcherOptions
337
- // (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
376
+ // (write handlers' `ctx.services.jobRunner.dispatch(...)` and the afterCommit
338
377
  // event-trigger hook both need it present at dispatcher-construction
339
378
  // time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
340
379
  // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
@@ -430,7 +469,7 @@ async function configureStackSearch(
430
469
 
431
470
  // buildServer only auto-wires an L3 RateLimitResolver from
432
471
  // `context.redis` — a degradation test that nulls `redis` via
433
- // extraContext (to exercise a handler's own ctx.redis-down guard) also
472
+ // extraContext (to exercise a handler's own ctx.services.redis-down guard) also
434
473
  // silently strips rate-limiting, even though every handler with a
435
474
  // `rateLimit` declaration still requires a resolver at dispatch time.
436
475
  // Since e7bf543ad, an IP-keyed bucket key is built unconditionally
@@ -457,6 +496,12 @@ function resolveStackRateLimitFallback(
457
496
  });
458
497
  }
459
498
 
499
+ function testCodeDb<T extends object>(options: TestStackOptions, db: T): T {
500
+ return (options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE) === "on"
501
+ ? systemScopedRunner(db, "test stack seeds and reads rows across tenants")
502
+ : db;
503
+ }
504
+
460
505
  async function resolveStackAnonymousAccess(
461
506
  options: TestStackOptions,
462
507
  testDb: StackStorage["testDb"],
@@ -464,12 +509,16 @@ async function resolveStackAnonymousAccess(
464
509
  services: StackServices,
465
510
  ): Promise<Pick<ServerBuildOptions, "anonymousAccess">> {
466
511
  const { registry, sseBroker } = services;
512
+ const hookDb = appHookScopedRunner(
513
+ testDb.db,
514
+ options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE,
515
+ );
467
516
  const baseAnon =
468
517
  typeof options.anonymousAccess === "function"
469
- ? options.anonymousAccess({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
518
+ ? options.anonymousAccess({ registry, db: hookDb, sseBroker, redis: testRedis.redis })
470
519
  : options.anonymousAccess;
471
520
  const resolvedAnon = options.enrichAnonymousAccess
472
- ? await options.enrichAnonymousAccess(baseAnon, { registry, db: testDb.db })
521
+ ? await options.enrichAnonymousAccess(baseAnon, { registry, db: hookDb })
473
522
  : baseAnon;
474
523
  return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
475
524
  }
@@ -578,7 +627,9 @@ function assembleTestStack(
578
627
  app: server.app,
579
628
  jwt: server.jwt,
580
629
  registry: services.registry,
581
- db: testDb.db,
630
+ // Test code sees every tenant's rows; the services keep the enforced pool.
631
+ db: testCodeDb(options, testDb.db),
632
+ unscopedDb: testDb.db,
582
633
  dbPoolMax: testDb.poolMax,
583
634
  redis: testRedis,
584
635
  cacheSync,
@@ -37,7 +37,10 @@ export type TestStack = {
37
37
  app: Hono;
38
38
  jwt: JwtHelper;
39
39
  registry: Registry;
40
+ /** Sees every tenant's rows (system scope) at rowLevelSecurity "on". */
40
41
  db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
42
+ /** The enforced pool the services use: a statement without a scope sees no tenant row. Same as `db` at "off". */
43
+ unscopedDb: import("@cosmicdrift/kumiko-framework/db").DbConnection;
41
44
  /** Resolved connection pool size of `db`. */
42
45
  readonly dbPoolMax: number;
43
46
  redis: TestRedis;
@@ -61,7 +64,7 @@ export type TestStack = {
61
64
  // wires itself (_fileProviderResolver). A dev-server that starts its own
62
65
  // lane job-runners beside this stack must hand them THIS, not a
63
66
  // `{ db, registry }` literal — that's the drift, and it makes an
64
- // event-triggered job reaching for ctx.files die where the request path works.
67
+ // event-triggered job reaching for ctx.services.files die where the request path works.
65
68
  context: AppContext;
66
69
  // Present whenever a system consumer (SSE, Search) or
67
70
  // r.multiStreamProjection is wired. Tests drain it via runOnce() (or the
@@ -73,7 +76,7 @@ export type TestStack = {
73
76
  lifecycle?: Lifecycle;
74
77
  // Only set when `options.jobs` is truthy AND ≥1 `r.job(...)` is
75
78
  // registered in the mounted features. Lets integration tests call
76
- // `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.jobRunner`.
79
+ // `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.services.jobRunner`.
77
80
  jobRunner?: JobRunner;
78
81
  // Waits for every in-flight job and its follow-up event-consumer /
79
82
  // job-trigger cascade to settle — instead of `waitFor` polling on a
@@ -116,7 +119,7 @@ export type TestStackOptions = {
116
119
  /** Wire up auth routes (login, tenant-switch). Leave undefined to skip. */
117
120
  authConfig?: AuthRoutesConfig;
118
121
  /** Register a file storage provider so uploads via POST /api/files work and
119
- * `ctx.files.ref(key)` is available to hooks/MSPs. Omit to skip — tests
122
+ * `ctx.services.files.ref(key)` is available to hooks/MSPs. Omit to skip — tests
120
123
  * without file handling don't need it. */
121
124
  files?: { storageProvider: import("@cosmicdrift/kumiko-framework/files").FileStorageProvider };
122
125
  /** Observability provider — omit for NoopProvider (no spans/metrics).
@@ -160,7 +163,7 @@ export type TestStackOptions = {
160
163
  persistentDb?: boolean;
161
164
  /** Cap on the main app pool (default: driver default). Lets tests saturate it deliberately. */
162
165
  dbPoolMax?: number;
163
- /** Size of the dedicated pool behind ctx.dbOutsideTransaction / ctx.outsideTransaction. */
166
+ /** Size of the dedicated pool behind ctx.db.outsideTransaction / ctx.outsideTransaction. */
164
167
  outsideTransactionPoolMax?: number;
165
168
  /** Forwarded to buildServer — when set, requests without a JWT pass
166
169
  * through as anonymous instead of 401. See AnonymousAccessConfig.
@@ -187,7 +190,7 @@ export type TestStackOptions = {
187
190
  ) => Promise<
188
191
  import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"] | undefined
189
192
  >;
190
- /** Opt-in JobRunner wired into ctx.jobRunner and merged into
193
+ /** Opt-in JobRunner wired into ctx.services.jobRunner and merged into
191
194
  * dispatcherOptions so event-triggered jobs enqueue on commit — mirrors
192
195
  * the prod entrypoint's `buildJobRunnerWithHook`. Unlike prod (which
193
196
  * always builds one), this only builds a runner when `registry.getAllJobs()`
@@ -254,6 +257,8 @@ export type TestStackOptions = {
254
257
  * entities across features included). Use it with the app's complete feature
255
258
  * composition; the default only checks what is safe on a feature subset. */
256
259
  validateBoot?: "full";
260
+ /** Postgres tenant isolation, as in prod. "on" (default) runs the stack as the non-bypass role kumiko_app against policies; "off" uses the superuser without policies. */
261
+ rowLevelSecurity?: import("@cosmicdrift/kumiko-framework/db").RowLevelSecurityMode;
257
262
  };
258
263
 
259
264
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";