@cosmicdrift/kumiko-framework 0.164.0 → 0.165.1

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 (160) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +62 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +326 -18
  5. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  6. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  7. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -1
  8. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  9. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +146 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
  13. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  14. package/src/api/__tests__/sse-broker.test.ts +57 -0
  15. package/src/api/__tests__/sse-route.test.ts +4 -0
  16. package/src/api/auth-routes.ts +178 -33
  17. package/src/api/index.ts +1 -0
  18. package/src/api/jwt.ts +22 -1
  19. package/src/api/routes.ts +117 -30
  20. package/src/api/server.ts +39 -7
  21. package/src/api/sse-broker.ts +39 -0
  22. package/src/bun-db/connection.ts +3 -3
  23. package/src/bun-db/index.ts +1 -0
  24. package/src/bun-db/query.ts +12 -3
  25. package/src/consumer-cli.ts +60 -13
  26. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +19 -13
  27. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  28. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  29. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  30. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  31. package/src/db/api.ts +2 -2
  32. package/src/db/bun-provider.ts +2 -2
  33. package/src/db/connection.ts +6 -3
  34. package/src/db/dialect.ts +1 -6
  35. package/src/db/entity-table-meta-types.ts +1 -1
  36. package/src/db/event-store-executor-context.ts +2 -3
  37. package/src/db/event-store-executor-read.ts +2 -3
  38. package/src/db/event-store-executor-write.ts +8 -0
  39. package/src/db/index.ts +8 -3
  40. package/src/db/located-timestamp.ts +4 -0
  41. package/src/db/migrate-runner.ts +107 -11
  42. package/src/db/pg-error.ts +8 -0
  43. package/src/db/postgres-provider.ts +2 -2
  44. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  45. package/src/db/queries/ddl.ts +45 -0
  46. package/src/db/queries/event-store.ts +97 -21
  47. package/src/db/queries/test-stack.ts +4 -30
  48. package/src/db/reference-data.ts +2 -3
  49. package/src/db/replay-migration-sql.ts +114 -12
  50. package/src/db/tenant-db.ts +2 -4
  51. package/src/engine/__tests__/boot-validator.test.ts +14 -0
  52. package/src/engine/__tests__/engine.test.ts +30 -0
  53. package/src/engine/__tests__/registry.test.ts +36 -0
  54. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  55. package/src/engine/__tests__/store-table.test.ts +2 -2
  56. package/src/engine/boot-validator/entity-handler.ts +14 -25
  57. package/src/engine/boot-validator/nav.ts +5 -0
  58. package/src/engine/constants.ts +32 -6
  59. package/src/engine/create-app.ts +11 -0
  60. package/src/engine/effective-features.ts +12 -2
  61. package/src/engine/extensions/user-data.ts +12 -4
  62. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
  63. package/src/engine/feature-ast/extractors/handlers.ts +13 -18
  64. package/src/engine/feature-ui-extensions.ts +2 -2
  65. package/src/engine/hook-helpers.ts +3 -1
  66. package/src/engine/index.ts +1 -1
  67. package/src/engine/ownership.ts +4 -3
  68. package/src/engine/registry-ingest.ts +14 -14
  69. package/src/engine/registry-state.ts +4 -1
  70. package/src/engine/registry-validate.ts +7 -2
  71. package/src/engine/schema-builder.ts +1 -0
  72. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  73. package/src/engine/steps/_duration-utils.ts +2 -0
  74. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  75. package/src/engine/types/config.ts +1 -1
  76. package/src/engine/types/define-handler.ts +1 -1
  77. package/src/engine/types/entity-handlers.ts +1 -1
  78. package/src/engine/types/event-type-map.ts +1 -1
  79. package/src/engine/types/feature.ts +1 -1
  80. package/src/engine/types/fields.ts +1 -1
  81. package/src/engine/types/handlers.ts +1 -1
  82. package/src/engine/types/hooks.ts +1 -1
  83. package/src/engine/types/http-route.ts +1 -1
  84. package/src/engine/types/index.ts +0 -2
  85. package/src/engine/types/nav.ts +1 -1
  86. package/src/engine/types/ownership.ts +1 -1
  87. package/src/engine/types/projection.ts +1 -1
  88. package/src/engine/types/relations.ts +1 -1
  89. package/src/engine/types/screen.ts +1 -1
  90. package/src/engine/types/step.ts +1 -1
  91. package/src/engine/types/target-ref.ts +1 -1
  92. package/src/engine/types/tree-node.ts +1 -1
  93. package/src/engine/types/workspace.ts +1 -1
  94. package/src/engine/validate-projection-allowlist.ts +5 -5
  95. package/src/errors/classes.ts +21 -0
  96. package/src/errors/index.ts +1 -0
  97. package/src/errors/write-error-info.ts +6 -2
  98. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  99. package/src/event-store/__tests__/event-store.integration.test.ts +60 -14
  100. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +27 -12
  101. package/src/event-store/admin-api.ts +11 -4
  102. package/src/event-store/event-store.ts +20 -21
  103. package/src/event-store/index.ts +0 -1
  104. package/src/event-store/types.ts +1 -1
  105. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  106. package/src/files/__tests__/local-provider.test.ts +31 -0
  107. package/src/files/__tests__/write-stream.test.ts +3 -3
  108. package/src/files/index.ts +1 -1
  109. package/src/files/local-provider.ts +6 -1
  110. package/src/files/types.ts +8 -1
  111. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  112. package/src/jobs/job-runner.ts +41 -11
  113. package/src/logging/types.ts +1 -1
  114. package/src/observability/index.ts +1 -0
  115. package/src/observability/standard-metrics.ts +35 -2
  116. package/src/observability/types/index.ts +1 -1
  117. package/src/observability/types/metric.ts +1 -1
  118. package/src/observability/types/provider.ts +1 -1
  119. package/src/observability/types/span.ts +1 -1
  120. package/src/pipeline/__tests__/dispatcher.test.ts +203 -0
  121. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  122. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  123. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  124. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +111 -88
  125. package/src/pipeline/dispatch-shared.ts +59 -6
  126. package/src/pipeline/dispatch-stream.ts +44 -17
  127. package/src/pipeline/dispatcher.ts +7 -1
  128. package/src/pipeline/event-consumer-state.ts +16 -13
  129. package/src/pipeline/event-dispatcher-delivery.ts +22 -7
  130. package/src/pipeline/event-dispatcher.ts +22 -0
  131. package/src/pipeline/index.ts +2 -0
  132. package/src/pipeline/system-hooks.ts +137 -1
  133. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  134. package/src/rate-limit/resolver.ts +6 -2
  135. package/src/schema-cli.ts +24 -12
  136. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  137. package/src/search/reindex-entity.ts +31 -2
  138. package/src/search/types.ts +1 -1
  139. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  140. package/src/stack/db.ts +2 -1
  141. package/src/stack/push-entity-projection-tables.ts +2 -1
  142. package/src/stack/request-helper.ts +20 -1
  143. package/src/stack/table-helpers.ts +6 -4
  144. package/src/stack/test-stack.ts +18 -15
  145. package/src/testing/__tests__/late-bound.test.ts +7 -0
  146. package/src/testing/__tests__/wait-for.test.ts +6 -0
  147. package/src/testing/file-provider-contract.ts +26 -6
  148. package/src/testing/index.ts +1 -0
  149. package/src/testing/late-bound.ts +5 -3
  150. package/src/testing/wait-for.ts +3 -0
  151. package/src/testing/without-ambient-temporal.ts +14 -0
  152. package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
  153. package/src/time/geo-tz.ts +1 -1
  154. package/src/time/polyfill.ts +28 -39
  155. package/src/time/tz-context.ts +30 -24
  156. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
  157. package/src/utils/safe-json.ts +3 -2
  158. package/src/db/__tests__/encryption.test.ts +0 -39
  159. package/src/db/encryption.ts +0 -45
  160. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -8,7 +8,7 @@
