@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
package/src/db/money.ts CHANGED
@@ -3,10 +3,15 @@
3
3
  // Vertrag (siehe auch db/located-timestamp.ts — gleicher Compound-Type-Pattern):
4
4
  // API-Form: { amount, currency } | number — amount in MAJOR units (56799.16 EUR)
5
5
  // DB-Form: <name> BIGINT (minor units, e.g. cents) + <name>Currency TEXT
6
- // Read-Form: { amount, currency, amountMinor } — amount in MAJOR units again,
7
- // amountMinor sits alongside as exact integer cents (fw#1830) for
8
- // callers that need cent-exact comparisons (e.g. invoice sums)
9
- // instead of round-tripping the float amount through /100·*100.
6
+ // Read-Form: { amount, currency, amountScaled } — amount in MAJOR units again,
7
+ // amountScaled sits alongside as the exact integer value in
8
+ // MINOR_UNIT_SCALE units (fw#1830) for callers that need
9
+ // scale-exact comparisons (e.g. invoice sums) instead of
10
+ // round-tripping the float amount through /100·*100.
11
+ // `amountMinor` stays as a @deprecated alias of the same
12
+ // value until #1976/4 and #1976/5 migrate their remaining
13
+ // consumers (renderer-web/primitives/index.tsx,
14
+ // renderer/components/render-field.tsx) off the old name.
10
15
  //
11
16
  // table-builder.ts's moneyAmount column has always documented BIGINT as
12
17
  // "the integer minor unit" — this file used to just pass the API amount
@@ -40,8 +45,8 @@ export function toMinorUnits(amount: number): number {
40
45
  return Math.round(amount * MINOR_UNIT_SCALE);
41
46
  }
42
47
 
43
- function toMajorUnits(amountMinor: number): number {
44
- return amountMinor / MINOR_UNIT_SCALE;
48
+ function toMajorUnits(amountScaled: number): number {
49
+ return amountScaled / MINOR_UNIT_SCALE;
45
50
  }
46
51
 
47
52
  // One money field's write payload — `{ amount, currency }` or a bare number
@@ -123,11 +128,20 @@ export function flattenMoney(
123
128
  }
124
129
 
125
130
  /** Shape of a single rehydrated money field — {amount major, currency,
126
- * amountMinor exact integer cents}. Exported so consumers type their own
127
- * copy against this instead of re-declaring the shape by hand. */
131
+ * amountScaled exact integer value in MINOR_UNIT_SCALE units}. Exported so
132
+ * consumers type their own copy against this instead of re-declaring the
133
+ * shape by hand. */
128
134
  export type MoneyRead = {
129
135
  readonly amount: number;
130
136
  readonly currency: string;
137
+ readonly amountScaled: number;
138
+ /**
139
+ * @deprecated Use `amountScaled` — this name implies ISO-4217 minor units
140
+ * (cents), which is wrong once a currency needing a different scale than
141
+ * the current flat MINOR_UNIT_SCALE=100 lands (e.g. JPY, 0 decimals).
142
+ * Alias of `amountScaled`, kept until #1976/4 and #1976/5 migrate their
143
+ * consumers off it.
144
+ */
131
145
  readonly amountMinor: number;
132
146
  };
133
147
 
@@ -159,18 +173,18 @@ export function rehydrateMoney(
159
173
  continue;
160
174
  }
161
175
 
