@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.
Files changed (47) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/batch.integration.test.ts +1 -1
  3. package/src/api/__tests__/server-boot-guards.test.ts +128 -1
  4. package/src/api/__tests__/sse-route.test.ts +129 -0
  5. package/src/api/auth-routes.ts +21 -6
  6. package/src/api/server.ts +44 -0
  7. package/src/api/sse-route.ts +13 -1
  8. package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
  9. package/src/bun-db/query.ts +5 -1
  10. package/src/db/__tests__/compound-types.test.ts +12 -2
  11. package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
  12. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
  13. package/src/db/__tests__/money.test.ts +49 -18
  14. package/src/db/__tests__/unchecked-system-db.test.ts +117 -0
  15. package/src/db/dialect.ts +13 -2
  16. package/src/db/event-store-executor-read.ts +12 -1
  17. package/src/db/index.ts +7 -2
  18. package/src/db/money.ts +35 -15
  19. package/src/db/table-builder.ts +7 -1
  20. package/src/db/tenant-db.ts +54 -2
  21. package/src/derivatives/derivatives-context.ts +9 -0
  22. package/src/engine/__tests__/build-app-schema.test.ts +50 -0
  23. package/src/engine/__tests__/nav.test.ts +12 -4
  24. package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
  25. package/src/engine/build-app-schema.ts +6 -0
  26. package/src/engine/build-config-feature-schema.ts +2 -2
  27. package/src/engine/index.ts +2 -1
  28. package/src/engine/registry-facade.ts +6 -0
  29. package/src/engine/registry-ingest.ts +1 -0
  30. package/src/engine/registry-state.ts +2 -0
  31. package/src/engine/types/index.ts +7 -1
  32. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
  33. package/src/entrypoint/index.ts +20 -3
  34. package/src/files/__tests__/files.integration.test.ts +16 -0
  35. package/src/files/file-routes.ts +12 -1
  36. package/src/jobs/__tests__/job-systemdb.integration.test.ts +152 -0
  37. package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
  38. package/src/jobs/job-runner.ts +42 -3
  39. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
  40. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +67 -0
  41. package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
  42. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
  43. package/src/pipeline/dispatch-batch.ts +2 -2
  44. package/src/pipeline/dispatch-shared.ts +3 -1
  45. package/src/pipeline/idempotency.ts +11 -6
  46. package/src/ui-types/app-schema.ts +10 -0
  47. 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>: minorUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency, amountMinor } }", () => {
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({ buyingPrice: { amount: 450, currency: "EUR", amountMinor: 45000 } });
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({ amount: 450, currency: "EUR", amountMinor: 45000 });
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({ amount: 450, currency: "EUR", amountMinor: 45000 });
129
+ expect(out["buyingPrice"]).toEqual({
130
+ amount: 450,
131
+ currency: "EUR",
132
+ amountScaled: 45000,
133
+ amountMinor: 45000,
134
+ });
116
135
  });
117
136
 
118
- test("amountMinor bleibt exakter Integer über mehrere Additionen (fw#1830)", () => {
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!.amountMinor + b!.amountMinor).toBe(c!.amountMinor);
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 amountMinor", () => {
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: { amount: 56799.16, currency: "USD", amountMinor: 5679916 },
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, amountMinor:45000 }", () => {
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({ amount: 450, currency: "EUR", amountMinor: 45000 });
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 amountMinor mit Nachkommastelle", () => {
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({ buyingPrice: { amount: 990, currency: "USD", amountMinor: 99_000 } });
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 => ({ kind: "sql-expr", text, params: [] });
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 && effectiveSearchAdapter && entityName) {
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 { castTenantRows, createTenantDb } from "./tenant-db";
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, amountMinor } — amount in MAJOR units again,
7
- // amountMinor sits alongside as exact integer cents (fw#1830) for
8
- // callers that need cent-exact comparisons (e.g. invoice sums)
9
- // instead of round-tripping the float amount through /100·*100.
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(amountMinor: number): number {
44
- return amountMinor / MINOR_UNIT_SCALE;
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
- * amountMinor exact integer cents}. Exported so consumers type their own
127
- * copy against this instead of re-declaring the shape by hand. */
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 amountMinor: number;
176
+ let amountScaled: number;
163
177
  if (typeof amountRaw === "number") {
164
- amountMinor = amountRaw;
178
+ amountScaled = amountRaw;
165
179
  } else if (typeof amountRaw === "bigint") {
166
- amountMinor = Number(amountRaw);
167
- if (!Number.isSafeInteger(amountMinor)) {
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
- amountMinor = Number(amountRaw);
173
- if (!Number.isSafeInteger(amountMinor)) {
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
- result[name] = { amount: toMajorUnits(amountMinor), currency, amountMinor };
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;
@@ -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 = { kind: "sql-expr", text: whereText, params: [] };
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);
@@ -1,5 +1,10 @@
1
1
  import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
2
- import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
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 type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
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: "box",
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: "box",
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: "box", order: 5 },
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: "box",
106
+ icon: "package",
99
107
  order: 5,
100
108
  screen: "shop:screen:products",
101
109
  });