8
8
  // selbst, das Guard-Script selbst.
9
9
 
10
10
  import type { DbRunner } from "../db";
11
- import { isUniqueViolation } from "../db/pg-error";
11
+ import { constraintOf, isUniqueViolation } from "../db/pg-error";
12
12
  import {
13
13
  eventPredecessorExists,
14
14
  findExistingEventVersion,
@@ -17,7 +17,7 @@ import {
17
17
  insertRawSubsequentEvent,
18
18
  } from "../db/queries/event-store-admin";
19
19
  import type { TenantId } from "../engine/types";
20
- import { VersionConflictError } from "./errors";
20
+ import { IdempotentAppendConflictError, VersionConflictError } from "./errors";
21
21
  import type { EventMetadata } from "./event-store";
22
22
 
23
23
  export type RawEventToAppend = {
@@ -39,6 +39,13 @@ export type RawEventToAppend = {
39
39
  readonly createdBy: string;
40
40
  };
41
41
 
42
+ function mapEventUniqueViolation(e: unknown, event: RawEventToAppend): Error {
43
+ if (constraintOf(e) === "events_idempotency_uq" && event.metadata.idempotencyKey) {
44
+ return new IdempotentAppendConflictError(event.tenantId, event.metadata.idempotencyKey);
45
+ }
46
+ return new VersionConflictError(event.aggregateId, event.expectedVersion);
47
+ }
48
+
42
49
  // Mirrors append()'s two-path structure: typed builder equivalent for v=0,
43
50
  // INSERT … SELECT … WHERE EXISTS gate for v>0. Caller-supplied createdAt +
44
51
  // createdBy skip the usual userResolver/now() paths.
@@ -54,7 +61,7 @@ export async function appendRaw(runner: DbRunner, event: RawEventToAppend): Prom
54
61
  }
55
62
  } catch (e) {
56
63
  if (isUniqueViolation(e)) {
57
- throw new VersionConflictError(event.aggregateId, event.expectedVersion);
64
+ throw mapEventUniqueViolation(e, event);
58
65
  }
59
66
  throw e;
60
67
  }
