@cosmicdrift/kumiko-framework 0.197.1 → 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 (34) 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/derivatives-context.ts +9 -0
  17. package/src/engine/__tests__/build-app-schema.test.ts +20 -0
  18. package/src/engine/__tests__/nav.test.ts +12 -4
  19. package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
  20. package/src/engine/build-config-feature-schema.ts +2 -2
  21. package/src/engine/index.ts +2 -1
  22. package/src/engine/types/index.ts +7 -1
  23. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
  24. package/src/entrypoint/index.ts +20 -3
  25. package/src/files/__tests__/files.integration.test.ts +16 -0
  26. package/src/files/file-routes.ts +12 -1
  27. package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
  28. package/src/jobs/job-runner.ts +32 -1
  29. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
  30. package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
  31. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
  32. package/src/pipeline/dispatch-batch.ts +2 -2
  33. package/src/pipeline/idempotency.ts +11 -6
  34. package/src/ui-types/index.ts +1 -1
@@ -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,