@cosmicdrift/kumiko-bundled-features 0.214.0 → 0.215.1

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.214.0",
3
+ "version": "0.215.1",
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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.214.0",
130
- "@cosmicdrift/kumiko-framework": "0.214.0",
131
- "@cosmicdrift/kumiko-headless": "0.214.0",
132
- "@cosmicdrift/kumiko-renderer": "0.214.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.214.0",
134
- "@cosmicdrift/kumiko-types": "0.214.0",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.215.1",
130
+ "@cosmicdrift/kumiko-framework": "0.215.1",
131
+ "@cosmicdrift/kumiko-headless": "0.215.1",
132
+ "@cosmicdrift/kumiko-renderer": "0.215.1",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.215.1",
134
+ "@cosmicdrift/kumiko-types": "0.215.1",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,6 +160,6 @@
160
160
  "devDependencies": {
161
161
  "@testing-library/user-event": "^14.6.1",
162
162
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.214.0"
163
+ "@cosmicdrift/kumiko-locale-de": "0.215.1"
164
164
  }
165
165
  }
@@ -19,6 +19,8 @@ export type AuthUserRow = {
19
19
  readonly lastActiveTenantId?: TenantId | string | null;
20
20
  // IANA zone — threaded into SessionUser.timezone at login (fw#1636).
21
21
  readonly timezone?: string | null;
22
+ // BCP-47 tag — threaded into SessionUser.locale at login (fw#2333).
23
+ readonly locale?: string | null;
22
24
  // JSON-encoded string[] — globale Rollen die parallel zu tenant-membership-
23
25
  // roles gelten (z.B. SystemAdmin, BillingAdmin). Caller deserialisiert via
24
26
  // parseRoles() vor dem Merge in die Session.
@@ -32,7 +32,7 @@ import {
32
32
  } from "@cosmicdrift/kumiko-framework/engine";
33
33
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
34
34
  import { z } from "zod";
35
- import { decryptStoredPii, sessionTimezoneField } from "../../shared";
35
+ import { decryptStoredPii, sessionLocaleField, sessionTimezoneField } from "../../shared";
36
36
  // kumiko-lint-ignore cross-feature-import invite-flow
37
37
  import {
38
38
  INVITATION_STATUS,
@@ -136,6 +136,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
136
136
  readonly id: string;
137
137
  readonly passwordHash: string | null;
138
138
  readonly timezone?: string | null;
139
+ readonly locale?: string | null;
139
140
  readonly emailVerified?: boolean | null;
140
141
  readonly status?: string | null;
141
142
  };
@@ -251,6 +252,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
251
252
  tenantId: invitationTenantId,
252
253
  roles: mergedRoles,
253
254
  ...sessionTimezoneField(userRow.timezone),
255
+ ...sessionLocaleField(userRow.locale),
254
256
  };
255
257
 
256
258
  committed = true;
@@ -230,12 +230,14 @@ export async function gateBuildSession(
230
230
  tenantId: TenantId,
231
231
  mergedRoles: readonly string[],
232
232
  timezone?: string | null,
233
+ locale?: string | null,
233
234
  ): Promise<{ readonly kind: "auth-session"; readonly session: SessionUser }> {
234
235
  const baseSession: SessionUser = {
235
236
  id: userId,
236
237
  tenantId,
237
238
  roles: mergedRoles,
238
239
  ...(timezone !== null && timezone !== undefined && { timezone }),
240
+ ...(locale !== null && locale !== undefined && { locale }),
239
241
  };
240
242
  const claims = await ctx.resolveAuthClaims(baseSession);
241
243
  const session: SessionUser =
@@ -306,6 +308,7 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
306
308
  chosen.tenantId,
307
309
  mergedRoles,
308
310
  found.timezone,
311
+ found.locale,
309
312
  );
310
313
  return { isSuccess: true, data: session };
311
314
  },
