@cosmicdrift/kumiko-framework 0.320.0 → 0.322.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.
Files changed (54) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/schema-cli.integration.test.ts +11 -0
  3. package/src/__tests__/upgrade-cli.test.ts +50 -3
  4. package/src/api/__tests__/client-ip.test.ts +25 -0
  5. package/src/api/__tests__/extra-routes.integration.test.ts +42 -0
  6. package/src/api/client-ip.ts +27 -0
  7. package/src/api/extra-route.ts +3 -0
  8. package/src/api/index.ts +7 -1
  9. package/src/api/request-context.ts +23 -1
  10. package/src/api/server.ts +3 -1
  11. package/src/changes.json +60 -0
  12. package/src/db/event-store-executor-write.ts +8 -0
  13. package/src/db/queries/event-store-transfer.ts +73 -0
  14. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  15. package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
  16. package/src/engine/entity-handlers.ts +20 -1
  17. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
  18. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
  19. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
  20. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
  21. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
  22. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
  23. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
  24. package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
  25. package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
  26. package/src/engine/feature-ast/extractors/index.ts +4 -0
  27. package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
  28. package/src/engine/feature-ast/extractors/shared.ts +26 -19
  29. package/src/engine/feature-ast/parse.ts +13 -20
  30. package/src/engine/index.ts +3 -0
  31. package/src/engine/registry-state.ts +2 -1
  32. package/src/engine/steps/webhook-send.ts +4 -2
  33. package/src/engine/types/index.ts +2 -0
  34. package/src/errors/i18n/de.yaml +14 -0
  35. package/src/errors/i18n/en.yaml +13 -0
  36. package/src/errors/reasons.ts +5 -0
  37. package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
  38. package/src/event-store/index.ts +6 -0
  39. package/src/event-store/transfer.ts +84 -0
  40. package/src/i18n/__tests__/i18n.test.ts +35 -0
  41. package/src/i18n/__tests__/mail-registry.test.ts +19 -0
  42. package/src/i18n/index.ts +11 -4
  43. package/src/i18n/mail-registry.ts +13 -7
  44. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  45. package/src/pipeline/__tests__/irreversible-operation-gate.integration.test.ts +249 -0
  46. package/src/pipeline/dispatch-shared.ts +33 -4
  47. package/src/pipeline/index.ts +4 -0
  48. package/src/pipeline/irreversible-operation-gate.ts +33 -0
  49. package/src/pipeline/multi-stream-apply-context.ts +4 -0
  50. package/src/schema-cli.ts +5 -1
  51. package/src/ui-types/__tests__/plural.test.ts +108 -0
  52. package/src/ui-types/index.ts +6 -0
  53. package/src/ui-types/plural.ts +69 -0
  54. package/src/upgrade-cli.ts +58 -18
