@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.
- package/package.json +4 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/client-ip.test.ts +25 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +42 -0
- package/src/api/client-ip.ts +27 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +7 -1
- package/src/api/request-context.ts +23 -1
- package/src/api/server.ts +3 -1
- package/src/changes.json +60 -0
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/entity-handlers.ts +20 -1
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/index.ts +3 -0
- package/src/engine/registry-state.ts +2 -1
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/engine/types/index.ts +2 -0
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +5 -0
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/i18n/__tests__/i18n.test.ts +35 -0
- package/src/i18n/__tests__/mail-registry.test.ts +19 -0
- package/src/i18n/index.ts +11 -4
- package/src/i18n/mail-registry.ts +13 -7
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
- package/src/pipeline/__tests__/irreversible-operation-gate.integration.test.ts +249 -0
- package/src/pipeline/dispatch-shared.ts +33 -4
- package/src/pipeline/index.ts +4 -0
- package/src/pipeline/irreversible-operation-gate.ts +33 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/schema-cli.ts +5 -1
- package/src/ui-types/__tests__/plural.test.ts +108 -0
- package/src/ui-types/index.ts +6 -0
- package/src/ui-types/plural.ts +69 -0
- package/src/upgrade-cli.ts +58 -18
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// transferAggregateStreams — the event store's own tenant-transfer primitive.
|
|
2
|
+
// Pins: events, snapshots and the archive marker all move together; a system
|
|
3
|
+
// aggregate.transferred event lands per moved aggregate; an empty id list is
|
|
4
|
+
// a no-op; an id with no events under the given tenant/aggregateType is left
|
|
5
|
+
// untouched; an aggregate belonging to an unrelated third tenant is never
|
|
6
|
+
// touched by another tenant's move; an archive marker for a DIFFERENT
|
|
7
|
+
// aggregate type sharing the same aggregate id stays behind.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
11
|
+
import { asRawClient } from "../../db/query";
|
|
12
|
+
import type { TenantId } from "../../engine/types";
|
|
13
|
+
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
14
|
+
import { generateId as uuid } from "../../utils";
|
|
15
|
+
import {
|
|
16
|
+
AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
17
|
+
AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
18
|
+
append,
|
|
19
|
+
archiveStream,
|
|
20
|
+
isStreamArchived,
|
|
21
|
+
loadAggregate,
|
|
22
|
+
loadLatestSnapshot,
|
|
23
|
+
saveSnapshot,
|
|
24
|
+
transferAggregateStreams,
|
|
25
|
+
} from "../index";
|
|
26
|
+
|
|
27
|
+
let bun: BunTestDb;
|
|
28
|
+
const sourceTenant = uuid() as TenantId;
|
|
29
|
+
const destinationTenant = uuid() as TenantId;
|
|
30
|
+
const otherTenant = uuid() as TenantId;
|
|
31
|
+
const userId = uuid();
|
|
32
|
+
const transferredBy = uuid();
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
await ensureTemporalPolyfill();
|
|
36
|
+
bun = await createTestDb();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await bun.cleanup();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
await asRawClient(bun.db).unsafe(
|
|
45
|
+
`TRUNCATE kumiko_events, kumiko_snapshots, kumiko_archived_streams RESTART IDENTITY`,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function seedAggregate(tenantId: TenantId, aggregateType = "counter"): Promise<string> {
|
|
50
|
+
const aggregateId = uuid();
|
|
51
|
+
await append(bun.db, {
|
|
52
|
+
aggregateId,
|
|
53
|
+
aggregateType,
|
|
54
|
+
tenantId,
|
|
55
|
+
expectedVersion: 0,
|
|
56
|
+
type: "counter.incremented",
|
|
57
|
+
payload: { by: 1 },
|
|
58
|
+
metadata: { userId },
|
|
59
|
+
});
|
|
60
|
+
return aggregateId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Each `aggregate.transferred` event lives on its OWN fresh stream (see
|
|
64
|
+
// transfer.ts) — the row's own `aggregate_id` column is that stream's id,
|
|
65
|
+
// not the moved aggregate's. The moved aggregate's id is in the payload.
|
|
66
|
+
async function transferredEvents(tenantId: TenantId): Promise<readonly Record<string, unknown>[]> {
|
|
67
|
+
const rows = (await asRawClient(bun.db).unsafe(
|
|
68
|
+
`SELECT payload FROM kumiko_events WHERE tenant_id = $1 AND aggregate_type = $2 AND type = $3`,
|
|
69
|
+
[tenantId, AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE],
|
|
70
|
+
)) as ReadonlyArray<{ payload: Record<string, unknown> }>;
|
|
71
|
+
return rows.map((row) => row.payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("transferAggregateStreams", () => {
|
|
75
|
+
test("moves events, drops the snapshot, carries the archive marker, and emits one aggregate.transferred event per moved aggregate", async () => {
|
|
76
|
+
const first = await seedAggregate(sourceTenant);
|
|
77
|
+
const second = await seedAggregate(sourceTenant);
|
|
78
|
+
await saveSnapshot(bun.db, {
|
|
79
|
+
aggregateId: first,
|
|
80
|
+
tenantId: sourceTenant,
|
|
81
|
+
aggregateType: "counter",
|
|
82
|
+
version: 1,
|
|
83
|
+
state: { count: 1 },
|
|
84
|
+
});
|
|
85
|
+
await archiveStream(bun.db, {
|
|
86
|
+
tenantId: sourceTenant,
|
|
87
|
+
aggregateId: first,
|
|
88
|
+
aggregateType: "counter",
|
|
89
|
+
archivedBy: "ops",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
93
|
+
sourceTenantId: sourceTenant,
|
|
94
|
+
destinationTenantId: destinationTenant,
|
|
95
|
+
aggregateType: "counter",
|
|
96
|
+
aggregateIds: [first, second],
|
|
97
|
+
transferredBy,
|
|
98
|
+
});
|
|
99
|
+
expect(movedCount).toBe(2);
|
|
100
|
+
|
|
101
|
+
// Events: gone from the source tenant, present under the destination.
|
|
102
|
+
expect(
|
|
103
|
+
await loadAggregate(bun.db, first, sourceTenant, { includeArchived: true }),
|
|
104
|
+
).toHaveLength(0);
|
|
105
|
+
expect(await loadAggregate(bun.db, second, sourceTenant)).toHaveLength(0);
|
|
106
|
+
const firstEvents = await loadAggregate(bun.db, first, destinationTenant, {
|
|
107
|
+
includeArchived: true,
|
|
108
|
+
});
|
|
109
|
+
expect(firstEvents.some((e) => e.type === "counter.incremented")).toBe(true);
|
|
110
|
+
const secondEvents = await loadAggregate(bun.db, second, destinationTenant);
|
|
111
|
+
expect(secondEvents.some((e) => e.type === "counter.incremented")).toBe(true);
|
|
112
|
+
|
|
113
|
+
// Snapshot dropped from the source tenant, not recreated anywhere.
|
|
114
|
+
expect(await loadLatestSnapshot(bun.db, first, sourceTenant)).toBeNull();
|
|
115
|
+
expect(await loadLatestSnapshot(bun.db, first, destinationTenant)).toBeNull();
|
|
116
|
+
|
|
117
|
+
// Archive marker moved with the stream.
|
|
118
|
+
expect(await isStreamArchived(bun.db, sourceTenant, first)).toBe(false);
|
|
119
|
+
expect(await isStreamArchived(bun.db, destinationTenant, first)).toBe(true);
|
|
120
|
+
expect(await isStreamArchived(bun.db, destinationTenant, second)).toBe(false);
|
|
121
|
+
|
|
122
|
+
// One system event per moved aggregate, in the destination tenant.
|
|
123
|
+
const events = await transferredEvents(destinationTenant);
|
|
124
|
+
expect(events).toHaveLength(2);
|
|
125
|
+
const byAggregateId = new Map(events.map((payload) => [payload["aggregateId"], payload]));
|
|
126
|
+
expect(byAggregateId.get(first)).toMatchObject({
|
|
127
|
+
aggregateType: "counter",
|
|
128
|
+
aggregateId: first,
|
|
129
|
+
sourceTenantId: sourceTenant,
|
|
130
|
+
destinationTenantId: destinationTenant,
|
|
131
|
+
});
|
|
132
|
+
expect(byAggregateId.get(second)).toMatchObject({
|
|
133
|
+
aggregateType: "counter",
|
|
134
|
+
aggregateId: second,
|
|
135
|
+
sourceTenantId: sourceTenant,
|
|
136
|
+
destinationTenantId: destinationTenant,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an empty id list is a no-op", async () => {
|
|
141
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
142
|
+
sourceTenantId: sourceTenant,
|
|
143
|
+
destinationTenantId: destinationTenant,
|
|
144
|
+
aggregateType: "counter",
|
|
145
|
+
aggregateIds: [],
|
|
146
|
+
transferredBy,
|
|
147
|
+
});
|
|
148
|
+
expect(movedCount).toBe(0);
|
|
149
|
+
expect(await transferredEvents(destinationTenant)).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("an id with no events under this tenant/aggregateType is left untouched and gets no event", async () => {
|
|
153
|
+
const untouched = uuid();
|
|
154
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
155
|
+
sourceTenantId: sourceTenant,
|
|
156
|
+
destinationTenantId: destinationTenant,
|
|
157
|
+
aggregateType: "counter",
|
|
158
|
+
aggregateIds: [untouched],
|
|
159
|
+
transferredBy,
|
|
160
|
+
});
|
|
161
|
+
expect(movedCount).toBe(0);
|
|
162
|
+
expect(await loadAggregate(bun.db, untouched, sourceTenant)).toHaveLength(0);
|
|
163
|
+
expect(await loadAggregate(bun.db, untouched, destinationTenant)).toHaveLength(0);
|
|
164
|
+
expect(await transferredEvents(destinationTenant)).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("an aggregate under an unrelated third tenant with the same id is untouched", async () => {
|
|
168
|
+
const sharedId = uuid();
|
|
169
|
+
await append(bun.db, {
|
|
170
|
+
aggregateId: sharedId,
|
|
171
|
+
aggregateType: "counter",
|
|
172
|
+
tenantId: otherTenant,
|
|
173
|
+
expectedVersion: 0,
|
|
174
|
+
type: "counter.incremented",
|
|
175
|
+
payload: { by: 1 },
|
|
176
|
+
metadata: { userId },
|
|
177
|
+
});
|
|
178
|
+
await append(bun.db, {
|
|
179
|
+
aggregateId: sharedId,
|
|
180
|
+
aggregateType: "counter",
|
|
181
|
+
tenantId: sourceTenant,
|
|
182
|
+
expectedVersion: 0,
|
|
183
|
+
type: "counter.incremented",
|
|
184
|
+
payload: { by: 1 },
|
|
185
|
+
metadata: { userId },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await transferAggregateStreams(bun.db, {
|
|
189
|
+
sourceTenantId: sourceTenant,
|
|
190
|
+
destinationTenantId: destinationTenant,
|
|
191
|
+
aggregateType: "counter",
|
|
192
|
+
aggregateIds: [sharedId],
|
|
193
|
+
transferredBy,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const otherTenantEvents = await loadAggregate(bun.db, sharedId, otherTenant);
|
|
197
|
+
expect(otherTenantEvents).toHaveLength(1);
|
|
198
|
+
expect(await loadAggregate(bun.db, sharedId, sourceTenant)).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("an archived marker for a different aggregate type sharing the moved aggregate's id stays in the source tenant", async () => {
|
|
202
|
+
const sharedId = uuid();
|
|
203
|
+
// `append()` shares its version-conflict check across the plain
|
|
204
|
+
// aggregateId, so a real "widget" stream can't coexist with a real
|
|
205
|
+
// "counter" stream under the same id/tenant — fabricate the archived
|
|
206
|
+
// marker directly (the primary verifier of the event-store primitive,
|
|
207
|
+
// same reasoning as guard-event-store-writes' own __tests__ exclusion)
|
|
208
|
+
// to prove the WHERE clause's aggregate_type filter, not the stream's
|
|
209
|
+
// version-numbering.
|
|
210
|
+
await asRawClient(bun.db).unsafe(
|
|
211
|
+
`INSERT INTO "kumiko_archived_streams" ("tenant_id", "aggregate_id", "aggregate_type", "archived_by") VALUES ($1, $2, $3, $4)`,
|
|
212
|
+
[sourceTenant, sharedId, "widget", "ops"],
|
|
213
|
+
);
|
|
214
|
+
await append(bun.db, {
|
|
215
|
+
aggregateId: sharedId,
|
|
216
|
+
aggregateType: "counter",
|
|
217
|
+
tenantId: sourceTenant,
|
|
218
|
+
expectedVersion: 0,
|
|
219
|
+
type: "counter.incremented",
|
|
220
|
+
payload: { by: 1 },
|
|
221
|
+
metadata: { userId },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
225
|
+
sourceTenantId: sourceTenant,
|
|
226
|
+
destinationTenantId: destinationTenant,
|
|
227
|
+
aggregateType: "counter",
|
|
228
|
+
aggregateIds: [sharedId],
|
|
229
|
+
transferredBy,
|
|
230
|
+
});
|
|
231
|
+
expect(movedCount).toBe(1);
|
|
232
|
+
|
|
233
|
+
expect(await isStreamArchived(bun.db, sourceTenant, sharedId)).toBe(true);
|
|
234
|
+
expect(await isStreamArchived(bun.db, destinationTenant, sharedId)).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/event-store/index.ts
CHANGED
|
@@ -53,6 +53,12 @@ export {
|
|
|
53
53
|
saveSnapshot,
|
|
54
54
|
snapshotsTable,
|
|
55
55
|
} from "./snapshot";
|
|
56
|
+
export {
|
|
57
|
+
AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
58
|
+
AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
59
|
+
type TransferAggregateStreamsArgs,
|
|
60
|
+
transferAggregateStreams,
|
|
61
|
+
} from "./transfer";
|
|
56
62
|
export {
|
|
57
63
|
type EventUpcasters,
|
|
58
64
|
makeUpcastCtx,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The event store's own tenant-transfer primitive: the ONE place allowed to
|
|
2
|
+
// move a stream across the tenant boundary (enforced by
|
|
3
|
+
// guard-event-store-writes' allowlist). Each move leaves an audit event in
|
|
4
|
+
// the destination tenant.
|
|
5
|
+
//
|
|
6
|
+
// Runs entirely on the caller-supplied DbRunner, so the move rides whatever
|
|
7
|
+
// transaction the caller is already inside (tenant-handover's claim handler
|
|
8
|
+
// commits it together with the root row's ownership write — see
|
|
9
|
+
// move-entity-graph.ts's file header for why that matters).
|
|
10
|
+
|
|
11
|
+
import type { DbRunner } from "../db";
|
|
12
|
+
import {
|
|
13
|
+
deleteSnapshotsForAggregates,
|
|
14
|
+
transferArchivedStreamRows,
|
|
15
|
+
transferEventRows,
|
|
16
|
+
} from "../db/queries/event-store-transfer";
|
|
17
|
+
import type { TenantId } from "../engine/types";
|
|
18
|
+
import { SYSTEM_EVENT_PREFIX } from "../pipeline/append-event-core";
|
|
19
|
+
import { generateId } from "../utils/ids";
|
|
20
|
+
import { append } from "./event-store";
|
|
21
|
+
|
|
22
|
+
export const AGGREGATE_TRANSFER_STREAM_TYPE = "aggregate-transfer";
|
|
23
|
+
export const AGGREGATE_TRANSFERRED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}aggregate.transferred`;
|
|
24
|
+
|
|
25
|
+
export type TransferAggregateStreamsArgs = {
|
|
26
|
+
readonly sourceTenantId: TenantId;
|
|
27
|
+
readonly destinationTenantId: TenantId;
|
|
28
|
+
readonly aggregateType: string;
|
|
29
|
+
readonly aggregateIds: readonly string[];
|
|
30
|
+
readonly transferredBy: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Moves one aggregate type's event history, drops its cached snapshots, and
|
|
34
|
+
// carries any archive marker across the tenant boundary. Returns the number
|
|
35
|
+
// of aggregates that actually had events (and therefore moved) — an id in
|
|
36
|
+
// `aggregateIds` with no events under `sourceTenantId`/`aggregateType`
|
|
37
|
+
// contributes nothing and gets no follow-up event.
|
|
38
|
+
export async function transferAggregateStreams(
|
|
39
|
+
db: DbRunner,
|
|
40
|
+
args: TransferAggregateStreamsArgs,
|
|
41
|
+
): Promise<number> {
|
|
42
|
+
if (args.aggregateIds.length === 0) return 0;
|
|
43
|
+
|
|
44
|
+
const movedAggregateIds = await transferEventRows(db, {
|
|
45
|
+
sourceTenantId: args.sourceTenantId,
|
|
46
|
+
destinationTenantId: args.destinationTenantId,
|
|
47
|
+
aggregateType: args.aggregateType,
|
|
48
|
+
aggregateIds: args.aggregateIds,
|
|
49
|
+
});
|
|
50
|
+
if (movedAggregateIds.length === 0) return 0;
|
|
51
|
+
|
|
52
|
+
await deleteSnapshotsForAggregates(db, {
|
|
53
|
+
tenantId: args.sourceTenantId,
|
|
54
|
+
aggregateIds: movedAggregateIds,
|
|
55
|
+
});
|
|
56
|
+
await transferArchivedStreamRows(db, {
|
|
57
|
+
sourceTenantId: args.sourceTenantId,
|
|
58
|
+
destinationTenantId: args.destinationTenantId,
|
|
59
|
+
aggregateType: args.aggregateType,
|
|
60
|
+
aggregateIds: movedAggregateIds,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const aggregateId of movedAggregateIds) {
|
|
64
|
+
// A fresh, own stream — not an append onto the moved aggregate's own
|
|
65
|
+
// stream — so the marker never bumps the moved aggregate's version or
|
|
66
|
+
// shows up when a projection replays that aggregate's history.
|
|
67
|
+
await append(db, {
|
|
68
|
+
aggregateId: generateId(),
|
|
69
|
+
aggregateType: AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
70
|
+
tenantId: args.destinationTenantId,
|
|
71
|
+
expectedVersion: 0,
|
|
72
|
+
type: AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
73
|
+
payload: {
|
|
74
|
+
aggregateType: args.aggregateType,
|
|
75
|
+
aggregateId,
|
|
76
|
+
sourceTenantId: args.sourceTenantId,
|
|
77
|
+
destinationTenantId: args.destinationTenantId,
|
|
78
|
+
},
|
|
79
|
+
metadata: { userId: args.transferredBy },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return movedAggregateIds.length;
|
|
84
|
+
}
|
|
@@ -76,3 +76,38 @@ describe("createI18n", () => {
|
|
|
76
76
|
expect(keys).toContain("adminUsers:field.email");
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
describe("createI18n — plural forms", () => {
|
|
81
|
+
const notificationsFeature = defineFeature("notifications", (r) => {
|
|
82
|
+
r.translations({
|
|
83
|
+
keys: {
|
|
84
|
+
"notifications.unread": {
|
|
85
|
+
de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
|
|
86
|
+
en: { one: "{count} unread message", other: "{count} unread messages" },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("resolves the CLDR category for the given locale and count", () => {
|
|
93
|
+
const registry = createRegistry([notificationsFeature]);
|
|
94
|
+
const i18n = createI18n(registry, { defaultLocale: "en" });
|
|
95
|
+
|
|
96
|
+
expect(i18n.t("notifications:notifications.unread", "en", { count: 1 })).toBe(
|
|
97
|
+
"1 unread message",
|
|
98
|
+
);
|
|
99
|
+
expect(i18n.t("notifications:notifications.unread", "en", { count: 5 })).toBe(
|
|
100
|
+
"5 unread messages",
|
|
101
|
+
);
|
|
102
|
+
expect(i18n.t("notifications:notifications.unread", "de", { count: 1 })).toBe(
|
|
103
|
+
"1 ungelesene Nachricht",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("falls back to `other` when no params are given", () => {
|
|
108
|
+
const registry = createRegistry([notificationsFeature]);
|
|
109
|
+
const i18n = createI18n(registry, { defaultLocale: "en" });
|
|
110
|
+
|
|
111
|
+
expect(i18n.t("notifications:notifications.unread", "en")).toBe("{count} unread messages");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -33,3 +33,22 @@ test("resolveMailLocale prefers exact, then root, then en", () => {
|
|
|
33
33
|
expect(resolveMailLocale("de-AT")).toBe("de");
|
|
34
34
|
expect(resolveMailLocale("fr-CA")).toBe("en");
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
describe("mailT — plural forms", () => {
|
|
38
|
+
registerMailTranslations("en", {
|
|
39
|
+
"test.unread": { one: "{count} unread message", other: "{count} unread messages" },
|
|
40
|
+
});
|
|
41
|
+
registerMailTranslations("de", {
|
|
42
|
+
"test.unread": { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("resolves the CLDR category for the count", () => {
|
|
46
|
+
expect(mailT("en", "test.unread", { count: 1 })).toBe("1 unread message");
|
|
47
|
+
expect(mailT("en", "test.unread", { count: 5 })).toBe("5 unread messages");
|
|
48
|
+
expect(mailT("de", "test.unread", { count: 1 })).toBe("1 ungelesene Nachricht");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("uses the fallback locale's own plural rules, not the requested locale's", () => {
|
|
52
|
+
expect(mailT("fr", "test.unread", { count: 1 })).toBe("1 unread message");
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/i18n/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Registry, TranslationKeys } from "../engine/types";
|
|
2
|
+
import { resolveTranslationValue } from "../ui-types/plural";
|
|
2
3
|
|
|
3
4
|
export {
|
|
4
5
|
hasMailTranslations,
|
|
@@ -20,7 +21,7 @@ export type I18nOptions = {
|
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
export type I18n = {
|
|
23
|
-
t(key: string, locale?: string): string;
|
|
24
|
+
t(key: string, locale?: string, params?: Readonly<Record<string, unknown>>): string;
|
|
24
25
|
getAllKeys(): string[];
|
|
25
26
|
};
|
|
26
27
|
|
|
@@ -29,12 +30,18 @@ export function createI18n(registry: Registry, options: I18nOptions): I18n {
|
|
|
29
30
|
const { defaultLocale } = options;
|
|
30
31
|
|
|
31
32
|
return {
|
|
32
|
-
t(key: string, locale?: string): string {
|
|
33
|
+
t(key: string, locale?: string, params?: Readonly<Record<string, unknown>>): string {
|
|
33
34
|
const entry = translations[key];
|
|
34
35
|
if (!entry) return key;
|
|
35
36
|
|
|
36
|
-
const
|
|
37
|
-
|
|
37
|
+
const requestedLocale = locale ?? defaultLocale;
|
|
38
|
+
// Plural category selection needs the locale the value actually came
|
|
39
|
+
// from, not the requested one — falling back to defaultLocale must
|
|
40
|
+
// apply defaultLocale's plural rules to that defaultLocale text.
|
|
41
|
+
const hitLocale = entry[requestedLocale] !== undefined ? requestedLocale : defaultLocale;
|
|
42
|
+
const value = entry[hitLocale];
|
|
43
|
+
if (value === undefined) return key;
|
|
44
|
+
return resolveTranslationValue(value, hitLocale, params);
|
|
38
45
|
},
|
|
39
46
|
|
|
40
47
|
getAllKeys(): string[] {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
import { resolveTranslationValue, type TranslationValue } from "../ui-types/plural";
|
|
2
|
+
|
|
3
|
+
const tables = new Map<string, Readonly<Record<string, TranslationValue>>>();
|
|
2
4
|
|
|
3
5
|
export function registerMailTranslations(
|
|
4
6
|
locale: string,
|
|
5
|
-
bundle: Readonly<Record<string,
|
|
7
|
+
bundle: Readonly<Record<string, TranslationValue>>,
|
|
6
8
|
): void {
|
|
7
9
|
const prev = tables.get(locale) ?? {};
|
|
8
10
|
tables.set(locale, { ...prev, ...bundle });
|
|
@@ -16,13 +18,17 @@ export function hasMailTranslations(locale: string): boolean {
|
|
|
16
18
|
export function mailT(
|
|
17
19
|
locale: string,
|
|
18
20
|
key: string,
|
|
19
|
-
params?: Readonly<Record<string, string>>,
|
|
21
|
+
params?: Readonly<Record<string, string | number>>,
|
|
20
22
|
): string {
|
|
21
23
|
const root = locale.split("-")[0] ?? locale;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Plural category selection needs the locale the value actually came
|
|
25
|
+
// from, not the requested one — a `pl` bundle falling back to `en`
|
|
26
|
+
// must apply English plural rules to that `en` text.
|
|
27
|
+
for (const localeToTry of [locale, root, "en"]) {
|
|
28
|
+
const value = tables.get(localeToTry)?.[key];
|
|
29
|
+
if (value !== undefined) return resolveTranslationValue(value, localeToTry, params);
|
|
30
|
+
}
|
|
31
|
+
return key;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
/** Locale key mailT would actually use (exact → root → en). Use for appUrl
|
|
@@ -102,7 +102,9 @@ const archFeature = defineFeature("archtest", (r) => {
|
|
|
102
102
|
"item:delete",
|
|
103
103
|
z.object({ id: z.uuid() }),
|
|
104
104
|
async (event, ctx) => executor.delete({ id: event.payload.id }, event.user, ctx.db),
|
|
105
|
-
|
|
105
|
+
// arch-item has no softDelete — a hard delete needs agent.risk: "high"
|
|
106
|
+
// to pass the irreversible-operation gate.
|
|
107
|
+
{ access: { roles: ["Admin"] }, agent: { risk: "high" } },
|
|
106
108
|
);
|
|
107
109
|
|
|
108
110
|
r.queryHandler(
|