@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.
- package/package.json +3 -3
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/sse-route.test.ts +129 -0
- package/src/api/auth-routes.ts +21 -6
- package/src/api/sse-route.ts +13 -1
- package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
- package/src/bun-db/query.ts +5 -1
- package/src/db/__tests__/compound-types.test.ts +12 -2
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
- package/src/db/__tests__/money.test.ts +49 -18
- package/src/db/dialect.ts +13 -2
- package/src/db/event-store-executor-read.ts +12 -1
- package/src/db/money.ts +35 -15
- package/src/db/table-builder.ts +7 -1
- package/src/derivatives/derivatives-context.ts +9 -0
- package/src/engine/__tests__/build-app-schema.test.ts +20 -0
- package/src/engine/__tests__/nav.test.ts +12 -4
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
- package/src/engine/build-config-feature-schema.ts +2 -2
- package/src/engine/index.ts +2 -1
- package/src/engine/types/index.ts +7 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
- package/src/entrypoint/index.ts +20 -3
- package/src/files/__tests__/files.integration.test.ts +16 -0
- package/src/files/file-routes.ts +12 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
- package/src/jobs/job-runner.ts +32 -1
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
- package/src/pipeline/dispatch-batch.ts +2 -2
- package/src/pipeline/idempotency.ts +11 -6
- 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,
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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(
|
|
44
|
-
return
|
|
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
|
-
*
|
|
127
|
-
* copy against this instead of re-declaring the
|
|
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
|
|
176
|
+
let amountScaled: number;
|
|
163
177
|
if (typeof amountRaw === "number") {
|
|
164
|
-
|
|
178
|
+
amountScaled = amountRaw;
|
|
165
179
|
} else if (typeof amountRaw === "bigint") {
|
|
166
|
-
|
|
167
|
-
if (!Number.isSafeInteger(
|
|
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
|
-
|
|
173
|
-
if (!Number.isSafeInteger(
|
|
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
|
-
|
|
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;
|
package/src/db/table-builder.ts
CHANGED
|
@@ -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 = {
|
|
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);
|
|
@@ -107,6 +107,15 @@ function outputMimeType(spec: VariantSpec, sourceMimeType: string): string {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
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.
|
|
110
119
|
export function createDerivativesContext(deps: DerivativesContextDeps): DerivativesContext {
|
|
111
120
|
return {
|
|
112
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 {
|
|
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[] }):
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
60
|
+
const SCOPE_ICON: Record<ConfigScope, NavIconKey> = {
|
|
61
61
|
system: "shield",
|
|
62
62
|
tenant: "building",
|
|
63
63
|
user: "user",
|
package/src/engine/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -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.
|
|
146
|
-
//
|
|
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 {
|
|
@@ -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", {
|
package/src/files/file-routes.ts
CHANGED
|
@@ -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 {
|
|
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) => {
|