@cosmicdrift/kumiko-bundled-features 0.327.0 → 0.328.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 +9 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +38 -2
- package/src/auth-email-password/web/index.ts +2 -0
- package/src/auth-email-password/web/tenant-menu-items.tsx +47 -0
- package/src/auth-email-password/web/user-menu.tsx +18 -6
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +61 -2
- package/src/jobs/handlers/projection-rebuild.job.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.328.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -134,12 +134,12 @@
|
|
|
134
134
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
135
135
|
},
|
|
136
136
|
"dependencies": {
|
|
137
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
142
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
137
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.328.0",
|
|
138
|
+
"@cosmicdrift/kumiko-framework": "0.328.0",
|
|
139
|
+
"@cosmicdrift/kumiko-headless": "0.328.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer": "0.328.0",
|
|
141
|
+
"@cosmicdrift/kumiko-renderer-web": "0.328.0",
|
|
142
|
+
"@cosmicdrift/kumiko-types": "0.328.0",
|
|
143
143
|
"@mollie/api-client": "^4.5.0",
|
|
144
144
|
"@node-rs/argon2": "^2.0.2",
|
|
145
145
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.328.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.328.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.328.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "User menu and tenant menu items fit the new shell",
|
|
6
|
+
"detail": "The web user menu and tenant menu items are adapted to the sidebar footer user block, which now hosts the tenant, language and theme controls."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.323.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { TokensProvider } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { SidebarProvider, ThemeMenuItem } from "@cosmicdrift/kumiko-renderer-web";
|
|
3
4
|
import { screen, within } from "@testing-library/react";
|
|
4
5
|
import userEvent from "@testing-library/user-event";
|
|
6
|
+
import { TenantMenuItems } from "../tenant-menu-items";
|
|
5
7
|
import { UserMenu } from "../user-menu";
|
|
6
8
|
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
7
9
|
|
|
@@ -77,4 +79,38 @@ describe("UserMenu", () => {
|
|
|
77
79
|
await user.click(screen.getByRole("button", { name: /Alice Wonder/ }));
|
|
78
80
|
expect(screen.getByText("Sign out")).toBeTruthy();
|
|
79
81
|
});
|
|
82
|
+
|
|
83
|
+
test("sidebar variant: tenant and theme controls live inside the menu, not beside the row", async () => {
|
|
84
|
+
const user = userEvent.setup();
|
|
85
|
+
const switchTenant = mock(async () => {});
|
|
86
|
+
const session = makeSessionApi({
|
|
87
|
+
tenants: [
|
|
88
|
+
{ tenantId: "tenant-1", name: "Alpha", roles: ["Admin"] },
|
|
89
|
+
{ tenantId: "tenant-2", name: "Beta", roles: ["Admin"] },
|
|
90
|
+
],
|
|
91
|
+
switchTenant,
|
|
92
|
+
});
|
|
93
|
+
const tokens = {
|
|
94
|
+
tokens: {} as never,
|
|
95
|
+
mode: "light" as const,
|
|
96
|
+
setMode: () => {},
|
|
97
|
+
toggleMode: () => {},
|
|
98
|
+
};
|
|
99
|
+
renderWithProviders(
|
|
100
|
+
<TokensProvider value={tokens}>
|
|
101
|
+
<SidebarProvider>
|
|
102
|
+
<UserMenu variant="sidebar">
|
|
103
|
+
<TenantMenuItems />
|
|
104
|
+
<ThemeMenuItem />
|
|
105
|
+
</UserMenu>
|
|
106
|
+
</SidebarProvider>
|
|
107
|
+
</TokensProvider>,
|
|
108
|
+
{ session },
|
|
109
|
+
);
|
|
110
|
+
expect(screen.queryByText("Beta")).toBeNull();
|
|
111
|
+
await user.click(screen.getByRole("button", { name: /Test User/ }));
|
|
112
|
+
await user.click(screen.getByText("Beta"));
|
|
113
|
+
expect(switchTenant).toHaveBeenCalledWith("tenant-2");
|
|
114
|
+
expect(screen.getByText(/Modus/)).toBeTruthy();
|
|
115
|
+
});
|
|
80
116
|
});
|
|
@@ -63,6 +63,8 @@ export type { SignupCompleteScreenProps } from "./signup-complete-screen";
|
|
|
63
63
|
export { SignupCompleteScreen } from "./signup-complete-screen";
|
|
64
64
|
export type { SignupScreenProps } from "./signup-screen";
|
|
65
65
|
export { SignupScreen } from "./signup-screen";
|
|
66
|
+
export type { TenantMenuItemsProps } from "./tenant-menu-items";
|
|
67
|
+
export { TenantMenuItems } from "./tenant-menu-items";
|
|
66
68
|
export type { TenantSwitcherProps } from "./tenant-switcher";
|
|
67
69
|
export { TenantSwitcher } from "./tenant-switcher";
|
|
68
70
|
export type { ShellUser } from "./use-shell-user";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Tenant choices as dropdown entries — the TenantSwitcher's list for a user
|
|
3
|
+
// menu that carries the shell controls. Renders nothing for a single tenant.
|
|
4
|
+
|
|
5
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenuCheckboxItem,
|
|
8
|
+
DropdownMenuLabel,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
} from "@cosmicdrift/kumiko-renderer-web";
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
import { useSession } from "./session";
|
|
13
|
+
|
|
14
|
+
export type TenantMenuItemsProps = {
|
|
15
|
+
readonly tenantName?: (tenantId: string) => string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function TenantMenuItems({ tenantName }: TenantMenuItemsProps): ReactNode {
|
|
19
|
+
const t = useTranslation();
|
|
20
|
+
const { user, tenants, activeTenantId, switchTenant } = useSession();
|
|
21
|
+
if (user === null || tenants.length <= 1) return null;
|
|
22
|
+
|
|
23
|
+
const nameOf = (tenantId: string): string => {
|
|
24
|
+
if (tenantName !== undefined) return tenantName(tenantId);
|
|
25
|
+
const membership = tenants.find((m) => m.tenantId === tenantId);
|
|
26
|
+
return membership?.name || membership?.key || tenantId.slice(0, 8);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<DropdownMenuLabel>{t("auth.tenant.switcher.label")}</DropdownMenuLabel>
|
|
32
|
+
{tenants.map((membership) => (
|
|
33
|
+
<DropdownMenuCheckboxItem
|
|
34
|
+
key={membership.tenantId}
|
|
35
|
+
checked={membership.tenantId === activeTenantId}
|
|
36
|
+
onSelect={(event) => {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
if (membership.tenantId !== activeTenantId) void switchTenant(membership.tenantId);
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<span className="truncate">{nameOf(membership.tenantId)}</span>
|
|
42
|
+
</DropdownMenuCheckboxItem>
|
|
43
|
+
))}
|
|
44
|
+
<DropdownMenuSeparator />
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -63,13 +63,21 @@ export function UserMenu({ children, variant = "pill" }: UserMenuProps): ReactNo
|
|
|
63
63
|
const avatarText = initials(displayName);
|
|
64
64
|
|
|
65
65
|
const content = (
|
|
66
|
-
<DropdownMenuContent
|
|
66
|
+
<DropdownMenuContent
|
|
67
|
+
align={variant === "sidebar" ? "start" : "end"}
|
|
68
|
+
aria-label={t("auth.user.menu.label")}
|
|
69
|
+
>
|
|
67
70
|
<DropdownMenuLabel className="text-xs">
|
|
68
71
|
<div className="font-medium text-foreground truncate">{displayName}</div>
|
|
69
72
|
{hasName && <div className="truncate">{user.email}</div>}
|
|
70
73
|
</DropdownMenuLabel>
|
|
71
74
|
<DropdownMenuSeparator />
|
|
72
|
-
{children
|
|
75
|
+
{children !== undefined && (
|
|
76
|
+
<>
|
|
77
|
+
{children}
|
|
78
|
+
<DropdownMenuSeparator />
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
73
81
|
<DropdownMenuItem onSelect={() => void logout()}>
|
|
74
82
|
<LogOut className="h-4 w-4" />
|
|
75
83
|
<span>{t("auth.user.menu.logout")}</span>
|
|
@@ -91,15 +99,19 @@ export function UserMenu({ children, variant = "pill" }: UserMenuProps): ReactNo
|
|
|
91
99
|
>
|
|
92
100
|
<span
|
|
93
101
|
aria-hidden="true"
|
|
94
|
-
className="flex
|
|
102
|
+
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-sidebar-accent text-xs font-medium text-sidebar-accent-foreground"
|
|
95
103
|
>
|
|
96
104
|
{avatarText}
|
|
97
105
|
</span>
|
|
98
106
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
99
|
-
<span className="truncate font-
|
|
100
|
-
|
|
107
|
+
<span className="truncate font-medium text-sidebar-foreground">
|
|
108
|
+
{displayName}
|
|
109
|
+
</span>
|
|
110
|
+
{hasName && (
|
|
111
|
+
<span className="truncate text-xs text-sidebar-muted">{user.email}</span>
|
|
112
|
+
)}
|
|
101
113
|
</div>
|
|
102
|
-
<ChevronsUpDown className="ml-auto size-4" />
|
|
114
|
+
<ChevronsUpDown className="ml-auto size-4 text-sidebar-muted" />
|
|
103
115
|
</SidebarMenuButton>
|
|
104
116
|
</DropdownMenuTrigger>
|
|
105
117
|
{content}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
table as pgTable,
|
|
16
16
|
selectMany,
|
|
17
17
|
type TenantDb,
|
|
18
|
+
text,
|
|
18
19
|
uuid,
|
|
19
20
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
21
|
import {
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
createTextField,
|
|
24
25
|
defineApply,
|
|
25
26
|
defineFeature,
|
|
27
|
+
defineMspApply,
|
|
26
28
|
type ProjectionDefinition,
|
|
27
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
30
|
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
@@ -30,7 +32,10 @@ import {
|
|
|
30
32
|
enqueueProjectionRebuild,
|
|
31
33
|
PROJECTION_REBUILD_JOB,
|
|
32
34
|
} from "@cosmicdrift/kumiko-framework/migrations";
|
|
33
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
createEventConsumerStateTable,
|
|
37
|
+
createProjectionStateTable,
|
|
38
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
34
39
|
import {
|
|
35
40
|
createTestDb,
|
|
36
41
|
createTestRedis,
|
|
@@ -79,12 +84,32 @@ const countsProjection: ProjectionDefinition = {
|
|
|
79
84
|
},
|
|
80
85
|
};
|
|
81
86
|
|
|
87
|
+
const itemNamesTable = pgTable("read_rebuild_msp_names", {
|
|
88
|
+
id: uuid("id").primaryKey(),
|
|
89
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
90
|
+
name: text("name").notNull(),
|
|
91
|
+
});
|
|
92
|
+
|
|
82
93
|
const PROJECTION = "rebuildtest:projection:rebuild-counts";
|
|
94
|
+
const MSP_PROJECTION = "rebuildtest:projection:rebuild-item-names";
|
|
83
95
|
const GROUP = "00000000-0000-4000-8000-000000000001";
|
|
84
96
|
|
|
85
97
|
const appFeature = defineFeature("rebuildtest", (r) => {
|
|
86
98
|
r.entity("rebuild-item", itemEntity);
|
|
87
99
|
r.projection(countsProjection);
|
|
100
|
+
r.multiStreamProjection({
|
|
101
|
+
name: "rebuild-item-names",
|
|
102
|
+
table: itemNamesTable,
|
|
103
|
+
apply: {
|
|
104
|
+
"rebuild-item.created": defineMspApply<{ name: string }>(async (event, tx) => {
|
|
105
|
+
await asRawClient(tx).unsafe(
|
|
106
|
+
`INSERT INTO "read_rebuild_msp_names" (id, tenant_id, name) VALUES ($1::uuid, $2::uuid, $3)
|
|
107
|
+
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`,
|
|
108
|
+
[event.aggregateId, event.tenantId, event.payload.name],
|
|
109
|
+
);
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
88
113
|
});
|
|
89
114
|
|
|
90
115
|
const admin = TestUsers.admin;
|
|
@@ -103,7 +128,13 @@ beforeAll(async () => {
|
|
|
103
128
|
|
|
104
129
|
await unsafeCreateEntityTable(db, itemEntity, "rebuild-item");
|
|
105
130
|
await createProjectionStateTable(db);
|
|
106
|
-
await
|
|
131
|
+
await createEventConsumerStateTable(db);
|
|
132
|
+
await unsafePushTables(db, {
|
|
133
|
+
readRebuildCounts: countsTable,
|
|
134
|
+
readRebuildMspNames: itemNamesTable,
|
|
135
|
+
jobRunsTable,
|
|
136
|
+
jobRunLogsTable,
|
|
137
|
+
});
|
|
107
138
|
tdb = createTenantDb(db, admin.tenantId);
|
|
108
139
|
|
|
109
140
|
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
@@ -168,4 +199,32 @@ describe("projection-rebuild job (jobs feature composed)", () => {
|
|
|
168
199
|
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
169
200
|
expect(runs.some((r) => r.status === "completed")).toBe(true);
|
|
170
201
|
}, 30000);
|
|
202
|
+
|
|
203
|
+
test("enqueueProjectionRebuild refills a multi-stream projection through the job", async () => {
|
|
204
|
+
await executor.create({ groupId: GROUP, name: "msp-a" }, admin, tdb);
|
|
205
|
+
await asRawClient(db).unsafe('DELETE FROM "read_rebuild_msp_names"');
|
|
206
|
+
expect(await selectMany(db, itemNamesTable)).toHaveLength(0);
|
|
207
|
+
|
|
208
|
+
const outcome = await enqueueProjectionRebuild(MSP_PROJECTION, { db, registry, jobRunner });
|
|
209
|
+
if (outcome.mode !== "dispatched") throw new Error(`expected dispatch, got ${outcome.mode}`);
|
|
210
|
+
|
|
211
|
+
await waitFor(async () => (await selectMany(db, itemNamesTable)).length > 0, {
|
|
212
|
+
delays: Array(40).fill(200),
|
|
213
|
+
});
|
|
214
|
+
const names = (await selectMany<{ name: string }>(db, itemNamesTable)).map((r) => r.name);
|
|
215
|
+
expect(names).toContain("msp-a");
|
|
216
|
+
|
|
217
|
+
let status: string | undefined;
|
|
218
|
+
await waitFor(
|
|
219
|
+
async () => {
|
|
220
|
+
const [run] = await selectMany<{ status: string }>(db, jobRunsTable, {
|
|
221
|
+
bullJobId: outcome.bullJobId,
|
|
222
|
+
});
|
|
223
|
+
status = run?.status;
|
|
224
|
+
return status === "completed" || status === "failed";
|
|
225
|
+
},
|
|
226
|
+
{ delays: Array(40).fill(200) },
|
|
227
|
+
);
|
|
228
|
+
expect(status).toBe("completed");
|
|
229
|
+
}, 30000);
|
|
171
230
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
|
-
import {
|
|
6
|
+
import { rebuildProjectionOrMultiStream } from "@cosmicdrift/kumiko-framework/migrations";
|
|
7
7
|
import * as z from "zod";
|
|
8
8
|
|
|
9
9
|
export const projectionRebuildPayloadSchema = z.object({
|
|
@@ -31,7 +31,7 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
|
|
|
31
31
|
// documents that instead of the previous implicit ctx.db cast.
|
|
32
32
|
ctx.systemDb.acknowledgeCrossTenant("global projection rebuild");
|
|
33
33
|
const db = ctx.systemDb.unsafeRaw("projection rebuild spans every tenant's rows") as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
|
|
34
|
-
const result = await
|
|
34
|
+
const result = await rebuildProjectionOrMultiStream(projection, {
|
|
35
35
|
db,
|
|
36
36
|
registry: ctx.registry,
|
|
37
37
|
...(skipApplyErrors === true && { errorPolicy: { skipApplyErrors: true } }),
|