@cosmicdrift/kumiko-framework 0.197.1 → 0.199.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/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +128 -1
- package/src/api/__tests__/sse-route.test.ts +129 -0
- package/src/api/auth-routes.ts +21 -6
- package/src/api/server.ts +44 -0
- package/src/api/sse-route.ts +13 -1
- package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
- package/src/bun-db/query.ts +5 -1
- package/src/db/__tests__/compound-types.test.ts +12 -2
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
- package/src/db/__tests__/money.test.ts +49 -18
- package/src/db/__tests__/unchecked-system-db.test.ts +117 -0
- package/src/db/dialect.ts +13 -2
- package/src/db/event-store-executor-read.ts +12 -1
- package/src/db/index.ts +7 -2
- package/src/db/money.ts +35 -15
- package/src/db/table-builder.ts +7 -1
- package/src/db/tenant-db.ts +54 -2
- package/src/derivatives/derivatives-context.ts +9 -0
- package/src/engine/__tests__/build-app-schema.test.ts +50 -0
- package/src/engine/__tests__/nav.test.ts +12 -4
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
- package/src/engine/build-app-schema.ts +6 -0
- package/src/engine/build-config-feature-schema.ts +2 -2
- package/src/engine/index.ts +2 -1
- package/src/engine/registry-facade.ts +6 -0
- package/src/engine/registry-ingest.ts +1 -0
- package/src/engine/registry-state.ts +2 -0
- package/src/engine/types/index.ts +7 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
- package/src/entrypoint/index.ts +20 -3
- package/src/files/__tests__/files.integration.test.ts +16 -0
- package/src/files/file-routes.ts +12 -1
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +152 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
- package/src/jobs/job-runner.ts +42 -3
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +67 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
- package/src/pipeline/dispatch-batch.ts +2 -2
- package/src/pipeline/dispatch-shared.ts +3 -1
- package/src/pipeline/idempotency.ts +11 -6
- package/src/ui-types/app-schema.ts +10 -0
- package/src/ui-types/index.ts +1 -1
|
@@ -99,23 +99,42 @@ describe("flattenMoney — Insert/Update Convert (major units → minor units)",
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
describe("rehydrateMoney — Read Convert (minor units → major units)", () => {
|
|
102
|
-
test("{ <name>:
|
|
102
|
+
test("{ <name>: scaledUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency, amountScaled, amountMinor } }", () => {
|
|
103
103
|
const out = rehydrateMoney({ buyingPrice: 45000, buyingPriceCurrency: "EUR" }, orderEntity);
|
|
104
|
-
expect(out).toEqual({
|
|
104
|
+
expect(out).toEqual({
|
|
105
|
+
buyingPrice: { amount: 450, currency: "EUR", amountScaled: 45000, amountMinor: 45000 },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("amountMinor is a deprecated alias of amountScaled, same value", () => {
|
|
110
|
+
const out = rehydrateMoney({ buyingPrice: 45000, buyingPriceCurrency: "EUR" }, orderEntity)[
|
|
111
|
+
"buyingPrice"
|
|
112
|
+
] as MoneyRead;
|
|
113
|
+
expect(out.amountMinor).toBe(out.amountScaled);
|
|
105
114
|
});
|
|
106
115
|
|
|
107
116
|
test("PG-BIGINT als String wird zu number gecastet", () => {
|
|
108
117
|
// Postgres-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
109
118
|
const out = rehydrateMoney({ buyingPrice: "45000", buyingPriceCurrency: "EUR" }, orderEntity);
|
|
110
|
-
expect(out["buyingPrice"]).toEqual({
|
|
119
|
+
expect(out["buyingPrice"]).toEqual({
|
|
120
|
+
amount: 450,
|
|
121
|
+
currency: "EUR",
|
|
122
|
+
amountScaled: 45000,
|
|
123
|
+
amountMinor: 45000,
|
|
124
|
+
});
|
|
111
125
|
});
|
|
112
126
|
|
|
113
127
|
test("fehlende Currency-Spalte fällt auf entity.defaultCurrency", () => {
|
|
114
128
|
const out = rehydrateMoney({ buyingPrice: 45000 }, orderEntity);
|
|
115
|
-
expect(out["buyingPrice"]).toEqual({
|
|
129
|
+
expect(out["buyingPrice"]).toEqual({
|
|
130
|
+
amount: 450,
|
|
131
|
+
currency: "EUR",
|
|
132
|
+
amountScaled: 45000,
|
|
133
|
+
amountMinor: 45000,
|
|
134
|
+
});
|
|
116
135
|
});
|
|
117
136
|
|
|
118
|
-
test("
|
|
137
|
+
test("amountScaled bleibt exakter Integer über mehrere Additionen (fw#1830)", () => {
|
|
119
138
|
const rows = [10, 20, 30].map(
|
|
120
139
|
(minor) =>
|
|
121
140
|
rehydrateMoney({ buyingPrice: minor, buyingPriceCurrency: "EUR" }, orderEntity)[
|
|
@@ -124,7 +143,7 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
124
143
|
);
|
|
125
144
|
const [a, b, c] = rows;
|
|
126
145
|
|
|
127
|
-
expect(a!.
|
|
146
|
+
expect(a!.amountScaled + b!.amountScaled).toBe(c!.amountScaled);
|
|
128
147
|
});
|
|
129
148
|
|
|
130
149
|
test("null/undefined amount → Field wird aus Output entfernt", () => {
|
|
@@ -143,12 +162,12 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
143
162
|
orderEntity,
|
|
144
163
|
);
|
|
145
164
|
expect(out).toEqual({
|
|
146
|
-
buyingPrice: { amount: 450, currency: "EUR", amountMinor: 45000 },
|
|
147
|
-
sellingPrice: { amount: 600, currency: "USD", amountMinor: 60000 },
|
|
165
|
+
buyingPrice: { amount: 450, currency: "EUR", amountScaled: 45000, amountMinor: 45000 },
|
|
166
|
+
sellingPrice: { amount: 600, currency: "USD", amountScaled: 60000, amountMinor: 60000 },
|
|
148
167
|
});
|
|
149
168
|
});
|
|
150
169
|
|
|
151
|
-
test("Round-Trip: flatten dann rehydrate ergibt dasselbe amount/currency, plus
|
|
170
|
+
test("Round-Trip: flatten dann rehydrate ergibt dasselbe amount/currency, plus amountScaled", () => {
|
|
152
171
|
const original = {
|
|
153
172
|
buyingPrice: { amount: 450.5, currency: "EUR" },
|
|
154
173
|
sellingPrice: { amount: 56799.16, currency: "USD" },
|
|
@@ -156,15 +175,25 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
156
175
|
const flat = flattenMoney(original, orderEntity);
|
|
157
176
|
const rehydrated = rehydrateMoney(flat, orderEntity);
|
|
158
177
|
expect(rehydrated).toEqual({
|
|
159
|
-
buyingPrice: { amount: 450.5, currency: "EUR", amountMinor: 45050 },
|
|
160
|
-
sellingPrice: {
|
|
178
|
+
buyingPrice: { amount: 450.5, currency: "EUR", amountScaled: 45050, amountMinor: 45050 },
|
|
179
|
+
sellingPrice: {
|
|
180
|
+
amount: 56799.16,
|
|
181
|
+
currency: "USD",
|
|
182
|
+
amountScaled: 5679916,
|
|
183
|
+
amountMinor: 5679916,
|
|
184
|
+
},
|
|
161
185
|
});
|
|
162
186
|
});
|
|
163
187
|
|
|
164
|
-
test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR,
|
|
188
|
+
test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR, amountScaled:45000 }", () => {
|
|
165
189
|
const flat = flattenMoney({ buyingPrice: 450 }, orderEntity);
|
|
166
190
|
const out = rehydrateMoney(flat, orderEntity);
|
|
167
|
-
expect(out["buyingPrice"]).toEqual({
|
|
191
|
+
expect(out["buyingPrice"]).toEqual({
|
|
192
|
+
amount: 450,
|
|
193
|
+
currency: "EUR",
|
|
194
|
+
amountScaled: 45000,
|
|
195
|
+
amountMinor: 45000,
|
|
196
|
+
});
|
|
168
197
|
});
|
|
169
198
|
|
|
170
199
|
test("ist pure — input wird nicht mutiert", () => {
|
|
@@ -180,7 +209,7 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
180
209
|
).toThrow(/not a safe integer — DB corruption/);
|
|
181
210
|
});
|
|
182
211
|
|
|
183
|
-
test("fractional string amount (fw#1833) → loud throw statt
|
|
212
|
+
test("fractional string amount (fw#1833) → loud throw statt amountScaled mit Nachkommastelle", () => {
|
|
184
213
|
expect(() =>
|
|
185
214
|
rehydrateMoney({ buyingPrice: "45000.7", buyingPriceCurrency: "EUR" }, orderEntity),
|
|
186
215
|
).toThrow(/not a safe integer — DB corruption/);
|
|
@@ -208,7 +237,9 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
|
|
|
208
237
|
|
|
209
238
|
// DB liefert dieselben Spalten zurück
|
|
210
239
|
const out = rehydrateMoney(flat, orderEntity);
|
|
211
|
-
expect(out).toEqual({
|
|
240
|
+
expect(out).toEqual({
|
|
241
|
+
buyingPrice: { amount: 990, currency: "USD", amountScaled: 99_000, amountMinor: 99_000 },
|
|
242
|
+
});
|
|
212
243
|
});
|
|
213
244
|
|
|
214
245
|
test("List-Pfad: mehrere Rows hintereinander rehydraten", () => {
|
|
@@ -219,9 +250,9 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
|
|
|
219
250
|
];
|
|
220
251
|
const apiRows = dbRows.map((r) => rehydrateMoney(r, orderEntity));
|
|
221
252
|
expect(apiRows).toEqual([
|
|
222
|
-
{ buyingPrice: { amount: 1, currency: "EUR", amountMinor: 100 } },
|
|
223
|
-
{ buyingPrice: { amount: 2, currency: "USD", amountMinor: 200 } },
|
|
224
|
-
{ buyingPrice: { amount: 3, currency: "GBP", amountMinor: 300 } },
|
|
253
|
+
{ buyingPrice: { amount: 1, currency: "EUR", amountScaled: 100, amountMinor: 100 } },
|
|
254
|
+
{ buyingPrice: { amount: 2, currency: "USD", amountScaled: 200, amountMinor: 200 } },
|
|
255
|
+
{ buyingPrice: { amount: 3, currency: "GBP", amountScaled: 300, amountMinor: 300 } },
|
|
225
256
|
]);
|
|
226
257
|
});
|
|
227
258
|
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SYSTEM_TENANT_ID } from "../../engine";
|
|
3
|
+
import { testTenantId } from "../../stack";
|
|
4
|
+
import type { DbRunner } from "../connection";
|
|
5
|
+
import { createTenantDb, createUncheckedSystemDb, SYSTEM_SCOPE_CHECK_BRAND } from "../tenant-db";
|
|
6
|
+
|
|
7
|
+
// createUncheckedSystemDb wraps a "system"-mode TenantDb (r.systemScope())
|
|
8
|
+
// so a handler must explicitly clear a self-check before using it — none of
|
|
9
|
+
// these checks execute a query, so a runner that always throws is enough to
|
|
10
|
+
// prove the wrapper never falls through to the DB on a mismatch.
|
|
11
|
+
function unusedRunner(): DbRunner {
|
|
12
|
+
return {
|
|
13
|
+
unsafe: async () => {
|
|
14
|
+
throw new Error("unchecked-system-db tests must not reach the DB");
|
|
15
|
+
},
|
|
16
|
+
begin: async () => {
|
|
17
|
+
throw new Error("unchecked-system-db tests must not reach the DB");
|
|
18
|
+
},
|
|
19
|
+
} as DbRunner;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const own = testTenantId(1);
|
|
23
|
+
const foreign = testTenantId(2);
|
|
24
|
+
|
|
25
|
+
describe("createUncheckedSystemDb", () => {
|
|
26
|
+
test("carries the SYSTEM_SCOPE_CHECK_BRAND", () => {
|
|
27
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
28
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
29
|
+
|
|
30
|
+
expect(unchecked[SYSTEM_SCOPE_CHECK_BRAND]).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("assertTenantMatch", () => {
|
|
34
|
+
test("returns the underlying TenantDb when the tenantId matches", () => {
|
|
35
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
36
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
37
|
+
|
|
38
|
+
expect(unchecked.assertTenantMatch(own)).toBe(systemDb);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("throws AccessDeniedError when the tenantId doesn't match", () => {
|
|
42
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
43
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
44
|
+
|
|
45
|
+
expect(() => unchecked.assertTenantMatch(foreign)).toThrow(/tenant self-check failed/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("assertRowsTenant", () => {
|
|
50
|
+
test("returns the rows unchanged when every row matches", () => {
|
|
51
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
52
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
53
|
+
const rows = [
|
|
54
|
+
{ tenantId: own, name: "a" },
|
|
55
|
+
{ tenantId: own, name: "b" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
expect(unchecked.assertRowsTenant(rows, "tenantId")).toBe(rows);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("throws AccessDeniedError on the first mismatched row instead of filtering", () => {
|
|
62
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
63
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
64
|
+
const rows = [
|
|
65
|
+
{ tenantId: own, name: "a" },
|
|
66
|
+
{ tenantId: foreign, name: "b" },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
expect(() => unchecked.assertRowsTenant(rows, "tenantId")).toThrow(
|
|
70
|
+
/row tenant self-check failed/,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("an empty row array trivially passes", () => {
|
|
75
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
76
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
77
|
+
|
|
78
|
+
expect(unchecked.assertRowsTenant([], "tenantId")).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("accepts SYSTEM_TENANT_ID rows as reference data, mirroring tenant-mode readWhere", () => {
|
|
82
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
83
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
84
|
+
const rows = [
|
|
85
|
+
{ tenantId: own, name: "a" },
|
|
86
|
+
{ tenantId: SYSTEM_TENANT_ID, name: "global-default" },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
expect(unchecked.assertRowsTenant(rows, "tenantId")).toBe(rows);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("acknowledgeCrossTenant", () => {
|
|
94
|
+
test("returns the underlying TenantDb without comparing tenants when given a reason", () => {
|
|
95
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
96
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
97
|
+
|
|
98
|
+
expect(unchecked.acknowledgeCrossTenant("user feature is cross-tenant by design")).toBe(
|
|
99
|
+
systemDb,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("throws on an empty reason", () => {
|
|
104
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
105
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
106
|
+
|
|
107
|
+
expect(() => unchecked.acknowledgeCrossTenant("")).toThrow(/non-empty reason/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("throws on a whitespace-only reason", () => {
|
|
111
|
+
const systemDb = createTenantDb(unusedRunner(), own, "system");
|
|
112
|
+
const unchecked = createUncheckedSystemDb(systemDb);
|
|
113
|
+
|
|
114
|
+
expect(() => unchecked.acknowledgeCrossTenant(" ")).toThrow(/non-empty reason/);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
package/src/db/dialect.ts
CHANGED
|
@@ -377,10 +377,16 @@ export function primaryKey(opts: {
|
|
|
377
377
|
// Limits: no nested SqlExpression composition (drizzle's recursive
|
|
378
378
|
// `sql\`${other}\``) — schema-files use single-level expressions only.
|
|
379
379
|
|
|
380
|
+
// Unforgeable via JSON — a client-supplied jsonb value can fake `kind:
|
|
381
|
+
// "sql-expr"` but can never carry a Symbol, so isSqlExpression() (bun-db/query.ts)
|
|
382
|
+
// can't be tricked into treating request data as a raw SQL literal.
|
|
383
|
+
export const SQL_EXPR_BRAND: unique symbol = Symbol("sql-expr");
|
|
384
|
+
|
|
380
385
|
export type SqlExpression = {
|
|
381
386
|
readonly kind: "sql-expr";
|
|
382
387
|
readonly text: string;
|
|
383
388
|
readonly params: readonly unknown[];
|
|
389
|
+
readonly [SQL_EXPR_BRAND]: true;
|
|
384
390
|
};
|
|
385
391
|
|
|
386
392
|
export function sql(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlExpression {
|
|
@@ -397,10 +403,15 @@ export function sql(strings: TemplateStringsArray, ...values: readonly unknown[]
|
|
|
397
403
|
}
|
|
398
404
|
}
|
|
399
405
|
}
|
|
400
|
-
return { kind: "sql-expr", text: parts.join(""), params };
|
|
406
|
+
return { kind: "sql-expr", text: parts.join(""), params, [SQL_EXPR_BRAND]: true };
|
|
401
407
|
}
|
|
402
408
|
|
|
403
|
-
sql.raw = (text: string): SqlExpression => ({
|
|
409
|
+
sql.raw = (text: string): SqlExpression => ({
|
|
410
|
+
kind: "sql-expr",
|
|
411
|
+
text,
|
|
412
|
+
params: [],
|
|
413
|
+
[SQL_EXPR_BRAND]: true,
|
|
414
|
+
});
|
|
404
415
|
|
|
405
416
|
// ---- table() — the schema-table factory ----
|
|
406
417
|
//
|
|
@@ -5,6 +5,7 @@ import { coerceRow, extractTableInfo } from "../db/query";
|
|
|
5
5
|
import { buildOwnershipClause, shiftParams } from "../engine/ownership";
|
|
6
6
|
import type { EntityId } from "../engine/types";
|
|
7
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
8
|
+
import { UnprocessableError } from "../errors";
|
|
8
9
|
import { getStreamVersion } from "../event-store";
|
|
9
10
|
import { rehydrateCompoundTypes } from "./compound-types";
|
|
10
11
|
import { decodeCursor, encodeCursor } from "./cursor";
|
|
@@ -54,7 +55,17 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
54
55
|
// ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
|
|
55
56
|
// beim Definition-Time noch keinen Server-Context hat).
|
|
56
57
|
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
57
|
-
if (payload.search
|
|
58
|
+
if (payload.search) {
|
|
59
|
+
// #2032 — a search term with no adapter wired must fail loud, not
|
|
60
|
+
// silently return the unfiltered list dressed up as a search result.
|
|
61
|
+
if (!effectiveSearchAdapter) {
|
|
62
|
+
throw new UnprocessableError("search_adapter_not_wired", {
|
|
63
|
+
details: {
|
|
64
|
+
entity: entityName,
|
|
65
|
+
hint: "Wire a SearchAdapter for this entity, or remove `searchable` from the field/screen.",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
58
69
|
const results = await effectiveSearchAdapter.search(user.tenantId, payload.search, {
|
|
59
70
|
filterType: entityName,
|
|
60
71
|
});
|
package/src/db/index.ts
CHANGED
|
@@ -143,5 +143,10 @@ export {
|
|
|
143
143
|
toSnakeCase,
|
|
144
144
|
toTableName,
|
|
145
145
|
} from "./table-builder";
|
|
146
|
-
export type { TenantDb, TenantDbMode } from "./tenant-db";
|
|
147
|
-
export {
|
|
146
|
+
export type { TenantDb, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
|
|
147
|
+
export {
|
|
148
|
+
castTenantRows,
|
|
149
|
+
createTenantDb,
|
|
150
|
+
createUncheckedSystemDb,
|
|
151
|
+
SYSTEM_SCOPE_CHECK_BRAND,
|
|
152
|
+
} from "./tenant-db";
|
package/src/db/money.ts
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
// Vertrag (siehe auch db/located-timestamp.ts — gleicher Compound-Type-Pattern):
|
|
4
4
|
// API-Form: { amount, currency } | number — amount in MAJOR units (56799.16 EUR)
|
|
5
5
|
// DB-Form: <name> BIGINT (minor units, e.g. cents) + <name>Currency TEXT
|
|
6
|
-
// Read-Form: { amount, currency,
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// Read-Form: { amount, currency, amountScaled } — amount in MAJOR units again,
|
|
7
|
+
// amountScaled sits alongside as the exact integer value in
|
|
8
|
+
// MINOR_UNIT_SCALE units (fw#1830) for callers that need
|
|
9
|
+
// scale-exact comparisons (e.g. invoice sums) instead of
|
|
10
|
+
// round-tripping the float amount through /100·*100.
|
|
11
|
+
// `amountMinor` stays as a @deprecated alias of the same
|
|
12
|
+
// value until #1976/4 and #1976/5 migrate their remaining
|
|
13
|
+
// consumers (renderer-web/primitives/index.tsx,
|
|
14
|
+
// renderer/components/render-field.tsx) off the old name.
|
|
10
15
|
//
|
|
11
16
|
// table-builder.ts's moneyAmount column has always documented BIGINT as
|
|
12
17
|
// "the integer minor unit" — this file used to just pass the API amount
|
|
@@ -40,8 +45,8 @@ export function toMinorUnits(amount: number): number {
|
|
|
40
45
|
return Math.round(amount * MINOR_UNIT_SCALE);
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
function toMajorUnits(
|
|
44
|
-
return
|
|
48
|
+
function toMajorUnits(amountScaled: number): number {
|
|
49
|
+
return amountScaled / MINOR_UNIT_SCALE;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
// One money field's write payload — `{ amount, currency }` or a bare number
|
|
@@ -123,11 +128,20 @@ export function flattenMoney(
|
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
/** Shape of a single rehydrated money field — {amount major, currency,
|
|
126
|
-
*
|
|
127
|
-
* copy against this instead of re-declaring the
|
|
131
|
+
* amountScaled exact integer value in MINOR_UNIT_SCALE units}. Exported so
|
|
132
|
+
* consumers type their own copy against this instead of re-declaring the
|
|
133
|
+
* shape by hand. */
|
|
128
134
|
export type MoneyRead = {
|
|
129
135
|
readonly amount: number;
|
|
130
136
|
readonly currency: string;
|
|
137
|
+
readonly amountScaled: number;
|
|
138
|
+
/**
|
|
139
|
+
* @deprecated Use `amountScaled` — this name implies ISO-4217 minor units
|
|
140
|
+
* (cents), which is wrong once a currency needing a different scale than
|
|
141
|
+
* the current flat MINOR_UNIT_SCALE=100 lands (e.g. JPY, 0 decimals).
|
|
142
|
+
* Alias of `amountScaled`, kept until #1976/4 and #1976/5 migrate their
|
|
143
|
+
* consumers off it.
|
|
144
|
+
*/
|
|
131
145
|
readonly amountMinor: number;
|
|
132
146
|
};
|
|
133
147
|
|
|
@@ -159,18 +173,18 @@ export function rehydrateMoney(
|
|
|
159
173
|
continue;
|
|
160
174
|
}
|
|
161
175
|
|
|
162
|
-
let
|
|
176
|
+
let amountScaled: number;
|
|
163
177
|
if (typeof amountRaw === "number") {
|
|
164
|
-
|
|
178
|
+
amountScaled = amountRaw;
|
|
165
179
|
} else if (typeof amountRaw === "bigint") {
|
|
166
|
-
|
|
167
|
-
if (!Number.isSafeInteger(
|
|
180
|
+
amountScaled = Number(amountRaw);
|
|
181
|
+
if (!Number.isSafeInteger(amountScaled)) {
|
|
168
182
|
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
|
|
169
183
|
}
|
|
170
184
|
} else if (typeof amountRaw === "string" && amountRaw !== "") {
|
|
171
185
|
// PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
172
|
-
|
|
173
|
-
if (!Number.isSafeInteger(
|
|
186
|
+
amountScaled = Number(amountRaw);
|
|
187
|
+
if (!Number.isSafeInteger(amountScaled)) {
|
|
174
188
|
throw new Error(
|
|
175
189
|
`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`,
|
|
176
190
|
);
|
|
@@ -184,7 +198,13 @@ export function rehydrateMoney(
|
|
|
184
198
|
const currency =
|
|
185
199
|
typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
|
|
186
200
|
|
|
187
|
-
|
|
201
|
+
// amountMinor: deprecated alias, same value as amountScaled — see MoneyRead.
|
|
202
|
+
result[name] = {
|
|
203
|
+
amount: toMajorUnits(amountScaled),
|
|
204
|
+
currency,
|
|
205
|
+
amountScaled,
|
|
206
|
+
amountMinor: amountScaled,
|
|
207
|
+
};
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
return result;
|
package/src/db/table-builder.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
moneyAmount,
|
|
26
26
|
table as pgTable,
|
|
27
27
|
plainDate,
|
|
28
|
+
SQL_EXPR_BRAND,
|
|
28
29
|
type SqlExpression,
|
|
29
30
|
serial,
|
|
30
31
|
sql,
|
|
@@ -586,7 +587,12 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
586
587
|
.filter((c, i) => c !== def.columns[i])
|
|
587
588
|
.map((c) => `"${toSnakeCase(c)}" IS NOT NULL`)
|
|
588
589
|
.join(" AND ");
|
|
589
|
-
const partialWhere: SqlExpression = {
|
|
590
|
+
const partialWhere: SqlExpression = {
|
|
591
|
+
kind: "sql-expr",
|
|
592
|
+
text: whereText,
|
|
593
|
+
params: [],
|
|
594
|
+
[SQL_EXPR_BRAND]: true,
|
|
595
|
+
};
|
|
590
596
|
indexes[`${indexName}_bidx`] = uniqueIndex(`${indexName}_bidx`)
|
|
591
597
|
.on(...bidxCols)
|
|
592
598
|
.where(partialWhere);
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
SYSTEM_SCOPE_CHECK_BRAND,
|
|
4
|
+
type TenantDb,
|
|
5
|
+
type TenantDbMode,
|
|
6
|
+
type UncheckedSystemDb,
|
|
7
|
+
} from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
3
8
|
import {
|
|
4
9
|
asEntityTableMeta,
|
|
5
10
|
asRawClient,
|
|
@@ -12,12 +17,59 @@ import {
|
|
|
12
17
|
type WhereObject,
|
|
13
18
|
} from "../db/query";
|
|
14
19
|
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
20
|
+
import { AccessDeniedError } from "../errors";
|
|
15
21
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
16
22
|
import type { DbRunner } from "./connection";
|
|
17
23
|
|
|
18
24
|
type Table = SchemaTable;
|
|
19
25
|
|
|
20
|
-
export
|
|
26
|
+
export {
|
|
27
|
+
SYSTEM_SCOPE_CHECK_BRAND,
|
|
28
|
+
type TenantDb,
|
|
29
|
+
type TenantDbMode,
|
|
30
|
+
type UncheckedSystemDb,
|
|
31
|
+
} from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
32
|
+
|
|
33
|
+
// buildHandlerContext (pipeline/dispatch-shared.ts) always builds "system"
|
|
34
|
+
// mode from the caller's own tenantId, never a foreign one.
|
|
35
|
+
export function createUncheckedSystemDb(db: TenantDb): UncheckedSystemDb {
|
|
36
|
+
const allowedTenantIds: readonly TenantId[] = [db.tenantId, SYSTEM_TENANT_ID];
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
[SYSTEM_SCOPE_CHECK_BRAND]: true,
|
|
40
|
+
|
|
41
|
+
assertTenantMatch(tenantId) {
|
|
42
|
+
if (tenantId !== db.tenantId) {
|
|
43
|
+
throw new AccessDeniedError({
|
|
44
|
+
message: `systemScope() tenant self-check failed: expected "${db.tenantId}", got "${tenantId}"`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return db;
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// Fails closed on any mismatch rather than silently dropping rows.
|
|
51
|
+
// Reference rows (tenantId === SYSTEM_TENANT_ID) are allowed, mirroring
|
|
52
|
+
// "tenant"-mode readWhere's own [tenantId, SYSTEM_TENANT_ID] allowlist.
|
|
53
|
+
assertRowsTenant<T>(rows: readonly T[], tenantField: keyof T): readonly T[] {
|
|
54
|
+
const hasOffender = rows.some(
|
|
55
|
+
(row) => !allowedTenantIds.includes(row[tenantField] as TenantId),
|
|
56
|
+
);
|
|
57
|
+
if (hasOffender) {
|
|
58
|
+
throw new AccessDeniedError({
|
|
59
|
+
message: `systemScope() row tenant self-check failed on field "${String(tenantField)}"`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return rows;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
acknowledgeCrossTenant(reason) {
|
|
66
|
+
if (reason.trim().length === 0) {
|
|
67
|
+
throw new Error("acknowledgeCrossTenant requires a non-empty reason");
|
|
68
|
+
}
|
|
69
|
+
return db;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
21
73
|
|
|
22
74
|
// @cast-boundary tenant-db-row
|
|
23
75
|
export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
|
|
@@ -107,6 +107,15 @@ function outputMimeType(spec: VariantSpec, sourceMimeType: string): string {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// `deps.db` is whatever the caller passes through — in the write-handler
|
|
111
|
+
// pipeline (dispatch-shared.ts) that's the handler's open DbTx. `variant()`
|
|
112
|
+
// then holds that transaction open across a full render (decode/resize/
|
|
113
|
+
// encode) plus the storage read+write, not just the fetchOne/exists checks.
|
|
114
|
+
// On a later rollback in the same handler, a variant already written to
|
|
115
|
+
// storage is orphaned there (row never committed, bytes never cleaned up).
|
|
116
|
+
// Prefer this context from the job/MSP path, where `deps.db` isn't tied to
|
|
117
|
+
// an in-flight write transaction; a synchronous write handler rendering a
|
|
118
|
+
// large image should hand off to a job instead of calling `variant()` inline.
|
|
110
119
|
export function createDerivativesContext(deps: DerivativesContextDeps): DerivativesContext {
|
|
111
120
|
return {
|
|
112
121
|
variant: async (fileRefId, spec, name) => {
|
|
@@ -71,6 +71,26 @@ describe("buildAppSchema", () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
// kumiko-framework#2034: createKumikoApp's boot diagnostic reads
|
|
75
|
+
// `screens[].dormant` from the CLIENT schema, not from the registry —
|
|
76
|
+
// this pins that the flag actually survives the server→client projection
|
|
77
|
+
// instead of only living in the registry's verbatim `feature.screens`.
|
|
78
|
+
test("custom screen's `dormant` flag survives the buildAppSchema projection verbatim (#2034)", () => {
|
|
79
|
+
const dormantScreenFeature = defineFeature("privacy", (r) => {
|
|
80
|
+
r.screen({
|
|
81
|
+
id: "privacy-center",
|
|
82
|
+
type: "custom",
|
|
83
|
+
renderer: { react: { __component: "PrivacyCenterScreen" } },
|
|
84
|
+
dormant: true,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const app = buildAppSchema(createRegistry([dormantScreenFeature]));
|
|
89
|
+
const screen = app.features.find((f) => f.featureName === "privacy")?.screens[0];
|
|
90
|
+
|
|
91
|
+
expect(screen).toMatchObject({ id: "privacy-center", dormant: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
74
94
|
test("Feature ohne r.translations lässt das Feld weg (omit-undefined-Pattern)", () => {
|
|
75
95
|
const f = defineFeature("bare", (r) => {
|
|
76
96
|
r.nav({ id: "x", label: "X" });
|
|
@@ -79,6 +99,36 @@ describe("buildAppSchema", () => {
|
|
|
79
99
|
expect(app.features[0]?.translations).toBeUndefined();
|
|
80
100
|
});
|
|
81
101
|
|
|
102
|
+
// #2062: buildAppSchema itself has no context, so the boot entrypoint
|
|
103
|
+
// (createKumikoServer, runProdApp) forwards its own context.searchAdapter
|
|
104
|
+
// presence check in via options.searchAdapterMissing.
|
|
105
|
+
test("options.searchAdapterMissing: true landet auf jeder FeatureSchema", () => {
|
|
106
|
+
const orderFeature = defineFeature("orders", (r) => {
|
|
107
|
+
r.nav({ id: "list", label: "List" });
|
|
108
|
+
});
|
|
109
|
+
const fleetFeature = defineFeature("fleet", (r) => {
|
|
110
|
+
r.nav({ id: "list", label: "List" });
|
|
111
|
+
});
|
|
112
|
+
const app = buildAppSchema(createRegistry([orderFeature, fleetFeature]), {
|
|
113
|
+
searchAdapterMissing: true,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(app.features.length).toBeGreaterThan(0);
|
|
117
|
+
expect(app.features.every((f) => f.searchAdapterMissing === true)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("options.searchAdapterMissing ohne/false lässt das Feld weg (omit-undefined-Pattern)", () => {
|
|
121
|
+
const f = defineFeature("bare", (r) => {
|
|
122
|
+
r.nav({ id: "x", label: "X" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(buildAppSchema(createRegistry([f])).features[0]?.searchAdapterMissing).toBeUndefined();
|
|
126
|
+
expect(
|
|
127
|
+
buildAppSchema(createRegistry([f]), { searchAdapterMissing: false }).features[0]
|
|
128
|
+
?.searchAdapterMissing,
|
|
129
|
+
).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
82
132
|
test("Workspaces — definition + aufgelöste navMembers landen auf AppSchema-Ebene", () => {
|
|
83
133
|
const ordersFeature = defineFeature("orders", (r) => {
|
|
84
134
|
r.nav({ id: "list", label: "List" });
|
|
@@ -38,7 +38,7 @@ describe("r.nav() — registration", () => {
|
|
|
38
38
|
r.nav({
|
|
39
39
|
id: "products",
|
|
40
40
|
label: "shop:nav.products",
|
|
41
|
-
icon: "
|
|
41
|
+
icon: "package",
|
|
42
42
|
order: 10,
|
|
43
43
|
parent: "shop:nav:catalog",
|
|
44
44
|
screen: "shop:screen:products",
|
|
@@ -47,7 +47,7 @@ describe("r.nav() — registration", () => {
|
|
|
47
47
|
});
|
|
48
48
|
const nav = feature.navs["products"];
|
|
49
49
|
expect(nav).toMatchObject({
|
|
50
|
-
icon: "
|
|
50
|
+
icon: "package",
|
|
51
51
|
order: 10,
|
|
52
52
|
parent: "shop:nav:catalog",
|
|
53
53
|
screen: "shop:screen:products",
|
|
@@ -78,6 +78,14 @@ describe("r.nav() — registration", () => {
|
|
|
78
78
|
}),
|
|
79
79
|
).not.toThrow();
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
test("@ts-expect-error: icon must be a registered NavIconKey, not any string", () => {
|
|
83
|
+
const feature = defineFeature("shop", (r) => {
|
|
84
|
+
// @ts-expect-error — "seting" is a typo of "settings", not a NavIconKey
|
|
85
|
+
r.nav({ id: "catalog", label: "x", icon: "seting" });
|
|
86
|
+
});
|
|
87
|
+
expect(feature.navs["catalog"]).toBeDefined();
|
|
88
|
+
});
|
|
81
89
|
});
|
|
82
90
|
|
|
83
91
|
describe("r.screen({ nav }) — inline nav sugar", () => {
|
|
@@ -89,13 +97,13 @@ describe("r.screen({ nav }) — inline nav sugar", () => {
|
|
|
89
97
|
type: "entityList",
|
|
90
98
|
entity: "product",
|
|
91
99
|
columns: ["name"],
|
|
92
|
-
nav: { label: "shop:nav.products", icon: "
|
|
100
|
+
nav: { label: "shop:nav.products", icon: "package", order: 5 },
|
|
93
101
|
});
|
|
94
102
|
});
|
|
95
103
|
expect(feature.navs["products"]).toMatchObject({
|
|
96
104
|
id: "products",
|
|
97
105
|
label: "shop:nav.products",
|
|
98
|
-
icon: "
|
|
106
|
+
icon: "package",
|
|
99
107
|
order: 5,
|
|
100
108
|
screen: "shop:screen:products",
|
|
101
109
|
});
|