@cosmicdrift/kumiko-testing 0.379.0 → 0.381.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.379.0",
3
+ "version": "0.381.0",
4
4
  "description": "Test template for Kumiko apps: per-flow seedTenant, app test stack, canonical test preloads, bunfig generator and integration runner.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -99,9 +99,9 @@
99
99
  "kumiko-testing": "./bin/kumiko-testing.ts"
100
100
  },
101
101
  "dependencies": {
102
- "@cosmicdrift/kumiko-framework": "0.379.0",
103
- "@cosmicdrift/kumiko-guards": "0.379.0",
104
- "@cosmicdrift/kumiko-types": "0.379.0",
102
+ "@cosmicdrift/kumiko-framework": "0.381.0",
103
+ "@cosmicdrift/kumiko-guards": "0.381.0",
104
+ "@cosmicdrift/kumiko-types": "0.381.0",
105
105
  "zod": "^4.4.3"
106
106
  },
107
107
  "peerDependencies": {
@@ -125,8 +125,8 @@
125
125
  }
126
126
  },
127
127
  "devDependencies": {
128
- "@cosmicdrift/kumiko-bundled-features": "0.379.0",
129
- "@cosmicdrift/kumiko-dev-server": "0.379.0",
128
+ "@cosmicdrift/kumiko-bundled-features": "0.381.0",
129
+ "@cosmicdrift/kumiko-dev-server": "0.381.0",
130
130
  "@happy-dom/global-registrator": "^20.11.2",
131
131
  "@playwright/test": "^1.62.1",
132
132
  "@testing-library/dom": "^10.4.1",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.381.0",
4
+ "type": "improvement",
5
+ "title": "Deterministic concurrency test helpers",
6
+ "detail": "createBarrier proves handler calls overlapped, burst fires N requests and tallies statuses with a guard against the pool size, TestStack exposes dbPoolMax."
7
+ },
2
8
  {
3
9
  "version": "0.376.0",
4
10
  "type": "fix",
@@ -0,0 +1,59 @@
1
+ export type Barrier = {
2
+ readonly arrive: () => Promise<void>;
3
+ readonly skip: () => void;
4
+ readonly arrived: number;
5
+ readonly skipped: number;
6
+ };
7
+
8
+ const DEFAULT_BARRIER_TIMEOUT_MS = 5000;
9
+
10
+ export function createBarrier(options: {
11
+ readonly expected: number;
12
+ readonly timeoutMs?: number;
13
+ }): Barrier {
14
+ const { expected, timeoutMs = DEFAULT_BARRIER_TIMEOUT_MS } = options;
15
+ if (expected < 1) {
16
+ throw new Error(`barrier expected must be at least 1, got ${expected}`);
17
+ }
18
+
19
+ let arrived = 0;
20
+ let skipped = 0;
21
+ let timer: ReturnType<typeof setTimeout> | undefined;
22
+ const waiting: { resolve: () => void; reject: (error: Error) => void }[] = [];
23
+
24
+ function openIfComplete(): void {
25
+ if (arrived + skipped >= expected) {
26
+ clearTimeout(timer);
27
+ for (const waiter of waiting.splice(0)) waiter.resolve();
28
+ }
29
+ }
30
+
31
+ return {
32
+ arrive() {
33
+ arrived++;
34
+ const pending = new Promise<void>((resolve, reject) => {
35
+ waiting.push({ resolve, reject });
36
+ });
37
+ if (arrived === 1 && arrived + skipped < expected) {
38
+ timer = setTimeout(() => {
39
+ const error = new Error(
40
+ `barrier timed out: ${arrived} of ${expected} arrived, ${skipped} skipped`,
41
+ );
42
+ for (const waiter of waiting.splice(0)) waiter.reject(error);
43
+ }, timeoutMs);
44
+ }
45
+ openIfComplete();
46
+ return pending;
47
+ },
48
+ skip() {
49
+ skipped++;
50
+ openIfComplete();
51
+ },
52
+ get arrived() {
53
+ return arrived;
54
+ },
55
+ get skipped() {
56
+ return skipped;
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,22 @@
1
+ export type BurstResult = {
2
+ readonly responses: readonly Response[];
3
+ readonly statuses: Readonly<Record<number, number>>;
4
+ };
5
+
6
+ export async function burst(
7
+ stack: { readonly dbPoolMax: number },
8
+ count: number,
9
+ request: (index: number) => Promise<Response>,
10
+ ): Promise<BurstResult> {
11
+ if (count > stack.dbPoolMax) {
12
+ throw new Error(
13
+ `burst of ${count} exceeds dbPoolMax ${stack.dbPoolMax}: extra requests queue on the pool and a barrier inside the handler deadlocks; raise TestStackOptions.dbPoolMax or lower the count`,
14
+ );
15
+ }
16
+ const responses = await Promise.all(Array.from({ length: count }, (_, index) => request(index)));
17
+ const statuses: Record<number, number> = {};
18
+ for (const response of responses) {
19
+ statuses[response.status] = (statuses[response.status] ?? 0) + 1;
20
+ }
21
+ return { responses, statuses };
22
+ }
@@ -1,5 +1,5 @@
1
1
  import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
- import { deleteMany } from "@cosmicdrift/kumiko-framework/db";
2
+ import { deleteMany, requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { AnyDb } from "@cosmicdrift/kumiko-framework/internal/stack-support";
4
4
  import { withExecutorManagedWritesForTests } from "@cosmicdrift/kumiko-framework/internal/testing";
5
5
 
@@ -13,30 +13,16 @@ function tableFromName(name: string): EntityTableMeta {
13
13
  return { tableName: name, columns: [], indexes: [], source: "unmanaged" };
14
14
  }
15
15
 
16
- function resolveClearableTable(table: ClearableTable): unknown {
16
+ function resolveClearableTable(table: ClearableTable): EntityTableMeta {
17
17
  if (typeof table === "string") return tableFromName(table);
18
- if (
19
- typeof table === "object" &&
20
- table !== null &&
21
- "tableName" in table &&
22
- typeof table.tableName === "string"
23
- ) {
24
- return table;
25
- }
26
- return table;
18
+ return requireEntityTableMeta(table, "clearTables");
27
19
  }
28
20
 
29
21
  /** Delete all rows from each table (order preserved — FK-sensitive callers order explicitly). */
30
22
  export async function clearTables(db: AnyDb, tables: readonly ClearableTable[]): Promise<void> {
31
23
  for (const table of tables) {
32
- // Test-teardown truncate: resolveClearableTable returns `unknown` (string
33
- // name or table object). The brand-strip cast is the sanctioned bypass —
34
- // clearing a managed projection between tests is not a production write.
35
- await deleteMany(
36
- withExecutorManagedWritesForTests(db),
37
- resolveClearableTable(table) as EntityTableMeta,
38
- {},
39
- );
24
+ // Test teardown: clearing a managed projection between tests is not a production write.
25
+ await deleteMany(withExecutorManagedWritesForTests(db), resolveClearableTable(table), {});
40
26
  }
41
27
  }
42
28
 
@@ -13,10 +13,11 @@ export {
13
13
  resetEventPiiCatalogForTests,
14
14
  resetPiiSubjectKmsForTests,
15
15
  } from "@cosmicdrift/kumiko-framework/internal/testing";
16
-
17
16
  export { rolesOf } from "./access-assertions.ts";
18
17
  export { expectError, expectSuccess } from "./assertions.ts";
18
+ export { type Barrier, createBarrier } from "./barrier.ts";
19
19
  export { withBootValidatorFixture } from "./boot-validator-fixture.ts";
20
+ export { type BurstResult, burst } from "./burst.ts";
20
21
  export {
21
22
  captureClosedConnectionError,
22
23
  terminateBackendsByApplicationName,
@@ -1,11 +1,10 @@
1
1
  // Test-only ES-bypass: seed / mutate a managed projection directly, skipping
2
- // the event log. Production code CANNOT do this — the write helpers reject a
3
- // branded EntityTable at compile time (#742). Tests seed read-model state to
4
- // arrange a scenario without driving the full event path; a projection rebuild
5
- // is not part of the test lifecycle, so eventless writes are safe here. The
6
- // `as EntityTableMeta` strips the executor-only brand at this single sanctioned
7
- // seam — the signatures mirror the query helpers so a call-site migration is a
8
- // plain identifier rename.
2
+ // the event log. Production code cannot do this: the write helpers only accept
3
+ // a runner carrying the executor capability or a Writable table. Tests seed
4
+ // read-model state to arrange a scenario without driving the full event path;
5
+ // a projection rebuild is not part of the test lifecycle, so eventless writes
6
+ // are safe here. The signatures mirror the query helpers so a call-site
7
+ // migration is a plain identifier rename.
9
8
 
10
9
  import type { EntityTable, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
11
10
  import {
@@ -25,7 +24,7 @@ export function seedRow<TRow = unknown>(
25
24
  table: SeedTable,
26
25
  values: Record<string, unknown>,
27
26
  ): Promise<TRow | undefined> {
28
- return insertOne<TRow>(withExecutorManagedWritesForTests(db), table as EntityTableMeta, values);
27
+ return insertOne<TRow>(withExecutorManagedWritesForTests(db), table, values);
29
28
  }
30
29
 
31
30
  export function seedRows<TRow = unknown>(
@@ -33,7 +32,7 @@ export function seedRows<TRow = unknown>(
33
32
  table: SeedTable,
34
33
  rows: ReadonlyArray<Record<string, unknown>>,
35
34
  ): Promise<readonly TRow[]> {
36
- return insertMany<TRow>(withExecutorManagedWritesForTests(db), table as EntityTableMeta, rows);
35
+ return insertMany<TRow>(withExecutorManagedWritesForTests(db), table, rows);
37
36
  }
38
37
 
39
38
  export function updateRows<TRow = unknown>(
@@ -42,14 +41,9 @@ export function updateRows<TRow = unknown>(
42
41
  set: Record<string, unknown>,
43
42
  where: WhereObject,
44
43
  ): Promise<readonly TRow[]> {
45
- return updateMany<TRow>(
46
- withExecutorManagedWritesForTests(db),
47
- table as EntityTableMeta,
48
- set,
49
- where,
50
- );
44
+ return updateMany<TRow>(withExecutorManagedWritesForTests(db), table, set, where);
51
45
  }
52
46
 
53
47
  export function deleteRows(db: AnyDb, table: SeedTable, where: WhereObject): Promise<number> {
54
- return deleteMany(withExecutorManagedWritesForTests(db), table as EntityTableMeta, where);
48
+ return deleteMany(withExecutorManagedWritesForTests(db), table, where);
55
49
  }
package/src/stack/db.ts CHANGED
@@ -22,8 +22,11 @@ function requireEnv(name: string): string {
22
22
  return value;
23
23
  }
24
24
 
25
+ export const DEFAULT_TEST_DB_POOL_MAX = 10;
26
+
25
27
  export type TestDb = {
26
28
  db: DbConnection;
29
+ poolMax: number;
27
30
  outsideTransactionDb: DbConnection;
28
31
  client: PgListenClient;
29
32
  dbName: string;
@@ -59,10 +62,8 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
59
62
  }
60
63
 
61
64
  const testUrl = url.replace(/\/[^/]+$/, `/${dbName}`);
62
- const conn = createDbConnection({
63
- url: testUrl,
64
- ...(opts.poolMax !== undefined ? { maxConnections: opts.poolMax } : {}),
65
- });
65
+ const poolMax = opts.poolMax ?? DEFAULT_TEST_DB_POOL_MAX;
66
+ const conn = createDbConnection({ url: testUrl, maxConnections: poolMax });
66
67
  const outsideConn = createDbConnection({
67
68
  url: testUrl,
68
69
  maxConnections: opts.outsideTransactionPoolMax ?? DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX,
@@ -73,6 +74,7 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
73
74
 
74
75
  return {
75
76
  db: conn.db,
77
+ poolMax,
76
78
  outsideTransactionDb: outsideConn.db,
77
79
  client: conn.client,
78
80
  dbName,
@@ -17,6 +17,7 @@ export {
17
17
  export {
18
18
  type CreateTestDbOptions,
19
19
  createTestDb,
20
+ DEFAULT_TEST_DB_POOL_MAX,
20
21
  type TestDb,
21
22
  } from "./db.ts";
22
23
  export { drainEventConsumers } from "./drain-event-consumers.ts";
@@ -61,6 +61,8 @@ export type TestStack = {
61
61
  jwt: JwtHelper;
62
62
  registry: Registry;
63
63
  db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
64
+ /** Resolved connection pool size of `db`. */
65
+ readonly dbPoolMax: number;
64
66
  redis: TestRedis;
65
67
  // The bus this stack publishes/subscribes on; pass to TierResolverPlugin.build
66
68
  // so a tier-engine resolver built outside buildServer joins the same sync.
@@ -686,6 +688,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
686
688
  jwt: server.jwt,
687
689
  registry,
688
690
  db: testDb.db,
691
+ dbPoolMax: testDb.poolMax,
689
692
  redis: testRedis,
690
693
  cacheSync,
691
694
  search: searchAdapter,