@@ -143,7 +150,7 @@ export async function appendRawBatch(
143
150
  if (isUniqueViolation(e)) {
144
151
  // Pre-flight ran but lost a race against a concurrent writer. Rare for
145
152
  // migration (single-runner) but possible; we can't name the exact row.
146
- throw new VersionConflictError(firstEvent.aggregateId, firstEvent.expectedVersion);
153
+ throw mapEventUniqueViolation(e, firstEvent);
147
154
  }
148
155
  throw e;
149
156
  }
@@ -1,4 +1,11 @@
1
1
  import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
2
+ // Value-only import, aliased to avoid shadowing the ambient global
3
+ // `Temporal` TYPE this file's other Temporal.Instant annotations resolve
4
+ // against (StoredEvent.createdAt et al. — importing the bare name here
5
+ // conflicts with that ambient type, see #1438). Bun doesn't expose Temporal
6
+ // as a runtime value on globalThis, so the un-aliased call below crashed
7
+ // with "Temporal is not defined" outside boot paths that install it (#1480).
8
+ import { Temporal as TemporalPolyfill } from "temporal-polyfill";
2
9
  import { encryptEventPayloadPii } from "../crypto/event-pii";
3
10
  import type { DbRunner } from "../db";
4
11
  import { constraintOf, isUniqueViolation } from "../db/pg-error";
@@ -6,7 +13,6 @@ import {
6
13
  insertSubsequentEventRow,
7
14
  notifyPgChannel,
8
15
  selectAggregateMaxVersion,
9
- selectAggregateStreamTenant,
10
16
  selectEventsHighWaterMark,
11
17
  selectStreamMaxVersion,
12
18
  } from "../db/queries/event-store";
@@ -152,12 +158,20 @@ async function insertSubsequentEvent(
152
158
  expectedVersion: event.expectedVersion,
153
159
  });
154
160
  if (!row) throw new VersionConflictError(event.aggregateId, event.expectedVersion);
161
+ const createdAt =
162
+ row.created_at instanceof Date
163
+ ? TemporalPolyfill.Instant.fromEpochMilliseconds(row.created_at.getTime())
164
+ : TemporalPolyfill.Instant.from(row.created_at);
155
165
  return {
156
166
  id: typeof row.id === "bigint" ? row.id : BigInt(row.id),
157
- createdAt:
158
- row.created_at instanceof Date
159
- ? Temporal.Instant.fromEpochMilliseconds(row.created_at.getTime())
160
- : Temporal.Instant.from(row.created_at),
167
+ // @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
168
+ // at runtime, two separate .d.ts sources (temporal-polyfill's bundled
169
+ // types vs the ambient temporal-spec global) disagree on a couple of
170
+ // method-overload signatures (until/equals argument types), so TS treats
171
+ // them as nominally distinct. Safe: this value is only ever consumed as
172
+ // a Temporal.Instant (comparisons, .toString(), arithmetic), never
173
+ // narrowed on the specific overload shape that differs.
174
+ createdAt: createdAt as unknown as Temporal.Instant,
161
175
  };
162
176
  }
163
177
 
@@ -260,21 +274,6 @@ export async function getUnscopedAggregateStreamMaxVersion(
260
274
  return selectAggregateMaxVersion(db, aggregateId);
261
275
  }
262
276
 
263
- /** Stream tenant of an aggregate (the tenant_id its events live under), with no
264
- * membership/tenant filter. SECURITY: existence-oracle, same caveat as
265
- * getUnscopedAggregateStreamMaxVersion — seed/system-internal use only. Recovers
266
- * the write target for a systemScope aggregate whose stream tenant isn't one of
267
- * the subject's memberships. Returns null for unknown streams. */
268
- export async function getUnscopedAggregateStreamTenant(
269
- db: DbRunner,
270
- aggregateId: string,
271
- aggregateType: string,
272
- ): Promise<TenantId | null> {
273
- const tenantId = await selectAggregateStreamTenant(db, aggregateId, aggregateType);
274
- // DB-boundary: kumiko_events.tenant_id is a TenantId-shaped uuid column.
275
- return tenantId as TenantId | null;
276
- }
277
-
278
277
  // Global high-water-mark = MAX(events.id). Marten/Wolverine standard for
279
278
  // projection/consumer lag math: lag = HWM - cursor. Single-row aggregate over
280
279
  // the bigserial PK index — sub-millisecond cost. Returns 0n on an empty log