@@ -9,7 +9,7 @@ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/error
9
9
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
10
10
  import { Temporal } from "temporal-polyfill";
11
11
  import { z } from "zod";
12
- import { burnToken, sessionTimezoneField } from "../../shared";
12
+ import { burnToken, sessionLocaleField, sessionTimezoneField } from "../../shared";
13
13
  import { USER_STATUS, UserQueries } from "../../user";
14
14
  import { base32Decode } from "../base32";
15
15
  import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
@@ -138,7 +138,12 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
138
138
  const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
139
139
  const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
140
140
  id: userId,
141
- })) as { roles?: string | null; status?: string; timezone?: string | null } | null; // @cast-boundary engine-payload
141
+ })) as {
142
+ roles?: string | null;
143
+ status?: string;
144
+ timezone?: string | null;
145
+ locale?: string | null;
146
+ } | null; // @cast-boundary engine-payload
142
147
 
143
148
  if (!userRow) return invalidSetupToken();
144
149
  if (
@@ -192,6 +197,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
192
197
  // a plain password login does — same helper as verify.write.ts /
193
198
  // invite-accept-with-login.write.ts (#1759).
194
199
  ...sessionTimezoneField(userRow?.timezone),
200
+ ...sessionLocaleField(userRow?.locale),
195
201
  };
196
202
  const claims = await ctx.resolveAuthClaims(baseSession);
197
203
  const session: SessionUser =
@@ -8,7 +8,7 @@ import {
8
8
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
9
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
10
10
  import { z } from "zod";
11
- import { burnToken, sessionTimezoneField } from "../../shared";
11
+ import { burnToken, sessionLocaleField, sessionTimezoneField } from "../../shared";
12
12
  import { USER_STATUS, UserQueries } from "../../user";
13
13
  import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
14
14
  import { findUserMfaRow } from "../db/queries";
@@ -135,7 +135,12 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
135
135
  const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
136
136
  const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
137
137
  id: userId,
138
- })) as { roles?: string | null; status?: string; timezone?: string | null } | null; // @cast-boundary engine-payload
138
+ })) as {
139
+ roles?: string | null;
140
+ status?: string;
141
+ timezone?: string | null;
142
+ locale?: string | null;
143
+ } | null; // @cast-boundary engine-payload
139
144
 
140
145
  // Re-check status + membership the way login.write.ts does after its
141
146
  // password check — the challenge token only proves "password was
@@ -173,6 +178,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
173
178
  tenantId,
174
179
  roles: mergedRoles,
175
180
  ...sessionTimezoneField(userRow?.timezone),
181
+ ...sessionLocaleField(userRow?.locale),
176
182
  };
177
183
  const claims = await ctx.resolveAuthClaims(baseSession);
178
184
  const session: SessionUser =
