@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.327.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.327.0",
138
- "@cosmicdrift/kumiko-framework": "0.327.0",
139
- "@cosmicdrift/kumiko-headless": "0.327.0",
140
- "@cosmicdrift/kumiko-renderer": "0.327.0",
141
- "@cosmicdrift/kumiko-renderer-web": "0.327.0",
142
- "@cosmicdrift/kumiko-types": "0.327.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.327.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.327.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 { SidebarProvider } from "@cosmicdrift/kumiko-renderer-web";
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 align="end" aria-label={t("auth.user.menu.label")}>
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 aspect-square size-8 items-center justify-center rounded-lg bg-muted text-xs font-medium text-muted-foreground"
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-semibold">{displayName}</span>
100
- {hasName && <span className="truncate text-xs">{user.email}</span>}
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 { createProjectionStateTable } from "@cosmicdrift/kumiko-framework/pipeline";
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 unsafePushTables(db, { readRebuildCounts: countsTable, jobRunsTable, jobRunLogsTable });
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 { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
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 rebuildProjection(projection, {
34
+ const result = await rebuildProjectionOrMultiStream(projection, {
35
35
  db,
36
36
  registry: ctx.registry,
37
37
  ...(skipApplyErrors === true && { errorPolicy: { skipApplyErrors: true } }),