@cosmicdrift/kumiko-framework 0.197.1 → 0.199.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 (47) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/batch.integration.test.ts +1 -1
  3. package/src/api/__tests__/server-boot-guards.test.ts +128 -1
  4. package/src/api/__tests__/sse-route.test.ts +129 -0
  5. package/src/api/auth-routes.ts +21 -6
  6. package/src/api/server.ts +44 -0
  7. package/src/api/sse-route.ts +13 -1
  8. package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
  9. package/src/bun-db/query.ts +5 -1
  10. package/src/db/__tests__/compound-types.test.ts +12 -2
  11. package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
  12. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
  13. package/src/db/__tests__/money.test.ts +49 -18
  14. package/src/db/__tests__/unchecked-system-db.test.ts +117 -0
  15. package/src/db/dialect.ts +13 -2
  16. package/src/db/event-store-executor-read.ts +12 -1
  17. package/src/db/index.ts +7 -2
  18. package/src/db/money.ts +35 -15
  19. package/src/db/table-builder.ts +7 -1
  20. package/src/db/tenant-db.ts +54 -2
  21. package/src/derivatives/derivatives-context.ts +9 -0
  22. package/src/engine/__tests__/build-app-schema.test.ts +50 -0
  23. package/src/engine/__tests__/nav.test.ts +12 -4
  24. package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
  25. package/src/engine/build-app-schema.ts +6 -0
  26. package/src/engine/build-config-feature-schema.ts +2 -2
  27. package/src/engine/index.ts +2 -1
  28. package/src/engine/registry-facade.ts +6 -0
  29. package/src/engine/registry-ingest.ts +1 -0
  30. package/src/engine/registry-state.ts +2 -0
  31. package/src/engine/types/index.ts +7 -1
  32. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
  33. package/src/entrypoint/index.ts +20 -3
  34. package/src/files/__tests__/files.integration.test.ts +16 -0
  35. package/src/files/file-routes.ts +12 -1
  36. package/src/jobs/__tests__/job-systemdb.integration.test.ts +152 -0
  37. package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
  38. package/src/jobs/job-runner.ts +42 -3
  39. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
  40. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +67 -0
  41. package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
  42. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
  43. package/src/pipeline/dispatch-batch.ts +2 -2
  44. package/src/pipeline/dispatch-shared.ts +3 -1
  45. package/src/pipeline/idempotency.ts +11 -6
  46. package/src/ui-types/app-schema.ts +10 -0
  47. package/src/ui-types/index.ts +1 -1
@@ -2,11 +2,13 @@ import { type Job, Queue, Worker } from "bullmq";
2
2
  import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context";
4
4
  import type { DbConnection, DbRow } from "../db/connection";
5
- import { createTenantDb } from "../db/tenant-db";
5
+ import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db";
6
6
  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
@@ -418,8 +427,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
418
427
  const configDb = context.db as DbConnection | undefined; // @cast-boundary db-operator
419
428
  // Shared by the config accessor and ctx.derivatives below — both need the
420
429
  // same tenant-scoped db, and building it twice would let the two calls
421
- // drift apart.
430
+ // drift apart. Always "system" mode regardless of the job's own
431
+ // systemScope() status (pre-existing, not something this change alters)
432
+ // — isSystemJob below is what actually keeps ctx.systemDb off a
433
+ // non-system job; it is the only thing standing between this db and an
434
+ // unchecked cross-tenant escape hatch for such a job.
422
435
  const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
436
+ const isSystemJob = registry.isJobSystemScoped(jobName);
437
+ const systemDb =
438
+ isSystemJob && tenantScopedDb ? createUncheckedSystemDb(tenantScopedDb) : undefined;
423
439
  const config =
424
440
  context._configAccessorFactory && tenantScopedDb
425
441
  ? context._configAccessorFactory({
@@ -439,12 +455,16 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
439
455
  tenantId,
440
456
  })
441
457
  : context.derivatives;
442
- const jobContext: AppContext = {
458
+ const jobContext: JobContext = {
443
459
  ...context,
460
+ // Same union as configDb above — job runners are always constructed
461
+ // with a real DbConnection; JobContext requires it non-optional.
462
+ db: configDb as DbConnection, // @cast-boundary db-operator
444
463
  files,
445
464
  derivatives,
446
465
  ...(notify !== undefined && { notify }),
447
466
  ...(config !== undefined && { config }),
467
+ ...(systemDb && { systemDb }),
448
468
  // The runner owns the registry it resolved this job from — expose it so
449
469
  // workers can reach projections/jobs without the app author duplicating
450
470
  // it into `context` (the JobContext contract guarantees `registry`).
@@ -456,6 +476,22 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
456
476
  triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
457
477
  log: createJobLogger(logs),
458
478
  ...(triggerName !== undefined && { triggerName }),
479
+ write: (qn: string, payload: unknown) => {
480
+ if (!dispatchWriteRef) {
481
+ throw new Error(
482
+ "JobContext.write called before dispatcher attached — call attachDispatcher() first",
483
+ );
484
+ }
485
+ return dispatchWriteRef.write(jobSystemUser, qn, payload);
486
+ },
487
+ queryAs: (user: SessionUser, qn: string, payload: unknown) => {
488
+ if (!dispatchWriteRef) {
489
+ throw new Error(
490
+ "JobContext.queryAs called before dispatcher attached — call attachDispatcher() first",
491
+ );
492
+ }
493
+ return dispatchWriteRef.queryAs(user, qn, payload);
494
+ },
459
495
  };
460
496
 
461
497
  await options.onJobStart?.(jobName, jobId, meta);
@@ -769,6 +805,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
769
805
  await queues[laneForJob(jobDef)].add(name, data);
770
806
  }
