@cosmicdrift/kumiko-framework 0.308.0 → 0.310.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-framework",
3
- "version": "0.308.0",
3
+ "version": "0.310.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.308.0",
202
- "@cosmicdrift/kumiko-types": "0.308.0",
201
+ "@cosmicdrift/kumiko-http": "0.310.0",
202
+ "@cosmicdrift/kumiko-types": "0.310.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -208,14 +208,14 @@
208
208
  "jose": "^6.2.3",
209
209
  "meilisearch": "^0.58.0",
210
210
  "pino": "^10.3.1",
211
- "postgres": "^3.4.9",
211
+ "postgres": "npm:@bender0oo0/postgres@3.4.9-kumiko.1",
212
212
  "temporal-polyfill": "^0.3.2",
213
213
  "ts-morph": "^28.0.0",
214
214
  "uuid": "^14.0.0",
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.308.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.310.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -0,0 +1,87 @@
1
+ // Guards the nextWrite null-socket backport (porsager/postgres#1209) on the
2
+ // @bender0oo0/postgres fork pinned in package.json: reserve a connection, kill
3
+ // the backend socket mid-flight, then send another query on the reserved
4
+ // connection. Upstream postgres 3.4.9 crashes the process with an
5
+ // uncaughtException ("null is not an object (evaluating 'socket.write')")
6
+ // instead of rejecting; the fork rejects with CONNECTION_CLOSED. Must stay
7
+ // green after switching back to an upstream release (kumiko-framework#3248).
8
+
9
+ import { afterEach, beforeEach, expect, test } from "bun:test";
10
+ import net from "node:net";
11
+ import postgres from "postgres";
12
+ import { testDatabaseUrl } from "../../testing/closed-connection-error";
13
+
14
+ const DATABASE_URL = testDatabaseUrl();
15
+ const dbUrl = new URL(DATABASE_URL);
16
+
17
+ let uncaught: unknown;
18
+ function onUncaught(err: unknown): void {
19
+ uncaught = err;
20
+ }
21
+
22
+ beforeEach(() => {
23
+ uncaught = undefined;
24
+ process.on("uncaughtException", onUncaught);
25
+ });
26
+
27
+ afterEach(() => {
28
+ process.off("uncaughtException", onUncaught);
29
+ });
30
+
31
+ test("reserved connection rejects with CONNECTION_CLOSED instead of crashing on a dead socket", async () => {
32
+ let downstream: net.Socket | undefined;
33
+ const proxy = net.createServer((socket) => {
34
+ downstream = socket;
35
+ const upstream = net.connect(Number(dbUrl.port), dbUrl.hostname);
36
+ socket.pipe(upstream).pipe(socket);
37
+ socket.on("error", () => upstream.destroy());
38
+ upstream.on("error", () => socket.destroy());
39
+ });
40
+ await new Promise<void>((resolve) => proxy.listen(0, resolve));
41
+ const address = proxy.address();
42
+ if (address === null || typeof address === "string") {
43
+ throw new Error("proxy did not bind to a TCP port");
44
+ }
45
+
46
+ const sql = postgres({
47
+ host: "127.0.0.1",
48
+ port: address.port,
49
+ user: dbUrl.username,
50
+ password: dbUrl.password,
51
+ database: dbUrl.pathname.slice(1),
52
+ max: 1,
53
+ });
54
+
55
+ try {
56
+ const reserved = await sql.reserve();
57
+ try {
58
+ await reserved`select 1`;
59
+ downstream?.end();
60
+ await new Promise((resolve) => setTimeout(resolve, 50));
61
+
62
+ const HANG = Symbol("hang");
63
+ const result = await Promise.race([
64
+ reserved`select 1`.then(
65
+ () => {
66
+ throw new Error("expected the query to reject with CONNECTION_CLOSED, but it resolved");
67
+ },
68
+ (err: unknown) => err,
69
+ ),
70
+ new Promise((resolve) => setTimeout(() => resolve(HANG), 3000)),
71
+ ]);
72
+
73
+ if (result === HANG) {
74
+ throw new Error("query on the dead-socket connection hung instead of rejecting");
75
+ }
76
+ expect(result).toMatchObject({ code: "CONNECTION_CLOSED" });
77
+ // Give the process a tick to surface an uncaughtException if the guard regressed.
78
+ await new Promise((resolve) => setTimeout(resolve, 50));
79
+ expect(uncaught).toBeUndefined();
80
+ } finally {
81
+ reserved.release();
82
+ }
83
+ } finally {
84
+ proxy.close();
85
+ await sql.end({ timeout: 1 });
86
+ }
87
+ });
@@ -54,6 +54,8 @@ export function createBunDbConnection(
54
54
  if (options.connectTimeoutSeconds !== undefined) {
55
55
  pgOpts.connect_timeout = options.connectTimeoutSeconds;
56
56
  }
57
+ // ponytail: "postgres" resolves to the @bender0oo0/postgres fork (nextWrite
58
+ // null-socket guard, porsager/postgres#1209); switch back per kumiko-framework#3248.
57
59
  const listenClient = postgres(url, pgOpts);
58
60
 
59
61
  return {
package/src/changes.json CHANGED
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.310.0",
4
+ "type": "improvement",
5
+ "title": "Boot validator checks the target tab of navigate actions",
6
+ "detail": "A navigate action with `tab` fails boot when its target is not a\nprojectionDetail with layout.mode \"tabs\" or has no section with that id.\nThe check covers list rowActions, relatedList rowActions, screen actions,\nsection actions and relatedList emptyState actions."
7
+ },
8
+ {
9
+ "version": "0.309.0",
10
+ "type": "fix",
11
+ "title": "LISTEN gauge drops to 0 on a DB outage",
12
+ "detail": "`kumiko_event_dispatcher_listen_connected` stayed at 1 when the LISTEN\nconnection died during a DB outage, although delivery had already fallen\nback to polling. The dispatcher now sets it to 0 when the idle pre-check\ndetects the outage. postgres.js re-LISTENs on its own once the DB is back\n(delayed by its connect backoff, measured 10-30s) and sets it back to 1."
13
+ },
14
+ {
15
+ "version": "0.309.0",
16
+ "type": "fix",
17
+ "title": "event-dispatcher logs when the DB comes back after an outage (fw#3243)",
18
+ "detail": "A DB outage already kept the dispatcher process alive (the postgres pool\nreconnects per query and the dispatcher keeps polling from its cursor) and\nlogged `idle pre-check failed` once per outage, but recovery was silent:\nops had no signal that delivery was back to normal without restarting the\nprocess. The idle pre-check now logs `idle pre-check recovered, database\nreachable again` once the next pass succeeds after a logged failure, no\nrestart needed."
19
+ },
20
+ {
21
+ "version": "0.309.0",
22
+ "type": "fix",
23
+ "title": "Pin postgres to a fork carrying the nextWrite null-socket fix (fw#3243)",
24
+ "detail": "The framework now depends on @bender0oo0/postgres@3.4.9-kumiko.1, an exact pin of\npostgres 3.4.9 plus a backport of porsager/postgres#1209. Without the backport, a\nclosed connection can crash the whole process with an uncaughtException\n(\"null is not an object (evaluating 'socket.write')\") instead of rejecting the\nin-flight query with CONNECTION_CLOSED. Apps get the fix automatically with this\nframework bump, no app-side changes needed. The pin is temporary and tracked for\nremoval in kumiko-framework#3248 once the fix lands upstream."
25
+ },
26
+ {
27
+ "version": "0.309.0",
28
+ "type": "fix",
29
+ "title": "setupTestStack creates r.entity() tables — same table list as kumiko schema generate (fw#3102)",
30
+ "detail": "Since 0.290.0 (fw#2881), every authenticated request reads `read_tenants` (rejectIfTenantTeardown) as soon as tenant-lifecycle is mounted. Test stacks needed a manually created tenant table for that to work, because setupTestStack pushed projection/multiStreamProjection/storeTable sources but never r.entity() tables. setupTestStack now creates every entity's backing table itself via collectTableMetas, the same table list `kumiko schema generate` uses, so a feature that only declares r.entity() no longer 42P01s on first write in a test stack. App-side wrappers like `unsafeEnsureEntityTable(db, tenantEntity, \"tenant\")` right after setupTestStack can be removed. Callers that create/migrate entity tables themselves can opt out via `entityTables: false` (used internally by `setupAppTestStack`'s `registryTables: false`), and setupTestStack now drops the ephemeral test DB/Redis if the table DDL fails instead of leaking them."
31
+ },
2
32
  {
3
33
  "version": "0.308.0",
4
34
  "type": "fix",
@@ -1,6 +1,6 @@
1
1
  // Regression test for #255-class drift: storeTables must appear in
2
- // enumerateFeatureTableSources so setupTestStack's auto-push and
3
- // collectTableMetas see the exact same table set. Before this fix,
2
+ // enumerateFeatureTableSources so collectTableMetas (shared by setupTestStack's
3
+ // auto-push and `kumiko schema generate`) sees the exact same table set. Before this fix,
4
4
  // unmanaged tables were only handled by collectTableMetas directly, so any
5
5
  // app relying on setupTestStack's ephemeral DB (Playwright/e2e) never got
6
6
  // its store tables created — e.g. the bundled "sessions" feature's
@@ -1,9 +1,6 @@
1
- // collectTableMetas — kanonische ENTITY_METAS-Quelle für `kumiko schema
2
- // generate`. Erfasst dieselben Tabellen-Quellen wie der setupTestStack-
3
- // auto-push (entities, storeTables, projections, multiStreamProjections) —
4
- // die frühere Template-Variante sammelte nur entities + storeTables, wodurch
5
- // projection-only-Tabellen (z.B. billing-foundation read_subscriptions) nie
6
- // in Migrations landeten und der erste Prod-Write crashte (#255).
1
+ // Canonical table source for both `kumiko schema generate` and setupTestStack
2
+ // (entities, storeTables, projections, multiStreamProjections). One shared
3
+ // source so the test-DB push and migrations cannot drift apart again (#255, #3102).
7
4
 
8
5
  import type { FeatureDefinition } from "../engine/types";
9
6
  import { compareByCodepoint } from "../utils";
@@ -1,9 +1,8 @@
1
1
  // Single enumeration of every table-bearing registration on a feature
2
2
  // (r.projection, r.multiStreamProjection with table, r.storeTable).
3
- // Consumed by BOTH the setupTestStack auto-push and collectTableMetas —
4
- // one list, so test-DB-push and `kumiko schema generate` cannot drift
5
- // apart again (#255).
6
- // A new table-bearing registrar must be added HERE, not in the consumers.
3
+ // Consumed by collectTableMetas's Pass 2 (`kumiko schema generate` and
4
+ // setupTestStack both go through collectTableMetas — see there).
5
+ // A new table-bearing registrar must be added HERE, not in the consumer.
7
6
 
8
7
  import type { FeatureDefinition } from "../engine/types";
9
8
 
@@ -0,0 +1,86 @@
1
+ // Shared binary-deletion helper: deletes an original storage key plus every
2
+ // derivative rendered from it (thumbnails/resized variants — see
3
+ // derivatives-context.ts). Derivatives are never tracked anywhere but the
4
+ // storage layer: they're rendered under a deterministic key (deriveKey +
5
+ // variantSuffix) computed from the original's key, on demand, and nothing
6
+ // writes that key back onto a FileRef row. Listing the original's key-prefix
7
+ // and keeping only candidates whose suffix matches the derivative grammar,
8
+ // anchored to the original's own basename AND extension, means a
9
+ // same-directory sibling (a different file, possibly another tenant's) shares
10
+ // the list prefix but fails the extension/suffix check and is never touched.
11
+ //
12
+ // Used by both the user-data-rights-defaults forget hook and the
13
+ // data-retention hardDelete cleanup — moved here so neither copies the logic.
14
+
15
+ import { assertSafeStorageKey } from "../files/types";
16
+ import { derivativeListPrefix, isDerivativeKeyOf } from "./variant-key";
17
+
18
+ export type StoredFileStore = {
19
+ list(prefix: string): Promise<readonly string[]>;
20
+ delete(key: string): Promise<void>;
21
+ };
22
+
23
+ // Wrap rather than let a raw provider error (e.g. S3 AccessDenied) surface
24
+ // unexplained — list() is a requirement on top of delete(); a bucket policy
25
+ // granting only s3:DeleteObject would otherwise fail every run instead of
26
+ // just leaving derivatives behind. Name the likely cause so an operator
27
+ // doesn't have to guess. `errorContext` names the caller for the message
28
+ // prefix (e.g. "user-data-rights-defaults:fileRef", "data-retention:hardDelete").
29
+ export async function listDerivativeKeys(
30
+ originalKey: string,
31
+ store: Pick<StoredFileStore, "list">,
32
+ errorContext: string,
33
+ ): Promise<readonly string[]> {
34
+ let candidates: readonly string[];
35
+ try {
36
+ candidates = await store.list(derivativeListPrefix(originalKey));
37
+ } catch (err) {
38
+ throw new Error(
39
+ `[${errorContext}] provider.list() failed while looking up derivatives of key=${originalKey} — forget/erasure requires list permission on the storage bucket (e.g. s3:ListBucket), not just delete. Original error: ${err instanceof Error ? err.message : String(err)}`,
40
+ );
41
+ }
42
+ const derivativeKeys: string[] = [];
43
+ for (const candidate of candidates) {
44
+ if (!isDerivativeKeyOf(originalKey, candidate)) continue;
45
+ try {
46
+ assertSafeStorageKey(candidate);
47
+ } catch {
48
+ // ponytail: a listing hit that fails the storage-key grammar can't be
49
+ // a real derivative (every derivative is written via a validated
50
+ // write()) — skip rather than pass a malformed key to delete().
51
+ continue;
52
+ }
53
+ derivativeKeys.push(candidate);
54
+ }
55
+ return derivativeKeys;
56
+ }
57
+
58
+ // Deletes the original key + every derivative. Returns the keys whose delete
59
+ // threw — callers fail closed on a non-empty list (delete is idempotent, so a
60
+ // retry on the next run converges). assertSafeStorageKey guards the original
61
+ // AND every derivative right before the delete call, not just at listing time.
62
+ export async function deleteStoredFileAndDerivatives(
63
+ originalKey: string,
64
+ store: StoredFileStore,
65
+ errorContext: string,
66
+ ): Promise<readonly string[]> {
67
+ assertSafeStorageKey(originalKey);
68
+ const keysToDelete = [
69
+ originalKey,
70
+ ...(await listDerivativeKeys(originalKey, store, errorContext)),
71
+ ];
72
+ const failedKeys: string[] = [];
73
+ for (const key of keysToDelete) {
74
+ try {
75
+ assertSafeStorageKey(key);
76
+ await store.delete(key);
77
+ } catch (err) {
78
+ failedKeys.push(key);
79
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
80
+ console.warn(
81
+ `[${errorContext}] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
82
+ );
83
+ }
84
+ }
85
+ return failedKeys;
86
+ }
@@ -1,3 +1,5 @@
1
+ export type { StoredFileStore } from "./delete-stored-file";
2
+ export { deleteStoredFileAndDerivatives, listDerivativeKeys } from "./delete-stored-file";
1
3
  export type { DerivativesContextDeps } from "./derivatives-context";
2
4
  export { createDerivativesContext, resolveRenderer } from "./derivatives-context";
3
5
  export { resolveFieldVariant } from "./field-variants";
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as z from "zod";
3
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
+ import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { defineFeature } from "../define-feature";
6
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
9
+ validateBootRaw(withBootValidatorFixture(features));
10
+ }
11
+
12
+ function buildFeature(options: {
13
+ readonly detailTabs: boolean;
14
+ readonly tab: string;
15
+ readonly via: "entityEditSection" | "entityListRowAction";
16
+ }) {
17
+ return defineFeature("app", (r) => {
18
+ r.entity(
19
+ "vehicle",
20
+ createEntity({
21
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
22
+ }),
23
+ );
24
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
25
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
26
+ });
27
+ r.screen({
28
+ id: "vehicle-detail",
29
+ type: "projectionDetail",
30
+ query: "app:query:vehicle:detail",
31
+ layout: options.detailTabs
32
+ ? {
33
+ mode: "tabs",
34
+ sections: [
35
+ { id: "overview", title: "Overview", fields: ["name"] },
36
+ { id: "channels", title: "Channels", fields: ["name"] },
37
+ ],
38
+ }
39
+ : { sections: [{ title: "Overview", fields: ["name"] }] },
40
+ });
41
+ const navigateAction = {
42
+ kind: "navigate" as const,
43
+ id: "view-channels",
44
+ label: "app.actions.viewChannels",
45
+ screen: "vehicle-detail",
46
+ tab: options.tab,
47
+ };
48
+ if (options.via === "entityEditSection") {
49
+ r.screen({
50
+ id: "vehicle-edit",
51
+ type: "entityEdit",
52
+ entity: "vehicle",
53
+ layout: {
54
+ sections: [
55
+ {
56
+ id: "main",
57
+ title: "Main",
58
+ columns: 1,
59
+ fields: ["name"],
60
+ actions: [{ ...navigateAction, entityId: "vehicleId" }],
61
+ },
62
+ ],
63
+ },
64
+ });
65
+ } else {
66
+ r.screen({
67
+ id: "vehicle-list",
68
+ type: "entityList",
69
+ entity: "vehicle",
70
+ columns: ["name"],
71
+ rowActions: [navigateAction],
72
+ });
73
+ }
74
+ });
75
+ }
76
+
77
+ describe("validateBoot — RowActionNavigate.tab", () => {
78
+ test("entityEdit section action navigates to a known tab boots cleanly", () => {
79
+ const feature = buildFeature({ detailTabs: true, tab: "channels", via: "entityEditSection" });
80
+ expect(() => validateBoot([feature])).not.toThrow();
81
+ });
82
+
83
+ test("entityEdit section action navigates to an unknown tab throws", () => {
84
+ const feature = buildFeature({ detailTabs: true, tab: "unknown", via: "entityEditSection" });
85
+ expect(() => validateBoot([feature])).toThrow(
86
+ /navigates to tab "unknown", which is not a section id on target screen/,
87
+ );
88
+ });
89
+
90
+ test("entityEdit section action navigating to a non-tabs projectionDetail throws", () => {
91
+ const feature = buildFeature({ detailTabs: false, tab: "overview", via: "entityEditSection" });
92
+ expect(() => validateBoot([feature])).toThrow(
93
+ /is not a projectionDetail with layout.mode "tabs"/,
94
+ );
95
+ });
96
+
97
+ test("entityList rowAction navigating to an unknown tab throws (central resolution path)", () => {
98
+ const feature = buildFeature({
99
+ detailTabs: true,
100
+ tab: "unknown",
101
+ via: "entityListRowAction",
102
+ });
103
+ expect(() => validateBoot([feature])).toThrow(
104
+ /navigates to tab "unknown", which is not a section id on target screen/,
105
+ );
106
+ });
107
+ });
@@ -542,11 +542,48 @@ type RowActionNavigateRuntime = RowActionNavigateBase & {
542
542
  readonly entity?: string;
543
543
  };
544
544
 
545
+ // The target can be any screen, so this checks the resolved target's
546
+ // sections, not the host's.
547
+ function validateRowActionNavigateTab(
548
+ featureName: string,
549
+ screenId: string,
550
+ screenType: "entityList" | "projectionList" | "projectionDetail" | "entityEdit",
551
+ actionLabel: string,
552
+ action: RowActionNavigateRuntime,
553
+ target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
554
+ ): void {
555
+ // skip: no tab declared — nothing to check against the target's sections
556
+ if (action.tab === undefined) return;
557
+ if (target === undefined) {
558
+ throw new Error(
559
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
560
+ `sets tab "${action.tab}", but its navigate-target could not be resolved to a screen.`,
561
+ );
562
+ }
563
+ if (target.screen.type !== "projectionDetail" || target.screen.layout.mode !== "tabs") {
564
+ throw new Error(
565
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
566
+ `sets tab "${action.tab}", but navigate-target screen "${target.screen.id}" is not a ` +
567
+ `projectionDetail with layout.mode "tabs" — only those read the tab param.`,
568
+ );
569
+ }
570
+ const sectionIds = target.screen.layout.sections
571
+ .map((section) => section.id)
572
+ .filter((id): id is string => id !== undefined);
573
+ if (!sectionIds.includes(action.tab)) {
574
+ throw new Error(
575
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
576
+ `navigates to tab "${action.tab}", which is not a section id on target screen ` +
577
+ `"${target.screen.id}". Available: ${sectionIds.join(", ") || "(none)"}.`,
578
+ );
579
+ }
580
+ }
581
+
545
582
  function resolveRowActionNavigateTarget(
546
583
  featureName: string,
547
584
  screenId: string,
548
585
  screenType: "entityList" | "projectionList" | "projectionDetail" | "entityEdit",
549
- actionLabel: "rowAction" | "action",
586
+ actionLabel: string,
550
587
  action: RowActionNavigateRuntime,
551
588
  allScreenQns: ReadonlySet<string>,
552
589
  navTargetShortIds: ReadonlySet<string>,
@@ -586,6 +623,7 @@ function resolveRowActionNavigateTarget(
586
623
  `detailFor: "${action.entity}".`,
587
624
  );
588
625
  }
626
+ validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, detail);
589
627
  return detail;
590
628
  }
591
629
  if (action.screen === undefined) {
@@ -601,7 +639,9 @@ function resolveRowActionNavigateTarget(
601
639
  `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
602
640
  );
603
641
  }
604
- return screensByShortId.get(action.screen)?.[0];
642
+ const target = screensByShortId.get(action.screen)?.[0];
643
+ validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target);
644
+ return target;
605
645
  }
606
646
 
607
647
  // Shared by the mint step's own handler (screen.handler) and its optional
@@ -1225,6 +1265,19 @@ export function validateScreens(
1225
1265
  `section "${sectionLabel}" action`,
1226
1266
  action,
1227
1267
  );
1268
+ if (action.kind === "navigate" && action.tab !== undefined) {
1269
+ resolveRowActionNavigateTarget(
1270
+ feature.name,
1271
+ screenId,
1272
+ "projectionDetail",
1273
+ `section "${sectionLabel}" action`,
1274
+ action,
1275
+ allScreenQns,
1276
+ navTargetShortIds,
1277
+ screensByShortId,
1278
+ detailForScreens,
1279
+ );
1280
+ }
1228
1281
  }
1229
1282
  }