162
- let amountMinor: number;
176
+ let amountScaled: number;
163
177
  if (typeof amountRaw === "number") {
164
- amountMinor = amountRaw;
178
+ amountScaled = amountRaw;
165
179
  } else if (typeof amountRaw === "bigint") {
166
- amountMinor = Number(amountRaw);
167
- if (!Number.isSafeInteger(amountMinor)) {
180
+ amountScaled = Number(amountRaw);
181
+ if (!Number.isSafeInteger(amountScaled)) {
168
182
  throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
169
183
  }
170
184
  } else if (typeof amountRaw === "string" && amountRaw !== "") {
171
185
  // PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
172
- amountMinor = Number(amountRaw);
173
- if (!Number.isSafeInteger(amountMinor)) {
186
+ amountScaled = Number(amountRaw);
187
+ if (!Number.isSafeInteger(amountScaled)) {
174
188
  throw new Error(
175
189
  `rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`,
176
190
  );
@@ -184,7 +198,13 @@ export function rehydrateMoney(
184
198
  const currency =
185
199
  typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
186
200
 
187
- result[name] = { amount: toMajorUnits(amountMinor), currency, amountMinor };
201
+ // amountMinor: deprecated alias, same value as amountScaled — see MoneyRead.
202
+ result[name] = {
203
+ amount: toMajorUnits(amountScaled),
204
+ currency,
205
+ amountScaled,
206
+ amountMinor: amountScaled,
207
+ };
188
208
  }
189
209
 
190
210
  return result;
@@ -25,6 +25,7 @@ import {
25
25
  moneyAmount,
26
26
  table as pgTable,
27
27
  plainDate,
28
+ SQL_EXPR_BRAND,
28
29
  type SqlExpression,
29
30
  serial,
30
31
  sql,
@@ -586,7 +587,12 @@ export function buildEntityTable<E extends EntityDefinition>(
586
587
  .filter((c, i) => c !== def.columns[i])
587
588
  .map((c) => `"${toSnakeCase(c)}" IS NOT NULL`)
588
589
  .join(" AND ");
589
- const partialWhere: SqlExpression = { kind: "sql-expr", text: whereText, params: [] };
590
+ const partialWhere: SqlExpression = {
591
+ kind: "sql-expr",
592
+ text: whereText,
593
+ params: [],
594
+ [SQL_EXPR_BRAND]: true,
595
+ };
590
596
  indexes[`${indexName}_bidx`] = uniqueIndex(`${indexName}_bidx`)
591
597
  .on(...bidxCols)
592
598
  .where(partialWhere);
@@ -171,4 +171,47 @@ describe("createDerivativesContext — variant()", () => {
171
171
  expect(provider.mimeTypeOf(result.storageKey)).toBe(result.mimeType);
172
172
  expect(result.mimeType).toBe("image/webp");
173
173
  });
174
+
175
+ // #2021 — a variant with no spec.format used to fall straight through to
176
+ // the source's mimeType, which is client-controlled (`file.type` off the
177
+ // upload). A field without `accept` plus a broad `image/*` renderer
178
+ // wildcard (like `setup()`'s here) let a client ride an active-content
179
+ // type like `image/svg+xml` all the way to the response Content-Type.
180
+ test("no spec.format + a non-allowlisted source mimeType does not take over that mimeType", async () => {
181
+ const { ctx, provider } = await setup("image/svg+xml");
182
+ const spec = {} as const;
183
+
184
+ const result = await ctx.variant(FILE_REF_ID, spec, "thumb");
185
+
186
+ expect(result.mimeType).not.toBe("image/svg+xml");
187
+ expect(result.mimeType).toBe("application/octet-stream");
188
+ expect(provider.mimeTypeOf(result.storageKey)).toBe("application/octet-stream");
189
+ });
190
+
191
+ test("no spec.format + a non-allowlisted source mimeType — a cache hit returns the same normalized value", async () => {
192
+ const { ctx } = await setup("image/svg+xml");
193
+ const spec = {} as const;
194
+
195
+ const first = await ctx.variant(FILE_REF_ID, spec, "thumb");
196
+ const second = await ctx.variant(FILE_REF_ID, spec, "thumb");
197
+
198
+ expect(first.mimeType).toBe("application/octet-stream");
199
+ expect(second.mimeType).toBe("application/octet-stream");
200
+ });
201
+
202
+ test("no spec.format keeps a known-safe source mimeType — resize-without-reformat stays intact", async () => {
203
+ const { ctx } = await setup("image/png");
204
+
205
+ const result = await ctx.variant(FILE_REF_ID, { maxEdge: 320 }, "thumb");
206
+
207
+ expect(result.mimeType).toBe("image/png");
208
+ });
209
+
210
+ test("no spec.format normalizes a `; charset=` suffix off the source mimeType instead of leaking it raw", async () => {
211
+ const { ctx } = await setup("image/jpeg; charset=binary");
212
+
213
+ const result = await ctx.variant(FILE_REF_ID, {}, "thumb");
214
+
215
+ expect(result.mimeType).toBe("image/jpeg");
216
+ });
174
217
  });
@@ -27,7 +27,14 @@ const fakeRender: DerivativeRendererPlugin["render"] = async () => {
27
27
  const photoEntity = createEntity({
28
28
  table: "variant_route_photos",
29
29
  fields: {
30
- avatar: createImageField({ variants: { thumb: { maxEdge: 100, format: "webp" } } }),
30
+ avatar: createImageField({
31
+ variants: {
32
+ thumb: { maxEdge: 100, format: "webp" },
33
+ // No format — resize-without-reformat, the legitimate reason
34
+ // spec.format can be omitted (see #2021).
35
+ raw: { maxEdge: 100 },
36
+ },
37
+ }),
31
38
  },
32
39
  });
33
40
 
@@ -207,4 +214,44 @@ describe("GET /api/files/:id/variant/:name", () => {
207
214
  expect(res.status).toBe(415);
208
215
  expect(await res.json()).toEqual({ error: "unsupported_media_type" });
209
216
  });
217
+
218
+ // #2021 — the "raw" variant has no `format`, so its mimeType comes from
219
+ // outputMimeType's fallback branch. `avatar` has no `accept`, so the
220
+ // source mimeType is exactly the client-controlled `file.type` from the
221
+ // upload — this proves the route no longer echoes it verbatim.
222
+ test("a variant with no spec.format never echoes a non-safe, client-controlled source mimeType", async () => {
223
+ const token = await stack.jwt.sign(user);
224
+ const fd = new FormData();
225
+ fd.append("file", new File([Buffer.from([1, 2, 3])], "avatar.svg", { type: "image/svg+xml" }));
226
+ fd.append("entityType", "photo");
227
+ fd.append("fieldName", "avatar");
228
+ const { body, contentType } = await buildMultipartBody(fd);
229
+ const uploadRes = await stack.app.request("/api/files", {
230
+ method: "POST",
231
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
232
+ body,
233
+ });
234
+ expect(uploadRes.status).toBe(201);
235
+ const { id: fileId } = (await uploadRes.json()) as { id: string };
236
+
237
+ const res = await stack.app.request(`/api/files/${fileId}/variant/raw`, {
238
+ headers: { Authorization: `Bearer ${token}` },
239
+ });
240
+
241
+ expect(res.status).toBe(200);
242
+ expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
243
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
244
+ });
245
+
246
+ test("a variant with no spec.format keeps a known-safe source mimeType through the full route (resize without reformat)", async () => {
247
+ const fileId = await uploadFile();
248
+ const token = await stack.jwt.sign(user);
249
+
250
+ const res = await stack.app.request(`/api/files/${fileId}/variant/raw`, {
251
+ headers: { Authorization: `Bearer ${token}` },
252
+ });
253
+
254
+ expect(res.status).toBe(200);
255
+ expect(res.headers.get("Content-Type")).toBe("image/jpeg");
256
+ });
210
257
  });
@@ -69,6 +69,23 @@ function isFileRefRow(row: Record<string, unknown>): row is FileRefRow {
69
69
  return typeof row["storageKey"] === "string" && typeof row["mimeType"] === "string";
70
70
  }
71
71
 
72
+ // sourceMimeType is client-controlled (it's `file.type` off the upload — see
73
+ // file-routes.ts's "a field without `accept` lets a client upload anything")
74
+ // and, when spec.format is unset, a renderer is documented to preserve the
75
+ // source's own format (DerivativeRendererPlugin's doc comment) — so the
76
+ // bytes really are in this format, but the STRING itself must still be
77
+ // allowlisted before it becomes a response Content-Type or storage-provider
78
+ // metadata. Otherwise an app whose renderer wildcard is broad enough (e.g.
79
+ // `image/*`) would let a client-declared `image/svg+xml` ride straight
80
+ // through as an honestly-declared (non-sniffed) active-content type (#2021).
81
+ const KNOWN_SAFE_VARIANT_MIME_TYPES: ReadonlySet<string> = new Set([
82
+ "image/jpeg",
83
+ "image/png",
84
+ "image/webp",
85
+ "image/avif",
86
+ "image/gif",
87
+ ]);
88
+
72
89
  // The spec — not the renderer — determines the output mimeType, so it's the
73
90
  // single source of truth for both what gets written to storage and what the
74
91
  // caller receives; a cache hit never runs the renderer, so there's nothing
@@ -81,11 +98,24 @@ function outputMimeType(spec: VariantSpec, sourceMimeType: string): string {
81
98
  return "image/avif";
82
99
  case "jpeg":
83
100
  return "image/jpeg";
84
- default:
85
- return sourceMimeType;
101
+ default: {
102
+ const normalized = normalizeMimeType(sourceMimeType);
103
+ return KNOWN_SAFE_VARIANT_MIME_TYPES.has(normalized)
104
+ ? normalized
105
+ : "application/octet-stream";
106
+ }
86
107
  }
87
108
  }
88
109
 
110
+ // `deps.db` is whatever the caller passes through — in the write-handler
111
+ // pipeline (dispatch-shared.ts) that's the handler's open DbTx. `variant()`
112
+ // then holds that transaction open across a full render (decode/resize/
113
+ // encode) plus the storage read+write, not just the fetchOne/exists checks.
114
+ // On a later rollback in the same handler, a variant already written to
115
+ // storage is orphaned there (row never committed, bytes never cleaned up).
116
+ // Prefer this context from the job/MSP path, where `deps.db` isn't tied to
117
+ // an in-flight write transaction; a synchronous write handler rendering a
118
+ // large image should hand off to a job instead of calling `variant()` inline.
89
119
  export function createDerivativesContext(deps: DerivativesContextDeps): DerivativesContext {
90
120
  return {
91
121
  variant: async (fileRefId, spec, name) => {
@@ -71,6 +71,26 @@ describe("buildAppSchema", () => {
71
71
  });
72
72
  });
73
73
 
74
+ // kumiko-framework#2034: createKumikoApp's boot diagnostic reads
75
+ // `screens[].dormant` from the CLIENT schema, not from the registry —
76
+ // this pins that the flag actually survives the server→client projection
77
+ // instead of only living in the registry's verbatim `feature.screens`.
78
+ test("custom screen's `dormant` flag survives the buildAppSchema projection verbatim (#2034)", () => {
79
+ const dormantScreenFeature = defineFeature("privacy", (r) => {
80
+ r.screen({
81
+ id: "privacy-center",
82
+ type: "custom",
83
+ renderer: { react: { __component: "PrivacyCenterScreen" } },
84
+ dormant: true,
85
+ });
86
+ });
87
+
88
+ const app = buildAppSchema(createRegistry([dormantScreenFeature]));
89
+ const screen = app.features.find((f) => f.featureName === "privacy")?.screens[0];
90
+
91
+ expect(screen).toMatchObject({ id: "privacy-center", dormant: true });
92
+ });
93
+
74
94
  test("Feature ohne r.translations lässt das Feld weg (omit-undefined-Pattern)", () => {
75
95
  const f = defineFeature("bare", (r) => {
76
96
  r.nav({ id: "x", label: "X" });
@@ -38,7 +38,7 @@ describe("r.nav() — registration", () => {
38
38
  r.nav({
39
39
  id: "products",
40
40
  label: "shop:nav.products",
41
- icon: "box",
41
+ icon: "package",
42
42
  order: 10,
43
43
  parent: "shop:nav:catalog",
44
44
  screen: "shop:screen:products",
@@ -47,7 +47,7 @@ describe("r.nav() — registration", () => {
47
47
  });
48
48
  const nav = feature.navs["products"];
49
49
  expect(nav).toMatchObject({
50
- icon: "box",
50
+ icon: "package",
51
51
  order: 10,
52
52
  parent: "shop:nav:catalog",
53
53
  screen: "shop:screen:products",
@@ -78,6 +78,14 @@ describe("r.nav() — registration", () => {
78
78
  }),
79
79
  ).not.toThrow();
80
80
  });
81
+
82
+ test("@ts-expect-error: icon must be a registered NavIconKey, not any string", () => {
83
+ const feature = defineFeature("shop", (r) => {
84
+ // @ts-expect-error — "seting" is a typo of "settings", not a NavIconKey
85
+ r.nav({ id: "catalog", label: "x", icon: "seting" });
86
+ });
87
+ expect(feature.navs["catalog"]).toBeDefined();
88
+ });
81
89
  });
82
90
 
83
91
  describe("r.screen({ nav }) — inline nav sugar", () => {
@@ -89,13 +97,13 @@ describe("r.screen({ nav }) — inline nav sugar", () => {
89
97
  type: "entityList",
90
98
  entity: "product",
91
99
  columns: ["name"],
92
- nav: { label: "shop:nav.products", icon: "box", order: 5 },
100
+ nav: { label: "shop:nav.products", icon: "package", order: 5 },
93
101
  });
94
102
  });
95
103
  expect(feature.navs["products"]).toMatchObject({
96
104
  id: "products",
97
105
  label: "shop:nav.products",
98
- icon: "box",
106
+ icon: "package",
99
107
  order: 5,
100
108
  screen: "shop:screen:products",
101
109
  });
@@ -9,7 +9,7 @@ import {
9
9
  softDeleteCleanupJob,
10
10
  softDeleteCleanupSystemJob,
11
11
  } from "../soft-delete-cleanup";
12
- import type { AppContext } from "../types/handlers";
12
+ import type { JobContext } from "../types/handlers";
13
13
 
14
14
  function featureWith(softDelete: boolean | undefined) {
15
15
  return defineFeature("probe-sd", (r) => {
@@ -53,7 +53,7 @@ describe("registry soft-delete auto-wiring", () => {
53
53
 
54
54
  type DeleteCall = { table: unknown; where: Record<string, unknown> };
55
55
 
56
- function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): AppContext {
56
+ function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): JobContext {
57
57
  // Shaped to satisfy bun-db's tenantDbDelegate() probe so deleteMany() routes
58
58
  // to this recorder instead of trying to extract real table metadata.
59
59
  const fakeDb = {
@@ -96,7 +96,7 @@ function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): AppContext
96
96
  ...(opts.graceDays !== undefined && {
97
97
  configResolver: { get: async () => opts.graceDays },
98
98
  }),
99
- } as unknown as AppContext;
99
+ } as unknown as JobContext;
100
100
  }
101
101
 
102
102
  describe("softDeleteCleanupJob handler", () => {
@@ -134,7 +134,7 @@ describe("softDeleteCleanupJob handler", () => {
134
134
  });
135
135
 
136
136
  test("throws when the job context is missing db/registry", async () => {
137
- await expect(softDeleteCleanupJob({}, {} as AppContext)).rejects.toThrow(
137
+ await expect(softDeleteCleanupJob({}, {} as JobContext)).rejects.toThrow(
138
138
  /ctx.db \+ ctx.registry/,
139
139
  );
140
140
  });
@@ -161,7 +161,7 @@ describe("softDeleteCleanupSystemJob handler", () => {
161
161
  });
162
162
 
163
163
  test("throws when the job context is missing db/registry", async () => {
164
- await expect(softDeleteCleanupSystemJob({}, {} as AppContext)).rejects.toThrow(
164
+ await expect(softDeleteCleanupSystemJob({}, {} as JobContext)).rejects.toThrow(
165
165
  /ctx.db \+ ctx.registry/,
166
166
  );
167
167
  });
@@ -28,7 +28,7 @@ import type { ConfigKeyDefinition } from "./types/config";
28
28
  import type { Registry } from "./types/feature";
29
29
  import type { FieldDefinition } from "./types/fields";
30
30
  import type { AccessRule } from "./types/handlers";
31
- import type { NavDefinition } from "./types/nav";
31
+ import type { NavDefinition, NavIconKey } from "./types/nav";
32
32
  import type {
33
33
  ConfigEditScreenDefinition,
34
34
  EditFieldsSection,
@@ -57,7 +57,7 @@ export type ConfigFeatureSchema = {
57
57
 
58
58
  // Audience-Reihenfolge im Sidebar: Plattform vor Tenant vor Benutzer.
59
59
  const SCOPE_ORDER: Record<ConfigScope, number> = { system: 10, tenant: 20, user: 30 };
60
- const SCOPE_ICON: Record<ConfigScope, string> = {
60
+ const SCOPE_ICON: Record<ConfigScope, NavIconKey> = {
61
61
  system: "shield",
62
62
  tenant: "building",
63
63
  user: "user",
@@ -353,6 +353,7 @@ export type {
353
353
  MultiStreamProjectionDefinition,
354
354
  NameOrRef,
355
355
  NavDefinition,
356
+ NavIconKey,
356
357
  NotificationDataFn,
357
358
  NotificationDefinition,
358
359
  NotificationRecipientFn,
@@ -412,7 +413,7 @@ export type {
412
413
  WriteResult,
413
414
  } from "./types";
414
415
  export { DEFAULT_CURRENCIES, HookPhases } from "./types";
415
- export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
416
+ export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
416
417
  export type {
417
418
  PipelineBuildCtx,
418
419
  PipelineCtx,
@@ -138,6 +138,7 @@ export type {
138
138
  ClaimKeyJsType,
139
139
  ClaimKeyType,
140
140
  DeclarativeEventMigration,
141
+ DispatchWriteRef,
141
142
  EntityRef,
142
143
  EventDef,
143
144
  EventMigrationDef,
@@ -204,10 +205,15 @@ export type {
204
205
  export type { EntityId, TenantId } from "@cosmicdrift/kumiko-types/identifiers";
205
206
  export {
206
207
  isSystemTenant,
208
+ isUuid,
207
209
  parseTenantId,
208
210
  SYSTEM_TENANT_ID,
209
211
  } from "@cosmicdrift/kumiko-types/identifiers";
210
- export type { ContentCollectionDefinition, NavDefinition } from "@cosmicdrift/kumiko-types/nav";
212
+ export type {
213
+ ContentCollectionDefinition,
214
+ NavDefinition,
215
+ NavIconKey,
216
+ } from "@cosmicdrift/kumiko-types/nav";
211
217
  export type {
212
218
  FromRule,
213
219
  FromRuleKind,
@@ -0,0 +1,138 @@
1
+ // Regression test for framework#2044 — attachDispatcher() wiring.
2
+ //
3
+ // #2043 added JobContext.write/queryAs plus JobRunner.attachDispatcher(), but
4
+ // left the entrypoint factories unwired: nothing ever called
5
+ // attachDispatcher() on the JobRunners they build, so ctx.write inside a job
6
+ // always hit the throwing stub in production too. This test proves the
7
+ // wiring closes that gap (a job's ctx.write actually commits when run
8
+ // through a real entrypoint) and that the gap is real (the same job, run
9
+ // against a bare createJobRunner() with no attachDispatcher() call, still
10
+ // throws the #2043 stub).
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { z } from "zod";
14
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
15
+ import { asRawClient } from "../../db/query";
16
+ import { createRegistry, defineFeature } from "../../engine";
17
+ import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
18
+ import { createJobRunner } from "../../jobs/job-runner";
19
+ import { createEventConsumerStateTable } from "../../pipeline";
20
+ import { createTestRedis, type TestRedis } from "../../stack";
21
+ import { waitFor } from "../../testing";
22
+ import { createWorkerEntrypoint } from "../index";
23
+
24
+ const writeProbeResults: Array<{ isSuccess: boolean }> = [];
25
+ const writeProbeFailures: string[] = [];
26
+
27
+ const writeProbeFeature = defineFeature("writeProbe", (r) => {
28
+ const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
29
+ r.writeHandler(
30
+ "note",
31
+ z.object({ note: z.string() }),
32
+ async (event, ctx) => {
33
+ await ctx.unsafeAppendEvent({
34
+ aggregateId: crypto.randomUUID(),
35
+ aggregateType: "write-probe-note",
36
+ type: noted.name,
37
+ payload: { note: event.payload.note },
38
+ });
39
+ return { isSuccess: true as const, data: { note: event.payload.note } };
40
+ },
41
+ { access: { openToAll: true } },
42
+ );
43
+ r.job("write-via-job", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
44
+ try {
45
+ const result = await ctx.write("write-probe:write:note", {
46
+ note: payload["note"] as string,
47
+ });
48
+ writeProbeResults.push({ isSuccess: result.isSuccess });
49
+ } catch (error) {
50
+ writeProbeFailures.push(error instanceof Error ? error.message : String(error));
51
+ throw error;
52
+ }
53
+ });
54
+ });
55
+
56
+ const JWT = "attach-dispatcher-test-secret-must-be-32-chars!";
57
+
58
+ let testDb: BunTestDb;
59
+ let testRedis: TestRedis;
60
+
61
+ beforeAll(async () => {
62
+ [testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
63
+ await createEventsTable(testDb.db);
64
+ await createArchivedStreamsTable(testDb.db);
65
+ await createEventConsumerStateTable(testDb.db);
66
+ });
67
+
68
+ afterAll(async () => {
69
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
70
+ });
71
+
72
+ function uniquePrefix(label: string): string {
73
+ return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
74
+ }
75
+
76
+ describe("createWorkerEntrypoint auto-wires attachDispatcher() (framework#2044)", () => {
77
+ test("ctx.write inside a job commits end-to-end through a real entrypoint", async () => {
78
+ writeProbeResults.length = 0;
79
+ const registry = createRegistry([writeProbeFeature]);
80
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
81
+ const worker = createWorkerEntrypoint({
82
+ registry,
83
+ context: { db: testDb.db, redis: testRedis.redis },
84
+ jwtSecret: JWT,
85
+ redisUrl,
86
+ queueNamePrefix: uniquePrefix("attach-dispatcher"),
87
+ });
88
+
89
+ await worker.start();
90
+ try {
91
+ await worker.jobRunner.dispatch("write-probe:job:write-via-job", {
92
+ note: "written from the job",
93
+ });
94
+
95
+ await waitFor(() => {
96
+ expect(writeProbeResults.length).toBe(1);
97
+ expect(writeProbeResults[0]?.isSuccess).toBe(true);
98
+ });
99
+
100
+ const rows = await asRawClient(testDb.db).unsafe(
101
+ `SELECT payload FROM kumiko_events WHERE type = 'write-probe:event:noted'`,
102
+ );
103
+ expect(rows).toHaveLength(1);
104
+ expect((rows[0] as { payload: { note: string } }).payload.note).toBe("written from the job");
105
+ } finally {
106
+ await worker.stop();
107
+ }
108
+ });
109
+ });
110
+
111
+ describe("createJobRunner without attachDispatcher() still hits the #2043 stub", () => {
112
+ test("ctx.write throws — proves the entrypoint's attachDispatcher() call is the thing that makes writes work", async () => {
113
+ writeProbeFailures.length = 0;
114
+ const registry = createRegistry([writeProbeFeature]);
115
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
116
+ const runner = createJobRunner({
117
+ registry,
118
+ context: { db: testDb.db, redis: testRedis.redis },
119
+ redisUrl,
120
+ consumerLane: "worker",
121
+ queueNamePrefix: uniquePrefix("attach-dispatcher-bare"),
122
+ });
123
+
124
+ await runner.start();
125
+ try {
126
+ await runner.dispatch("write-probe:job:write-via-job", { note: "should never land" });
127
+
128
+ await waitFor(() => {
129
+ expect(writeProbeFailures.length).toBe(1);
130
+ });
131
+ expect(writeProbeFailures[0]).toContain(
132
+ "JobContext.write called before dispatcher attached — call attachDispatcher() first",
133
+ );
134
+ } finally {
135
+ await runner.stop();
136
+ }
137
+ });
138
+ });
@@ -38,7 +38,7 @@ import { buildServer, withFileProviderResolver } from "../api/server";
38
38
  import type { SseBroker } from "../api/sse-broker";
39
39
  import type { PgClient } from "../db/connection";
40
40
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
41
- import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
41
+ import type { AppContext, DispatchWriteRef, JobRunIn, Registry, RunIn } from "../engine/types";
42
42
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
43
43
  import { createJobRunner } from "../jobs/job-runner";
44
44
  import type { Lifecycle } from "../lifecycle";
@@ -142,8 +142,9 @@ export type WorkerEntrypoint = {
142
142
  readonly eventDispatcher: EventDispatcher;
143
143
  readonly jobRunner: JobRunner;
144
144
  readonly observability: ObservabilityProvider;
145
- // Same dispatcher the API process exposes. Background components in the
146
- // worker persist through the write-path JobContext has no write/query.
145
+ // Same dispatcher the API process exposes. App-wired background
146
+ // components that need the dispatcher directly (not JobContext.write)
147
+ // still persist through it.
147
148
  readonly dispatcher: Dispatcher;
148
149
  readonly mode: "worker";
149
150
  // Starts event-dispatcher poll + BullMQ worker. SIGTERM triggers
@@ -205,6 +206,18 @@ function contextWithObservability(
205
206
  };
206
207
  }
207
208
 
209
+ // Adapts the command-dispatcher's positional (type, payload, user) calls to
210
+ // DispatchWriteRef's (user, qn, payload) shape — JobContext.write/queryAs
211
+ // pass identity explicitly per call (boot-time singleton), while Dispatcher
212
+ // takes it last (request-scoped closure caller). Same underlying pipeline,
213
+ // different argument order.
214
+ function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
215
+ return {
216
+ write: (user, qn, payload) => dispatcher.write(qn, payload, user),
217
+ queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
218
+ };
219
+ }
220
+
208
221
  // buildApiServer shapes ServerOptions from API-mode caller-options.
209
222
  // AllInOneEntrypointOptions extends ApiEntrypointOptions, so structural
210
223
  // subtyping makes the all-in-one path a valid caller without an explicit
@@ -381,6 +394,7 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
381
394
  apiJobRunner,
382
395
  runLocalDispatcher ? "both" : "api",
383
396
  );
397
+ apiJobRunner?.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
384
398
 
385
399
  return {
386
400
  app: server.app,
@@ -422,6 +436,7 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
422
436
  "jobRunner",
423
437
  );
424
438
  const server = buildWorkerServer({ ...options, context }, lifecycle, jobRunner);
439
+ jobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
425
440
  const eventDispatcher = requireDispatcher(server, "worker");
426
441
 
427
442
  return {
@@ -491,6 +506,8 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
491
506
  workerJobRunner,
492
507
  "both",
493
508
  );
509
+ workerJobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
510
+ apiJobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
494
511
  const eventDispatcher = requireDispatcher(server, "all-in-one");
495
512
 
496
513
  return {