@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.
- package/package.json +3 -3
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +128 -1
- package/src/api/__tests__/sse-route.test.ts +129 -0
- package/src/api/auth-routes.ts +21 -6
- package/src/api/server.ts +44 -0
- 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/__tests__/unchecked-system-db.test.ts +117 -0
- package/src/db/dialect.ts +13 -2
- package/src/db/event-store-executor-read.ts +12 -1
- package/src/db/index.ts +7 -2
- package/src/db/money.ts +35 -15
- package/src/db/table-builder.ts +7 -1
- package/src/db/tenant-db.ts +54 -2
- package/src/derivatives/derivatives-context.ts +9 -0
- package/src/engine/__tests__/build-app-schema.test.ts +50 -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-app-schema.ts +6 -0
- package/src/engine/build-config-feature-schema.ts +2 -2
- package/src/engine/index.ts +2 -1
- package/src/engine/registry-facade.ts +6 -0
- package/src/engine/registry-ingest.ts +1 -0
- package/src/engine/registry-state.ts +2 -0
- 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__/job-systemdb.integration.test.ts +152 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
- package/src/jobs/job-runner.ts +42 -3
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +67 -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/dispatch-shared.ts +3 -1
- package/src/pipeline/idempotency.ts +11 -6
- package/src/ui-types/app-schema.ts +10 -0
- package/src/ui-types/index.ts +1 -1
|
@@ -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
|
});
|
|
@@ -40,6 +40,11 @@ export type BuildAppSchemaOptions = {
|
|
|
40
40
|
/** Dev-server authoring hints (Settings-Hub placement). Default off — only
|
|
41
41
|
* `createKumikoServer` opts in; prod boot + unit tests stay silent. */
|
|
42
42
|
readonly authoringWarnings?: boolean;
|
|
43
|
+
/** Forwarded onto every FeatureSchema.searchAdapterMissing. Set by the boot
|
|
44
|
+
* entrypoint (createKumikoServer, runProdApp) from its own
|
|
45
|
+
* context.searchAdapter presence check — buildAppSchema itself has no
|
|
46
|
+
* context, only the registry. Omit/false when a SearchAdapter is wired. */
|
|
47
|
+
readonly searchAdapterMissing?: boolean;
|
|
43
48
|
};
|
|
44
49
|
|
|
45
50
|
export function buildAppSchema(registry: Registry, options: BuildAppSchemaOptions = {}): AppSchema {
|
|
@@ -67,6 +72,7 @@ export function buildAppSchema(registry: Registry, options: BuildAppSchemaOption
|
|
|
67
72
|
...(Object.keys(feature.translations ?? {}).length > 0 && {
|
|
68
73
|
translations: feature.translations,
|
|
69
74
|
}),
|
|
75
|
+
...(options.searchAdapterMissing === true && { searchAdapterMissing: true }),
|
|
70
76
|
};
|
|
71
77
|
features.push(featureSchema);
|
|
72
78
|
}
|
|
@@ -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,
|
|
@@ -203,6 +203,12 @@ export function buildRegistryFacade(state: RegistryState): Registry {
|
|
|
203
203
|
return state.featureMap.get(featureName)?.systemScope ?? false;
|
|
204
204
|
},
|
|
205
205
|
|
|
206
|
+
isJobSystemScoped(qualifiedJobName: string): boolean {
|
|
207
|
+
const featureName = state.jobFeatureMap.get(qualifiedJobName);
|
|
208
|
+
if (!featureName) return false;
|
|
209
|
+
return state.featureMap.get(featureName)?.systemScope ?? false;
|
|
210
|
+
},
|
|
211
|
+
|
|
206
212
|
getHandlerFeature(qualifiedHandler: string): string | undefined {
|
|
207
213
|
return state.handlerFeatureMap.get(qualifiedHandler);
|
|
208
214
|
},
|
|
@@ -180,6 +180,7 @@ export type RegistryState = {
|
|
|
180
180
|
searchPayloadExtensions: Map<string, OwnedFn<SearchPayloadContributorFn>[]>;
|
|
181
181
|
configKeyMap: Map<string, ConfigKeyDefinition>;
|
|
182
182
|
jobMap: Map<string, JobDefinition>;
|
|
183
|
+
jobFeatureMap: Map<string, string>;
|
|
183
184
|
notificationMap: Map<string, NotificationDefinition>;
|
|
184
185
|
notificationFeatureMap: Map<string, string>;
|
|
185
186
|
eventMap: Map<string, EventDef>;
|
|
@@ -248,6 +249,7 @@ export function createInitialState(): RegistryState {
|
|
|
248
249
|
searchPayloadExtensions: new Map(),
|
|
249
250
|
configKeyMap: new Map(),
|
|
250
251
|
jobMap: new Map(),
|
|
252
|
+
jobFeatureMap: new Map(),
|
|
251
253
|
notificationMap: new Map(),
|
|
252
254
|
notificationFeatureMap: new Map(),
|
|
253
255
|
eventMap: new Map(),
|
|
@@ -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 });
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
3
|
+
import { createRegistry, defineFeature, type Registry } from "../../engine";
|
|
4
|
+
import { createTestRedis, type TestRedis, testTenantId } from "../../stack";
|
|
5
|
+
import { waitFor } from "../../testing";
|
|
6
|
+
import { createJobRunner, type JobRunner } from "../job-runner";
|
|
7
|
+
|
|
8
|
+
// r.systemScope() is feature-level (define-feature.ts), not per-job — so two
|
|
9
|
+
// features prove both sides, mirroring pipeline/__tests__/ctx-systemdb.integration.test.ts
|
|
10
|
+
// (the handler-dispatch counterpart from framework#2069/PR#2091).
|
|
11
|
+
|
|
12
|
+
type JobRunResult = {
|
|
13
|
+
readonly name: "system" | "tenant" | "system-per-tenant";
|
|
14
|
+
readonly present: boolean;
|
|
15
|
+
// assertTenantMatch() must return a TenantDb whose `.raw` is the SAME
|
|
16
|
+
// underlying DbConnection ctx.db carries — proves systemDb is bound to
|
|
17
|
+
// the job's own tenant-scoped db, not a separate instance.
|
|
18
|
+
readonly boundToRawDb: boolean | undefined;
|
|
19
|
+
// A foreign tenantId must throw fail-closed (AccessDeniedError), same as
|
|
20
|
+
// the HandlerContext.systemDb self-check.
|
|
21
|
+
readonly foreignTenantThrew: boolean | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const results: JobRunResult[] = [];
|
|
25
|
+
const ownTenant = testTenantId(1);
|
|
26
|
+
const foreignTenant = testTenantId(2);
|
|
27
|
+
|
|
28
|
+
const systemScopedFeature = defineFeature("jobsystemdb-system", (r) => {
|
|
29
|
+
r.systemScope();
|
|
30
|
+
|
|
31
|
+
r.job("check", { trigger: { manual: true } }, async (_payload, ctx) => {
|
|
32
|
+
if (!ctx.systemDb) {
|
|
33
|
+
results.push({
|
|
34
|
+
name: "system",
|
|
35
|
+
present: false,
|
|
36
|
+
boundToRawDb: undefined,
|
|
37
|
+
foreignTenantThrew: undefined,
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const checked = ctx.systemDb.assertTenantMatch(ctx.systemUser.tenantId);
|
|
42
|
+
let foreignTenantThrew = false;
|
|
43
|
+
try {
|
|
44
|
+
ctx.systemDb.assertTenantMatch(foreignTenant);
|
|
45
|
+
} catch {
|
|
46
|
+
foreignTenantThrew = true;
|
|
47
|
+
}
|
|
48
|
+
results.push({
|
|
49
|
+
name: "system",
|
|
50
|
+
present: true,
|
|
51
|
+
boundToRawDb: checked.raw === ctx.db,
|
|
52
|
+
foreignTenantThrew,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// perTenant jobs go through a separate dispatch path (_perTenant: wrapper
|
|
57
|
+
// fans out into one child job per tenant, job-runner.ts ~331-352) that
|
|
58
|
+
// re-enqueues under the bare qualified name before handleJob rebuilds the
|
|
59
|
+
// context — proves isJobSystemScoped() sees the same jobName fan-out
|
|
60
|
+
// children get, not the "_perTenant:"-prefixed wrapper name.
|
|
61
|
+
r.job(
|
|
62
|
+
"check-per-tenant",
|
|
63
|
+
{ trigger: { manual: true }, perTenant: true },
|
|
64
|
+
async (_payload, ctx) => {
|
|
65
|
+
results.push({
|
|
66
|
+
name: "system-per-tenant",
|
|
67
|
+
present: ctx.systemDb !== undefined,
|
|
68
|
+
boundToRawDb: undefined,
|
|
69
|
+
foreignTenantThrew: undefined,
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const tenantScopedFeature = defineFeature("jobsystemdb-tenant", (r) => {
|
|
76
|
+
r.job("check", { trigger: { manual: true } }, async (_payload, ctx) => {
|
|
77
|
+
results.push({
|
|
78
|
+
name: "tenant",
|
|
79
|
+
present: ctx.systemDb !== undefined,
|
|
80
|
+
boundToRawDb: undefined,
|
|
81
|
+
foreignTenantThrew: undefined,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
let testDb: BunTestDb;
|
|
87
|
+
let testRedis: TestRedis;
|
|
88
|
+
let registry: Registry;
|
|
89
|
+
let jobRunner: JobRunner;
|
|
90
|
+
|
|
91
|
+
beforeAll(async () => {
|
|
92
|
+
testDb = await createTestDb();
|
|
93
|
+
testRedis = await createTestRedis();
|
|
94
|
+
|
|
95
|
+
registry = createRegistry([systemScopedFeature, tenantScopedFeature]);
|
|
96
|
+
|
|
97
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
98
|
+
|
|
99
|
+
jobRunner = createJobRunner({
|
|
100
|
+
registry,
|
|
101
|
+
context: { db: testDb.db },
|
|
102
|
+
redisUrl,
|
|
103
|
+
consumerLane: "worker",
|
|
104
|
+
queueNamePrefix: `kumiko-job-systemdb-test-${Date.now()}`,
|
|
105
|
+
getActiveTenantIds: async () => [ownTenant],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await jobRunner.start();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
await jobRunner.stop();
|
|
113
|
+
await testDb.cleanup();
|
|
114
|
+
await testRedis.cleanup();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("JobContext.systemDb", () => {
|
|
118
|
+
test("is present, tenant-bound and fail-closed for r.systemScope() jobs", async () => {
|
|
119
|
+
results.length = 0;
|
|
120
|
+
await jobRunner.dispatch("jobsystemdb-system:job:check", { tenantId: ownTenant });
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
const result = results.find((r) => r.name === "system");
|
|
124
|
+
expect(result).toBeDefined();
|
|
125
|
+
expect(result?.present).toBe(true);
|
|
126
|
+
expect(result?.boundToRawDb).toBe(true);
|
|
127
|
+
expect(result?.foreignTenantThrew).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("is absent for non-system-scoped jobs", async () => {
|
|
132
|
+
results.length = 0;
|
|
133
|
+
await jobRunner.dispatch("jobsystemdb-tenant:job:check", { tenantId: ownTenant });
|
|
134
|
+
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
const result = results.find((r) => r.name === "tenant");
|
|
137
|
+
expect(result).toBeDefined();
|
|
138
|
+
expect(result?.present).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("is present on the per-tenant fan-out child, not just the direct dispatch path", async () => {
|
|
143
|
+
results.length = 0;
|
|
144
|
+
await jobRunner.dispatch("jobsystemdb-system:job:check-per-tenant", {});
|
|
145
|
+
|
|
146
|
+
await waitFor(() => {
|
|
147
|
+
const result = results.find((r) => r.name === "system-per-tenant");
|
|
148
|
+
expect(result).toBeDefined();
|
|
149
|
+
expect(result?.present).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -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) => {
|