1230
1283
  if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
@@ -1235,6 +1288,22 @@ export function validateScreens(
1235
1288
  `section "${sectionLabel}" emptyState action`,
1236
1289
  section.emptyState.action,
1237
1290
  );
1291
+ if (
1292
+ section.emptyState.action.kind === "navigate" &&
1293
+ section.emptyState.action.tab !== undefined
1294
+ ) {
1295
+ resolveRowActionNavigateTarget(
1296
+ feature.name,
1297
+ screenId,
1298
+ "projectionDetail",
1299
+ `section "${sectionLabel}" emptyState action`,
1300
+ section.emptyState.action,
1301
+ allScreenQns,
1302
+ navTargetShortIds,
1303
+ screensByShortId,
1304
+ detailForScreens,
1305
+ );
1306
+ }
1238
1307
  }
1239
1308
  if (isExtensionEditSection(section)) {
1240
1309
  // projectionDetail is read-only (no composed form submit) — an
@@ -1983,6 +2052,19 @@ export function validateScreens(
1983
2052
  `section "${sectionLabel}" action`,
1984
2053
  action,
1985
2054
  );
2055
+ if (action.kind === "navigate" && action.tab !== undefined) {
2056
+ resolveRowActionNavigateTarget(
2057
+ feature.name,
2058
+ screenId,
2059
+ "entityEdit",
2060
+ `section "${sectionLabel}" action`,
2061
+ action,
2062
+ allScreenQns,
2063
+ navTargetShortIds,
2064
+ screensByShortId,
2065
+ detailForScreens,
2066
+ );
2067
+ }
1986
2068
  }