@@ -312,7 +311,7 @@ export async function loadEventsAfterVersion(
312
311
  // prevent.
313
312
  export const LOAD_ALL_EVENTS_ROW_LIMIT = 100_000;
314
313
 
315
- /** @deprecated buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) instead. */
314
+ /** Buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) for production reads; this is test-only in practice. */
316
315
  export async function loadAllEventsByType(
317
316
  db: DbRunner,
318
317
  aggregateType: string,
@@ -21,7 +21,6 @@ export {
21
21
  getEventsHighWaterMark,
22
22
  getStreamVersion,
23
23
  getUnscopedAggregateStreamMaxVersion,
24
- getUnscopedAggregateStreamTenant,
25
24
  LOAD_ALL_EVENTS_ROW_LIMIT,
26
25
  loadAggregate,
27
26
  loadAggregateAsOf,
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/event-store-types";
2
+ export type * from "@cosmicdrift/kumiko-types/event-store-types";
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildStorageKey } from "../types";
3
+
4
+ describe("buildStorageKey", () => {
5
+ test("uses the lowercased extension for a normal filename", () => {
6
+ const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "logo.PNG", "u1");
7
+ expect(key).toBe("T1/invoice/1/attachment/u1.png");
8
+ });
9
+
10
+ test("uses the sole segment as extension when there is no dot", () => {
11
+ const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "noext", "u1");
12
+ expect(key).toBe("T1/invoice/1/attachment/u1.noext");
13
+ });
14
+
15
+ test("rejects a path-traversal filename and falls back to bin instead of leaking the payload", () => {
16
+ const key = buildStorageKey(
17
+ "T1" as never,
18
+ "unattached",
19
+ "file",
20
+ "u",
21
+ "a.b/../../../../evil",
22
+ "u1",
23
+ );
24
+ expect(key).toBe("T1/unattached/file/u/u1.bin");
25
+ expect(key).not.toContain("..");
26
+ expect(key.split("/")).toHaveLength(5);
27
+ });
28
+ });
@@ -0,0 +1,31 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createLocalProvider } from "../local-provider";
6
+
7
+ describe("createLocalProvider path-traversal guard", () => {
8
+ const basePath = join(tmpdir(), `kumiko-local-provider-test-${Date.now()}`);
9
+ const provider = createLocalProvider(basePath);
10
+
11
+ afterEach(async () => {
12
+ await rm(basePath, { recursive: true, force: true });
13
+ });
14
+
15
+ test("rejects a key with a `..` segment even when it resolves inside basePath", async () => {
16
+ await expect(
17
+ provider.write("T1/e/1/f/x.png/../../../../T2/y.png", new Uint8Array([1])),
18
+ ).rejects.toThrow(/path-traversal/);
19
+ });
20
+
21
+ test("rejects a `..` segment on read/delete too, and exists() reports false instead of throwing", async () => {
22
+ await expect(provider.read("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
23
+ await expect(provider.delete("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
24
+ expect(await provider.exists("a/../../etc/passwd")).toBe(false);
25
+ });
26
+
27
+ test("still allows a normal, contained key", async () => {
28
+ await provider.write("T1/entity/1/field/file.png", new Uint8Array([1, 2, 3]));
29
+ expect(await provider.exists("T1/entity/1/field/file.png")).toBe(true);
30
+ });
31
+ });
@@ -153,10 +153,10 @@ describe("FileStorageProvider.writeStream — local-filesystem", () => {
153
153
  const escapingKey = "../../etc/passwd";
154
154
 
155
155
  await expect(provider.write(escapingKey, new Uint8Array([1]))).rejects.toThrow(
156
- /escapes basePath/,
156
+ /path-traversal/,
157
157
  );
158
- await expect(provider.read(escapingKey)).rejects.toThrow(/escapes basePath/);
159
- await expect(provider.delete(escapingKey)).rejects.toThrow(/escapes basePath/);
158
+ await expect(provider.read(escapingKey)).rejects.toThrow(/path-traversal/);
159
+ await expect(provider.delete(escapingKey)).rejects.toThrow(/path-traversal/);
160
160
  // exists() treats any stat failure (missing file, escaping key) as "not there".
161
161
  expect(await provider.exists(escapingKey)).toBe(false);
162
162
  });
@@ -34,6 +34,6 @@ export type {
34
34
  SignedUrlOptions,
35
35
  WriteStreamOptions,
36
36
  } from "./types";
37
- export { buildStorageKey, parseMaxSize, validateFile } from "./types";
37
+ export { assertSafeStorageKey, buildStorageKey, parseMaxSize, validateFile } from "./types";
38
38
  export type { ZipEntry } from "./zip-stream";
39
39
  export { createZipStream } from "./zip-stream";
@@ -2,7 +2,7 @@ import { createReadStream, createWriteStream } from "node:fs";
2
2
  import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
- import type { FileStorageProvider } from "./types";
5
+ import { assertSafeStorageKey, type FileStorageProvider } from "./types";
6
6
 
7
7
  // Local-filesystem backend — intended for dev + tests. Production deploys
8
8
  // pick an object-store provider (S3/R2/…). mimeType is ignored here; the
@@ -16,6 +16,11 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
16
16
  // segment that slipped past an upstream check), regardless of the key's
17
17
  // source.
18
18
  function resolveContainedPath(key: string): string {
19
+ assertSafeStorageKey(key);
20
+ // assertSafeStorageKey rejects every literal "."/".." segment, which
21
+ // covers all string-based traversal a join+resolve can produce; this
22
+ // startsWith check is a second, independent line of defense in case a
23
+ // future caller feeds resolveContainedPath something pre-resolved.
19
24
  const filePath = resolve(join(basePath, key));
20
25
  if (filePath !== resolvedBase && !filePath.startsWith(resolvedBase + sep)) {
21
26
  throw new Error(`storage key escapes basePath: "${key}"`);
@@ -84,6 +84,12 @@ export function validateFile(
84
84
  return null;
85
85
  }
86
86
 
87
+ export function assertSafeStorageKey(key: string): void {
88
+ if (key.split("/").some((segment) => segment === "." || segment === "..")) {
89
+ throw new Error(`storage key contains a path-traversal segment: "${key}"`);
90
+ }
91
+ }
92
+
87
93
  export function buildStorageKey(
88
94
  tenantId: TenantId,
89
95
  entityType: string,
@@ -92,6 +98,7 @@ export function buildStorageKey(
92
98
  fileName: string,
93
99
  uniqueId: string,
94
100
  ): string {
95
- const ext = fileName.split(".").pop() ?? "bin";
101
+ const rawExt = fileName.split(".").pop() ?? "";
102
+ const ext = /^[A-Za-z0-9]+$/.test(rawExt) ? rawExt.toLowerCase() : "bin";
96
103
  return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uniqueId}.${ext}`;
97
104
  }
@@ -1,12 +1,22 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { Queue } from "bullmq";
2
3
  import { z } from "zod";
3
4
  import { requestContext } from "../../api/request-context";
4
5
  import { createRegistry, defineFeature } from "../../engine";
5
- import type { AppContext, Registry } from "../../engine/types";
6
+ import type {
7
+ AppContext,
8
+ ConfigAccessor,
9
+ ConfigAccessorFactory,
10
+ NotifyFactory,
11
+ Registry,
12
+ TenantId,
13
+ } from "../../engine/types";
14
+ import { createInMemoryFileProvider } from "../../files/in-memory-provider";
6
15
  import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
7
16
  import { sleep, waitFor } from "../../testing";
8
17
  import {
9
18
  createJobRunner,
19
+ type JobLogEntry,
10
20
  type JobMeta,
11
21
  type JobRunner,
12
22
  type JobRunnerOptions,
@@ -167,6 +177,30 @@ const testFeature = defineFeature("test", (r) => {
167
177
  log.child({ probe: true }).info("log-probe-child");
168
178
  jobLog.push({ name: "test:job:log-probe", payload, timestamp: Date.now() });
169
179
  });
180
+ // job-runner must build ctx.files the same way dispatch-shared.ts does for
181
+ // write-handlers/MSPs — regression guard for #1520 (ctx.files was never
182
+ // materialized for jobs, so ctx.files.ref(...).read() threw for every job).
183
+ r.job("filesProbe", { trigger: { manual: true } }, async (payload, ctx) => {
184
+ const content = await ctx.files!.ref(payload["key"] as string).read();
185
+ jobLog.push({
186
+ name: "test:job:files-probe",
187
+ payload: { text: new TextDecoder().decode(content) },
188
+ timestamp: Date.now(),
189
+ });
190
+ });
191
+
192
+ // job-runner must build ctx.notify/ctx.config the same way dispatch-shared.ts
193
+ // does for write-handlers — regression guard for #1532 (only ctx.files got
194
+ // the same fix; ctx.notify/ctx.config stayed undefined for every job).
195
+ r.job("notifyProbe", { trigger: { manual: true } }, async (payload, ctx) => {
196
+ await ctx.notify!("test-notification", { to: payload["toUserId"] as string });
197
+ jobLog.push({ name: "test:job:notify-probe", payload, timestamp: Date.now() });
198
+ });
199
+
200
+ r.job("configProbe", { trigger: { manual: true } }, async (_payload, ctx) => {
201
+ const value = await ctx.config!("test:config:probe-key");
202
+ jobLog.push({ name: "test:job:config-probe", payload: { value }, timestamp: Date.now() });
203
+ });
170
204
  });
171
205
 
172
206
  beforeAll(async () => {
@@ -278,6 +312,75 @@ describe("scenario 3: manual trigger", () => {
278
312
  });
279
313
  });
280
314
 
315
+ describe("scenario 3b: ctx.files resolves through the file provider", () => {
316
+ test("job handler can read back a file via ctx.files.ref(key)", async () => {
317
+ clearLog();
318
+ const provider = createInMemoryFileProvider();
319
+ await provider.write("probe/hello.txt", new TextEncoder().encode("hello from files probe"));
320
+
321
+ await withRunner(
322
+ async (runner) => {
323
+ await runner.dispatch("test:job:files-probe", { key: "probe/hello.txt" });
324
+ await waitFor(() => {
325
+ const entries = jobLog.filter((e) => e.name === "test:job:files-probe");
326
+ expect(entries.length).toBe(1);
327
+ expect(entries[0]?.payload["text"]).toBe("hello from files probe");
328
+ });
329
+ },
330
+ { context: { _fileProviderResolver: () => Promise.resolve(provider) } },
331
+ );
332
+ });
333
+ });
334
+
335
+ describe("scenario 3c: ctx.notify resolves through the notify factory (#1532)", () => {
336
+ test("job handler can call ctx.notify and the factory is bound to the job's system user + tenant", async () => {
337
+ clearLog();
338
+ const calls: Array<{ readonly notifierTenantId: string; readonly notificationType: string }> =
339
+ [];
340
+ const notifyFactory: NotifyFactory = (_user, tenantId) => async (notificationType) => {
341
+ calls.push({ notifierTenantId: tenantId, notificationType });
342
+ };
343
+
344
+ await withRunner(
345
+ async (runner) => {
346
+ await runner.dispatch("test:job:notify-probe", { toUserId: "u1" });
347
+ await waitFor(() => {
348
+ const entries = jobLog.filter((e) => e.name === "test:job:notify-probe");
349
+ expect(entries.length).toBe(1);
350
+ });
351
+ expect(calls).toHaveLength(1);
352
+ expect(calls[0]?.notificationType).toBe("test-notification");
353
+ },
354
+ { context: { _notifyFactory: notifyFactory } },
355
+ );
356
+ });
357
+ });
358
+
359
+ describe("scenario 3d: ctx.config resolves through the config accessor factory (#1532)", () => {
360
+ test("job handler can call ctx.config and the factory receives a tenant-scoped db", async () => {
361
+ clearLog();
362
+ let receivedDb: unknown;
363
+ const configAccessorFactory: ConfigAccessorFactory = (deps) => {
364
+ receivedDb = deps.db;
365
+ // Stub accessor: only the string-key overload is exercised here.
366
+ return (async () => "probe-value") as ConfigAccessor;
367
+ };
368
+
369
+ await withRunner(
370
+ async (runner) => {
371
+ await runner.dispatch("test:job:config-probe", {});
372
+ await waitFor(() => {
373
+ const entries = jobLog.filter((e) => e.name === "test:job:config-probe");
374
+ expect(entries.length).toBe(1);
375
+ expect(entries[0]?.payload["value"]).toBe("probe-value");
376
+ });
377
+ expect(receivedDb).toBeDefined();
378
+ },
379
+ { context: { _configAccessorFactory: configAccessorFactory, db: {} as AppContext["db"] } },
380
+ );
381
+ });
382
+ });
383
+
281
384
  // --- Concurrency modes ---
282
385
 
283
386
  describe("concurrency: parallel", () => {
@@ -464,6 +567,7 @@ describe("concurrency: replace", () => {
464
567
  test("enqueuer-only: each dispatch removes prior waiting peers", async () => {
465
568
  // No consumer → jobs stay waiting. The replace branch walks getWaiting()
466
569
  // and removes same-name peers before add — so three dispatches leave one.
570
+ const queueNamePrefix = `kumiko-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
467
571
  await withRunner(
468
572
  async (runner) => {
469
573
  const id1 = await runner.dispatch("test:job:replace-job", { n: 1 });
@@ -474,8 +578,21 @@ describe("concurrency: replace", () => {
474
578
  expect(id3).toBeDefined();
475
579
  expect(id1).not.toBe("skipped");
476
580
  expect(id3).not.toBe(id1);
581
+
582
+ const queue = new Queue(`${queueNamePrefix}-worker`, {
583
+ connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
584
+ });
585
+ try {
586
+ const waiting = (await queue.getWaiting()).filter(
587
+ (job) => job.name === "test:job:replace-job",
588
+ );
589
+ expect(waiting).toHaveLength(1);
590
+ expect(waiting[0]?.data.n).toBe(3);
591
+ } finally {
592
+ await queue.close();
593
+ }
477
594
  },
478
- { consumerLane: undefined },
595
+ { consumerLane: undefined, queueNamePrefix },
479
596
  );
480
597
  });
481
598
  });
@@ -553,6 +670,36 @@ describe("job logger", () => {
553
670
  });
554
671
  });
555
672
  });
673
+
674
+ // Inspects the actual createJobLogger output instead of only asserting the
675
+ // handler ran — a child() that drops its context or a warn() mapped to
676
+ // debug would previously stay green.
677
+ test("info/warn/error land with the right level; debug is dropped; child merges its context", async () => {
678
+ clearLog();
679
+ let capturedLogs: JobLogEntry[] = [];
680
+ await withRunner(
681
+ async (runner) => {
682
+ await runner.dispatch("test:job:log-probe", { n: 1 });
683
+ await waitFor(() => {
684
+ expect(capturedLogs.length).toBeGreaterThan(0);
685
+ });
686
+ },
687
+ {
688
+ onJobComplete: (jobName, _id, _duration, logs) => {
689
+ if (jobName === "test:job:log-probe") capturedLogs = logs;
690
+ },
691
+ },
692
+ );
693
+
694
+ // debug() is a no-op by design (JobLogEntry.level has no "debug" slot) —
695
+ // info/warn/error from the handler body, plus one from child().info(...).
696
+ expect(capturedLogs).toHaveLength(4);
697
+ expect(capturedLogs.map((l) => l.level)).toEqual(["info", "warn", "error", "info"]);
698
+ expect(capturedLogs[0]?.message).toContain("log-probe-info");
699
+ expect(capturedLogs[3]?.message).toContain("log-probe-child");
700
+ expect(capturedLogs[3]?.message).toContain("probe");
701
+ expect(capturedLogs[3]?.message).toContain("true");
702
+ });
556
703
  });
