@cosmicdrift/kumiko-framework 0.146.4 → 0.147.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 (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. package/src/engine/deep-link.ts +0 -23
@@ -0,0 +1,95 @@
1
+ // Regression test for #983 — a write handler calling `ctx["jobRunner"]
2
+ // .dispatch(...)` directly (the documented pattern, e.g. bundled jobs
3
+ // feature's trigger.write.ts) threw `TypeError: undefined is not an
4
+ // object` under both `setupTestStack` and `runDevApp`, because neither
5
+ // built a JobRunner nor merged one into the dispatcher's context.
6
+ //
7
+ // Two things had to be fixed together (see dispatch-shared.ts and
8
+ // test-stack.ts for the respective commits):
9
+ // 1. `setupTestStack({ jobs: {...} })` now builds a real JobRunner and
10
+ // merges it into dispatcherOptions — this test's `jobs: {}` opt-in.
11
+ // 2. `buildHandlerContext` (packages/framework/src/pipeline/dispatch-
12
+ // shared.ts) never spread `DispatchContext.jobRunner` into the
13
+ // handler-facing ctx at all — a gap that predates this issue and
14
+ // affects the prod entrypoint too, not just dev/test. Fixed there,
15
+ // not band-aided into `context:` here — otherwise test/dev would
16
+ // pass while prod's `ctx.jobRunner` stayed undefined.
17
+ //
18
+ // This test drives the manual-dispatch repro from the issue, not the
19
+ // auto-trigger path (already covered by entrypoint-job-wiring.integration
20
+ // .test.ts) — the two paths read from different context slots and a fix
21
+ // for one doesn't guarantee the other.
22
+
23
+ import { afterEach, describe, expect, test } from "bun:test";
24
+ import { z } from "zod";
25
+ import { defineFeature } from "../../engine";
26
+ import { InternalError, writeFailure } from "../../errors";
27
+ import { waitFor } from "../../testing";
28
+ import { setupTestStack, type TestStack } from "../test-stack";
29
+ import { TestUsers } from "../test-users";
30
+
31
+ const jobRuns: Array<{ name: string; payload: Record<string, unknown> }> = [];
32
+
33
+ const manualDispatchFeature = defineFeature("manualdispatch", (r) => {
34
+ r.writeHandler(
35
+ "create",
36
+ z.object({ note: z.string() }),
37
+ async (event, ctx) => {
38
+ // Exact repro from #983: bracket-access, dynamic context extension —
39
+ // not a typed HandlerContext field (mirrors trigger.write.ts).
40
+ const jobRunner = ctx["jobRunner"] as
41
+ | { dispatch: (name: string, payload: Record<string, unknown>) => Promise<string> }
42
+ | undefined;
43
+ if (!jobRunner) {
44
+ return writeFailure(new InternalError({ message: "no jobRunner on ctx" }));
45
+ }
46
+ await jobRunner.dispatch("manualdispatch:job:record", { note: event.payload.note });
47
+ return { isSuccess: true as const, data: { id: 1, note: event.payload.note } };
48
+ },
49
+ { access: { openToAll: true } },
50
+ );
51
+ r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload) => {
52
+ jobRuns.push({ name: "manualdispatch:job:record", payload });
53
+ });
54
+ });
55
+
56
+ let stack: TestStack | undefined;
57
+
58
+ afterEach(async () => {
59
+ if (stack) await stack.cleanup();
60
+ stack = undefined;
61
+ });
62
+
63
+ describe("setupTestStack({ jobs }) wires ctx.jobRunner for manual dispatch", () => {
64
+ test("write handler's ctx.jobRunner.dispatch(...) works end-to-end", async () => {
65
+ jobRuns.length = 0;
66
+ stack = await setupTestStack({
67
+ features: [manualDispatchFeature],
68
+ // consumerLane: this test has no separate consumer (unlike runDevApp's
69
+ // startDevJobRunners), so it must be the one running the job.
70
+ jobs: { consumerLane: "worker" },
71
+ });
72
+
73
+ await stack.http.writeOk("manualdispatch:write:create", { note: "hi" }, TestUsers.admin);
74
+
75
+ await waitFor(() => {
76
+ const run = jobRuns.find((e) => e.name === "manualdispatch:job:record");
77
+ expect(run).toBeDefined();
78
+ expect(run?.payload["note"]).toBe("hi");
79
+ });
80
+ });
81
+
82
+ test("without `jobs` opt-in, ctx.jobRunner stays undefined (no-op, not a throw)", async () => {
83
+ stack = await setupTestStack({ features: [manualDispatchFeature] });
84
+ expect(stack.jobRunner).toBeUndefined();
85
+
86
+ // The handler itself guards against a missing jobRunner (writeFailure,
87
+ // not a thrown TypeError) — same contract as trigger.write.ts's cast.
88
+ const err = await stack.http.writeErr(
89
+ "manualdispatch:write:create",
90
+ { note: "hi" },
91
+ TestUsers.admin,
92
+ );
93
+ expect(err.code).toBe("internal_error");
94
+ });
95
+ });
@@ -6,8 +6,9 @@ import { createSseBroker } from "../api/sse-broker";
6
6
  import type { PgClient } from "../db/connection";