1987
2069
  }
1988
2070
  if (isExtensionEditSection(section)) {
@@ -0,0 +1,192 @@
1
+ // kumiko-framework#3243: a DB outage must not crash the dispatcher and must
2
+ // not stay silent forever. The idle pre-check's error log (doPass) fires once
3
+ // per outage, and a matching "recovered" line must fire once the DB answers
4
+ // again, so ops can see the return without restarting the process. Routes the
5
+ // dispatcher's own db connection through a TCP proxy (same pattern as
6
+ // bun-db/__tests__/postgres-closed-socket-write.integration.test.ts) so the
7
+ // outage can be simulated without touching the shared test DB other suites
8
+ // depend on.
9
+
10
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
11
+ import net from "node:net";
12
+ import postgres from "postgres";
13
+ import { createDbConnection } from "../../db/connection";
14
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
15
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
16
+ import { defineFeature } from "../../engine";
17
+ import type { StoredEvent } from "../../event-store";
18
+ import type { Logger } from "../../logging/types";
19
+ import { type MetricEvent, RecordingMeter, registerStandardMetrics } from "../../observability";
20
+ import { createEventDispatcher, type EventConsumer, type EventDispatcher } from "../../pipeline";
21
+ import {
22
+ resetEventStore,
23
+ setupTestStack,
24
+ type TestStack,
25
+ TestUsers,
26
+ unsafeCreateEntityTable,
27
+ } from "../../stack";
28
+ import { sharedWidgetEntity, sharedWidgetTable, waitFor } from "../../testing";
29
+ import { testDatabaseUrl } from "../../testing/closed-connection-error";
30
+
31
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
32
+ entityName: "widget",
33
+ });
34
+
35
+ const feature = defineFeature("dboutage", (r) => {
36
+ r.entity("widget", sharedWidgetEntity);
37
+ });
38
+
39
+ const admin = TestUsers.admin;
40
+ let stack: TestStack;
41
+ let tdb: TenantDb;
42
+ // setupTestStack creates its own ephemeral kumiko_test_<random> DB and never
43
+ // exposes its name/URL. Passing an explicit name here so the proxy below
44
+ // can build the exact connection string setupTestStack itself would have.
45
+ const testDbName = `kumiko_test_dboutage_${crypto.randomUUID().slice(0, 8)}`;
46
+
47
+ beforeAll(async () => {
48
+ stack = await setupTestStack({ features: [feature], systemHooks: [], dbName: testDbName });
49
+ await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
50
+ tdb = createTenantDb(stack.db, admin.tenantId);
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await resetEventStore(stack, ["read_widgets"]);
55
+ });
56
+
57
+ async function appendWidget(name: string): Promise<void> {
58
+ await executor.create({ name }, admin, tdb);
59
+ }
60
+
61
+ function recordingLogger(): Logger & { readonly lines: string[] } {
62
+ const lines: string[] = [];
63
+ const logger: Logger & { lines: string[] } = {
64
+ lines,
65
+ info: (msg) => lines.push(`info:${msg}`),
66
+ warn: (msg) => lines.push(`warn:${msg}`),
67
+ error: (msg) => lines.push(`error:${msg}`),
68
+ debug: () => {},
69
+ child: () => logger,
70
+ };
71
+ return logger;
72
+ }
73
+
74
+ // A TCP passthrough to the real test DB. Stopping it (server.close() +
75
+ // destroying every live socket) simulates an outage without touching the
76
+ // shared test DB; restarting on the SAME port simulates the DB coming back
77
+ // without the dispatcher's client needing a new connection string.
78
+ function startDbProxy(dbUrl: URL): { server: net.Server; stop: () => Promise<void> } {
79
+ const sockets = new Set<net.Socket>();
80
+ const server = net.createServer((downstream) => {
81
+ sockets.add(downstream);
82
+ const upstream = net.connect(Number(dbUrl.port), dbUrl.hostname);
83
+ sockets.add(upstream);
84
+ downstream.pipe(upstream).pipe(downstream);
85
+ downstream.on("error", () => upstream.destroy());
86
+ upstream.on("error", () => downstream.destroy());
87
+ downstream.on("close", () => sockets.delete(downstream));
88
+ upstream.on("close", () => sockets.delete(upstream));
89
+ });
90
+ return {
91
+ server,
92
+ stop: async () => {
93
+ for (const socket of sockets) socket.destroy();
94
+ await new Promise<void>((resolve) => server.close(() => resolve()));
95
+ },
96
+ };
97
+ }
98
+
99
+ async function listenOn(server: net.Server, port: number): Promise<void> {
100
+ await new Promise<void>((resolve, reject) => {
101
+ server.once("error", reject);
102
+ server.listen(port, resolve);
103
+ });
104
+ }
105
+
106
+ describe("E: dispatcher survives a DB outage and logs the recovery", () => {
107
+ test("idle pre-check logs the outage once and the recovery once, then resumes delivery", async () => {
108
+ const dbUrl = new URL(testDatabaseUrl().replace(/\/[^/]+$/, `/${testDbName}`));
109
+ const proxy1 = startDbProxy(dbUrl);
110
+ await listenOn(proxy1.server, 0);
111
+ const address = proxy1.server.address();
112
+ if (address === null || typeof address === "string") {
113
+ throw new Error("proxy did not bind to a TCP port");
114
+ }
115
+ const proxyPort = address.port;
116
+
117
+ const proxiedUrl = new URL(dbUrl.toString());
118
+ proxiedUrl.hostname = "127.0.0.1";
119
+ proxiedUrl.port = String(proxyPort);
120
+ const proxiedDb = createDbConnection(proxiedUrl.toString(), { maxConnections: 1 });
121
+
122
+ const observed: string[] = [];
123
+ const consumer: EventConsumer = {
124
+ name: "dboutage:consumer",
125
+ handler: async (event: StoredEvent) => {
126
+ observed.push(String(event.payload["name"]));
127
+ },
128
+ };
129
+
130
+ const logger = recordingLogger();
131
+ const metricEvents: MetricEvent[] = [];
132
+ const lastListenGauge = (): number | undefined =>
133
+ metricEvents
134
+ .filter(
135
+ (e) => e.type === "gauge.set" && e.name === "kumiko_event_dispatcher_listen_connected",
136
+ )
137
+ .pop()?.value;
138
+ const meter = new RecordingMeter((e) => metricEvents.push(e));
139
+ registerStandardMetrics(meter);
140
+ const listenClient = postgres(proxiedUrl.toString(), { max: 1 });
141
+ const dispatcher: EventDispatcher = createEventDispatcher({
142
+ db: proxiedDb.db,
143
+ consumers: [consumer],
144
+ context: { db: proxiedDb.db, log: logger },
145
+ pollIntervalMs: 50,
146
+ pgClient: listenClient,
147
+ meter,
148
+ });
149
+
150
+ try {
151
+ await dispatcher.start();
152
+ expect(lastListenGauge()).toBe(1);
153
+
154
+ await appendWidget("before-outage");
155
+ await waitFor(() => observed.includes("before-outage"));
156
+ // Delivery is at-least-once, so killing the proxy mid-commit would
157
+ // redeliver "before-outage" after recovery. Drain first so the turn's
158
+ // TX has already committed.
159
+ await dispatcher.drain();
160
+
161
+ await proxy1.stop();
162
+
163
+ await waitFor(() => logger.lines.some((line) => line.includes("idle pre-check failed")));
164
+ expect(lastListenGauge()).toBe(0);
165
+
166
+ const proxy2 = startDbProxy(dbUrl);
167
+ await listenOn(proxy2.server, proxyPort);
168
+ try {
169
+ await waitFor(() => logger.lines.some((line) => line.includes("idle pre-check recovered")));
170
+
171
+ await appendWidget("after-recovery");
172
+ await waitFor(() => observed.includes("after-recovery"));
173
+ } finally {
174
+ await proxy2.stop();
175
+ }
176
+
177
+ const failedCount = logger.lines.filter((line) =>
178
+ line.includes("idle pre-check failed"),
179
+ ).length;
180
+ const recoveredCount = logger.lines.filter((line) =>
181
+ line.includes("idle pre-check recovered"),
182
+ ).length;
183
+ expect(failedCount).toBe(1);
184
+ expect(recoveredCount).toBe(1);
185
+ expect(observed).toEqual(["before-outage", "after-recovery"]);
186
+ } finally {
187
+ await dispatcher.stop();
188
+ await listenClient.end({ timeout: 1 });
189
+ await proxiedDb.close();
190
+ }
191
+ });
192
+ });
@@ -434,7 +434,16 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
434
434
  instanceId: consumerInstanceId(consumer, options.instanceId),