@@ -0,0 +1,93 @@
1
+ // #2323: field-access.ts, quota.ts, user-data-rights.ts and retention.ts read
2
+ // custom-field rows via asRawClient(db).unsafe() directly, bypassing the
3
+ // #1163 closed-connection retry that only covered bun-db/query.ts's own
4
+ // selectMany/countWhere. Routed the SELECT-only call sites through
5
+ // unsafeReadRetrying instead — this test mirrors
6
+ // bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
7
+ // the retry now actually fires for each call site. applyRetentionRemovals
8
+ // (an UPDATE) is out of scope per #1358 — writes stay unretried.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { selectSerializedFieldDefinition } from "../field-access";
12
+ import { countTenantFieldDefinitions } from "../quota";
13
+ import { selectHostRowsWithCustomFields } from "../retention";
14
+ import { selectCustomFieldsHostRows, selectFieldDefinitionsForEntity } from "../user-data-rights";
15
+
16
+ function closedConnectionError(): Error {
17
+ return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
18
+ }
19
+
20
+ type FakeClient = {
21
+ unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
22
+ begin: () => never;
23
+ calls: number;
24
+ };
25
+
26
+ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient {
27
+ const remaining = [...failures];
28
+ const client: FakeClient = {
29
+ calls: 0,
30
+ unsafe: async () => {
31
+ client.calls++;
32
+ const err = remaining.shift();
33
+ if (err) throw err;
34
+ return [row];
35
+ },
36
+ begin: () => {
37
+ throw new Error("not used in test");
38
+ },
39
+ };
40
+ return client;
41
+ }
42
+
43
+ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
44
+ test("selectSerializedFieldDefinition retries once through db.raw and returns the row", async () => {
45
+ const raw = fakeClient([closedConnectionError()], { serialized_field: "sf1" });
46
+ const result = await selectSerializedFieldDefinition({ raw } as never, "t1", "entity", "field");
47
+ expect(result).toBe("sf1");
48
+ expect(raw.calls).toBe(2);
49
+ });
50
+
51
+ test("countTenantFieldDefinitions retries once through db.raw and returns the count", async () => {
52
+ const raw = fakeClient([closedConnectionError()], { n: 3 });
53
+ const result = await countTenantFieldDefinitions({ raw } as never, "t1");
54
+ expect(result).toBe(3);
55
+ expect(raw.calls).toBe(2);
56
+ });
57
+
58
+ test("selectCustomFieldsHostRows retries once and returns rows", async () => {
59
+ const db = fakeClient([closedConnectionError()], { id: "1", custom_fields: {} });
60
+ const rows = await selectCustomFieldsHostRows(db as never, "host_table", "user_id", "u1", "t1");
61
+ expect(rows).toHaveLength(1);
62
+ expect(db.calls).toBe(2);
63
+ });
64
+
65
+ test("selectFieldDefinitionsForEntity retries once and returns rows", async () => {
66
+ const db = fakeClient([closedConnectionError()], { field_key: "k1", serialized_field: "sf" });
67
+ const rows = await selectFieldDefinitionsForEntity(db as never, "entity", "t1");
68
+ expect(rows).toHaveLength(1);
69
+ expect(rows[0]?.field_key).toBe("k1");
70
+ expect(db.calls).toBe(2);
71
+ });
72
+
73
+ test("selectHostRowsWithCustomFields retries once and returns rows", async () => {
74
+ const db = fakeClient([closedConnectionError()], {
75
+ id: "1",
76
+ modified_at: null,
77
+ custom_fields: {},
78
+ });
79
+ const rows = await selectHostRowsWithCustomFields(db as never, "host_table", "t1");
80
+ expect(rows).toHaveLength(1);
81
+ expect(db.calls).toBe(2);
82
+ });
83
+
84
+ test("gives up after the single retry when the connection stays closed", async () => {
85
+ const raw = fakeClient([closedConnectionError(), closedConnectionError()], {
86
+ serialized_field: "sf1",
87
+ });
88
+ await expect(
89
+ selectSerializedFieldDefinition({ raw } as never, "t1", "entity", "field"),
90
+ ).rejects.toThrow("connection was closed");
91
+ expect(raw.calls).toBe(2);
92
+ });
93
+ });
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
4
  export async function selectSerializedFieldDefinition(
@@ -7,7 +7,8 @@ export async function selectSerializedFieldDefinition(
7
7
  entityName: string,
8
8
  fieldKey: string,
9
9
  ): Promise<unknown | null> {
10
- const rows = await asRawClient(db.raw).unsafe(
10
+ const rows = await unsafeReadRetrying(
11
+ db.raw,
11
12
  "SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
12
13
  [entityName, fieldKey, tenantId],
13
14
  );
@@ -1,10 +1,11 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
4
  export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string): Promise<number> {
5
5
  // Active definitions only — delete soft-deletes (the deterministic stream is
6
6
  // kept so a re-define can restore it), so isDeleted rows must not consume quota.
7
- const rowsResult = await asRawClient(db.raw).unsafe(
7
+ const rowsResult = await unsafeReadRetrying(
8
+ db.raw,
8
9
  "SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND is_deleted = FALSE",
9
10
  [tenantId],
10
11
  );
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { asRawClient, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
4
  export async function selectHostRowsWithCustomFields(
@@ -7,7 +7,8 @@ export async function selectHostRowsWithCustomFields(
7
7
  tenantId: string,
8
8
  ): Promise<readonly unknown[]> {
9
9
  const quoted = `"${tableName.replace(/"/g, '""')}"`;
10
- const rowsResult = await asRawClient(db).unsafe(
10
+ const rowsResult = await unsafeReadRetrying(
11
+ db,
11
12
  `SELECT id, modified_at, custom_fields FROM ${quoted} WHERE tenant_id = $1 AND custom_fields IS NOT NULL`,
12
13
  [tenantId],
13
14
  );
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
4
  function quoteTable(tableName: string): string {
@@ -18,7 +18,8 @@ export async function selectCustomFieldsHostRows(
18
18
  ): Promise<readonly unknown[]> {
19
19
  const tbl = quoteTable(tableName);
20
20
  const userCol = quoteColumn(userIdColumn);
21
- const rowsResult = await asRawClient(db).unsafe(
21
+ const rowsResult = await unsafeReadRetrying(
22
+ db,
22
23
  `SELECT id, custom_fields FROM ${tbl} WHERE ${userCol} = $1 AND tenant_id = $2`,
23
24
  [userId, tenantId],
24
25
  );
@@ -30,7 +31,8 @@ export async function selectFieldDefinitionsForEntity(
30
31
  entityName: string,
31
32
  tenantId: string,
32
33
  ): Promise<readonly { field_key: string; serialized_field: unknown }[]> {
33
- return asRawClient(db).unsafe(
34
+ return unsafeReadRetrying(
35
+ db,
34
36
  "SELECT field_key, serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND tenant_id = $2",
35
37
  [entityName, tenantId],
36
38
  ) as Promise<readonly { field_key: string; serialized_field: unknown }[]>;
@@ -0,0 +1,87 @@
1
+ // #2323: draft-count.ts, owned-file-refs.ts and cleanup.ts read form-draft
2
+ // rows via asRawClient(db).unsafe() directly, bypassing the #1163
3
+ // closed-connection retry that only covered bun-db/query.ts's own
4
+ // selectMany/countWhere. Routed the SELECT-only call sites through
5
+ // unsafeReadRetrying instead — this test mirrors
6
+ // bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
7
+ // the retry now actually fires for each call site.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { Temporal } from "temporal-polyfill";
11
+ import { selectStaleDraftsBatch } from "../cleanup";
12
+ import { countDraftsByOwner } from "../draft-count";
13
+ import { filterOwnedFileRefs } from "../owned-file-refs";
14
+
15
+ function closedConnectionError(): Error {
16
+ return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
17
+ }
18
+
19
+ type FakeClient = {
20
+ unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
21
+ begin: () => never;
22
+ calls: number;
23
+ };
24
+
25
+ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient {
26
+ const remaining = [...failures];
27
+ const client: FakeClient = {
28
+ calls: 0,
29
+ unsafe: async () => {
30
+ client.calls++;
31
+ const err = remaining.shift();
32
+ if (err) throw err;
33
+ return [row];
34
+ },
35
+ begin: () => {
36
+ throw new Error("not used in test");
37
+ },
38
+ };
39
+ return client;
40
+ }
41
+
42
+ describe("form-draft db/queries — closed-connection retry (#2323)", () => {
43
+ test("countDraftsByOwner retries once and returns the count", async () => {
44
+ const db = fakeClient([closedConnectionError()], { count: 4 });
45
+ const result = await countDraftsByOwner(db as never, "t1" as never, "owner1");
46
+ expect(result).toBe(4);
47
+ expect(db.calls).toBe(2);
48
+ });
49
+
50
+ test("filterOwnedFileRefs retries once and returns rows", async () => {
51
+ const db = fakeClient([closedConnectionError()], { id: "ref1", storage_key: "key1" });
52
+ const rows = await filterOwnedFileRefs(
53
+ db as never,
54
+ "t1" as never,
55
+ "owner1",
56
+ ["key1"],
57
+ Temporal.Now.instant(),
58
+ false,
59
+ );
60
+ expect(rows).toHaveLength(1);
61
+ expect(rows[0]?.storageKey).toBe("key1");
62
+ expect(db.calls).toBe(2);
63
+ });
64
+
65
+ test("selectStaleDraftsBatch retries once and returns rows", async () => {
66
+ const db = fakeClient([closedConnectionError()], {
67
+ id: "d1",
68
+ tenant_id: "t1",
69
+ owner_id: "owner1",
70
+ draft_key: "screen:1",
71
+ draft: {},
72
+ inserted_at: new Date("2026-01-01T00:00:00Z"),
73
+ });
74
+ const rows = await selectStaleDraftsBatch(db as never, 30, 10);
75
+ expect(rows).toHaveLength(1);
76
+ expect(rows[0]?.id).toBe("d1");
77
+ expect(db.calls).toBe(2);
78
+ });
79
+
80
+ test("gives up after the single retry when the connection stays closed", async () => {
81
+ const db = fakeClient([closedConnectionError(), closedConnectionError()], { count: 4 });
82
+ await expect(countDraftsByOwner(db as never, "t1" as never, "owner1")).rejects.toThrow(
83
+ "connection was closed",
84
+ );
85
+ expect(db.calls).toBe(2);
86
+ });
87
+ });
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { Temporal } from "temporal-polyfill";
@@ -29,7 +29,8 @@ export async function selectStaleDraftsBatch(
29
29
  olderThanDays: number,
30
30
  batchSize: number,
31
31
  ): Promise<readonly StaleDraftRow[]> {
32
- const rows = (await asRawClient(db).unsafe(
32
+ const rows = (await unsafeReadRetrying(
33
+ db,
33
34
  `SELECT "id", "tenant_id", "owner_id", "draft_key", "draft", "inserted_at" FROM "read_form_drafts"
34
35
  WHERE COALESCE("modified_at", "inserted_at") < now() - ($1::int * interval '1 day')
35
36
  ORDER BY COALESCE("modified_at", "inserted_at") ASC
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
 
@@ -11,7 +11,8 @@ export async function countDraftsByOwner(
11
11
  tenantId: TenantId,
12
12
  ownerId: string,
13
13
  ): Promise<number> {
14
- const rows = (await asRawClient(db).unsafe(
14
+ const rows = (await unsafeReadRetrying(
15
+ db,
15
16
  `SELECT count(*)::int AS "count" FROM "read_form_drafts"
16
17
  WHERE "tenant_id" = $1 AND "owner_id" = $2`,
17
18
  [tenantId, ownerId],
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { Temporal } from "temporal-polyfill";
@@ -62,7 +62,8 @@ export async function filterOwnedFileRefs(
62
62
  isCreateMode: boolean,
63
63
  ): Promise<readonly OwnedFileRef[]> {
64
64
  if (candidateKeys.length === 0) return [];
65
- const rows = (await asRawClient(db).unsafe(
65
+ const rows = (await unsafeReadRetrying(
66
+ db,
66
67
  `SELECT "id", "storage_key" FROM "file_refs"
67
68
  WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
68
69
  AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false
@@ -16,6 +16,7 @@ export { isWithinGracePeriod } from "./grace-period";
16
16
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
17
17
  export { mapWithConcurrency } from "./map-with-concurrency";
18
18
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
19
+ export { sessionLocaleField } from "./session-locale-field";
19
20
  export { sessionTimezoneField } from "./session-timezone-field";
20
21
  export type { SystemQueryFn } from "./system-query";
21
22
  export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
@@ -0,0 +1,7 @@
1
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ export function sessionLocaleField(
4
+ locale: string | null | undefined,
5
+ ): Pick<SessionUser, "locale"> | Record<string, never> {
6
+ return locale !== null && locale !== undefined ? { locale } : {};
7
+ }
@@ -0,0 +1,52 @@
1
+ // #2323: stream-tenant-backfill.ts's candidate scan (a plain, non-transactional
2
+ // SELECT) read via asRawClient(db).unsafe() directly, bypassing the #1163
3
+ // closed-connection retry. Routed it through unsafeReadRetrying instead — this
4
+ // test mirrors bun-db/__tests__/select-many-retry.test.ts's fake-client
5
+ // pattern to prove the retry now fires. migrateAggregate's per-aggregate
6
+ // SELECT ... FOR UPDATE stays out of scope: it runs inside transaction(),
7
+ // where the retry guard (no begin() on a tx handle) is a no-op by design.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { backfillUserStreamTenants } from "../stream-tenant-backfill";
11
+
12
+ function closedConnectionError(): Error {
13
+ return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
14
+ }
15
+
16
+ type FakeClient = {
17
+ unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
18
+ begin: () => never;
19
+ calls: number;
20
+ };
21
+
22
+ function fakeClient(failures: Error[]): FakeClient {
23
+ const remaining = [...failures];
24
+ const client: FakeClient = {
25
+ calls: 0,
26
+ unsafe: async () => {
27
+ client.calls++;
28
+ const err = remaining.shift();
29
+ if (err) throw err;
30
+ return [];
31
+ },
32
+ begin: () => {
33
+ throw new Error("not used in test");
34
+ },
35
+ };
36
+ return client;
37
+ }
38
+
39
+ describe("user db/queries — closed-connection retry (#2323)", () => {
40
+ test("backfillUserStreamTenants retries the candidate scan once and completes with no candidates", async () => {
41
+ const db = fakeClient([closedConnectionError()]);
42
+ const result = await backfillUserStreamTenants(db as never);
43
+ expect(result).toEqual({ aggregatesMigrated: 0, eventsMigrated: 0, failed: [] });
44
+ expect(db.calls).toBe(2);
45
+ });
46
+
47
+ test("gives up after the single retry when the connection stays closed", async () => {
48
+ const db = fakeClient([closedConnectionError(), closedConnectionError()]);
49
+ await expect(backfillUserStreamTenants(db as never)).rejects.toThrow("connection was closed");
50
+ expect(db.calls).toBe(2);
51
+ });
52
+ });
@@ -24,7 +24,7 @@
24
24
  // the stream move: rebuildProjection("user:projection:user-entity", ...) or
25
25
  // the jobs:job:projection-rebuild job.
26
26
 
27
- import { asRawClient, transaction } from "@cosmicdrift/kumiko-framework/bun-db";
27
+ import { asRawClient, transaction, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
28
28
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
29
29
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
30
30
 
@@ -37,7 +37,8 @@ export type UserStreamBackfillResult = {
37
37
  export async function backfillUserStreamTenants(
38
38
  db: DbConnection,
39
39
  ): Promise<UserStreamBackfillResult> {
40
- const candidates = (await asRawClient(db).unsafe(
40
+ const candidates = (await unsafeReadRetrying(
41
+ db,
41
42
  `SELECT DISTINCT "aggregate_id" FROM "kumiko_events"
42
43
  WHERE "aggregate_type" = 'user' AND "tenant_id" <> $1::uuid`,
43
44
  [SYSTEM_TENANT_ID],
@@ -15,8 +15,10 @@
15
15
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
16
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
17
17
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
18
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
18
19
  import {
19
20
  createEntity,
21
+ createSystemUser,
20
22
  createTextField,
21
23
  defineFeature,
22
24
  EXT_USER_DATA,
@@ -38,6 +40,8 @@ import { buildEnvConfigOverrides, createConfigResolver } from "../../config/reso
38
40
  import { configValueEntity } from "../../config/table";
39
41
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
40
42
  import { createSessionsFeature } from "../../sessions";
43
+ import { tenantMembershipEntity, tenantMembershipsTable } from "../../tenant";
44
+ import { seedTenantMembership } from "../../tenant/seeding";
41
45
  import { createUserFeature, userEntity } from "../../user";
42
46
  import { TENANT_MODEL_CONFIG_KEY } from "../constants";
43
47
  import { createUserDataRightsFeature } from "../feature";
@@ -74,6 +78,14 @@ const contributorFeature = defineFeature("dsgvo-tenant-scoped", (r) => {
74
78
  });
75
79
  });
76
80
 
81
+ const membershipExecutor = createEventStoreExecutor(
82
+ tenantMembershipsTable,
83
+ tenantMembershipEntity,
84
+ {
85
+ entityName: "tenant-membership",
86
+ },
87
+ );
88
+
77
89
  let stack: TestStack;
78
90
  const seed = (db: unknown) =>
79
91
  // biome-ignore lint/suspicious/noExplicitAny: dummy writer; this contributor has no binaries.
@@ -224,6 +236,69 @@ describe("forget pipeline honours the effective tenant model", () => {
224
236
  expect(result.errors).toHaveLength(0);
225
237
  expect(await rowCount()).toBe(1);
226
238
  });
239
+
240
+ test("single-user, sole live member, but a departed co-member's history remains → rows preserved (ent#346)", async () => {
241
+ await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c5");
242
+ await seed(stack.db).seedForgetUser(FORGET_USER);
243
+ // Both memberships go through the REAL event-store executor (not the
244
+ // raw-INSERT seedMembership() helper) — the historical check reads
245
+ // `tenant-membership.created` events, so FORGET_USER needs its own
246
+ // `.created` event too, or the tenant would only ever show 1 distinct
247
+ // historical member (CO_MEMBER) even after it truly had 2.
248
+ // Explicit `by`: streamTenantFor(user) keys each event's OWN tenantId
249
+ // column off the acting user, not the payload's tenantId — the
250
+ // seedTenantMembership default (TestUsers.systemAdmin) lives on a
251
+ // different tenant, which would emit .created under the wrong tenantId
252
+ // and make it invisible to everHadMultipleMembers' tenant-scoped query.
253
+ await seedTenantMembership(stack.db, {
254
+ userId: FORGET_USER,
255
+ tenantId: TENANT,
256
+ roles: ["Member"],
257
+ by: createSystemUser(TENANT),
258
+ });
259
+
260
+ // CO_MEMBER joins for real (emits tenant-membership.created) and is then
261
+ // removed for real (emits tenant-membership.deleted, deletes the
262
+ // projection row) — mirrors removeMemberWrite exactly, so the created
263
+ // event survives in kumiko_events even though the live row is gone.
264
+ const created = await seedTenantMembership(stack.db, {
265
+ userId: CO_MEMBER,
266
+ tenantId: TENANT,
267
+ roles: ["Member"],
268
+ by: createSystemUser(TENANT),
269
+ });
270
+ const deleteResult = await membershipExecutor.delete(
271
+ { id: created.id },
272
+ createSystemUser(TENANT),
273
+ createTenantDb(stack.db, TENANT, "system"),
274
+ );
275
+ if (!deleteResult.isSuccess) {
276
+ throw new Error(`test setup: co-member delete failed: ${deleteResult.error.code}`);
277
+ }
278
+
279
+ // Pins the test's premise: exactly 1 live membership row, so a live-count-
280
+ // only check would call this tenant single-user (and the fix must reject
281
+ // that call via history instead).
282
+ const liveMemberships = await asRawClient(stack.db).unsafe(
283
+ "SELECT id FROM read_tenant_memberships WHERE tenant_id = $1",
284
+ [TENANT],
285
+ );
286
+ expect(liveMemberships.length).toBe(1);
287
+
288
+ const result = await runForgetCleanup({
289
+ db: stack.db,
290
+ registry: stack.registry,
291
+ now: nowInstant(),
292
+ tenantModel: "single-user",
293
+ });
294
+
295
+ expect(result.errors).toHaveLength(0);
296
+ expect(result.processedUserIds).toContain(FORGET_USER);
297
+ // Only 1 live membership remains (FORGET_USER) — the OLD live-only check
298
+ // would have called this single-user and wiped the co-member's row. The
299
+ // historical check must still resolve multi-user here.
300
+ expect(await rowCount()).toBe(1);
301
+ });
227
302
  });
228
303
 
229
304
  describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel", () => {
@@ -38,7 +38,11 @@ import {
38
38
  type KmsAdapter,
39
39
  subjectIdToKey,
40
40
  } from "@cosmicdrift/kumiko-framework/crypto";
41
- import { type DbRunner, nullBlindIndexesForSubject } from "@cosmicdrift/kumiko-framework/db";
41
+ import {
42
+ type DbRunner,
43
+ entityEventName,
44
+ nullBlindIndexesForSubject,
45
+ } from "@cosmicdrift/kumiko-framework/db";
42
46
  import {
43
47
  EXT_USER_DATA,
44
48
  EXT_USER_DATA_ORDER,
@@ -49,6 +53,7 @@ import {
49
53
  type UserDataDeleteStrategy,
50
54
  type UserDataStorageProvider,
51
55
  } from "@cosmicdrift/kumiko-framework/engine";
56
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
52
57
  import {
53
58
  purgeSearchDocumentsForSubject,
54
59
  type SearchAdapter,
@@ -554,7 +559,37 @@ async function resolveEffectiveTenantModel(
554
559
  { tenantId },
555
560
  { limit: 2 },
556
561
  );
557
- return members.length === 1 ? "single-user" : "multi-user";
562
+ if (members.length !== 1) return "multi-user";
563
+ return (await everHadMultipleMembers(db, tenantId)) ? "multi-user" : "single-user";
564
+ }
565
+
566
+ // removeMemberWrite only deletes the tenant-membership PROJECTION row — the
567
+ // event history survives forever, and a departed member's own tenant-scoped
568
+ // data rows (contributor-style records with no per-user column) can
569
+ // outlive their membership (ent#346). A live count of 1 is therefore not
570
+ // enough: a tenant that ever had >1 distinct member must stay multi-user for
571
+ // the forget path even after everyone but one is removed, or the sole
572
+ // remaining member's forget request wipes the departed co-member's leftover
573
+ // rows. LIMIT chosen generously for a nominally single-user tenant; hitting
574
+ // it is itself a multi-user signal, so it also resolves to "multi-user".
575
+ const MAX_MEMBERSHIP_CREATED_EVENTS = 1000;
576
+
577
+ async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise<boolean> {
578
+ const createdEvents = await selectMany<{ payload: Record<string, unknown> }>(
579
+ db,
580
+ eventsTable,
581
+ {
582
+ tenantId,
583
+ aggregateType: "tenant-membership",
584
+ type: entityEventName("tenant-membership", "created"),
585
+ },
586
+ { limit: MAX_MEMBERSHIP_CREATED_EVENTS },
587
+ );
588
+ if (createdEvents.length >= MAX_MEMBERSHIP_CREATED_EVENTS) return true;
589
+ const distinctUserIds = new Set(
590
+ createdEvents.map((e) => e.payload["userId"]).filter((v): v is string => typeof v === "string"),
591
+ );
592
+ return distinctUserIds.size > 1;
558
593
  }
559
594
 
560
595
  // Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.