@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 +5 -5
- package/src/bun-db/__tests__/postgres-closed-socket-write.integration.test.ts +87 -0
- package/src/bun-db/connection.ts +2 -0
- package/src/changes.json +30 -0
- package/src/db/__tests__/feature-table-sources.test.ts +2 -2
- package/src/db/collect-table-metas.ts +3 -6
- package/src/db/feature-table-sources.ts +3 -4
- package/src/derivatives/delete-stored-file.ts +86 -0
- package/src/derivatives/index.ts +2 -0
- package/src/engine/__tests__/row-action-navigate-tab.test.ts +107 -0
- package/src/engine/boot-validator/screens.ts +84 -2
- package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +192 -0
- package/src/pipeline/event-dispatcher.ts +26 -9
- package/src/stack/__tests__/setup-test-stack-entity-tables.integration.test.ts +65 -0
- package/src/stack/test-stack.ts +61 -54
- package/src/testing/__tests__/real-providers.test.ts +9 -1
- package/src/testing/index.ts +1 -1
- package/src/testing/real-providers.ts +12 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "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": "
|
|
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.
|
|
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
|
+
});
|
package/src/bun-db/connection.ts
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
+
}
|
package/src/derivatives/index.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
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
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
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
|
+
});
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
});
|
package/src/testing/index.ts
CHANGED
|
@@ -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
|
|
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 ${
|
|
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 (
|
|
23
|
+
if (!isRealProviderRun()) {
|
|
16
24
|
throw new Error(
|
|
17
|
-
`Real-provider test needs ${
|
|
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
|
}
|