@@ -0,0 +1,249 @@
1
+ // Agent-risk floor for irreversible operations (delete on a non-softDelete
2
+ // entity, forget on any entity): the directly-dispatched entry handler must
3
+ // resolve agent.risk "high", or the executor gate refuses before any DB read.
4
+ // Real HTTP calls + setupTestStack — never createTestDispatcher.
5
+
6
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
7
+ import * as z from "zod";
8
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
9
+ import { buildEntityTable } from "../../db/table-builder";
10
+ import { createEntity, createTextField, defineFeature } from "../../engine";
11
+ import type { Registry } from "../../engine/types";
12
+ import { resolveAgentExposure } from "../../engine/types";
13
+ import { resetEventStore, setupTestStack, type TestStack, TestUsers } from "../../stack";
14
+ import { waitFor } from "../../testing";
15
+
16
+ const admin = TestUsers.admin;
17
+ const NONEXISTENT_ID = "00000000-0000-4000-8000-00000000dead";
18
+
19
+ const hardEntity = createEntity({
20
+ table: "gate_hard_items",
21
+ fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
22
+ });
23
+ const hardTable = buildEntityTable("hard", hardEntity);
24
+ const hardExecutor = createEventStoreExecutor(hardTable, hardEntity, { entityName: "hard" });
25
+
26
+ const softEntity = createEntity({
27
+ table: "gate_soft_items",
28
+ fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
29
+ softDelete: true,
30
+ });
31
+ const softTable = buildEntityTable("soft", softEntity);
32
+ const softExecutor = createEventStoreExecutor(softTable, softEntity, { entityName: "soft" });
33
+
34
+ const jobForgetRuns: Array<{ readonly id: string }> = [];
35
+
36
+ const gateFeature = defineFeature("gatetest", (r) => {
37
+ r.entity("hard", hardEntity);
38
+ r.entity("soft", softEntity);
39
+
40
+ r.crud("hard", hardEntity, {
41
+ write: { access: { roles: ["Admin"] } },
42
+ read: { access: { roles: ["Admin"] } },
43
+ });
44
+ r.crud("soft", softEntity, {
45
+ write: { access: { roles: ["Admin"] } },
46
+ read: { access: { roles: ["Admin"] } },
47
+ });
48
+
49
+ r.writeHandler(
50
+ "forget-hard-mid",
51
+ z.object({ id: z.uuid() }),
52
+ async (event, ctx) => hardExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
53
+ { access: { roles: ["Admin"] } },
54
+ );
55
+
56
+ r.writeHandler(
57
+ "forget-hard-high",
58
+ z.object({ id: z.uuid() }),
59
+ async (event, ctx) => hardExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
60
+ { access: { roles: ["Admin"] }, agent: { risk: "high" } },
61
+ );
62
+
63
+ r.writeHandler(
64
+ "delete-hard-mid",
65
+ z.object({ id: z.uuid() }),
66
+ async (event, ctx) => hardExecutor.delete({ id: event.payload.id }, event.user, ctx.db),
67
+ { access: { roles: ["Admin"] } },
68
+ );
69
+
70
+ r.writeHandler(
71
+ "delete-soft-mid",
72
+ z.object({ id: z.uuid() }),
73
+ async (event, ctx) => softExecutor.delete({ id: event.payload.id }, event.user, ctx.db),
74
+ { access: { roles: ["Admin"] } },
75
+ );
76
+
77
+ r.writeHandler(
78
+ "delegate-write-to-forget-high",
79
+ z.object({ id: z.uuid() }),
80
+ async (event, ctx) => ctx.write("gatetest:write:forget-hard-high", { id: event.payload.id }),
81
+ { access: { roles: ["Admin"] } },
82
+ );
83
+
84
+ r.writeHandler(
85
+ "delegate-write-as-to-forget-high",
86
+ z.object({ id: z.uuid() }),
87
+ async (event, ctx) =>
88
+ ctx.writeAs(event.user, "gatetest:write:forget-hard-high", { id: event.payload.id }),
89
+ { access: { roles: ["Admin"] } },
90
+ );
91
+
92
+ r.job("forgetHardJob", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
93
+ const id = payload["id"] as string; // @cast-boundary dynamic-key
94
+ await hardExecutor.forget({ id }, ctx.systemUser, ctx.db);
95
+ jobForgetRuns.push({ id });
96
+ });
97
+ });
98
+
99
+ function errorReason(details: unknown): string | undefined {
100
+ if (!details || typeof details !== "object" || !("reason" in details)) return undefined;
101
+ return typeof details.reason === "string" ? details.reason : undefined;
102
+ }
103
+
104
+ let stack: TestStack;
105
+
106
+ beforeAll(async () => {
107
+ stack = await setupTestStack({ features: [gateFeature], jobs: { consumerLane: "worker" } });
108
+ });
109
+
110
+ afterAll(async () => {
111
+ await stack.cleanup();
112
+ });
113
+
114
+ afterEach(async () => {
115
+ await resetEventStore(stack, ["gate_hard_items", "gate_soft_items"]);
116
+ });
117
+
118
+ async function createHard(label: string): Promise<string> {
119
+ const { id } = await stack.http.writeOk<{ id: string }>(
120
+ "gatetest:write:hard:create",
121
+ { label },
122
+ admin,
123
+ );
124
+ return id;
125
+ }
126
+
127
+ async function createSoft(label: string): Promise<string> {
128
+ const { id } = await stack.http.writeOk<{ id: string }>(
129
+ "gatetest:write:soft:create",
130
+ { label },
131
+ admin,
132
+ );
133
+ return id;
134
+ }
135
+
136
+ // The entity-convention "detail" query returns `data: null` for a missing
137
+ // row instead of an HTTP error — assert via queryOk + null, not queryErr.
138
+ async function hardRowExists(id: string): Promise<boolean> {
139
+ const row = await stack.http.queryOk("gatetest:query:hard:detail", { id }, admin);
140
+ return row !== null;
141
+ }
142
+
143
+ describe("executor.forget — always irreversible", () => {
144
+ test("mid-risk handler is denied, row survives", async () => {
145
+ const id = await createHard("survives-mid-forget");
146
+ const err = await stack.http.writeErr("gatetest:write:forget-hard-mid", { id }, admin);
147
+ expect(err.httpStatus).toBe(403);
148
+ expect(err.code).toBe("access_denied");
149
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
150
+ expect(err.message).toContain("gatetest:write:forget-hard-mid");
151
+ expect(await hardRowExists(id)).toBe(true);
152
+ });
153
+
154
+ test("high-risk handler succeeds, row is gone", async () => {
155
+ const id = await createHard("gone-after-high-forget");
156
+ await stack.http.writeOk("gatetest:write:forget-hard-high", { id }, admin);
157
+ expect(await hardRowExists(id)).toBe(false);
158
+ });
159
+
160
+ test("nonexistent id at mid-risk still answers 403, not 404", async () => {
161
+ const err = await stack.http.writeErr(
162
+ "gatetest:write:forget-hard-mid",
163
+ { id: NONEXISTENT_ID },
164
+ admin,
165
+ );
166
+ expect(err.httpStatus).toBe(403);
167
+ expect(err.code).toBe("access_denied");
168
+ });
169
+ });
170
+
171
+ describe("executor.delete — irreversible only without softDelete", () => {
172
+ test("mid-risk handler on a hard-delete entity is denied", async () => {
173
+ const id = await createHard("survives-mid-delete");
174
+ const err = await stack.http.writeErr("gatetest:write:delete-hard-mid", { id }, admin);
175
+ expect(err.httpStatus).toBe(403);
176
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
177
+ });
178
+
179
+ test("mid-risk handler on a softDelete entity succeeds", async () => {
180
+ const id = await createSoft("soft-delete-ok");
181
+ await stack.http.writeOk("gatetest:write:delete-soft-mid", { id }, admin);
182
+ const row = await stack.http.queryOk("gatetest:query:soft:detail", { id }, admin);
183
+ expect(row).toBeNull();
184
+ });
185
+ });
186
+
187
+ describe("delegation via ctx.write/writeAs does not inherit the inner handler's risk", () => {
188
+ test("mid handler delegating via ctx.write to a high forget handler is denied", async () => {
189
+ const id = await createHard("delegate-write-denied");
190
+ const err = await stack.http.writeErr(
191
+ "gatetest:write:delegate-write-to-forget-high",
192
+ { id },
193
+ admin,
194
+ );
195
+ expect(err.httpStatus).toBe(403);
196
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
197
+ expect(await hardRowExists(id)).toBe(true);
198
+ });
199
+
200
+ test("mid handler delegating via ctx.writeAs to a high forget handler is denied", async () => {
201
+ const id = await createHard("delegate-write-as-denied");
202
+ const err = await stack.http.writeErr(
203
+ "gatetest:write:delegate-write-as-to-forget-high",
204
+ { id },
205
+ admin,
206
+ );
207
+ expect(err.httpStatus).toBe(403);
208
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
209
+ expect(await hardRowExists(id)).toBe(true);
210
+ });
211
+
212
+ test("calling the high handler directly still succeeds", async () => {
213
+ const id = await createHard("direct-high-ok");
214
+ await stack.http.writeOk("gatetest:write:forget-hard-high", { id }, admin);
215
+ expect(await hardRowExists(id)).toBe(false);
216
+ });
217
+ });
218
+
219
+ describe("standard entity-convention delete handler", () => {
220
+ test("resolves to high risk on a non-softDelete entity, mid on a softDelete one", () => {
221
+ const registry = stack.registry as Registry;
222
+ const hardDelete = registry.getWriteHandler("gatetest:write:hard:delete");
223
+ const softDelete = registry.getWriteHandler("gatetest:write:soft:delete");
224
+ if (!hardDelete || !softDelete) throw new Error("delete handlers missing from registry");
225
+ expect(resolveAgentExposure(hardDelete, "write").risk).toBe("high");
226
+ expect(resolveAgentExposure(softDelete, "write").risk).toBe("mid");
227
+ });
228
+
229
+ test("succeeds over HTTP on the non-softDelete entity", async () => {
230
+ const id = await createHard("standard-delete-ok");
231
+ await stack.http.writeOk("gatetest:write:hard:delete", { id }, admin);
232
+ expect(await hardRowExists(id)).toBe(false);
233
+ });
234
+ });
235
+
236
+ describe("jobs have no entry handler — the gate stays out of their way", () => {
237
+ test("a job calling executor.forget without a write-handler entry runs through", async () => {
238
+ jobForgetRuns.length = 0;
239
+ const id = await createHard("job-forget-ok");
240
+ await stack.jobRunner?.dispatch("gatetest:job:forget-hard-job", {
241
+ id,
242
+ tenantId: admin.tenantId,
243
+ });
244
+ await waitFor(() => {
245
+ expect(jobForgetRuns.some((run) => run.id === id)).toBe(true);
246
+ });
247
+ expect(await hardRowExists(id)).toBe(false);
248
+ });
249
+ });
@@ -13,6 +13,7 @@ import { createDerivativesContext } from "../derivatives/derivatives-context";
13
13
  import type { defineTransitions } from "../engine/state-machine";