557
704
 
558
705
  describe("correlation propagation", () => {
@@ -671,7 +818,8 @@ describe("error handling", () => {
671
818
 
672
819
  test("perTenant with getActiveTenantIds fans out one run per tenant", async () => {
673
820
  clearLog();
674
- const tenants = [101, 102];
821
+ // @cast-boundary test fixture: TenantId is a branded string.
822
+ const tenants = ["per-tenant-fanout-a", "per-tenant-fanout-b"] as TenantId[];
675
823
  await withRunner(
676
824
  async (runner) => {
677
825
  await runner.dispatch("test:job:per-tenant-fanout", { n: 7 });
@@ -693,11 +841,23 @@ describe("handleEvent maxPerTenant", () => {
693
841
  const user = { ...TestUsers.admin, tenantId: "cap-tenant-evt" };
694
842
  await runner.handleEvent("test:write:capped-event", { n: 1 }, user);
695
843
  await runner.handleEvent("test:write:capped-event", { n: 2 }, user);
696
- // Third must be skipped by the maxPerTenant guard.
844
+ await waitFor(() => {
845
+ const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
846
+ expect(started.length).toBe(2);
847
+ });
848
+ // Third must be skipped by the maxPerTenant guard. handleEvent's cap
849
+ // check runs synchronously before this call resolves, but a wrongly
850
+ // enqueued job still needs the worker to pick it up and push its
851
+ // jobLog entry — polling several times (failing at the first sign of
852
+ // a 3rd start) is more CI-load-tolerant than a single fixed sleep(200)
853
+ // racing against the handler's own 500ms sleep, which could land
854
+ // outside the window on a starved runner and silently pass.
697
855
  await runner.handleEvent("test:write:capped-event", { n: 3 }, user);
698
- await sleep(200);
699
- const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
700
- expect(started.length).toBeLessThanOrEqual(2);
856
+ for (const delayMs of [50, 100, 200, 400]) {
857
+ await sleep(delayMs);
858
+ const startedNow = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
859
+ expect(startedNow.length).toBe(2);
860
+ }
701
861
  });
702
862
  });
703
863
  });
@@ -1,7 +1,8 @@
1
1
  import { type Job, Queue, Worker } from "bullmq";
2
2
  import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context";
4
- import type { DbRow } from "../db/connection";
4
+ import type { DbConnection, DbRow } from "../db/connection";
5
+ import { createTenantDb } from "../db/tenant-db";
5
6
  import { createSystemUser } from "../engine/system-user";
6
7
  import {
7
8
  type AppContext,
@@ -9,7 +10,9 @@ import {
9
10
  type Registry,
10
11
  type SessionUser,
11
12
  SYSTEM_TENANT_ID,
13
+ type TenantId,
12
14
  } from "../engine/types";
15
+ import { createFileContext } from "../files/file-handle";
13
16
  import type { Logger } from "../logging/types";
14
17
  import {
15
18
  emitJobQueueDepth,
@@ -37,9 +40,10 @@ export type JobLogEntry = {
37
40
  timestamp: Temporal.Instant;
38
41
  };
39
42
 
40
- function createJobLogger(logs: JobLogEntry[]): Logger {
43
+ function createJobLogger(logs: JobLogEntry[], baseContext: Record<string, unknown> = {}): Logger {
41
44
  function push(level: "info" | "warn" | "error", msg: string, data?: Record<string, unknown>) {
42
- const message = data ? `${msg} ${JSON.stringify(data)}` : msg;
45
+ const merged = { ...baseContext, ...data };
46
+ const message = Object.keys(merged).length > 0 ? `${msg} ${JSON.stringify(merged)}` : msg;
43
47
  logs.push({ level, message, timestamp: Temporal.Now.instant() });
44
48
  }
45
49
  const logger: Logger = {
@@ -52,9 +56,13 @@ function createJobLogger(logs: JobLogEntry[]): Logger {
52
56
  error(msg, data) {
53
57
  push("error", msg, data);
54
58
  },
59
+ // No-op by design, not missing coverage: JobLogEntry.level (and the
60
+ // persisted run-completed/-failed event schema in bundled-features/
61
+ // jobs/events.ts) only has info|warn|error — a job's debug() calls have
62
+ // nowhere durable to land.
55
63
  debug() {},
56
- child() {
57
- return logger;
64
+ child(context) {
65
+ return createJobLogger(logs, { ...baseContext, ...context });
58
66
  },
59
67
  };
60
68
  return logger;
@@ -100,7 +108,7 @@ export type JobRunnerOptions = {
100
108
  // Tests set a unique prefix (e.g. `"test-${Date.now()}"`) for isolation —
101
109
  // two parallel test-runners never see each other's jobs.
102
110
  queueNamePrefix?: string | undefined;
103
- getActiveTenantIds?: () => Promise<number[]>;
111
+ getActiveTenantIds?: () => Promise<TenantId[]>;
104
112
  onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
105
113
  onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[]) => void;
106
114
  onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[]) => void;
@@ -331,13 +339,35 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
331
339
  SYSTEM_TENANT_ID;
332
340
  const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null; // @cast-boundary dynamic-key
333
341
 
334
- // _triggerName aus rawData übernehmen falls gesetzt — handleEvent
335
- // packt das beim Multi-Trigger-Dispatch rein (siehe unten). Über
336
- // jobContext.triggerName freigegeben damit der Handler nicht selbst
337
- // im rohen Payload kramen muss.
342
+ // Carry `_triggerName` from rawData when set — handleEvent injects it on
343
+ // multi-trigger dispatch; exposed as jobContext.triggerName so handlers
344
+ // don't dig through the raw payload themselves.
338
345
  const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
346
+ // Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
347
+ // through the same _fileProviderResolver for jobs as for write-handlers,
348
+ // otherwise event-triggered jobs silently get an unresolved ctx.files.
349
+ const fileResolver = context._fileProviderResolver;
350
+ const files = fileResolver ? createFileContext(() => fileResolver(tenantId)) : context.files;
351
+ const jobSystemUser = createSystemUser(tenantId);
352
+ // Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
353
+ // must resolve for jobs the same way they do for write-handlers, or an
354
+ // app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
355
+ // at runtime the write-handler path never would (framework#1532).
356
+ const notify = context._notifyFactory?.(jobSystemUser, tenantId);
357
+ const configDb = context.db as DbConnection | undefined; // @cast-boundary db-operator
358
+ const config =
359
+ context._configAccessorFactory && configDb
360
+ ? context._configAccessorFactory({
361
+ user: { id: jobSystemUser.id, tenantId },
362
+ db: createTenantDb(configDb, tenantId, "system"),
363
+ secrets: context.secrets,
364
+ })
365
+ : undefined;
339
366
  const jobContext: AppContext = {
340
367
  ...context,
368
+ files,
369
+ ...(notify !== undefined && { notify }),
370
+ ...(config !== undefined && { config }),
341
371
  // The runner owns the registry it resolved this job from — expose it so
342
372
  // workers can reach projections/jobs without the app author duplicating
343
373
  // it into `context` (the JobContext contract guarantees `registry`).
@@ -345,7 +375,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
345
375
  // Expose the runner so handlers can chain a follow-up job. Symmetric with
346
376
  // how the command-dispatcher hands write-handlers their jobRunner.
347
377
  ...(selfRunner !== undefined && { jobRunner: selfRunner }),
348
- systemUser: createSystemUser(tenantId),
378
+ systemUser: jobSystemUser,
349
379
  triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
350
380
  log: createJobLogger(logs),
351
381
  ...(triggerName !== undefined && { triggerName }),
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/logger";
1
+ export type * from "@cosmicdrift/kumiko-types/logger";
@@ -50,6 +50,7 @@ export {
50
50
  emitDispatcherHandler,
51
51
  emitEventConsumerLag,
52
52
  emitEventConsumerPassOutcome,
53
+ emitEventConsumerRearmExhausted,
53
54
  emitEventDispatcherListenConnected,
54
55
  emitHttpRequest,
55
56
  emitJobQueueDepth,