7
7
  import { extractTableInfo } from "../db/query";
8
8
  import { createRegistry } from "../engine/registry";
9
- import type { FeatureDefinition, Registry, TenantId } from "../engine/types";
9
+ import type { FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
10
10
  import { createArchivedStreamsTable, createEventsTable } from "../event-store";
11
+ import { createJobRunner, type JobRunner } from "../jobs";
11
12
  import type { Lifecycle } from "../lifecycle";
12
13
  import type { ObservabilityProvider } from "../observability";
13
14
  import type { Dispatcher, EventDispatcher } from "../pipeline";
@@ -41,6 +42,10 @@ export type TestStack = {
41
42
  // Only set when the caller passed `lifecycle` via options. Tests that
42
43
  // exercise drain() / /health/ready wire one in; ordinary suites ignore it.
43
44
  lifecycle?: Lifecycle;
45
+ // Only set when `options.jobs` is truthy AND ≥1 `r.job(...)` is
46
+ // registered in the mounted features. Lets integration tests call
47
+ // `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.jobRunner`.
48
+ jobRunner?: JobRunner;
44
49
  cleanup: () => Promise<void>;
45
50
  };
46
51
 
@@ -119,6 +124,23 @@ export type TestStackOptions = {
119
124
  sseBroker: import("../api/sse-broker").SseBroker;
120
125
  redis: import("ioredis").default;
121
126
  }) => import("../api/server").ServerOptions["anonymousAccess"]);
127
+ /** Opt-in JobRunner wired into ctx.jobRunner (write handlers'
128
+ * `ctx.jobRunner.dispatch(...)`) and merged into dispatcherOptions so
129
+ * event-triggered jobs enqueue on commit — mirrors the prod entrypoint's
130
+ * `buildJobRunnerWithHook`. No-ops when no `r.job(...)` is registered
131
+ * anywhere in the mounted features — the bundled `createJobsFeature()`
132
+ * (operator UI) is NOT required, matching prod's unconditional build.
133
+ * Default `undefined` — enqueuer-only, holds queue-clients for both
134
+ * lanes so `ctx.jobRunner.dispatch(...)` routes correctly but starts no
135
+ * local consumer/cron-scheduler (avoids double-running `runOnBoot`/cron
136
+ * jobs when the caller, e.g. runDevApp, already runs its own consumer
137
+ * for that lane). Pass `consumerLane` only when this IS the sole
138
+ * consumer (e.g. a test that wants the dispatched job to actually
139
+ * execute without a separate runner). */
140
+ jobs?: {
141
+ consumerLane?: JobRunIn;
142
+ queueNamePrefix?: string;
143
+ };
122
144
  };
123
145
 
124
146
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
@@ -213,6 +235,26 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
213
235
  const events = createEventCollector();
214
236
  const registry = createRegistry([...options.features]);
215
237
 
238
+ // Built before buildServer() so it can be merged into dispatcherOptions
239
+ // (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
240
+ // event-trigger hook both need it present at dispatcher-construction
241
+ // time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
242
+ // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
243
+ let jobRunner: JobRunner | undefined;
244
+ if (options.jobs && registry.getAllJobs().size > 0) {
245
+ const redisOpts = testRedis.redis.options;
246
+ jobRunner = createJobRunner({
247
+ registry,
248
+ context: { db: testDb.db, registry },
249
+ redisUrl: `redis://${redisOpts.host}:${redisOpts.port}/${redisOpts.db}`,
250
+ ...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
251
+ ...(options.jobs.queueNamePrefix !== undefined && {
252
+ queueNamePrefix: options.jobs.queueNamePrefix,
253
+ }),
254
+ });
255
+ await jobRunner.start();
256
+ }
257
+
216
258
  // Auto-configure search for tenant 1 based on registry
