@cosmicdrift/kumiko-framework 0.311.0 → 0.313.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 +4 -4
- package/src/api/auth-routes.ts +27 -2
- package/src/changes.json +36 -0
- package/src/crypto/pg-kms-adapter.ts +2 -1
- package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
- package/src/db/api.ts +6 -0
- package/src/db/bun-provider.ts +8 -3
- package/src/db/connection.ts +3 -3
- package/src/db/eagerload.ts +2 -9
- package/src/db/index.ts +1 -1
- package/src/db/postgres-provider.ts +7 -4
- package/src/db/queries/event-consumer.ts +4 -3
- package/src/derivatives/derivatives-context.ts +1 -8
- package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
- package/src/engine/boot-validator/transfer-graph.ts +2 -1
- package/src/engine/feature-config-events-jobs.ts +3 -0
- package/src/engine/index.ts +3 -0
- package/src/engine/parse-ref-target.ts +6 -0
- package/src/engine/registry-validate.ts +3 -12
- package/src/engine/types/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +115 -1
- package/src/files/__tests__/storage-tracking.test.ts +46 -0
- package/src/files/file-routes.ts +13 -2
- package/src/files/index.ts +8 -1
- package/src/files/storage-tracking.ts +160 -15
- package/src/files/types.ts +44 -2
- package/src/jobs/job-runner.ts +39 -0
- package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +133 -6
- package/src/pipeline/event-dispatcher-delivery.ts +22 -1
- package/src/pipeline/event-dispatcher.ts +25 -2
- package/src/pipeline/index.ts +4 -1
- package/src/pipeline/msp-rebuild.ts +1 -0
- package/src/pipeline/multi-stream-apply-context.ts +1 -0
- package/src/pipeline/system-hooks.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.313.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.313.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.313.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.313.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -148,6 +148,20 @@ const SignupConfirmBody = z.object({
|
|
|
148
148
|
password: z.string().min(8).max(200),
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
// Cross-device try-first handover (kumiko-framework#3035 follow-up,
|
|
152
|
+
// offlot-app#454): an optional tenant-handover grant the signup-request
|
|
153
|
+
// route forwards to the handler for server-side verification and binding —
|
|
154
|
+
// the grant itself never appears anywhere else (URL, mail, log, response).
|
|
155
|
+
const SignupRequestBody = z.object({
|
|
156
|
+
email: z.email(),
|
|
157
|
+
handover: z
|
|
158
|
+
.object({
|
|
159
|
+
entityType: z.string().min(1).max(200),
|
|
160
|
+
token: z.string().min(1).max(4096),
|
|
161
|
+
})
|
|
162
|
+
.optional(),
|
|
163
|
+
});
|
|
164
|
+
|
|
151
165
|
const InviteAcceptBody = z.object({
|
|
152
166
|
token: z.string().min(1),
|
|
153
167
|
});
|
|
@@ -1122,6 +1136,7 @@ export function createAuthRoutes(
|
|
|
1122
1136
|
dispatcher,
|
|
1123
1137
|
path: Routes.authSignupRequest,
|
|
1124
1138
|
requestHandler: sg.requestHandler,
|
|
1139
|
+
schema: SignupRequestBody,
|
|
1125
1140
|
});
|
|
1126
1141
|
|
|
1127
1142
|
api.post(Routes.authSignupConfirm, async (c) => {
|
|
@@ -1149,6 +1164,7 @@ export function createAuthRoutes(
|
|
|
1149
1164
|
kind: "auth-session";
|
|
1150
1165
|
session: SessionUser;
|
|
1151
1166
|
tenantKey: string;
|
|
1167
|
+
handover?: { entityType: string; id: string };
|
|
1152
1168
|
};
|
|
1153
1169
|
|
|
1154
1170
|
// Session creation + JWT sign + cookies — see mintSessionAndRespond.
|
|
@@ -1168,6 +1184,11 @@ export function createAuthRoutes(
|
|
|
1168
1184
|
// membership — die Frontend-UI nimmt das direkt als Redirect-
|
|
1169
1185
|
// Target.
|
|
1170
1186
|
tenantKey: data.tenantKey,
|
|
1187
|
+
// Cross-device try-first handover (kumiko-framework#3035 follow-up):
|
|
1188
|
+
// present only when signup-confirm actually claimed a bound grant —
|
|
1189
|
+
// absent, not null, so a client that doesn't know the field sees
|
|
1190
|
+
// nothing unusual.
|
|
1191
|
+
...(data.handover !== undefined && { handover: data.handover }),
|
|
1171
1192
|
});
|
|
1172
1193
|
});
|
|
1173
1194
|
}
|
|
@@ -1462,8 +1483,12 @@ function registerTokenRequestRoute(opts: {
|
|
|
1462
1483
|
dispatcher: Dispatcher;
|
|
1463
1484
|
path: string;
|
|
1464
1485
|
requestHandler: string;
|
|
1486
|
+
// reset/verify/unlock keep the plain RequestTokenBody; signup passes its
|
|
1487
|
+
// own schema (adds an optional `handover`, see SignupRequestBody above)
|
|
1488
|
+
// so the extra field reaches the handler instead of being parsed away.
|
|
1489
|
+
schema?: typeof RequestTokenBody | typeof SignupRequestBody;
|
|
1465
1490
|
}): void {
|
|
1466
|
-
const body = RequestTokenBody;
|
|
1491
|
+
const body = opts.schema ?? RequestTokenBody;
|
|
1467
1492
|
opts.api.post(opts.path, async (c) => {
|
|
1468
1493
|
const raw = await c.req.json().catch(() => null);
|
|
1469
1494
|
const parsed = body.safeParse(raw);
|
|
@@ -1478,7 +1503,7 @@ function registerTokenRequestRoute(opts: {
|
|
|
1478
1503
|
// the log line (PII).
|
|
1479
1504
|
const result = await opts.dispatcher.write(
|
|
1480
1505
|
opts.requestHandler,
|
|
1481
|
-
|
|
1506
|
+
parsed.data,
|
|
1482
1507
|
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
1483
1508
|
);
|
|
1484
1509
|
if (!result.isSuccess) {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.313.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "DB pool close() no longer hangs forever during an outage",
|
|
6
|
+
"detail": "createDbConnection/createConnection's close() and PgKmsAdapter.close()\ncalled client.end() with no timeout, which postgres.js waits on\nindefinitely if a query is still referenced on a dead connection — a DB\noutage during process shutdown could hang forever. close() now bounds\nthe wait via a new closeTimeoutSeconds option on DbConnectionOptions\n(default 5s, DEFAULT_DB_CLOSE_TIMEOUT_SECONDS). No migration needed."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.313.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "event-dispatcher stop() waits for a pass still in its idle pre-check",
|
|
12
|
+
"detail": "Since 0.308.0 a pass started by the timer or a NOTIFY wake-up shortly\nbefore stop() could still be inside its idle pre-check, which stop()\nnever waited for. That pass kept running after stop() returned and\nqueried the DB pool the caller was closing, which could make\npostgres.js' end() hang. stop() now also drains in-flight passes.\nNo migration needed."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.313.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Event-triggered jobs get the triggering event's id and headers via ctx.triggerEvent",
|
|
18
|
+
"detail": "r.job's `trigger.on` reached via an r.defineEvent QN (async, at-least-once\ndelivery through the job-trigger event consumer) had no way to key an\nidempotency check on the stored event that fired it. JobContext now\ncarries `triggerEvent?: { id, headers }` — the stored event's id and\nmetadata.headers — when the job was reached this way. Undefined for\ncron/manual jobs and for jobs triggered synchronously off a write/query\nhandler (no stored event to hand over). No migration needed."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.312.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "files: doc/docx accepted, with magic-byte content verification",
|
|
24
|
+
"detail": "EXTENSION_MIME_WHITELIST and MAGIC_BYTE_SIGNATURES now cover .doc (application/msword, OLE signature) and .docx (application/vnd.openxmlformats-officedocument.wordprocessingml.document, ZIP signature). New exported validateFileContent(mimeType, content) checks, for doc/docx only, the declared mimeType against the file's magic bytes independent of options.accept — validateFile's own signature is unchanged (metadata-only); file-routes.ts calls validateFile first, then validateFileContent once after reading the upload body. normalizeMimeType is exported from files/types.ts as the single case/parameter-normalization helper (derivatives-context.ts's local copy was removed in favor of it)."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.312.0",
|
|
28
|
+
"type": "breaking",
|
|
29
|
+
"title": "MultiStreamApplyContext gains a required registry field",
|
|
30
|
+
"migration": "Any hand-built MultiStreamApplyContext (e.g. in an MSP test that constructs the context object literal instead of using createMultiStreamApplyContext/setupTestStack) must add registry: <the app Registry instance>, the same instance HandlerContext/JobContext already expose. Lets an apply resolve extension-point usages (registry.getExtensionUsages) to pick behavior by payload discriminant, e.g. a provider-routed MSP."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.312.0",
|
|
34
|
+
"type": "improvement",
|
|
35
|
+
"title": "r.job triggers can filter on payload fields via JobTrigger.where",
|
|
36
|
+
"detail": "A job's {on} trigger can now carry an optional where: equality filter on top-level event-payload fields, checked in job-runner's handleEvent before the job is enqueued (both the sync write-handler dispatch path and the async event-consumer path share this check). Lets N jobs share one broad event QN, partitioned by a payload discriminant, instead of each needing its own narrow event type."
|
|
37
|
+
},
|
|
2
38
|
{
|
|
3
39
|
"version": "0.310.0",
|
|
4
40
|
"type": "improvement",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
2
|
import postgres from "postgres";
|
|
3
|
+
import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "../db/api";
|
|
3
4
|
import {
|
|
4
5
|
KeyAlreadyExistsError,
|
|
5
6
|
KeyErasedError,
|
|
@@ -172,7 +173,7 @@ export class PgKmsAdapter implements LocalKeyKmsAdapter {
|
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
async close(): Promise<void> {
|
|
175
|
-
await this.sql.end();
|
|
176
|
+
await this.sql.end({ timeout: DEFAULT_DB_CLOSE_TIMEOUT_SECONDS });
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
private ensureSchema(): Promise<void> {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A DB outage must not turn process shutdown into a hang: postgres.js keeps
|
|
2
|
+
// an in-flight query referenced on a dead socket, so a plain end() (no
|
|
3
|
+
// timeout = wait for queries to finish) never resolves once the DB is gone.
|
|
4
|
+
// close() has to bound that wait (db/api.ts's closeTimeoutSeconds). Routes
|
|
5
|
+
// createDbConnection through a TCP proxy that stops forwarding bytes after
|
|
6
|
+
// connect ("blackhole") — same simulated-outage approach as
|
|
7
|
+
// pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts, but
|
|
8
|
+
// without touching the shared test DB's actual availability.
|
|
9
|
+
|
|
10
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import net from "node:net";
|
|
12
|
+
import { waitFor } from "../../testing";
|
|
13
|
+
import { testDatabaseUrl } from "../../testing/closed-connection-error";
|
|
14
|
+
import { createDbConnection } from "../connection";
|
|
15
|
+
|
|
16
|
+
function startBlackholeProxy(dbUrl: URL): {
|
|
17
|
+
readonly server: net.Server;
|
|
18
|
+
blackhole: () => void;
|
|
19
|
+
swallowedBytes: () => number;
|
|
20
|
+
destroy: () => void;
|
|
21
|
+
} {
|
|
22
|
+
let blackholed = false;
|
|
23
|
+
let swallowed = 0;
|
|
24
|
+
const sockets = new Set<net.Socket>();
|
|
25
|
+
const server = net.createServer((downstream) => {
|
|
26
|
+
sockets.add(downstream);
|
|
27
|
+
const upstream = net.connect(Number(dbUrl.port), dbUrl.hostname);
|
|
28
|
+
sockets.add(upstream);
|
|
29
|
+
downstream.on("data", (chunk: Buffer) => {
|
|
30
|
+
if (blackholed) {
|
|
31
|
+
swallowed += chunk.length;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
upstream.write(chunk);
|
|
35
|
+
});
|
|
36
|
+
upstream.on("data", (chunk: Buffer) => {
|
|
37
|
+
if (!blackholed) downstream.write(chunk);
|
|
38
|
+
});
|
|
39
|
+
downstream.on("error", () => {});
|
|
40
|
+
upstream.on("error", () => {});
|
|
41
|
+
downstream.on("close", () => sockets.delete(downstream));
|
|
42
|
+
upstream.on("close", () => sockets.delete(upstream));
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
server,
|
|
46
|
+
blackhole: () => {
|
|
47
|
+
blackholed = true;
|
|
48
|
+
},
|
|
49
|
+
swallowedBytes: () => swallowed,
|
|
50
|
+
destroy: () => {
|
|
51
|
+
for (const socket of sockets) socket.destroy();
|
|
52
|
+
server.close();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function listenOn(server: net.Server, port: number): Promise<void> {
|
|
58
|
+
await new Promise<void>((resolve, reject) => {
|
|
59
|
+
server.once("error", reject);
|
|
60
|
+
server.listen(port, resolve);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let proxy: ReturnType<typeof startBlackholeProxy>;
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
proxy?.destroy();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("createDbConnection close() bounds shutdown during a DB outage", () => {
|
|
71
|
+
test("close() resolves within the bounded timeout even with in-flight queries on a dead socket", async () => {
|
|
72
|
+
const dbUrl = new URL(testDatabaseUrl());
|
|
73
|
+
proxy = startBlackholeProxy(dbUrl);
|
|
74
|
+
await listenOn(proxy.server, 0);
|
|
75
|
+
const address = proxy.server.address();
|
|
76
|
+
if (address === null || typeof address === "string") {
|
|
77
|
+
throw new Error("proxy did not bind to a TCP port");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const proxiedUrl = new URL(dbUrl.toString());
|
|
81
|
+
proxiedUrl.hostname = "127.0.0.1";
|
|
82
|
+
proxiedUrl.port = String(address.port);
|
|
83
|
+
|
|
84
|
+
const conn = createDbConnection(proxiedUrl.toString(), {
|
|
85
|
+
maxConnections: 1,
|
|
86
|
+
closeTimeoutSeconds: 1,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Establish the connection while the proxy still forwards normally —
|
|
90
|
+
// isolates the assertion to close()'s own timeout, not connect_timeout.
|
|
91
|
+
await conn.client.unsafe("select 1");
|
|
92
|
+
|
|
93
|
+
// Fired without awaiting: once the socket goes dead, end() has to
|
|
94
|
+
// reject these instead of waiting on them forever. .catch prevents an
|
|
95
|
+
// unhandled rejection when end() tears the connection down.
|
|
96
|
+
const inFlightBeforeBlackhole = conn.client.unsafe("select 1").catch(() => undefined);
|
|
97
|
+
proxy.blackhole();
|
|
98
|
+
const inFlightAfterBlackhole = conn.client.unsafe("select 1").catch(() => undefined);
|
|
99
|
+
|
|
100
|
+
await waitFor(() => proxy.swallowedBytes() > 0);
|
|
101
|
+
|
|
102
|
+
const start = Date.now();
|
|
103
|
+
await conn.close();
|
|
104
|
+
const elapsed = Date.now() - start;
|
|
105
|
+
|
|
106
|
+
expect(elapsed).toBeLessThan(3000);
|
|
107
|
+
|
|
108
|
+
await Promise.all([inFlightBeforeBlackhole, inFlightAfterBlackhole]);
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/db/api.ts
CHANGED
|
@@ -11,8 +11,14 @@ export type DbConnectionOptions = {
|
|
|
11
11
|
readonly maxConnections?: number;
|
|
12
12
|
readonly idleTimeoutSeconds?: number;
|
|
13
13
|
readonly connectTimeoutSeconds?: number;
|
|
14
|
+
readonly closeTimeoutSeconds?: number;
|
|
14
15
|
};
|
|
15
16
|
|
|
17
|
+
// A DB outage can leave an in-flight query referenced on a dead connection,
|
|
18
|
+
// so a plain end() (no timeout = wait for queries to finish) hangs forever —
|
|
19
|
+
// close() must always bound how long shutdown waits.
|
|
20
|
+
export const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
|
|
21
|
+
|
|
16
22
|
// Connection-Handle: db für Queries, client für Legacy-Zugriff (LISTEN/NOTIFY-Peer
|
|
17
23
|
// bei Bun.SQL), close für Pool-Shutdown.
|
|
18
24
|
export type DbPoolHandle = {
|
package/src/db/bun-provider.ts
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
// Bun.SQL hat kein LISTEN — postgres-js-Peer für event-dispatcher.
|
|
9
9
|
|
|
10
10
|
import postgres from "postgres";
|
|
11
|
-
import
|
|
11
|
+
import {
|
|
12
|
+
type DbConnectionOptions,
|
|
13
|
+
type DbPoolHandle,
|
|
14
|
+
DEFAULT_DB_CLOSE_TIMEOUT_SECONDS,
|
|
15
|
+
} from "./api";
|
|
12
16
|
|
|
13
17
|
export function createBunConnection(url: string, options: DbConnectionOptions = {}): DbPoolHandle {
|
|
14
18
|
const bunOpts: { max?: number; idleTimeout?: number; connectionTimeout?: number } = {};
|
|
@@ -25,13 +29,14 @@ export function createBunConnection(url: string, options: DbConnectionOptions =
|
|
|
25
29
|
pgOpts.connect_timeout = options.connectTimeoutSeconds;
|
|
26
30
|
const listenClient = postgres(url, pgOpts);
|
|
27
31
|
|
|
32
|
+
const closeTimeout = options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS;
|
|
28
33
|
return {
|
|
29
34
|
db,
|
|
30
35
|
client: listenClient,
|
|
31
36
|
listenClient,
|
|
32
37
|
close: async () => {
|
|
33
|
-
await db.end();
|
|
34
|
-
await listenClient.end();
|
|
38
|
+
await db.end({ timeout: closeTimeout });
|
|
39
|
+
await listenClient.end({ timeout: closeTimeout });
|
|
35
40
|
},
|
|
36
41
|
};
|
|
37
42
|
}
|
package/src/db/connection.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { DbConnection, PgClient } from "@cosmicdrift/kumiko-types/db-connection";
|
|
4
4
|
import postgres from "postgres";
|
|
5
5
|
import { readPositiveIntEnv } from "../utils/env-parse";
|
|
6
|
+
import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "./api";
|
|
6
7
|
|
|
7
8
|
// Raw client types (postgres-js | Bun.SQL) — the name used across query/
|
|
8
9
|
// event-store/pipeline call sites. The structural pool handle from ./api is
|
|
@@ -33,9 +34,8 @@ export function createDbConnection(
|
|
|
33
34
|
return {
|
|
34
35
|
db: client,
|
|
35
36
|
client,
|
|
36
|
-
close:
|
|
37
|
-
|
|
38
|
-
},
|
|
37
|
+
close: () =>
|
|
38
|
+
client.end({ timeout: options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS }),
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
package/src/db/eagerload.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { requestContext } from "../api/request-context";
|
|
27
27
|
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues } from "../crypto";
|
|
28
28
|
import { selectMany } from "../db/query";
|
|
29
|
+
import { parseRefTargetEntityName } from "../engine/parse-ref-target";
|
|
29
30
|
import type { EntityDefinition, FieldDefinition, ReferenceFieldDef } from "../engine/types";
|
|
30
31
|
import {
|
|
31
32
|
collectEncryptedFieldNames,
|
|
@@ -67,21 +68,13 @@ function isReferenceField(field: FieldDefinition): field is ReferenceFieldDef {
|
|
|
67
68
|
return field.type === "reference";
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function parseRefEntity(raw: string): string {
|
|
71
|
-
// Same-feature ("user") oder cross-feature ("users:user") — wir
|
|
72
|
-
// brauchen nur den entity-name (Names sind global eindeutig in
|
|
73
|
-
// entityMap). Der feature-prefix dient nur der Author-Klarheit.
|
|
74
|
-
const idx = raw.indexOf(":");
|
|
75
|
-
return idx < 0 ? raw : raw.slice(idx + 1);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
71
|
export function collectReferenceFields(entity: EntityDefinition): readonly ReferenceFieldEntry[] {
|
|
79
72
|
const out: ReferenceFieldEntry[] = [];
|
|
80
73
|
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
81
74
|
if (!isReferenceField(fieldDef)) continue;
|
|
82
75
|
out.push({
|
|
83
76
|
fieldName,
|
|
84
|
-
refEntityName:
|
|
77
|
+
refEntityName: parseRefTargetEntityName(fieldDef.entity),
|
|
85
78
|
multiple: fieldDef.multiple === true,
|
|
86
79
|
});
|
|
87
80
|
}
|
package/src/db/index.ts
CHANGED
|
@@ -114,7 +114,7 @@ export {
|
|
|
114
114
|
type PgErrorInfo,
|
|
115
115
|
} from "./pg-error";
|
|
116
116
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock";
|
|
117
|
-
export { executeRawQuery } from "./queries/raw-sql";
|
|
117
|
+
export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql";
|
|
118
118
|
export type { SelectOptions, WhereObject, WhereValue } from "./query-api";
|
|
119
119
|
export {
|
|
120
120
|
asRawClient,
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// `asRawClient(db)` wrappt .unsafe und .begin transparent.
|
|
4
4
|
|
|
5
5
|
import postgres from "postgres";
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
type DbConnectionOptions,
|
|
8
|
+
type DbPoolHandle,
|
|
9
|
+
DEFAULT_DB_CLOSE_TIMEOUT_SECONDS,
|
|
10
|
+
} from "./api";
|
|
7
11
|
|
|
8
12
|
export function createPgConnection(url: string, options: DbConnectionOptions = {}): DbPoolHandle {
|
|
9
13
|
const pgOptions: Parameters<typeof postgres>[1] = {};
|
|
@@ -18,8 +22,7 @@ export function createPgConnection(url: string, options: DbConnectionOptions = {
|
|
|
18
22
|
return {
|
|
19
23
|
db: client,
|
|
20
24
|
client,
|
|
21
|
-
close:
|
|
22
|
-
|
|
23
|
-
},
|
|
25
|
+
close: () =>
|
|
26
|
+
client.end({ timeout: options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS }),
|
|
24
27
|
};
|
|
25
28
|
}
|
|
@@ -326,11 +326,12 @@ export async function selectConsumerForUpdate(
|
|
|
326
326
|
db: AnyDb,
|
|
327
327
|
name: string,
|
|
328
328
|
instanceId: string,
|
|
329
|
-
): Promise<
|
|
330
|
-
await asRawClient(db).unsafe(
|
|
329
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
330
|
+
const rows = (await asRawClient(db).unsafe(
|
|
331
331
|
`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE`,
|
|
332
332
|
[name, instanceId],
|
|
333
|
-
)
|
|
333
|
+
)) as ReadonlyArray<Record<string, unknown>>;
|
|
334
|
+
return rows[0];
|
|
334
335
|
}
|
|
335
336
|
|
|
336
337
|
export async function updateConsumerRebuildCursor(
|
|
@@ -14,7 +14,7 @@ import type { Registry, TenantId } from "../engine/types";
|
|
|
14
14
|
import { InternalError, NotFoundError } from "../errors";
|
|
15
15
|
import type { FileContext } from "../files/file-handle";
|
|
16
16
|
import { fileRefsTable } from "../files/file-ref-table";
|
|
17
|
-
import { assertSafeStorageKey } from "../files/types";
|
|
17
|
+
import { assertSafeStorageKey, normalizeMimeType } from "../files/types";
|
|
18
18
|
import { variantSuffix } from "./variant-key";
|
|
19
19
|
|
|
20
20
|
export type DerivativesContextDeps = {
|
|
@@ -30,13 +30,6 @@ function isDerivativeRendererPlugin(o: unknown): o is DerivativeRendererPlugin {
|
|
|
30
30
|
return typeof o === "object" && o !== null && "render" in o && typeof o.render === "function";
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// Mirrors validateFile's mime-normalization: strip a `; charset=…` suffix,
|
|
34
|
-
// trim, lowercase — so a provider-supplied `image/jpeg; charset=binary`
|
|
35
|
-
// still resolves.
|
|
36
|
-
function normalizeMimeType(mimeType: string): string {
|
|
37
|
-
return mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
|
38
|
-
}
|
|
39
|
-
|
|
40
33
|
export function resolveRenderer(
|
|
41
34
|
registry: Registry,
|
|
42
35
|
mimeType: string,
|
|
@@ -115,6 +115,18 @@ describe("validateTransferGraph", () => {
|
|
|
115
115
|
expect(() => validate(chain)).not.toThrow();
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
// A feature-prefixed reference target ("<feature>:<entity>") must resolve
|
|
119
|
+
// to the same entity name as an unprefixed one, or a chain built entirely
|
|
120
|
+
// out of prefixed refs would silently never trip the depth limit.
|
|
121
|
+
test("counts a chain of feature-prefixed reference targets into the depth limit", () => {
|
|
122
|
+
const chain: Record<string, EntityDefinition> = { e0: entity({}) };
|
|
123
|
+
for (let i = 1; i <= MAX_TRANSFER_DEPTH + 1; i++) {
|
|
124
|
+
chain[`e${i}`] = entity({ references: { parentId: { entity: `graph:e${i - 1}` } } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
expect(() => validate(chain)).toThrow(/deeper than the 5-level limit.*e0 -> e1/s);
|
|
128
|
+
});
|
|
129
|
+
|
|
118
130
|
test("terminates on a reference cycle instead of reporting false depth", () => {
|
|
119
131
|
expect(() =>
|
|
120
132
|
validate({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseRefTargetEntityName } from "../parse-ref-target";
|
|
1
2
|
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
2
3
|
|
|
3
4
|
// --- Transfer-graph boot validation (fw#3088) ---
|
|
@@ -39,7 +40,7 @@ function referenceTargets(entity: EntityDefinition): readonly string[] {
|
|
|
39
40
|
for (const field of Object.values(entity.fields)) {
|
|
40
41
|
if (field.type !== "reference") continue;
|
|
41
42
|
if (field.multiple === true) continue;
|
|
42
|
-
targets.push(field.entity);
|
|
43
|
+
targets.push(parseRefTargetEntityName(field.entity));
|
|
43
44
|
}
|
|
44
45
|
return targets;
|
|
45
46
|
}
|
|
@@ -222,6 +222,9 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
222
222
|
on: Array.isArray(jobOptions.trigger.on)
|
|
223
223
|
? jobOptions.trigger.on.map(resolveName)
|
|
224
224
|
: resolveName(jobOptions.trigger.on as NameOrRef), // @cast-boundary engine-bridge
|
|
225
|
+
...(jobOptions.trigger.where !== undefined
|
|
226
|
+
? { where: jobOptions.trigger.where }
|
|
227
|
+
: {}),
|
|
225
228
|
}
|
|
226
229
|
: jobOptions.trigger;
|
|
227
230
|
state.jobs[jobName] = { ...jobOptions, trigger, name: jobName, handler: jobHandler };
|
package/src/engine/index.ts
CHANGED
|
@@ -264,6 +264,8 @@ export {
|
|
|
264
264
|
userCanReadFieldRow,
|
|
265
265
|
userCanWriteFieldRow,
|
|
266
266
|
} from "./ownership";
|
|
267
|
+
export type { ParsedRefTarget } from "./parse-ref-target";
|
|
268
|
+
export { parseRefTarget, parseRefTargetEntityName } from "./parse-ref-target";
|
|
267
269
|
export { buildPipelineSteps, stepsPipeline } from "./pipeline";
|
|
268
270
|
export { defineApply, defineMspApply, setFields } from "./projection-helpers";
|
|
269
271
|
export type { BuiltinQnType, ParsedQn, QnType } from "./qualified-name";
|
|
@@ -431,6 +433,7 @@ export type {
|
|
|
431
433
|
JobDefinition,
|
|
432
434
|
JobHandlerFn,
|
|
433
435
|
JobTrigger,
|
|
436
|
+
JobTriggerEvent,
|
|
434
437
|
JsonbFieldDef,
|
|
435
438
|
KumikoEntityTypeMap,
|
|
436
439
|
KumikoEventTypeMap,
|
|
@@ -20,3 +20,9 @@ export function parseRefTarget(raw: string, currentFeature: string): ParsedRefTa
|
|
|
20
20
|
}
|
|
21
21
|
return { featureName: raw.slice(0, idx), entityName: raw.slice(idx + 1) };
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
// Entity names are globally unique in the registry, so callers that only
|
|
25
|
+
// need the entity (not its owning feature) can drop the currentFeature arg.
|
|
26
|
+
export function parseRefTargetEntityName(raw: string): string {
|
|
27
|
+
return parseRefTarget(raw, "").entityName;
|
|
28
|
+
}
|
|
@@ -2,6 +2,7 @@ import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
|
2
2
|
import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption";
|
|
3
3
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
|
|
4
4
|
import { resolveName } from "./handler-helpers";
|
|
5
|
+
import { parseRefTargetEntityName } from "./parse-ref-target";
|
|
5
6
|
import type {
|
|
6
7
|
RegistryState,
|
|
7
8
|
SearchableReferenceField,
|
|
@@ -192,16 +193,6 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
|
|
195
|
-
// "customer" (same-feature) or "users:customer" (cross-feature,
|
|
196
|
-
// "<featureName>:<entityName>") — only the entity name matters here, names
|
|
197
|
-
// are globally unique in entityMap. Local duplicate of the same parse done
|
|
198
|
-
// in db/eagerload.ts's parseRefEntity — this loop has no feature context to
|
|
199
|
-
// hand to engine/parse-ref-target.ts's parseRefTarget.
|
|
200
|
-
function parseReferenceTargetEntityName(raw: string): string {
|
|
201
|
-
const idx = raw.indexOf(":");
|
|
202
|
-
return idx < 0 ? raw : raw.slice(idx + 1);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
196
|
// fw#2660: labelField defaults to "id" (a UUID column) elsewhere, but a
|
|
206
197
|
// searchable reference ILIKEs the label column — an implicit or explicit
|
|
207
198
|
// "id" default would 500 on every search request. Fail at boot.
|
|
@@ -224,7 +215,7 @@ function buildSearchableReferenceField(
|
|
|
224
215
|
`(array) reference fields — remove "multiple" or "searchable".`,
|
|
225
216
|
);
|
|
226
217
|
}
|
|
227
|
-
const targetEntityName =
|
|
218
|
+
const targetEntityName = parseRefTargetEntityName(field.entity);
|
|
228
219
|
const targetEntity = entityMap.get(targetEntityName);
|
|
229
220
|
if (targetEntity !== undefined) {
|
|
230
221
|
if (
|
|
@@ -273,7 +264,7 @@ function buildSortableReferenceField(
|
|
|
273
264
|
}
|
|
274
265
|
return {
|
|
275
266
|
fieldName,
|
|
276
|
-
targetEntityName:
|
|
267
|
+
targetEntityName: parseRefTargetEntityName(field.entity),
|
|
277
268
|
labelField: field.labelField,
|
|
278
269
|
};
|
|
279
270
|
}
|