435
435
  })),
436
436
  );
437
- idlePreCheckErrorLogged = false;
437
+ if (idlePreCheckErrorLogged) {
438
+ idlePreCheckErrorLogged = false;
439
+ const recoveredMsg =
440
+ "[event-dispatcher] idle pre-check recovered, database reachable again";
441
+ if (context.log) {
442
+ context.log.info(recoveredMsg);
443
+ } else {
444
+ console.log(recoveredMsg);
445
+ }
446
+ }
438
447
  } catch (e) {
439
448
  idleKeys = new Set();
440
449
  // Log once per outage, not once per poll tick — see
@@ -443,6 +452,9 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
443
452
  // signal for ops, not a delivery failure.
444
453
  if (!idlePreCheckErrorLogged) {
445
454
  idlePreCheckErrorLogged = true;
455
+ // The LISTEN peer talks to the same DB, so it is gone too; only a
456
+ // later onlisten may flip the gauge back to 1.
457
+ emitEventDispatcherListenConnected(meter, false);
446
458
  const msg = e instanceof Error ? e.message : String(e);
447
459
  const logMsg = `[event-dispatcher] idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
448
460
  if (context.log) {
@@ -697,10 +709,11 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
697
709
  // fallback (dropped subscriptions, missed commits under load).
698
710
  //
699
711
  // Observability: the gauge kumiko_event_dispatcher_listen_connected
700
- // flips to 1 on initial subscribe AND on every postgres.js silent
701
- // reconnect (via the onlisten callback). A drop to 0 while running
702
- // means delivery latency regressed from TCP-round-trip to
703
- // pollIntervalMs — ops-visible.
712
+ // flips to 1 on initial subscribe and again on every reconnect that
713
+ // succeeds (onlisten callback). It drops to 0 on the initial connect
714
+ // failure, on stop() and when the idle pre-check detects a DB outage.
715
+ // An outage shorter than one poll tick is not detected; the gauge then
716
+ // stays at 1 until postgres.js's delayed re-LISTEN lands.
704
717
  emitEventDispatcherListenConnected(meter, false);
705
718
  if (options.pgClient) {
706
719
  try {
@@ -712,10 +725,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
712
725
  });
713
726
  },
714
727
  () => {
715
- // Fires on initial connect AND on each reconnect. postgres.js
716
- // reconnects transparently if the TCP connection drops, so the
717
- // only way to see the recovery window is to flip the gauge
718
- // every time this callback lands.
728
+ // Fires on initial connect and again when postgres.js's own
729
+ // re-LISTEN lands after a drop; during an outage that re-LISTEN
730
+ // waits in its connection backoff (measured 10-30s after the DB
731
+ // returns), polling covers delivery meanwhile. Never unlisten()
732
+ // + listen() by hand: that leaves duplicate listeners.
733
+ // ponytail: no own LISTEN reconnect, the backoff gap is polled;
734
+ // add a dedicated LISTEN connection if prod wires a pgClient
735
+ // and that latency matters.
719
736
  emitEventDispatcherListenConnected(meter, true);
720
737
  },
721
738
  );
@@ -0,0 +1,65 @@
1
+ // #3102: setupTestStack pushed projection/MSP/storeTable sources but never
2
+ // r.entity() tables, so any feature relying purely on entities crashed on
3
+ // first write with 42P01 unless the test manually created the table.
4
+
5
+ import { afterAll, describe, expect, test } from "bun:test";
6
+ import { asRawClient } from "../../db/query";
7
+ import { createEntity, createTextField, defineFeature } from "../../engine";
8
+ import { setupTestStack, type TestStack } from "../test-stack";
9
+
10
+ const widgetEntity = createEntity({
11
+ table: "widgets",
12
+ fields: {
13
+ name: createTextField({ personal: false, reason: "test_fixture", required: true }),
14
+ },
15
+ });
16
+
17
+ const gadgetEntity = createEntity({
18
+ table: "gadgets",
19
+ fields: {
20
+ label: createTextField({ personal: false, reason: "test_fixture", required: true }),
21
+ },
22
+ });
23
+
24
+ const widgetFeature = defineFeature("widget", (r) => {
25
+ r.entity("widget", widgetEntity);
26
+ });
27
+
28
+ const twoEntityFeature = defineFeature("gadgetbox", (r) => {
29
+ r.entity("widget", widgetEntity);
30
+ r.entity("gadget", gadgetEntity);
31
+ });
32
+
33
+ async function tableExistsInInformationSchema(
34
+ db: TestStack["db"],
35
+ tableName: string,
36
+ ): Promise<boolean> {
37
+ const rows = await asRawClient(db).unsafe<{ table_name: string }>(
38
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`,
39
+ [tableName],
40
+ );
41
+ return rows.length === 1;
42
+ }
43
+
44
+ describe("setupTestStack — r.entity() tables", () => {
45
+ let stack: TestStack | undefined;
46
+
47
+ afterAll(async () => {
48
+ await stack?.cleanup();
49
+ });
50
+
51
+ test("creates the backing table for a plain r.entity() without manual table setup", async () => {
52
+ stack = await setupTestStack({ features: [widgetFeature] });
53
+ expect(await tableExistsInInformationSchema(stack.db, "widgets")).toBe(true);
54
+ });
55
+
56
+ test("creates every entity's backing table when a feature registers more than one", async () => {
57
+ const twoEntityStack = await setupTestStack({ features: [twoEntityFeature] });
58
+ try {
59
+ expect(await tableExistsInInformationSchema(twoEntityStack.db, "widgets")).toBe(true);
60
+ expect(await tableExistsInInformationSchema(twoEntityStack.db, "gadgets")).toBe(true);
61
+ } finally {
62
+ await twoEntityStack.cleanup();
63
+ }
64
+ });
65
+ });
@@ -4,7 +4,6 @@ import type { JwtHelper } from "../api/jwt";
4
4
  import { buildServer } from "../api/server";
