@cosmicdrift/kumiko-bundled-features 0.213.0 → 0.215.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 +8 -8
- package/src/auth-email-password/auth-user-row.ts +2 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -1
- package/src/auth-email-password/handlers/login.write.ts +3 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +8 -2
- package/src/auth-mfa/handlers/verify.write.ts +8 -2
- package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +93 -0
- package/src/custom-fields/db/queries/field-access.ts +3 -2
- package/src/custom-fields/db/queries/quota.ts +3 -2
- package/src/custom-fields/db/queries/retention.ts +3 -2
- package/src/custom-fields/db/queries/user-data-rights.ts +5 -3
- package/src/form-draft/db/queries/__tests__/unsafe-read-retrying.test.ts +87 -0
- package/src/form-draft/db/queries/cleanup.ts +3 -2
- package/src/form-draft/db/queries/draft-count.ts +3 -2
- package/src/form-draft/db/queries/owned-file-refs.ts +3 -2
- package/src/shared/index.ts +1 -0
- package/src/shared/session-locale-field.ts +7 -0
- package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +52 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.215.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>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.215.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.215.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.215.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.215.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.215.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.215.0",
|
|
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.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.215.0"
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
package/src/shared/index.ts
CHANGED
|
@@ -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
|
|
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],
|