217
259
  if (enabledHooks.includes("search")) {
218
260
  const searchableFields: string[] = [];
@@ -285,6 +327,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
285
327
  dispatcherOptions: {
286
328
  idempotency,
287
329
  ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
330
+ ...(jobRunner && { jobRunner }),
288
331
  },
289
332
  eventDedup,
290
333
  sseBroker,
@@ -354,7 +397,9 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
354
397
  dispatcher: server.dispatcher,
355
398
  ...(eventDispatcher ? { eventDispatcher } : {}),
356
399
  ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
400
+ ...(jobRunner ? { jobRunner } : {}),
357
401
  cleanup: async () => {
402
+ if (jobRunner) await jobRunner.stop();
358
403
  if (eventDispatcher) await eventDispatcher.stop();
359
404
  await server.observability.shutdown();
360
405
  await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
@@ -1,3 +1,4 @@
1
+ import { SEARCHABLE_FALSE_WHITELIST } from "../engine/boot-validator/entity-list-screens";
1
2
  import type {
2
3
  EntityDefinition,
3
4
  EntityListScreenDefinition,
@@ -7,8 +8,6 @@ import type {
7
8
  import { normalizeListColumn } from "../engine/types/screen";
8
9
  import { featureHasI18nSurface, requiredKeysFromFeature } from "../i18n/required-surface-keys";
9
10
 
10
- const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
11
-
12
11
  function ensureEntityListSortable(feature: FeatureDefinition): FeatureDefinition {
13
12
  if (!feature.entities) return feature;
14
13
  const entities: Record<string, EntityDefinition> = { ...feature.entities };
@@ -1,35 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { buildDeepLinkUrl } from "../deep-link";
3
-
4
- describe("buildDeepLinkUrl()", () => {
5
- test("screenId only", () => {
6
- expect(buildDeepLinkUrl("https://app.example.com", { screenId: "jobs" })).toBe(
7
- "https://app.example.com/jobs",
8
- );
9
- });
10
-
11
- test("screenId + entityId", () => {
12
- expect(
13
- buildDeepLinkUrl("https://app.example.com", { screenId: "jobs", entityId: "job-1" }),
14
- ).toBe("https://app.example.com/jobs/job-1");
15
- });
16
-
17
- test("workspaceId + screenId + entityId, in path order", () => {
18
- expect(
19
- buildDeepLinkUrl("https://app.example.com", {
20
- workspaceId: "admin",
21
- screenId: "jobs",
22
- entityId: "job-1",
23
- }),
24
- ).toBe("https://app.example.com/admin/jobs/job-1");
25
- });
26
-
27
- test("strips trailing slash(es) from baseUrl", () => {
28
- expect(buildDeepLinkUrl("https://app.example.com/", { screenId: "jobs" })).toBe(
29
- "https://app.example.com/jobs",
30
- );
31
- expect(buildDeepLinkUrl("https://app.example.com//", { screenId: "jobs" })).toBe(
32
- "https://app.example.com/jobs",
33
- );
34
- });
35
- });
@@ -1,23 +0,0 @@
1
- // Deep-Link-URL-Builder für Notification-Templates (#449, Lazy-Scope: nur
2
- // Notification→Screen, kein Permalink-Sharing-Layer). Server-seitig nutzbar
3
- // (kein React) — der Renderer hat mit `formatPath`
4
- // (packages/renderer/src/app/nav.tsx) dasselbe Pfad-Format fürs Client-Routing,
5
- // hier bewusst dupliziert statt importiert: `renderer` zieht React als
6
- // Dependency, Notification-Data-Fns laufen server-seitig im Write-Handler.
7
- //
8
- // baseUrl kommt vom App-Autor (analog `AuthMailOptions.baseUrl` /
9
- // magic-link-mail.ts appendToken) — kein Auto-Detect, kein Env-Var-Read hier.
10
-
11
- export type DeepLinkTarget = {
12
- readonly screenId: string;
13
- readonly entityId?: string;
14
- readonly workspaceId?: string;
15
- };
16
-
17
- export function buildDeepLinkUrl(baseUrl: string, target: DeepLinkTarget): string {
18
- const segments: string[] = [];
19
- if (target.workspaceId !== undefined) segments.push(target.workspaceId);
20
- segments.push(target.screenId);
21
- if (target.entityId !== undefined) segments.push(target.entityId);
22
- return `${baseUrl.replace(/\/+$/, "")}/${segments.join("/")}`;
23
- }