771
807
  },
808
+ attachDispatcher(ref: DispatchWriteRef): void {
809
+ dispatchWriteRef = ref;
810
+ },
772
811
  };
773
812
 
774
813
  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", {
@@ -0,0 +1,67 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../engine";
4
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
5
+
6
+ // r.systemScope() is feature-level (define-feature.ts), not per-handler — so
7
+ // two features prove both sides: one system-scoped, one not.
8
+
9
+ const systemScopedFeature = defineFeature("ctxsystemdb-system", (r) => {
10
+ r.systemScope();
11
+
12
+ r.queryHandler(
13
+ "check",
14
+ z.object({}),
15
+ async (query, ctx) => {
16
+ if (!ctx.systemDb) return { present: false as const };
17
+ // assertTenantMatch returns the underlying TenantDb — proves systemDb
18
+ // is bound to the SAME internally-built TenantDb as ctx.db, not a
19
+ // separate instance (dispatch-shared.ts builds `as HandlerContext`,
20
+ // so a mis-wired property wouldn't be caught by tsc).
21
+ const checked = ctx.systemDb.assertTenantMatch(query.user.tenantId);
22
+ return { present: true as const, boundToDb: checked === ctx.db };
23
+ },
24
+ { access: { roles: ["Admin"] } },
25
+ );
26
+ });
27
+
28
+ const tenantScopedFeature = defineFeature("ctxsystemdb-tenant", (r) => {
29
+ r.queryHandler(
30
+ "check",
31
+ z.object({}),
32
+ async (_query, ctx) => ({ present: ctx.systemDb !== undefined }),
33
+ { access: { roles: ["Admin"] } },
34
+ );
35
+ });
36
+
37
+ let stack: TestStack;
38
+ const admin = TestUsers.admin;
39
+
40
+ beforeAll(async () => {
41
+ stack = await setupTestStack({ features: [systemScopedFeature, tenantScopedFeature] });
42
+ });
43
+
44
+ afterAll(async () => {
45
+ await stack.cleanup();
46
+ });
47
+
48
+ describe("ctx.systemDb", () => {
49
+ test("is present and bound to ctx.db for r.systemScope() handlers", async () => {
50
+ const result = await stack.http.queryOk<{ present: boolean; boundToDb: boolean }>(
51
+ "ctxsystemdb-system:query:check",
52
+ {},
53
+ admin,
54
+ );
55
+ expect(result.present).toBe(true);
56
+ expect(result.boundToDb).toBe(true);
57
+ });
58
+
59
+ test("is absent for non-system-scoped handlers", async () => {
60
+ const result = await stack.http.queryOk<{ present: boolean }>(
61
+ "ctxsystemdb-tenant:query:check",
62
+ {},
63
+ admin,
64
+ );
65
+ expect(result.present).toBe(false);
66
+ });
67
+ });
@@ -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
  };
@@ -3,7 +3,7 @@ import type { SseBroker } from "../api/sse-broker";
3
3
  import type { DbConnection, DbRunner, DbTx } from "../db/connection";
4
4
  import { runInSavepoint, selectMany } from "../db/query";
5
5
  import type { buildEntityTable } from "../db/table-builder";
6
- import { createTenantDb } from "../db/tenant-db";
6
+ import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db";
7
7
  import { createDerivativesContext } from "../derivatives/derivatives-context";
8
8
  import type { defineTransitions } from "../engine/state-machine";
9
9
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
@@ -183,6 +183,7 @@ export async function buildHandlerContext(
183
183
  // the client has disconnected — handlers with many sequential queries skip
184
184
  // the rest of the chain instead of burning DB-CPU for results no one reads.
185
185
  const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
186
+ const systemDb = isSystem && db ? createUncheckedSystemDb(db) : undefined;
186
187
  // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
187
188
  // through it survive a rollback of the handler's own transaction. No
188
189
  // AbortSignal here: a client disconnect must not abort a durability write
@@ -573,6 +574,7 @@ export async function buildHandlerContext(
573
574
  registry,
574
575
  db,
575
576
  dbOutsideTransaction,
577
+ ...(systemDb && { systemDb }),
576
578
  log,
577
579
  notify,
578
580
  ...(config && { config }),
@@ -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
  }
@@ -43,6 +43,16 @@ export type FeatureSchema = {
43
43
  // Fallback erhalten damit alte clientSchema-Files (vor AppSchema)
44
44
  // ohne Migration weiter laufen — toAppSchema() hebt die Liste hoch.
45
45
  readonly workspaces?: readonly WorkspaceSchema[];
46
+ // True only when the server confirmed at boot that no SearchAdapter is
47
+ // wired on context.searchAdapter — mirrors the global (not per-entity)
48
+ // check behind api/server.ts's boot warning (#2051). Duplicated
49
+ // identically across every feature purely so it threads through the
50
+ // existing per-feature prop chain into screen renderers (kumiko-screen.tsx)
51
+ // without a separate app-level plumbing path. Omitted for schemas that
52
+ // don't flow through buildAppSchema() (hand-authored fixtures, legacy
53
+ // toAppSchema()) — treated as "not missing" so search bars keep rendering
54
+ // exactly as before this flag existed (#2062).
55
+ readonly searchAdapterMissing?: boolean;
46
56
  };
47
57
 
48
58
  // A content collection as it reaches the client: the declaration plus the
@@ -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,