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