5
5
  import { createSseBroker, type SseBroker } from "../api/sse-broker";
6
6
  import type { PgClient } from "../db/connection";
7
- import { extractTableInfo } from "../db/query";
8
7
  import { validateOwnershipBoot } from "../engine/boot-validator/ownership";
9
8
  import { createRegistry } from "../engine/registry";
10
9
  import type { AppContext, FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
@@ -181,6 +180,10 @@ export type TestStackOptions = {
181
180
  * out (e.g. 60_000) so the polling timer can't land inside the
182
181
  * assertion window and mask a dead subscription — see E.4 (#2042). */
183
182
  eventDispatcherPollIntervalMs?: number;
183
+ /** Opt out of auto-created r.entity() tables for callers that create/migrate
184
+ * them themselves. Default true — false pushes only projection/MSP/
185
+ * storeTable sources, same as before fw#3102. */
186
+ entityTables?: boolean;
184
187
  };
185
188
 
186
189
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
@@ -215,68 +218,72 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
215
218
  createTestRedis(),
216
219
  ]);
217
220
 
218
- // Every ES-entity writes events via createEventStoreExecutor in the
219
- // feature's write handlers. Auto-create the events table so every
220
- // setupTestStack call is ready for writes without needing a manual
221
- // createEventsTable().
222
- await createEventsTable(testDb.db);
223
- // Archive-stream metadata — needed by ctx.appendEvent's archive guard and
224
- // loadAggregate's default-skip. Idempotent, so production boot running
225
- // the same call is fine.
226
- await createArchivedStreamsTable(testDb.db);
221
+ // Any throw in this DDL region must still drop the just-created ephemeral
222
+ // DB/Redis — the caller never gets the stack object back to call
223
+ // cleanup() itself (same "leaked ephemeral DB" failure class as the later
224
+ // try/catch below, but for setup that runs before that try starts).
225
+ try {
226
+ // Every ES-entity writes events via createEventStoreExecutor in the
227
+ // feature's write handlers. Auto-create the events table so every
228
+ // setupTestStack call is ready for writes without needing a manual
229
+ // createEventsTable().
230
+ await createEventsTable(testDb.db);
231
+ // Archive-stream metadata — needed by ctx.appendEvent's archive guard and
232
+ // loadAggregate's default-skip. Idempotent, so production boot running
233
+ // the same call is fine.
234
+ await createArchivedStreamsTable(testDb.db);
227
235
 
228
- // Framework state for projection rebuild/status + event-consumer cursors.
229
- // Idempotent — production boot flows run the same calls.
230
- const { createProjectionStateTable, createEventConsumerStateTable } = await import("../pipeline");
231
- await createProjectionStateTable(testDb.db);
232
- await createEventConsumerStateTable(testDb.db);
236
+ // Framework state for projection rebuild/status + event-consumer cursors.
237
+ // Idempotent — production boot flows run the same calls.
238
+ const { createProjectionStateTable, createEventConsumerStateTable } = await import(
239
+ "../pipeline"
240
+ );
241
+ await createProjectionStateTable(testDb.db);
242
+ await createEventConsumerStateTable(testDb.db);
233
243
 
234
- // Files support: when a provider is registered, the fileRefs table must
235
- // exist before the first upload. Skipped when no provider — the table
236
- // stays off tenant test DBs that never touch files.
237
- if (options.files) {
238
- const { fileRefsTable } = await import("../files");
239
- await unsafePushTables(testDb.db, { fileRefsTable });
240
- }
241
-
242
- // Projection-/MSP-/raw-tables: the executor (or async dispatcher) writes
243
- // into them as soon as the first matching event flows, so the DDL must
244
- // exist before setupTestStack returns. The source list is shared with
245
- // collectTableMetas (`kumiko schema generate`) — divergence between the
246
- // two was exactly the #255 prod-crash. Two registrations backed by the
247
- // same physical table (e.g. an alternative apply-shape for the same
248
- // read-model in a test feature) are deduped by table reference so we
249
- // emit only one CREATE TABLE per physical table.
250
- const { enumerateFeatureTableSources } = await import("../db/feature-table-sources");
251
- const projectionTables: Record<string, unknown> = {};
252
- // Dedup by NAME, matching collectTableMetas — by-reference alone let two
253
- // distinct table objects with the same name slip through as a double
254
- // CREATE TABLE while schema-generate emitted only one meta (silent
255
- // test-vs-schema divergence).
256
- const seenTableNames = new Set<string>();
257
- for (const feature of options.features) {
258
- for (const { table, origin } of enumerateFeatureTableSources(feature)) {
259
- const name = extractTableInfo(table).name;
260
- if (seenTableNames.has(name)) continue;
261
- seenTableNames.add(name);
262
- projectionTables[origin] = table;
244
+ // Files support: when a provider is registered, the fileRefs table must
245
+ // exist before the first upload. Skipped when no provider — the table
246
+ // stays off tenant test DBs that never touch files.
247
+ if (options.files) {
248
+ const { fileRefsTable } = await import("../files");
249
+ await unsafePushTables(testDb.db, { fileRefsTable });
263
250
  }
264
- }
265
- if (Object.keys(projectionTables).length > 0) {
266
- // unsafePushTables emits raw CREATE TABLE — fine for ephemeral test DBs but
267
- // collides on re-boot against a persistent DB whose projection tables
268
- // were created during a previous run. Filter out the ones that already
269
- // exist so the re-boot doesn't fail on duplicate CREATE TABLE.
251
+
252
+ // Same table list as `kumiko schema generate` (collectTableMetas) — divergence
253
+ // was the #255 prod-crash and the #3102 missing r.entity() tables.
254
+ const { collectTableMetas } = await import("../db/collect-table-metas");
270
255
  const { tableExists } = await import("../db/schema-inspection");
256
+ let metas = collectTableMetas(options.features);
257
+ if (options.entityTables === false) {
258
+ const { enumerateFeatureTableSources } = await import("../db/feature-table-sources");
259
+ const { extractTableInfo } = await import("../db/query");
260
+ const nonEntityNames = new Set(
261
+ options.features.flatMap((f) =>
262
+ enumerateFeatureTableSources(f).map((s) => extractTableInfo(s.table).name),
263
+ ),
264
+ );
265
+ metas = metas.filter((m) => nonEntityNames.has(m.tableName));
266
+ }
271
267
  const missing: Record<string, unknown> = {};
272
- for (const [key, tbl] of Object.entries(projectionTables)) {
273
- const physical = extractTableInfo(tbl).name;
274
- if (await tableExists(testDb.db, `public.${physical}`)) continue;
275
- missing[key] = tbl;
268
+ for (const meta of metas) {
269
+ if (await tableExists(testDb.db, `public.${meta.tableName}`)) continue;
270
+ missing[meta.tableName] = meta;
276
271
  }
277
272
  if (Object.keys(missing).length > 0) {
278
273
  await unsafePushTables(testDb.db, missing);
279
274
  }
275
+ } catch (error) {
276
+ try {
277
+ await testDb.cleanup();
278
+ } catch {
279
+ // ignore — `error` is the one that matters
280
+ }
281
+ try {
282
+ await testRedis.cleanup();
283
+ } catch {
284
+ // ignore — `error` is the one that matters
285
+ }
286
+ throw error;
280
287
  }
281
288
 
282
289
  const searchAdapter = createInMemorySearchAdapter();
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { requireRealProviders } from "../real-providers";
2
+ import { isRealProviderRun, REAL_PROVIDERS_ENV, requireRealProviders } from "../real-providers";
3
3
 
4
4
  const CI = "CI";
5
5
  const FLAG = "KUMIKO_REAL_PROVIDERS";
@@ -52,3 +52,11 @@ describe("requireRealProviders", () => {
52
52
  expect(() => requireRealProviders()).not.toThrow();
53
53
  });
54
54
  });
55
+
56
+ describe("isRealProviderRun", () => {
57
+ test("reads the exported flag name from an explicit env map, defaulting to process.env", () => {
58
+ expect(isRealProviderRun({ [REAL_PROVIDERS_ENV]: "1" })).toBe(true);
59
+ expect(isRealProviderRun({ [REAL_PROVIDERS_ENV]: "true" })).toBe(false);
60
+ expect(isRealProviderRun({})).toBe(false);
61
+ });
62
+ });
@@ -45,7 +45,7 @@ export {
45
45
  createRecordingProvider,
46
46
  type RecordingProvider,
47
47
  } from "./observability-recorder";
48
- export { requireRealProviders } from "./real-providers";
48
+ export { isRealProviderRun, REAL_PROVIDERS_ENV, requireRealProviders } from "./real-providers";
49
49
  export { deleteRows, seedRow, seedRows, updateRows } from "./seed";
50
50
  export {
51
51
  sharedItemEntity,
@@ -1,4 +1,4 @@
1
- const REAL_PROVIDERS_FLAG = "KUMIKO_REAL_PROVIDERS";
1
+ export const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
2
2
  const CI_FLAG = "CI";
3
3
 
4
4
  function isCi(): boolean {
@@ -6,15 +6,23 @@ function isCi(): boolean {
6
6
  return ci !== undefined && ci !== "" && ci !== "0" && ci.toLowerCase() !== "false";
7
7
  }
8
8
 
9
+ // Apps and the e2e/bunfig templates gate real-provider runs on this same
10
+ // check instead of re-reading KUMIKO_REAL_PROVIDERS themselves.
11
+ export function isRealProviderRun(
12
+ env: Readonly<Record<string, string | undefined>> = process.env,
13
+ ): boolean {
14
+ return env[REAL_PROVIDERS_ENV] === "1";
15
+ }
16
+
9
17
  export function requireRealProviders(): void {
10
18
  if (isCi()) {
11
19
  throw new Error(
12
- `Real-provider tests never run in CI (CI is set). Run them locally with ${REAL_PROVIDERS_FLAG}=1 via the test:real / e2e:real script.`,
20
+ `Real-provider tests never run in CI (CI is set). Run them locally with ${REAL_PROVIDERS_ENV}=1 via the test:real / e2e:real script.`,
13
21
  );
14
22
  }
15
- if (process.env[REAL_PROVIDERS_FLAG] !== "1") {
23
+ if (!isRealProviderRun()) {
16
24
  throw new Error(
17
- `Real-provider test needs ${REAL_PROVIDERS_FLAG}=1 (an API key alone never enables it). Run it via the test:real / e2e:real script.`,
25
+ `Real-provider test needs ${REAL_PROVIDERS_ENV}=1 (an API key alone never enables it). Run it via the test:real / e2e:real script.`,
18
26
  );
19
27
  }
20
28
  }