@cosmicdrift/kumiko-framework 0.212.0 → 0.213.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 +3 -3
- package/src/__tests__/schema-cli.integration.test.ts +45 -0
- package/src/api/__tests__/request-locale.integration.test.ts +96 -0
- package/src/api/api-constants.ts +7 -0
- package/src/api/request-context.ts +5 -0
- package/src/api/request-id-middleware.ts +10 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +6 -3
- package/src/i18n/index.ts +6 -0
- package/src/i18n/request-locale.ts +63 -0
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +31 -0
- package/src/pipeline/dispatch-shared.ts +8 -0
- package/src/pipeline/distributed-lock.ts +26 -0
- package/src/schema-cli.ts +11 -0
- package/src/testing/handler-context.ts +17 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.213.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
"./package.json": "./package.json"
|
|
195
195
|
},
|
|
196
196
|
"dependencies": {
|
|
197
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
197
|
+
"@cosmicdrift/kumiko-types": "0.213.0",
|
|
198
198
|
"bullmq": "^5.76.7",
|
|
199
199
|
"bun-types": "^1.3.13",
|
|
200
200
|
"hono": "^4.13.1",
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"zod": "^4.4.3"
|
|
211
211
|
},
|
|
212
212
|
"devDependencies": {
|
|
213
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
213
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.213.0",
|
|
214
214
|
"bun-types": "^1.3.13",
|
|
215
215
|
"pino-pretty": "^13.1.3"
|
|
216
216
|
},
|
|
@@ -69,6 +69,51 @@ describe("runSchemaCli — no-DB paths", () => {
|
|
|
69
69
|
expect(cap.err.join("\n")).not.toContain("kumiko-schema");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("generate --help prints usage, exits 0, writes no migration (framework#2191)", async () => {
|
|
73
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
74
|
+
const cap = captureOut();
|
|
75
|
+
const code = await runSchemaCli(["generate", "--help"], appCwd, cap.out);
|
|
76
|
+
expect(code).toBe(0);
|
|
77
|
+
expect(cap.log.join("\n")).toContain("Usage: schema generate <name>");
|
|
78
|
+
expect(cap.err).toHaveLength(0);
|
|
79
|
+
expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("generate -h prints usage, exits 0, writes no migration", async () => {
|
|
83
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
84
|
+
const cap = captureOut();
|
|
85
|
+
const code = await runSchemaCli(["generate", "-h"], appCwd, cap.out);
|
|
86
|
+
expect(code).toBe(0);
|
|
87
|
+
expect(cap.log.join("\n")).toContain("Usage: schema generate <name>");
|
|
88
|
+
expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("generate name starting with -- is rejected, exits 1, writes no migration", async () => {
|
|
92
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
93
|
+
const cap = captureOut();
|
|
94
|
+
const code = await runSchemaCli(["generate", "--evil"], appCwd, cap.out);
|
|
95
|
+
expect(code).toBe(1);
|
|
96
|
+
expect(cap.err.join("\n")).toContain('Invalid migration name "--evil"');
|
|
97
|
+
expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("generate name with path separators is rejected (path traversal)", async () => {
|
|
101
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
102
|
+
const cap = captureOut();
|
|
103
|
+
const code = await runSchemaCli(["generate", "../../evil"], appCwd, cap.out);
|
|
104
|
+
expect(code).toBe(1);
|
|
105
|
+
expect(cap.err.join("\n")).toContain("Invalid migration name");
|
|
106
|
+
expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("generate with a hyphenated name still works", async () => {
|
|
110
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
111
|
+
const cap = captureOut();
|
|
112
|
+
const code = await runSchemaCli(["generate", "add-user-table"], appCwd, cap.out);
|
|
113
|
+
expect(code).toBe(0);
|
|
114
|
+
expect(existsSync(join(appCwd, "kumiko/migrations/0001_add-user-table.sql"))).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
72
117
|
test("generate with missing schema.ts exits 1", async () => {
|
|
73
118
|
const cap = captureOut();
|
|
74
119
|
const code = await runSchemaCli(["generate", "init"], appCwd, cap.out);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Regression guard for the request-locale wiring: X-Locale header (or, when
|
|
2
|
+
// absent, Accept-Language) must reach ctx.locale on real HTTP calls — the
|
|
3
|
+
// AsyncLocalStorage plumbing in request-id-middleware.ts / dispatch-shared.ts
|
|
4
|
+
// can't be exercised via createTestDispatcher, which skips the HTTP layer
|
|
5
|
+
// entirely.
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import {
|
|
9
|
+
createTestUser,
|
|
10
|
+
setupTestStack,
|
|
11
|
+
type TestStack,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { LOCALE_HEADER_NAME } from "../api-constants";
|
|
15
|
+
|
|
16
|
+
const localeProbe = defineFeature("locale-probe", (r) => {
|
|
17
|
+
r.writeHandler(
|
|
18
|
+
"read-locale",
|
|
19
|
+
z.object({}),
|
|
20
|
+
async (_event, ctx) => ({ isSuccess: true, data: { locale: ctx.locale } }),
|
|
21
|
+
{ access: { openToAll: true } },
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function readLocale(stack: TestStack, headers: Record<string, string>): Promise<string> {
|
|
26
|
+
const res = await stack.http.writeWithHeaders(
|
|
27
|
+
"locale-probe:write:read-locale",
|
|
28
|
+
{},
|
|
29
|
+
createTestUser({ id: 1 }),
|
|
30
|
+
headers,
|
|
31
|
+
);
|
|
32
|
+
const body = (await res.json()) as { data: { locale: string } };
|
|
33
|
+
return body.data.locale;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("ctx.locale resolution over real HTTP", () => {
|
|
37
|
+
let stack: TestStack;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
stack = await setupTestStack({ features: [localeProbe] });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await stack.cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("X-Locale header wins", async () => {
|
|
48
|
+
const locale = await readLocale(stack, { [LOCALE_HEADER_NAME]: "de-AT" });
|
|
49
|
+
expect(locale).toBe("de-AT");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("falls back to Accept-Language when X-Locale is absent", async () => {
|
|
53
|
+
const locale = await readLocale(stack, { "accept-language": "fr-FR,fr;q=0.9,en;q=0.8" });
|
|
54
|
+
expect(locale).toBe("fr-FR");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("Accept-Language with only invalid tags falls back to the boot default", async () => {
|
|
58
|
+
const locale = await readLocale(stack, { "accept-language": "*, ;q=0.1" });
|
|
59
|
+
expect(locale).toBe("en");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a malformed/manipulated X-Locale header is rejected, not passed through", async () => {
|
|
63
|
+
const locale = await readLocale(stack, {
|
|
64
|
+
[LOCALE_HEADER_NAME]: "<script>alert(1)</script>",
|
|
65
|
+
});
|
|
66
|
+
expect(locale).toBe("en");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("an invalid X-Locale header falls through to a valid Accept-Language instead of the default", async () => {
|
|
70
|
+
const locale = await readLocale(stack, {
|
|
71
|
+
[LOCALE_HEADER_NAME]: "not-a-real-locale-tag-way-too-long-to-be-valid",
|
|
72
|
+
"accept-language": "es-ES",
|
|
73
|
+
});
|
|
74
|
+
expect(locale).toBe("es-ES");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("ctx.locale falls back to the app's boot-configured defaultLocale", () => {
|
|
79
|
+
let stack: TestStack;
|
|
80
|
+
|
|
81
|
+
beforeAll(async () => {
|
|
82
|
+
stack = await setupTestStack({
|
|
83
|
+
features: [localeProbe],
|
|
84
|
+
extraContext: { defaultLocale: "ja" },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
await stack.cleanup();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("no header signal at all uses the app's defaultLocale, not the hardcoded default", async () => {
|
|
93
|
+
const locale = await readLocale(stack, {});
|
|
94
|
+
expect(locale).toBe("ja");
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/api/api-constants.ts
CHANGED
|
@@ -118,4 +118,11 @@ export const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set([
|
|
|
118
118
|
export const TENANT_HEADER_NAME = "X-Tenant";
|
|
119
119
|
export const TENANT_COOKIE_NAME = "kumiko_tenant";
|
|
120
120
|
|
|
121
|
+
// Client-declared active UI locale (BCP-47). Read once per request in
|
|
122
|
+
// request-id-middleware.ts, before auth, so it reaches public routes
|
|
123
|
+
// (e.g. signup-request) too. Falls back to Accept-Language, then the app's
|
|
124
|
+
// boot-configured defaultLocale, when absent or malformed — see
|
|
125
|
+
// request-locale.ts and dispatch-shared.ts's ctx.locale resolution.
|
|
126
|
+
export const LOCALE_HEADER_NAME = "X-Locale";
|
|
127
|
+
|
|
121
128
|
export type Route = (typeof Routes)[keyof typeof Routes];
|
|
@@ -33,6 +33,11 @@ export type RequestContextData = {
|
|
|
33
33
|
// Raw User-Agent header — audit trails (download tokens, GDPR export
|
|
34
34
|
// access) want it alongside `ip`. Undefined for non-HTTP entry points.
|
|
35
35
|
readonly userAgent?: string;
|
|
36
|
+
// Client-declared active UI locale (BCP-47), resolved once from
|
|
37
|
+
// X-Locale / Accept-Language by requestIdMiddleware — see
|
|
38
|
+
// request-locale.ts. Undefined when neither header carried a valid tag;
|
|
39
|
+
// callers fall back further (dispatch-shared.ts's ctx.locale chain).
|
|
40
|
+
readonly locale?: string;
|
|
36
41
|
};
|
|
37
42
|
|
|
38
43
|
const storage = new AsyncLocalStorage<RequestContextData>();
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Context, Next } from "hono";
|
|
2
|
+
import { resolveHeaderLocale } from "../i18n/request-locale";
|
|
3
|
+
import { LOCALE_HEADER_NAME } from "./api-constants";
|
|
2
4
|
import { type RequestContextData, requestContext } from "./request-context";
|
|
3
5
|
|
|
4
6
|
const REQUEST_ID_HEADER = "X-Request-ID";
|
|
@@ -45,6 +47,13 @@ export function buildRequestContextData(c: Context): RequestContextData {
|
|
|
45
47
|
const xff = c.req.header("x-forwarded-for");
|
|
46
48
|
const ip = xff?.split(",")[0]?.trim();
|
|
47
49
|
const userAgent = c.req.header("user-agent");
|
|
50
|
+
// Runs before auth-middleware, so this reaches public routes too (e.g.
|
|
51
|
+
// signup-request) — that's the whole point: the active UI locale must
|
|
52
|
+
// survive to anonymous callers, not just authenticated ones.
|
|
53
|
+
const locale = resolveHeaderLocale({
|
|
54
|
+
headerLocale: c.req.header(LOCALE_HEADER_NAME),
|
|
55
|
+
acceptLanguage: c.req.header("accept-language"),
|
|
56
|
+
});
|
|
48
57
|
|
|
49
58
|
return {
|
|
50
59
|
requestId,
|
|
@@ -52,6 +61,7 @@ export function buildRequestContextData(c: Context): RequestContextData {
|
|
|
52
61
|
...(signal ? { signal } : {}),
|
|
53
62
|
...(ip && ip.length > 0 ? { ip } : {}),
|
|
54
63
|
...(userAgent !== undefined ? { userAgent } : {}),
|
|
64
|
+
...(locale !== undefined ? { locale } : {}),
|
|
55
65
|
};
|
|
56
66
|
}
|
|
57
67
|
|
package/src/bun-db/index.ts
CHANGED
package/src/bun-db/query.ts
CHANGED
|
@@ -685,7 +685,10 @@ function isClosedConnectionError(err: unknown): boolean {
|
|
|
685
685
|
);
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
-
|
|
688
|
+
// Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
|
|
689
|
+
// db/queries/*.ts) can opt into the same #1163 retry instead of calling
|
|
690
|
+
// asRawClient(db).unsafe(...) directly and losing it.
|
|
691
|
+
export async function unsafeReadRetrying<TRow>(
|
|
689
692
|
db: AnyDb,
|
|
690
693
|
sqlText: string,
|
|
691
694
|
params: readonly unknown[],
|
|
@@ -736,7 +739,7 @@ export async function selectMany<TRow = any>(
|
|
|
736
739
|
}
|
|
737
740
|
sqlText += ` LIMIT ${options.limit}`;
|
|
738
741
|
}
|
|
739
|
-
const raw = (await
|
|
742
|
+
const raw = (await unsafeReadRetrying(db, sqlText, values)) as readonly Record<string, unknown>[];
|
|
740
743
|
return coerceRows(raw, info) as readonly TRow[];
|
|
741
744
|
}
|
|
742
745
|
|
|
@@ -970,7 +973,7 @@ export async function countWhere(
|
|
|
970
973
|
sqlText += ` WHERE ${w.sqlText}`;
|
|
971
974
|
values = w.values;
|
|
972
975
|
}
|
|
973
|
-
const rows = (await
|
|
976
|
+
const rows = (await unsafeReadRetrying(db, sqlText, values)) as readonly { count: number }[];
|
|
974
977
|
return rows[0]?.count ?? 0;
|
|
975
978
|
}
|
|
976
979
|
|
package/src/i18n/index.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Registry, TranslationKeys } from "../engine/types";
|
|
2
2
|
|
|
3
3
|
export { hasMailTranslations, mailT, registerMailTranslations } from "./mail-registry";
|
|
4
|
+
export {
|
|
5
|
+
DEFAULT_LOCALE,
|
|
6
|
+
isValidLocaleTag,
|
|
7
|
+
pickAcceptLanguage,
|
|
8
|
+
resolveHeaderLocale,
|
|
9
|
+
} from "./request-locale";
|
|
4
10
|
|
|
5
11
|
export type I18nOptions = {
|
|
6
12
|
defaultLocale: string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Request-scoped locale resolution — the language counterpart to ctx.tz
|
|
2
|
+
// (time/tz-context.ts). Unlike TzContext there's no closed catalog to
|
|
3
|
+
// validate against here: mail-registry.ts is a dynamic, per-package
|
|
4
|
+
// registry that locale packages (kumiko-locale-de, ...) populate at import
|
|
5
|
+
// time, so "known locale" isn't a fixed enum. Validation checks
|
|
6
|
+
// well-formedness instead — a header is user input either way.
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_LOCALE = "en";
|
|
9
|
+
|
|
10
|
+
// Loose BCP-47: 2-3 letter primary subtag, then 1-8 more alphanumeric
|
|
11
|
+
// subtags separated by "-" (region/script/variant/extension). Rejects
|
|
12
|
+
// control characters, oversized values, and header-injection shapes
|
|
13
|
+
// without implementing a full RFC 5646 parser.
|
|
14
|
+
const LOCALE_TAG_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
|
|
15
|
+
const MAX_LOCALE_TAG_LENGTH = 35;
|
|
16
|
+
|
|
17
|
+
export function isValidLocaleTag(value: string): boolean {
|
|
18
|
+
return value.length <= MAX_LOCALE_TAG_LENGTH && LOCALE_TAG_RE.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type AcceptLanguageCandidate = { readonly tag: string; readonly q: number; readonly index: number };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Picks the best well-formed tag from an Accept-Language header (RFC 9110
|
|
25
|
+
* §12.5.4): parses "tag;q=x" pairs, sorts by q descending (header order
|
|
26
|
+
* breaks ties), and returns the first tag that passes isValidLocaleTag.
|
|
27
|
+
* Tags with q=0 (explicitly excluded) are dropped entirely.
|
|
28
|
+
*/
|
|
29
|
+
export function pickAcceptLanguage(header: string | undefined): string | undefined {
|
|
30
|
+
if (header === undefined || header.length === 0) return undefined;
|
|
31
|
+
|
|
32
|
+
const candidates: AcceptLanguageCandidate[] = header
|
|
33
|
+
.split(",")
|
|
34
|
+
.map((part, index): AcceptLanguageCandidate | undefined => {
|
|
35
|
+
const [tagRaw, ...params] = part.trim().split(";");
|
|
36
|
+
const tag = tagRaw?.trim();
|
|
37
|
+
if (tag === undefined || tag.length === 0 || !isValidLocaleTag(tag)) return undefined;
|
|
38
|
+
const qParam = params.find((p) => p.trim().startsWith("q="));
|
|
39
|
+
const parsedQ = qParam !== undefined ? Number(qParam.trim().slice(2)) : 1;
|
|
40
|
+
const q = Number.isFinite(parsedQ) ? parsedQ : 0;
|
|
41
|
+
return { tag, q, index };
|
|
42
|
+
})
|
|
43
|
+
.filter((c): c is AcceptLanguageCandidate => c !== undefined && c.q > 0)
|
|
44
|
+
.sort((a, b) => b.q - a.q || a.index - b.index);
|
|
45
|
+
|
|
46
|
+
return candidates[0]?.tag;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Request-layer resolution: an explicit, validated X-Locale header wins;
|
|
51
|
+
* otherwise the best Accept-Language tag; otherwise undefined — no signal
|
|
52
|
+
* from this request, callers fall back further (the app's boot-configured
|
|
53
|
+
* defaultLocale, then DEFAULT_LOCALE — see dispatch-shared.ts).
|
|
54
|
+
*/
|
|
55
|
+
export function resolveHeaderLocale(options: {
|
|
56
|
+
readonly headerLocale?: string;
|
|
57
|
+
readonly acceptLanguage?: string;
|
|
58
|
+
}): string | undefined {
|
|
59
|
+
if (options.headerLocale !== undefined && isValidLocaleTag(options.headerLocale)) {
|
|
60
|
+
return options.headerLocale;
|
|
61
|
+
}
|
|
62
|
+
return pickAcceptLanguage(options.acceptLanguage);
|
|
63
|
+
}
|
|
@@ -64,4 +64,35 @@ describe("distributed lock", () => {
|
|
|
64
64
|
const token = await lock.acquire("test-lock-5");
|
|
65
65
|
expect(token).not.toBeNull();
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
test("renew extends the TTL for the owning token", async () => {
|
|
69
|
+
const lock = createDistributedLock(testRedis.redis);
|
|
70
|
+
const token = await lock.acquire("test-lock-6", { ttlSeconds: 1 });
|
|
71
|
+
if (!token) throw new Error("expected token");
|
|
72
|
+
|
|
73
|
+
const renewed = await lock.renew("test-lock-6", token, 5);
|
|
74
|
+
expect(renewed).toBe(true);
|
|
75
|
+
|
|
76
|
+
// Past the original 1s TTL, but renew pushed it out to 5s — still held.
|
|
77
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
78
|
+
expect(await lock.acquire("test-lock-6")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("renew with wrong token fails and does not extend the TTL", async () => {
|
|
82
|
+
const lock = createDistributedLock(testRedis.redis);
|
|
83
|
+
await lock.acquire("test-lock-7", { ttlSeconds: 1 });
|
|
84
|
+
|
|
85
|
+
const renewed = await lock.renew("test-lock-7", "wrong-token", 5);
|
|
86
|
+
expect(renewed).toBe(false);
|
|
87
|
+
|
|
88
|
+
// The original 1s TTL still applies — the wrong-token renew didn't touch it.
|
|
89
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
90
|
+
expect(await lock.acquire("test-lock-7")).not.toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("renew on an expired/absent key fails", async () => {
|
|
94
|
+
const lock = createDistributedLock(testRedis.redis);
|
|
95
|
+
const renewed = await lock.renew("test-lock-8-never-acquired", "some-token", 5);
|
|
96
|
+
expect(renewed).toBe(false);
|
|
97
|
+
});
|
|
67
98
|
});
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from "../event-store/snapshot";
|
|
52
52
|
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster";
|
|
53
53
|
import { createFileContext } from "../files/file-handle";
|
|
54
|
+
import { DEFAULT_LOCALE } from "../i18n/request-locale";
|
|
54
55
|
import {
|
|
55
56
|
createMetricsHandle,
|
|
56
57
|
createNoopMetricsHandle,
|
|
@@ -620,6 +621,12 @@ export async function buildHandlerContext(
|
|
|
620
621
|
...(safeUserTz !== undefined && { user: safeUserTz }),
|
|
621
622
|
});
|
|
622
623
|
|
|
624
|
+
// ctx.locale — request-layer signal (X-Locale header → Accept-Language,
|
|
625
|
+
// resolved once at the HTTP boundary by request-id-middleware.ts) wins;
|
|
626
|
+
// falls back to the app's boot-configured defaultLocale, then
|
|
627
|
+
// DEFAULT_LOCALE. Mirrors ctx.tz's Request → Boot-Default chain above.
|
|
628
|
+
const locale = reqCtx?.locale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
629
|
+
|
|
623
630
|
return {
|
|
624
631
|
...context,
|
|
625
632
|
registry,
|
|
@@ -647,6 +654,7 @@ export async function buildHandlerContext(
|
|
|
647
654
|
metrics,
|
|
648
655
|
metricsFor,
|
|
649
656
|
tz,
|
|
657
|
+
locale,
|
|
650
658
|
// Cancellation signal flows from the HTTP middleware via
|
|
651
659
|
// requestContext. Conditional spread so non-HTTP entry-points
|
|
652
660
|
// (jobs, dispatcher MSP-applies) don't get a phantom signal that
|
|
@@ -5,6 +5,11 @@ import { RedisKeys } from "./redis-keys";
|
|
|
5
5
|
export type DistributedLock = {
|
|
6
6
|
acquire(key: string, options?: { ttlSeconds?: number }): Promise<string | null>;
|
|
7
7
|
release(key: string, token: string): Promise<boolean>;
|
|
8
|
+
/** Extends the TTL of a lock this caller still holds (token matches).
|
|
9
|
+
* Returns false when the token doesn't match — expired and re-claimed
|
|
10
|
+
* by someone else, or never held — the caller must treat that as
|
|
11
|
+
* "no longer the owner" and stop renewing, not retry. */
|
|
12
|
+
renew(key: string, token: string, ttlSeconds: number): Promise<boolean>;
|
|
8
13
|
};
|
|
9
14
|
|
|
10
15
|
export function createDistributedLock(
|
|
@@ -20,6 +25,16 @@ export function createDistributedLock(
|
|
|
20
25
|
end
|
|
21
26
|
`;
|
|
22
27
|
|
|
28
|
+
// Lua script for atomic check-and-extend — same ownership check as release,
|
|
29
|
+
// but resets the TTL instead of deleting the key.
|
|
30
|
+
const renewScript = `
|
|
31
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
32
|
+
return redis.call("expire", KEYS[1], ARGV[2])
|
|
33
|
+
else
|
|
34
|
+
return 0
|
|
35
|
+
end
|
|
36
|
+
`;
|
|
37
|
+
|
|
23
38
|
return {
|
|
24
39
|
async acquire(key, options = {}) {
|
|
25
40
|
const ttl = options.ttlSeconds ?? 30;
|
|
@@ -33,5 +48,16 @@ export function createDistributedLock(
|
|
|
33
48
|
const result = (await redis.eval(releaseScript, 1, `${prefix}${key}`, token)) as number; // @cast-boundary db-operator
|
|
34
49
|
return result === 1;
|
|
35
50
|
},
|
|
51
|
+
|
|
52
|
+
async renew(key, token, ttlSeconds) {
|
|
53
|
+
const result = (await redis.eval(
|
|
54
|
+
renewScript,
|
|
55
|
+
1,
|
|
56
|
+
`${prefix}${key}`,
|
|
57
|
+
token,
|
|
58
|
+
String(ttlSeconds),
|
|
59
|
+
)) as number; // @cast-boundary db-operator
|
|
60
|
+
return result === 1;
|
|
61
|
+
},
|
|
36
62
|
};
|
|
37
63
|
}
|
package/src/schema-cli.ts
CHANGED
|
@@ -143,10 +143,21 @@ export async function runSchemaCli(
|
|
|
143
143
|
switch (sub) {
|
|
144
144
|
case "generate": {
|
|
145
145
|
const name = argv[1];
|
|
146
|
+
if (name === "--help" || name === "-h") {
|
|
147
|
+
out.log(" Usage: schema generate <name>");
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
146
150
|
if (!name) {
|
|
147
151
|
out.err(" Usage: schema generate <name>");
|
|
148
152
|
return 1;
|
|
149
153
|
}
|
|
154
|
+
// name lands unescaped in `${seq}_${name}.sql` (generateMigration) — this
|
|
155
|
+
// allowlist blocks flag-like names and path traversal (`../../x`).
|
|
156
|
+
if (name.startsWith("-") || !/^[A-Za-z0-9_-]+$/.test(name)) {
|
|
157
|
+
out.err(` Invalid migration name "${name}" — use letters, digits, "-", "_" only.`);
|
|
158
|
+
out.err(" Usage: schema generate <name>");
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
150
161
|
if (!existsSync(schemaFile)) {
|
|
151
162
|
out.err(` ${schemaFile} fehlt.`);
|
|
152
163
|
out.err(" App-Convention: kumiko/schema.ts mit");
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// @runtime runtime
|
|
2
2
|
//
|
|
3
|
-
// bridgeStub
|
|
4
|
-
// (ctx.query/write/loadAggregate/...).
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// bridgeStub hands back a HandlerContext shape with throw-on-use bridge
|
|
4
|
+
// methods (ctx.query/write/loadAggregate/...). Used by both test code AND
|
|
5
|
+
// production services (delivery-service uses it to run cross-feature notify
|
|
6
|
+
// calls without a real dispatcher). Hence the runtime classification despite
|
|
7
|
+
// living under `testing/` — no vitest imports, no test side-effects.
|
|
8
8
|
import type {
|
|
9
9
|
AppendEventArgs,
|
|
10
10
|
FetchForWritingArgs,
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
SessionUser,
|
|
13
13
|
WriteResult,
|
|
14
14
|
} from "../engine/types";
|
|
15
|
+
import { DEFAULT_LOCALE } from "../i18n/request-locale";
|
|
15
16
|
import { createNoopMetricsHandle, getFallbackTracer } from "../observability";
|
|
16
17
|
import { createTzContext } from "../time";
|
|
17
18
|
|
|
@@ -61,13 +62,14 @@ export function bridgeStub(opts?: {
|
|
|
61
62
|
| "metricsFor"
|
|
62
63
|
| "tracer"
|
|
63
64
|
| "tz"
|
|
65
|
+
| "locale"
|
|
64
66
|
| "user"
|
|
65
67
|
> {
|
|
66
|
-
// ctx.user
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
68
|
+
// ctx.user is a convenience alias for event.user (see HandlerContext
|
|
69
|
+
// docs). Caller code expects the field; bridgeStub hands back a stub with
|
|
70
|
+
// anonymous default values when no user is passed explicitly. Test code
|
|
71
|
+
// that cares about identity passes its own SessionUser here and gets it
|
|
72
|
+
// back on ctx.
|
|
71
73
|
const stubUser: SessionUser = opts?.user ?? {
|
|
72
74
|
id: "00000000-0000-0000-0000-000000000000",
|
|
73
75
|
tenantId: "00000000-0000-0000-0000-000000000000" as SessionUser["tenantId"], // @cast-boundary engine-bridge
|
|
@@ -122,8 +124,11 @@ export function bridgeStub(opts?: {
|
|
|
122
124
|
metrics: createNoopMetricsHandle(),
|
|
123
125
|
metricsFor: () => createNoopMetricsHandle(),
|
|
124
126
|
tracer: noopTracer,
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
+
// Real TzContext, not notAvailable — test code uses ctx.tz routinely,
|
|
128
|
+
// it isn't a "bridge" concept. Defaults to UTC.
|
|
127
129
|
tz: createTzContext(),
|
|
130
|
+
// Same reasoning as tz above — ctx.locale is always-present, not a
|
|
131
|
+
// bridge method. Defaults to DEFAULT_LOCALE.
|
|
132
|
+
locale: DEFAULT_LOCALE,
|
|
128
133
|
};
|
|
129
134
|
}
|