@cosmicdrift/kumiko-framework 0.197.0 → 0.198.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 (36) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/batch.integration.test.ts +1 -1
  3. package/src/api/__tests__/sse-route.test.ts +129 -0
  4. package/src/api/auth-routes.ts +21 -6
  5. package/src/api/sse-route.ts +13 -1
  6. package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
  7. package/src/bun-db/query.ts +5 -1
  8. package/src/db/__tests__/compound-types.test.ts +12 -2
  9. package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
  10. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
  11. package/src/db/__tests__/money.test.ts +49 -18
  12. package/src/db/dialect.ts +13 -2
  13. package/src/db/event-store-executor-read.ts +12 -1
  14. package/src/db/money.ts +35 -15
  15. package/src/db/table-builder.ts +7 -1
  16. package/src/derivatives/__tests__/derivatives-context.test.ts +43 -0
  17. package/src/derivatives/__tests__/variant-route.integration.test.ts +48 -1
  18. package/src/derivatives/derivatives-context.ts +32 -2
  19. package/src/engine/__tests__/build-app-schema.test.ts +20 -0
  20. package/src/engine/__tests__/nav.test.ts +12 -4
  21. package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
  22. package/src/engine/build-config-feature-schema.ts +2 -2
  23. package/src/engine/index.ts +2 -1
  24. package/src/engine/types/index.ts +7 -1
  25. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
  26. package/src/entrypoint/index.ts +20 -3
  27. package/src/files/__tests__/files.integration.test.ts +16 -0
  28. package/src/files/file-routes.ts +12 -1
  29. package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
  30. package/src/jobs/job-runner.ts +32 -1
  31. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
  32. package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
  33. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
  34. package/src/pipeline/dispatch-batch.ts +2 -2
  35. package/src/pipeline/idempotency.ts +11 -6
  36. package/src/ui-types/index.ts +1 -1
@@ -581,6 +581,22 @@ describe("error handling", () => {
581
581
  expect(res.status).toBe(404);
582
582
  });
583
583
 