14
14
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
15
15
  import type {
16
+ AgentRisk,
16
17
  AggregateStreamHandle,
17
18
  AppContext,
18
19
  AppendEventArgs,
@@ -28,7 +29,7 @@ import type {
28
29
  SessionUser,
29
30
  WriteResult,
30
31
  } from "../engine/types";
31
- import { isRateLimitDisabled } from "../engine/types";
32
+ import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types";
32
33
  import type { TenantId } from "../engine/types/identifiers";
33
34
  import {
34
35
  FeatureDisabledError,
@@ -877,6 +878,25 @@ export async function buildHandlerContext(
877
878
  : handlerContext;
878
879
  }
879
880
 
881
+ // Resolves the agent risk the irreversible-operation gate should attribute to
882
+ // `type` when it becomes the request's entry handler — same defaulting rule
883
+ // (resolveAgentExposure) the agent manifest itself uses, so the two never diverge.
884
+ function resolveEntryHandlerRisk(
885
+ registry: Registry,
886
+ type: string,
887
+ operation: "query" | "write" | "stream",
888
+ ): AgentRisk {
889
+ if (operation === "write") {
890
+ return resolveAgentExposure(registry.getWriteHandler(type) ?? {}, "write").risk;
891
+ }
892
+ if (operation === "stream") {
893
+ // StreamHandlerDef carries no `agent` hint — streams can never declare
894
+ // "high", so nested irreversible writes stay conservatively denied.
895
+ return "mid";
896
+ }
897
+ return resolveAgentExposure(registry.getQueryHandler(type) ?? {}, "query").risk;
898
+ }
899
+
880
900
  // Wrap handler execution in a dispatcher.handler span AND emit the standard
881
901
  // dispatcher metrics (duration + error counter). Errors are re-thrown so
882
902
  // control flow stays identical to the uninstrumented path.
@@ -916,7 +936,11 @@ export async function runHandlerInstrumented<T>(
916
936
  // #3043 — everything the handler writes, including entity-executor
917
937
  // writes below it, is attributed to this handler.
918
938
  const result = await runWithOrigin(
919
- { handler: type, feature: registry.getHandlerFeature(type) },
939
+ {
940
+ handler: type,
941
+ feature: registry.getHandlerFeature(type),
942
+ entryHandler: { qn: type, risk: resolveEntryHandlerRisk(registry, type, operation) },
943
+ },
920
944
  inner,
921
945
  );
922
946
  if (operation === "write" && isFailedWriteResult(result)) {
@@ -978,8 +1002,13 @@ export async function* runStreamInstrumented<T>(
978
1002
  // generator suspension, so wrapping inner() once would leave every event a
979
1003
  // stream writes unattributed.
980
1004
  const inScope = <R>(fn: () => R): R =>
981
- runWithOrigin({ handler: type, feature: registry.getHandlerFeature(type) }, () =>
982
- observabilityContext.run({ activeSpan: span }, fn),
1005
+ runWithOrigin(
1006
+ {
1007
+ handler: type,
1008
+ feature: registry.getHandlerFeature(type),
1009
+ entryHandler: { qn: type, risk: resolveEntryHandlerRisk(registry, type, "stream") },
1010
+ },
1011
+ () => observabilityContext.run({ activeSpan: span }, fn),
983
1012
  );
984
1013
  try {
985
1014
  let next = await inScope(() => it.next());
@@ -50,6 +50,10 @@ export type { PruneEventsOptions, PruneEventsResult } from "./event-retention";
50
50
  export { ConsumerLagError, pruneEvents } from "./event-retention";
51
51
  export type { IdempotencyGuard } from "./idempotency";
52
52
  export { createIdempotencyGuard } from "./idempotency";
53
+ export {
54
+ assertIrreversibleOperationAllowed,
55
+ isIrreversibleEntityVerb,
56
+ } from "./irreversible-operation-gate";
53
57
  export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pipeline";
54
58
  export { createLifecycleHooks } from "./lifecycle-pipeline";
55
59
  export type { MspRebuildDeps } from "./msp-rebuild";
@@ -0,0 +1,33 @@
1
+ import { requestContext } from "../api/request-context";
2
+ import type { EntityDefinition } from "../engine/types";
3
+ import { AccessDeniedError, FrameworkReasons } from "../errors";
4
+
5
+ // forget hard-purges regardless of softDelete; delete is only irreversible
6
+ // when the entity has no soft-delete recovery path.
7
+ export function isIrreversibleEntityVerb(
8
+ verb: "delete" | "forget",
9
+ entity: EntityDefinition,
10
+ ): boolean {
11
+ return verb === "forget" || !entity.softDelete;
12
+ }
13
+
14
+ // No entryHandler means the call didn't originate from a directly-dispatched
15
+ // handler (job, MSP/consumer apply, seed, afterCommit hook, HTTP route
16
+ // without a handler) — those are outside agent reach and stay ungated.
17
+ export function assertIrreversibleOperationAllowed(operation: string): void {
18
+ const entryHandler = requestContext.get()?.entryHandler;
19
+ // skip: no entry handler (see above) or the entry handler already resolves "high".
20
+ if (!entryHandler || entryHandler.risk === "high") return;
21
+ throw new AccessDeniedError({
22
+ message:
23
+ `Handler "${entryHandler.qn}" performs an irreversible ${operation} but resolves agent risk ` +
24
+ `"${entryHandler.risk}" — declare agent: { risk: "high" } on "${entryHandler.qn}". The ` +
25
+ "directly called handler must carry it; delegating via ctx.write/writeAs does not.",
26
+ details: {
27
+ reason: FrameworkReasons.irreversibleOperationRequiresHighRisk,
28
+ handler: entryHandler.qn,
29
+ operation,
30
+ resolvedRisk: entryHandler.risk,
31
+ },
32
+ });
33
+ }
@@ -1,5 +1,6 @@
1
1
  import type { DerivativesContext } from "@cosmicdrift/kumiko-types/derivatives-types";
2
2
  import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-stream-apply-context-types";
3
+ import type { SecretsContext } from "@cosmicdrift/kumiko-types/secrets-types";
3
4
  import type { DbRunner } from "../db/connection";
4
5
  import type { AppendEventArgs, AppendEventFn, Registry, TenantId } from "../engine/types";
5
6
  import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
@@ -32,6 +33,8 @@ export type MultiStreamApplyContextDeps = {
32
33
  readonly files?: FileContext;
33
34
  // Same DerivativesContext the outer AppContext carries — mirrors `files`.
34
35
  readonly derivatives?: DerivativesContext;
36
+ // Same SecretsContext the outer AppContext carries, mirrors `files`.
37
+ readonly secrets?: SecretsContext;
35
38
  };
36
39
 
37
40
  export function createMultiStreamApplyContext(
@@ -41,6 +44,7 @@ export function createMultiStreamApplyContext(
41
44
  registry: deps.registry,
42
45
  ...(deps.files ? { files: deps.files } : {}),
43
46
  ...(deps.derivatives ? { derivatives: deps.derivatives } : {}),
47
+ ...(deps.secrets ? { secrets: deps.secrets } : {}),
44
48
  appendEvent: (async (args: AppendEventArgs) => {
45
49
  await appendDomainEventCore(
46
50
  {
package/src/schema-cli.ts CHANGED
@@ -115,7 +115,11 @@ export async function runSchemaCli(
115
115
  // migration, since the crashed process still records the migration as
116
116
  // applied and the rebuild is never retried.
117
117
  await ensureTemporalPolyfill();
118
- const sub = argv[0];
118
+ // `--help`/`-h`/`help` fall through to the same "no subcommand" usage
119
+ // branch below (undefined) — a real request for help, not a typo that
120
+ // should fail loud like an unrecognized subcommand does.
121
+ const rawSub = argv[0];
122
+ const sub = rawSub === "--help" || rawSub === "-h" || rawSub === "help" ? undefined : rawSub;
119
123
  const schemaFile = resolvePath(appCwd, "kumiko/schema.ts");
120
124
  const migrationsDir = resolvePath(appCwd, "kumiko/migrations");
121
125
 
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveTranslationValue, translationValueOtherText } from "../plural";
3
+
4
+ const messageCount = {
5
+ de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
6
+ en: { one: "{count} unread message", other: "{count} unread messages" },
7
+ pl: {
8
+ one: "{count} nieprzeczytana wiadomość",
9
+ few: "{count} nieprzeczytane wiadomości",
10
+ many: "{count} nieprzeczytanych wiadomości",
11
+ other: "{count} nieprzeczytanej wiadomości",
12
+ },
13
+ } as const;
14
+
15
+ describe("resolveTranslationValue — plain strings", () => {
16
+ test("interpolates {name} placeholders", () => {
17
+ expect(resolveTranslationValue("Hello, {name}!", "en", { name: "Ada" })).toBe("Hello, Ada!");
18
+ });
19
+
20
+ test("leaves an unknown placeholder untouched", () => {
21
+ expect(resolveTranslationValue("Hello, {name}!", "en")).toBe("Hello, {name}!");
22
+ });
23
+ });
24
+
25
+ describe("resolveTranslationValue — plural forms", () => {
26
+ test("en: singular vs plural", () => {
27
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 1 })).toBe("1 unread message");
28
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 0 })).toBe("0 unread messages");
29
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 2 })).toBe("2 unread messages");
30
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 5 })).toBe("5 unread messages");
31
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 21 })).toBe(
32
+ "21 unread messages",
33
+ );
34
+ });
35
+
36
+ test("de: singular vs plural", () => {
37
+ expect(resolveTranslationValue(messageCount.de, "de", { count: 1 })).toBe(
38
+ "1 ungelesene Nachricht",
39
+ );
40
+ expect(resolveTranslationValue(messageCount.de, "de", { count: 5 })).toBe(
41
+ "5 ungelesene Nachrichten",
42
+ );
43
+ });
44
+
45
+ test("pl: one/few/many CLDR categories", () => {
46
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 1 })).toBe(
47
+ "1 nieprzeczytana wiadomość",
48
+ );
49
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 2 })).toBe(
50
+ "2 nieprzeczytane wiadomości",
51
+ );
52
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 5 })).toBe(
53
+ "5 nieprzeczytanych wiadomości",
54
+ );
55
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 21 })).toBe(
56
+ "21 nieprzeczytanych wiadomości",
57
+ );
58
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 22 })).toBe(
59
+ "22 nieprzeczytane wiadomości",
60
+ );
61
+ });
62
+
63
+ test("a formal BCP-47 subtag still resolves plural categories (renderer's <locale>-x-formal tier)", () => {
64
+ expect(resolveTranslationValue(messageCount.pl, "pl-x-formal", { count: 2 })).toBe(
65
+ "2 nieprzeczytane wiadomości",
66
+ );
67
+ });
68
+
69
+ test("falls back to `other` when the selected category has no form", () => {
70
+ const partial = { one: "one item", other: "{count} items" };
71
+ // pl "5" selects "many", which `partial` doesn't define.
72
+ expect(resolveTranslationValue(partial, "pl", { count: 5 })).toBe("5 items");
73
+ });
74
+
75
+ test("falls back to `other` when count is missing", () => {
76
+ expect(resolveTranslationValue(messageCount.en, "en")).toBe("{count} unread messages");
77
+ });
78
+
79
+ test("falls back to `other` when count is not a finite number", () => {
80
+ expect(resolveTranslationValue(messageCount.en, "en", { count: "many" })).toBe(
81
+ "many unread messages",
82
+ );
83
+ // count is present (interpolated verbatim) but non-finite, so the plural
84
+ // category still falls back to `other`.
85
+ expect(resolveTranslationValue(messageCount.en, "en", { count: Number.NaN })).toBe(
86
+ "NaN unread messages",
87
+ );
88
+ });
89
+
90
+ test("an invalid locale tag falls back to `other` without throwing", () => {
91
+ expect(() =>
92
+ resolveTranslationValue(messageCount.en, "not-a-locale-!!", { count: 1 }),
93
+ ).not.toThrow();
94
+ expect(resolveTranslationValue(messageCount.en, "not-a-locale-!!", { count: 1 })).toBe(
95
+ "1 unread messages",
96
+ );
97
+ });
98
+ });
99
+
100
+ describe("translationValueOtherText", () => {
101
+ test("returns a plain string as-is", () => {
102
+ expect(translationValueOtherText("Welcome")).toBe("Welcome");
103
+ });
104
+
105
+ test("returns the `other` form for a plural entry", () => {
106
+ expect(translationValueOtherText(messageCount.en)).toBe("{count} unread messages");
107
+ });
108
+ });
@@ -150,3 +150,9 @@ export {
150
150
  REFERENCE_LOOKUP_SOURCES,
151
151
  SYSTEM_REFERENCE_LABELS,
152
152
  } from "./list-row-meta";
