@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.
Files changed (35) hide show
  1. package/package.json +4 -4
  2. package/src/api/auth-routes.ts +27 -2
  3. package/src/changes.json +36 -0
  4. package/src/crypto/pg-kms-adapter.ts +2 -1
  5. package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
  6. package/src/db/api.ts +6 -0
  7. package/src/db/bun-provider.ts +8 -3
  8. package/src/db/connection.ts +3 -3
  9. package/src/db/eagerload.ts +2 -9
  10. package/src/db/index.ts +1 -1
  11. package/src/db/postgres-provider.ts +7 -4
  12. package/src/db/queries/event-consumer.ts +4 -3
  13. package/src/derivatives/derivatives-context.ts +1 -8
  14. package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
  15. package/src/engine/boot-validator/transfer-graph.ts +2 -1
  16. package/src/engine/feature-config-events-jobs.ts +3 -0
  17. package/src/engine/index.ts +3 -0
  18. package/src/engine/parse-ref-target.ts +6 -0
  19. package/src/engine/registry-validate.ts +3 -12
  20. package/src/engine/types/index.ts +1 -0
  21. package/src/files/__tests__/files.integration.test.ts +115 -1
  22. package/src/files/__tests__/storage-tracking.test.ts +46 -0
  23. package/src/files/file-routes.ts +13 -2
  24. package/src/files/index.ts +8 -1
  25. package/src/files/storage-tracking.ts +160 -15
  26. package/src/files/types.ts +44 -2
  27. package/src/jobs/job-runner.ts +39 -0
  28. package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
  29. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +133 -6
  30. package/src/pipeline/event-dispatcher-delivery.ts +22 -1
  31. package/src/pipeline/event-dispatcher.ts +25 -2
  32. package/src/pipeline/index.ts +4 -1
  33. package/src/pipeline/msp-rebuild.ts +1 -0
  34. package/src/pipeline/multi-stream-apply-context.ts +1 -0
  35. 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.311.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.311.0",
202
- "@cosmicdrift/kumiko-types": "0.311.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.311.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
  },
@@ -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
- { email: parsed.data.email },
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 = {
@@ -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 type { DbConnectionOptions, DbPoolHandle } from "./api";
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
  }
@@ -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: async () => {
37
- await client.end();
38
- },
37
+ close: () =>
38
+ client.end({ timeout: options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS }),
39
39
  };
40
40
  }
41
41
 
@@ -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: parseRefEntity(fieldDef.entity),
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 type { DbConnectionOptions, DbPoolHandle } from "./api";
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: async () => {
22
- await client.end();
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<void> {
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 };
@@ -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 = parseReferenceTargetEntityName(field.entity);
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: parseReferenceTargetEntityName(field.entity),
267
+ targetEntityName: parseRefTargetEntityName(field.entity),
277
268
  labelField: field.labelField,
278
269
  };
279
270
  }
@@ -185,6 +185,7 @@ export type {
185
185
  HandlerRef,
186
186
  JobContext,
187
187
  JobRunnerRef,
188
+ JobTriggerEvent,
188
189
  MemberReader,
189
190
  NameOrRef,
190
191
  NotifyFactory,