584
+ // #1950: a malformed (non-UUID) id used to reach `selectMany` unchecked —
585
+ // Postgres rejects it with 22P02, which the pooled Bun.SQL connection
586
+ // treated as poisoned (see the NONEXISTENT_UUID comment above, added as a
587
+ // workaround before this guard existed). 404, same as a real-but-absent
588
+ // id, not the raw DB error.
589
+ test("download with a malformed (non-UUID) id returns 404, not a DB error", async () => {
590
+ const res = await getFile(adminUser, "not-a-uuid");
591
+ expect(res.status).toBe(404);
592
+ });
593
+
594
+ test("a malformed id doesn't poison the pooled connection for the next request", async () => {
595
+ await getFile(adminUser, "not-a-uuid");
596
+ const res = await getFile(adminUser, NONEXISTENT_UUID);
597
+ expect(res.status).toBe(404);
598
+ });
599
+
584
600
  test("upload wrong file type for entity field is rejected", async () => {
585
601
  const pdfContent = new TextEncoder().encode("fake-pdf-content");
586
602
  const res = await uploadFile(adminUser, "document.pdf", pdfContent, "application/pdf", {
@@ -5,7 +5,13 @@ import type { DbConnection } from "../db/connection";
5
5
  import { createEventStoreExecutor } from "../db/event-store-executor";
6
6
  import { createTenantDb } from "../db/tenant-db";
7
7
  import { createDerivativesContext, resolveFieldVariant, resolveRenderer } from "../derivatives";
8
- import { isFileField, type Registry, type SessionUser, type TenantId } from "../engine/types";
8
+ import {
9
+ isFileField,
10
+ isUuid,
11
+ type Registry,
12
+ type SessionUser,
13
+ type TenantId,
14
+ } from "../engine/types";
9
15
  import { generateId } from "../utils";
10
16
  import { buildContentDispositionHeader } from "./content-disposition";
11
17
  import { createFileContext } from "./file-handle";
@@ -396,6 +402,11 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
396
402
  });
397
403
 
398
404
  async function loadFileForTenant(id: string, tenantId: TenantId): Promise<FileRef | null> {
405
+ // fileRefsTable.id is a UUID column — a malformed id must fail here, not
406
+ // at Postgres (22P02 poisons the pooled Bun.SQL connection). 404, not
407
+ // 400: an authenticated caller sending a bad id must see the same
408
+ // response as a nonexistent one, no existence-oracle.
409
+ if (!isUuid(id)) return null;
399
410
  // isDeleted:false — soft-deleted (trashed) rows stay recoverable but must
400
411
  // never surface to reads/guards.
401
412
  const [row] = await selectMany(db, fileRefsTable, { id, tenantId, isDeleted: false });
@@ -201,6 +201,13 @@ const testFeature = defineFeature("test", (r) => {
201
201
  const value = await ctx.config!("test:config:probe-key");
202
202
  jobLog.push({ name: "test:job:config-probe", payload: { value }, timestamp: Date.now() });
203
203
  });
204
+
205
+ // ctx.write/queryAs throw until JobRunner.attachDispatcher() has run —
206
+ // regression guard for framework#2043. No test runner here ever calls
207
+ // attachDispatcher(), so this always hits the stub.
208
+ r.job("writeProbe", { trigger: { manual: true }, retries: 0 }, async (_payload, ctx) => {
209
+ await ctx.write("test:write:probe", {});
210
+ });
204
211
  });
205
212
 
206
213
  beforeAll(async () => {
@@ -803,6 +810,27 @@ describe("error handling", () => {
803
810
  );
804
811
  });
805
812
 
813
+ test("ctx.write throws before attachDispatcher() has run (framework#2043)", async () => {
814
+ clearLog();
815
+ const failures: string[] = [];
816
+ await withRunner(
817
+ async (runner) => {
818
+ await runner.dispatch("test:job:write-probe");
819
+ await waitFor(() => {
820
+ expect(failures.length).toBeGreaterThanOrEqual(1);
821
+ });
822
+ expect(failures[0]).toContain(
823
+ "JobContext.write called before dispatcher attached — call attachDispatcher() first",
824
+ );
825
+ },
826
+ {
827
+ onJobFailed: (jobName, _id, error) => {
828
+ if (jobName === "test:job:write-probe") failures.push(error);
829
+ },
830
+ },
831
+ );
832
+ });
833
+
806
834
  test("perTenant without getActiveTenantIds fails wrapper; worker stays alive", async () => {
807
835
  clearLog();
808
836
  await withRunner(async (runner) => {
@@ -7,6 +7,8 @@ import { createDerivativesContext } from "../derivatives/derivatives-context";
7
7
  import { createSystemUser } from "../engine/system-user";
8
8
  import {
9
9
  type AppContext,
10
+ type DispatchWriteRef,
11
+ type JobContext,
10
12
  type JobRunIn,
11
13
  type Registry,
12
14
  type SessionUser,
@@ -114,6 +116,10 @@ export type JobRunner = {
114
116
  payload: Record<string, unknown>,
115
117
  user?: SessionUser,
116
118
  ): Promise<void>;
119
+ // Wires JobContext.write/queryAs to the real dispatcher — called once at
120
+ // boot, after the dispatcher exists (job-runner construction happens
121
+ // before it). Before this runs, JobContext.write/queryAs throw.
122
+ attachDispatcher(ref: DispatchWriteRef): void;
117
123
  };
118
124
 
119
125
  export type JobRunnerOptions = {
@@ -285,6 +291,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
285
291
  // delivery.render → delivery.send). Assigned just before return; reads happen
286
292
  // at job-execution time (after start()), so it is always defined by then.
287
293
  let selfRunner: JobRunner | undefined;
294
+ // Set by attachDispatcher() once the boot-level dispatcher exists.
295
+ // JobContext.write/queryAs throw until this is set — see JobContext doc.
296
+ let dispatchWriteRef: DispatchWriteRef | undefined;
288
297
 
289
298
  // Counts active + waiting jobs with this name for this tenant across
290
299
  // BOTH lane queues. Jobs with the same name should only live in one
@@ -439,8 +448,11 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
439
448
  tenantId,
440
449
  })
441
450
  : context.derivatives;
442
- const jobContext: AppContext = {
451
+ const jobContext: JobContext = {
443
452
  ...context,
453
+ // Same union as configDb above — job runners are always constructed
454
+ // with a real DbConnection; JobContext requires it non-optional.
455
+ db: configDb as DbConnection, // @cast-boundary db-operator
444
456
  files,
445
457
  derivatives,
446
458
  ...(notify !== undefined && { notify }),
@@ -456,6 +468,22 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
456
468
  triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
457
469
  log: createJobLogger(logs),
458
470
  ...(triggerName !== undefined && { triggerName }),
471
+ write: (qn: string, payload: unknown) => {
472
+ if (!dispatchWriteRef) {
473
+ throw new Error(
474
+ "JobContext.write called before dispatcher attached — call attachDispatcher() first",
475
+ );
476
+ }
477
+ return dispatchWriteRef.write(jobSystemUser, qn, payload);
478
+ },
479
+ queryAs: (user: SessionUser, qn: string, payload: unknown) => {
480
+ if (!dispatchWriteRef) {
481
+ throw new Error(
482
+ "JobContext.queryAs called before dispatcher attached — call attachDispatcher() first",
483
+ );
484
+ }
485
+ return dispatchWriteRef.queryAs(user, qn, payload);
486
+ },
459
487
  };
460
488
 
461
489
  await options.onJobStart?.(jobName, jobId, meta);
@@ -769,6 +797,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
769
797
  await queues[laneForJob(jobDef)].add(name, data);
770
798
  }
771
799
  },
800
+ attachDispatcher(ref: DispatchWriteRef): void {
801
+ dispatchWriteRef = ref;
802
+ },
772
803
  };
773
804
 
774
805
  selfRunner = runnerApi;
@@ -318,6 +318,7 @@ describe("enqueueProjectionRebuild — inline fallback (jobRunner without the jo
318
318
  dispatchCalls++;
319
319
  return "should-not-happen";
320
320
  },
321
+ attachDispatcher: () => {},
321
322
  };
322
323
 
323
324
  const outcome = await enqueueProjectionRebuild("pendingtest:projection:pending-counts", {
@@ -1021,11 +1021,11 @@ describe("dispatcher context.geoTzProvider (680/1)", () => {
1021
1021
  function createMockIdempotencyGuard() {
1022
1022
  const cache = new Map<string, string>();
1023
1023
  return {
1024
- async check(requestId: string) {
1025
- return cache.get(requestId) ?? null;
1024
+ async check(tenantId: string, userId: string, requestId: string) {
1025
+ return cache.get(`${tenantId}:${userId}:${requestId}`) ?? null;
1026
1026
  },
1027
- async store(requestId: string, result: unknown) {
1028
- cache.set(requestId, JSON.stringify(result));
1027
+ async store(tenantId: string, userId: string, requestId: string, result: unknown) {
1028
+ cache.set(`${tenantId}:${userId}:${requestId}`, JSON.stringify(result));
1029
1029
  },
1030
1030
  };
1031
1031
  }
@@ -19,9 +19,12 @@ afterAll(async () => {
19
19
  // --- Idempotency ---
20
20
 
21
21
  describe("idempotency guard", () => {
22
+ const tenantA = "00000000-0000-4000-8000-00000000000a";
23
+ const userA = "00000000-0000-4000-8000-0000000000a1";
24
+
22
25
  test("returns null for new request", async () => {
23
26
  const guard = createIdempotencyGuard(testRedis.redis);
24
- const result = await guard.check("req-new-123");
27
+ const result = await guard.check(tenantA, userA, "req-new-123");
25
28
  expect(result).toBeNull();
26
29
  });
27
30
 
@@ -29,8 +32,8 @@ describe("idempotency guard", () => {
29
32
  const guard = createIdempotencyGuard(testRedis.redis);
30
33
  const requestId = "req-dup-456";
31
34
 
32
- await guard.store(requestId, { isSuccess: true, data: { id: 1 } });
33
- const cached = await guard.check(requestId);
35
+ await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { id: 1 } });
36
+ const cached = await guard.check(tenantA, userA, requestId);
34
37
 
35
38
  expect(cached).not.toBeNull();
36
39
  if (!cached) throw new Error("expected cached value");
@@ -41,15 +44,15 @@ describe("idempotency guard", () => {
41
44
  const guard = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 1 });
42
45
  const requestId = "req-ttl-789";
43
46
 
44
- await guard.store(requestId, { done: true });
47
+ await guard.store(tenantA, userA, requestId, { done: true });
45
48
 
46
49
  // Should exist immediately
47
- expect(await guard.check(requestId)).not.toBeNull();
50
+ expect(await guard.check(tenantA, userA, requestId)).not.toBeNull();
48
51
 
49
52
  // Wait for expiry
50
53
  await new Promise((r) => setTimeout(r, 1100));
51
54
 
52
- expect(await guard.check(requestId)).toBeNull();
55
+ expect(await guard.check(tenantA, userA, requestId)).toBeNull();
53
56
  });
54
57
 
55
58
  test("parallel check(): second caller waits for the first's store() instead of racing", async () => {
@@ -61,11 +64,11 @@ describe("idempotency guard", () => {
61
64
  const requestId = "req-race-1";
62
65
 
63
66
  // Request #1 starts — claims the in-progress lock.
64
- const first = await guard.check(requestId);
67
+ const first = await guard.check(tenantA, userA, requestId);
65
68
  expect(first).toBeNull(); // got the lock
66
69
 
67
70
  // Request #2 runs concurrently — must block until #1 stores a result.
68
- const secondPromise = guard.check(requestId);
71
+ const secondPromise = guard.check(tenantA, userA, requestId);
69
72
 
70
73
  // After a tick the second must still be pending: no result yet.
71
74
  await new Promise((r) => setTimeout(r, 80));
@@ -77,7 +80,7 @@ describe("idempotency guard", () => {
77
80
  expect(quickResult.done).toBe(false);
78
81
 
79
82
  // Request #1 finishes.
80
- await guard.store(requestId, { isSuccess: true, data: { id: 99 } });
83
+ await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { id: 99 } });
81
84
 
82
85
  // Request #2 should now see the stored result, not null — no duplicate work.
83
86
  const second = await secondPromise;
@@ -93,13 +96,39 @@ describe("idempotency guard", () => {
93
96
  });
94
97
  const requestId = "req-crashed";
95
98
 
96
- const first = await guard.check(requestId);
99
+ const first = await guard.check(tenantA, userA, requestId);
97
100
  expect(first).toBeNull(); // we acquired the lock, then "crash" — never call store()
98
101
 
99
102
  // After the pending-TTL lapses, a retry should be allowed to take over.
100
- const second = await guard.check(requestId);
103
+ const second = await guard.check(tenantA, userA, requestId);
101
104
  expect(second).toBeNull(); // reclaimed
102
105
  });
106
+
107
+ test("same requestId from different tenant/user does not hit the same cache entry", async () => {
108
+ const guard = createIdempotencyGuard(testRedis.redis);
109
+ const requestId = "req-shared-across-tenants";
110
+ const tenantB = "00000000-0000-4000-8000-00000000000b";
111
+ const userB = "00000000-0000-4000-8000-0000000000b1";
112
+
113
+ // Tenant A / user A owns the request and stores its result.
114
+ const firstCheck = await guard.check(tenantA, userA, requestId);
115
+ expect(firstCheck).toBeNull();
116
+ await guard.store(tenantA, userA, requestId, { isSuccess: true, data: { tenant: "A" } });
117
+
118
+ // Same requestId, different tenant+user: must be treated as a fresh
119
+ // request, not see tenant A's cached/pending state.
120
+ const otherTenantCheck = await guard.check(tenantB, userB, requestId);
121
+ expect(otherTenantCheck).toBeNull();
122
+
123
+ // Different user, same tenant: also isolated.
124
+ const otherUserCheck = await guard.check(tenantA, userB, requestId);
125
+ expect(otherUserCheck).toBeNull();
126
+
127
+ // Tenant A's own result is still retrievable and unaffected.
128
+ const ownResult = await guard.check(tenantA, userA, requestId);
129
+ expect(ownResult).not.toBeNull();
130
+ expect(JSON.parse(ownResult as string)).toEqual({ isSuccess: true, data: { tenant: "A" } });
131
+ });
103
132
  });
104
133
 
105
134
  // --- Event Dedup ---
@@ -30,7 +30,7 @@ export async function runBatch(
30
30
  // Idempotency: if the same requestId has already been processed, return the
31
31
  // cached result without re-executing. The cache holds the full BatchResult.
32
32
  if (requestId && idempotency) {
33
- const cached = await idempotency.check(requestId);
33
+ const cached = await idempotency.check(user.tenantId, user.id, requestId);
34
34
  if (cached) {
35
35
  const parsed = parseJsonSafe<BatchResult | null>(cached, null);
36
36
  if (parsed) return parsed;
@@ -42,7 +42,7 @@ export async function runBatch(
42
42
  // the same answer (both success and failure results are cached).
43
43
  const finalize = async (result: BatchResult): Promise<BatchResult> => {
44
44
  if (requestId && idempotency) {
45
- await idempotency.store(requestId, result);
45
+ await idempotency.store(user.tenantId, user.id, requestId, result);
46
46
  }
47
47
  return result;
48
48
  };
@@ -2,8 +2,8 @@ import type Redis from "ioredis";
2
2
  import { RedisKeys } from "./redis-keys";
3
3
 
4
4
  export type IdempotencyGuard = {
5
- check(requestId: string): Promise<string | null>;
6
- store(requestId: string, result: unknown): Promise<void>;
5
+ check(tenantId: string, userId: string, requestId: string): Promise<string | null>;
6
+ store(tenantId: string, userId: string, requestId: string, result: unknown): Promise<void>;
7
7
  };
8
8
 
9
9
  // Sentinel stored under the key while the handler is running. A second
@@ -39,8 +39,8 @@ export function createIdempotencyGuard(
39
39
  // both see a cache miss, both execute side-effects, and only one persist
40
40
  // the result. This version uses a pending-marker lock so the second caller
41
41
  // waits for the first to finish and reuses its result.
42
- async check(requestId) {
43
- const key = `${prefix}${requestId}`;
42
+ async check(tenantId, userId, requestId) {
43
+ const key = `${prefix}${tenantId}:${userId}:${requestId}`;
44
44
 
45
45
  // Try to acquire the in-progress lock.
46
46
  const acquired = await redis.set(key, PENDING_MARKER, "EX", pendingTtl, "NX");
@@ -67,10 +67,15 @@ export function createIdempotencyGuard(
67
67
  return null;
68
68
  },
69
69
 
70
- async store(requestId, result) {
70
+ async store(tenantId, userId, requestId, result) {
71
71
  // Overwrite the pending marker with the real result. Plain SET (no NX)
72
72
  // on purpose: we own the lock; writing the result is the final step.
73
- await redis.set(`${prefix}${requestId}`, JSON.stringify(result), "EX", ttl);
73
+ await redis.set(
74
+ `${prefix}${tenantId}:${userId}:${requestId}`,
75
+ JSON.stringify(result),
76
+ "EX",
77
+ ttl,
78
+ );
74
79
  },
75
80
  };
76
81
  }
@@ -51,7 +51,7 @@ export type {
51
51
  TextFieldDef,
52
52
  } from "../engine/types/fields";
53
53
  export type { AccessRule } from "../engine/types/handlers";
54
- export type { NavDefinition } from "../engine/types/nav";
54
+ export type { NavDefinition, NavIconKey } from "../engine/types/nav";
55
55
  export type {
56
56
  ActionFormScreenDefinition,
57
57
  ConfigEditScreenDefinition,