153
+ export {
154
+ type PluralForms,
155
+ resolveTranslationValue,
156
+ type TranslationValue,
157
+ translationValueOtherText,
158
+ } from "./plural";
@@ -0,0 +1,69 @@
1
+ import type { PluralForms, TranslationValue } from "@cosmicdrift/kumiko-types/config";
2
+
3
+ export type { PluralForms, TranslationValue } from "@cosmicdrift/kumiko-types/config";
4
+
5
+ // Hermes on older Expo/React Native builds doesn't ship Intl.PluralRules —
6
+ // fall back to `other` there instead of hand-rolling CLDR plural rules.
7
+ const hasPluralRulesSupport = typeof Intl !== "undefined" && typeof Intl.PluralRules === "function";
8
+
9
+ // `null` cache entries mark a locale tag Intl.PluralRules rejected, so a bad
10
+ // tag doesn't retry the constructor (and its exception) on every call.
11
+ const pluralRulesCache = new Map<string, Intl.PluralRules | null>();
12
+
13
+ function pluralRulesFor(locale: string): Intl.PluralRules | null {
14
+ if (!hasPluralRulesSupport) return null;
15
+ const cached = pluralRulesCache.get(locale);
16
+ if (cached !== undefined) return cached;
17
+ let rules: Intl.PluralRules | null;
18
+ try {
19
+ rules = new Intl.PluralRules(locale);
20
+ } catch {
21
+ rules = null;
22
+ }
23
+ pluralRulesCache.set(locale, rules);
24
+ return rules;
25
+ }
26
+
27
+ function isFiniteCount(value: unknown): value is number {
28
+ return typeof value === "number" && Number.isFinite(value);
29
+ }
30
+
31
+ function pluralFormText(forms: PluralForms, locale: string, count: unknown): string {
32
+ if (!isFiniteCount(count)) return forms.other;
33
+ const rules = pluralRulesFor(locale);
34
+ if (rules === null) return forms.other;
35
+ const category = rules.select(count);
36
+ return forms[category] ?? forms.other;
37
+ }
38
+
39
+ function interpolate(
40
+ template: string,
41
+ params: Readonly<Record<string, unknown>> | undefined,
42
+ ): string {
43
+ if (params === undefined) return template;
44
+ return template.replace(/\{(\w+)\}/g, (_, name: string) => {
45
+ const value = params[name];
46
+ return value !== undefined ? String(value) : `{${name}}`;
47
+ });
48
+ }
49
+
50
+ /** Text-only reading of a TranslationValue — the CLDR `other` form for a
51
+ * plural entry, or the string as-is. For call sites that render a single
52
+ * string (agent manifests, labels) without a locale/count context. */
53
+ export function translationValueOtherText(value: TranslationValue): string {
54
+ return typeof value === "string" ? value : value.other;
55
+ }
56
+
57
+ /** Resolve a TranslationValue to display text: a plural entry picks its CLDR
58
+ * category via `Intl.PluralRules` from `params.count`, falling back to
59
+ * `other` when `count` is missing, non-finite, or `locale` is invalid.
60
+ * `{name}` interpolation then applies to the resolved string — an unknown
61
+ * placeholder is left as-is. */
62
+ export function resolveTranslationValue(
63
+ value: TranslationValue,
64
+ locale: string,
65
+ params?: Readonly<Record<string, unknown>>,
66
+ ): string {
67
+ const text = typeof value === "string" ? value : pluralFormText(value, locale, params?.["count"]);
68
+ return interpolate(text, params);
69
+ }