@cosmicdrift/kumiko-bundled-features 0.357.0 → 0.359.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.
@@ -1,5 +1,5 @@
1
1
  import { type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import { type HandlerContext, type SessionUser, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type HandlerContext, type JobContext, type SessionUser, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
3
3
  export declare function retryCounterWriteOnVersionConflict(writeAttempt: () => Promise<WriteResult>): Promise<WriteResult>;
4
4
  export declare function retryOnStreamVersionConflict<T>(attempt: () => Promise<T>): Promise<T>;
5
5
  export type BookCapUsageOptions = {
@@ -9,16 +9,22 @@ export type BookCapUsageOptions = {
9
9
  readonly outsideTransaction?: boolean;
10
10
  readonly guardCurrentValue?: (currentValue: number) => void;
11
11
  };
12
- export declare function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb;
13
- export declare function bookCapUsage(ctx: HandlerContext, options: BookCapUsageOptions): Promise<WriteResult>;
14
- export declare function releaseCapUsage(ctx: HandlerContext, options: Omit<BookCapUsageOptions, "guardCurrentValue">): Promise<WriteResult>;
12
+ export type CapCounterContext = HandlerContext | JobContext;
13
+ export type CapCounterScope = {
14
+ readonly user: SessionUser;
15
+ readonly readDb: TenantDb;
16
+ readonly writeDb: TenantDb;
17
+ };
18
+ export declare function resolveCapCounterScope(ctx: CapCounterContext, outsideTransaction: boolean): CapCounterScope;
19
+ export declare function bookCapUsage(ctx: CapCounterContext, options: BookCapUsageOptions): Promise<WriteResult>;
20
+ export declare function releaseCapUsage(ctx: CapCounterContext, options: Omit<BookCapUsageOptions, "guardCurrentValue">): Promise<WriteResult>;
15
21
  export declare function applyCapDeltaOn(db: TenantDb, user: SessionUser, options: BookCapUsageOptions, direction: "add" | "subtract"): Promise<WriteResult>;
16
22
  export type MarkCapSoftWarnedOptions = {
17
23
  readonly capName: string;
18
24
  readonly periodStartIso: string;
19
25
  readonly outsideTransaction?: boolean;
20
26
  };
21
- export declare function markCapSoftWarned(ctx: HandlerContext, options: MarkCapSoftWarnedOptions): Promise<WriteResult>;
27
+ export declare function markCapSoftWarned(ctx: CapCounterContext, options: MarkCapSoftWarnedOptions): Promise<WriteResult>;
22
28
  export type ReadRollingCapUsageOptions = {
23
29
  readonly capName: string;
24
30
  readonly windowDays: number;
@@ -1,6 +1,7 @@
1
1
  // In-process cap booking for the caller's own tenant; no SystemAdmin dispatch needed.
2
2
  import { runInOwnTransaction } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createEntityExecutor, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
5
6
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
7
  import * as z from "zod";
@@ -48,11 +49,51 @@ export async function retryOnStreamVersionConflict(attempt) {
48
49
  }
49
50
  }
50
51
  }
51
- export function requireOutsideTransactionDb(ctx) {
52
- if (!ctx.dbOutsideTransaction) {
53
- throw new Error("cap-counter.bookCapUsage: outsideTransaction requested but ctx.dbOutsideTransaction is undefined");
52
+ // A tenant-mode db pins every row it writes to its own tenant; counter and reservation row would
53
+ // silently land on different tenants if that is not the user's.
54
+ function assertDbServesUser(db, user) {
55
+ if (db.mode === "tenant" && db.tenantId !== user.tenantId) {
56
+ throw new AccessDeniedError({
57
+ message: "cap-counter: the context's database belongs to a different tenant than its user",
58
+ });
54
59
  }
55
- return ctx.dbOutsideTransaction;
60
+ return db;
61
+ }
62
+ // Jobs write in place (their db is not transaction-bound); systemScope handlers reach both dbs via
63
+ // systemDb, because their ctx.db / ctx.dbOutsideTransaction are fail-closed proxies.
64
+ export function resolveCapCounterScope(ctx, outsideTransaction) {
65
+ // A job context spread into a handler-shaped one (user set) is a handler context: its user, not
66
+ // the job's systemUser, names the metered tenant.
67
+ if (!("user" in ctx) || !ctx.user) {
68
+ const jobUser = "systemUser" in ctx ? ctx.systemUser : undefined;
69
+ if (!jobUser) {
70
+ throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
71
+ }
72
+ const db = assertDbServesUser(ctx.db, jobUser);
73
+ return { user: jobUser, readDb: db, writeDb: db };
74
+ }
75
+ const { user } = ctx;
76
+ if (!user.tenantId) {
77
+ throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
78
+ }
79
+ if (ctx.systemDb) {
80
+ const readDb = ctx.systemDb.assertTenantMatch(user.tenantId);
81
+ const writeDb = outsideTransaction
82
+ ? ctx.systemDb.outsideTransaction.assertTenantMatch(user.tenantId)
83
+ : readDb;
84
+ return { user, readDb, writeDb };
85
+ }
86
+ const writeDb = outsideTransaction ? ctx.dbOutsideTransaction : ctx.db;
87
+ if (!writeDb) {
88
+ throw new Error(outsideTransaction
89
+ ? "cap-counter: outsideTransaction requested but ctx.dbOutsideTransaction is undefined"
90
+ : "cap-counter: ctx.db missing — run inside a handler context");
91
+ }
92
+ return {
93
+ user,
94
+ readDb: assertDbServesUser(ctx.db, user),
95
+ writeDb: assertDbServesUser(writeDb, user),
96
+ };
56
97
  }
57
98
  export async function bookCapUsage(ctx, options) {
58
99
  return applyCapDelta(ctx, options, "add");
@@ -96,19 +137,20 @@ export async function applyCapDeltaOn(db, user, options, direction) {
96
137
  }, user, db);
97
138
  }
98
139
  async function applyCapDelta(ctx, options, direction) {
140
+ const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
99
141
  if (options.outsideTransaction) {
100
- const outsideDb = requireOutsideTransactionDb(ctx);
101
- return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)));
142
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, (txDb) => applyCapDeltaOn(txDb, user, options, direction)));
102
143
  }
103
- return retryCounterWriteOnVersionConflict(() => applyCapDeltaOn(ctx.db, ctx.user, options, direction));
144
+ return retryCounterWriteOnVersionConflict(() => applyCapDeltaOn(writeDb, user, options, direction));
104
145
  }
105
146
  export async function markCapSoftWarned(ctx, options) {
106
147
  const parsed = capPeriodSchema.parse(options);
107
- const aggregateId = capCounterAggregateId(ctx.user.tenantId, parsed.capName, parsed.periodStartIso);
148
+ const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
149
+ const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
108
150
  async function attemptMark(db) {
109
151
  const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
110
152
  if (existing.length === 0) {
111
- throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`);
153
+ throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`);
112
154
  }
113
155
  const row = existing[0];
114
156
  if (!row) {
@@ -124,13 +166,12 @@ export async function markCapSoftWarned(ctx, options) {
124
166
  id: aggregateId,
125
167
  version: currentVersion,
126
168
  changes: { lastSoftWarnedAt: Temporal.Now.instant() },
127
- }, ctx.user, db);
169
+ }, user, db);
128
170
  }
129
171
  if (options.outsideTransaction) {
130
- const outsideDb = requireOutsideTransactionDb(ctx);
131
- return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
172
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, attemptMark));
132
173
  }
133
- return retryCounterWriteOnVersionConflict(() => attemptMark(ctx.db));
174
+ return retryCounterWriteOnVersionConflict(() => attemptMark(writeDb));
134
175
  }
135
176
  async function sumRollingEventAmounts(db, tenantId, options, type) {
136
177
  const aggregateId = rollingCapAggregateId(tenantId, options.capName);
@@ -0,0 +1,17 @@
1
+ import type { CapCounterContext } from "./book-cap-usage.js";
2
+ import { type CalendarCapReservation } from "./cap-reservation.js";
3
+ import { type SoftHitNotifier } from "./enforce-cap.js";
4
+ import type { CalendarCapDef } from "./with-cap-enforcement.js";
5
+ export type CalendarCapUsageReservationSpec = Omit<CalendarCapDef, "amount" | "notify"> & {
6
+ readonly amount: number;
7
+ readonly notify?: SoftHitNotifier;
8
+ readonly outsideTransaction?: boolean;
9
+ };
10
+ export type CalendarCapUsageReservation = {
11
+ readonly amount: number;
12
+ readonly counterValue: number;
13
+ settle(actualAmount: number): Promise<void>;
14
+ release(): Promise<void>;
15
+ };
16
+ export declare function reserveEnforcedCalendarCap(ctx: CapCounterContext, cap: CalendarCapUsageReservationSpec): Promise<CalendarCapReservation>;
17
+ export declare function reserveCalendarCapUsage(ctx: CapCounterContext, spec: CalendarCapUsageReservationSpec): Promise<CalendarCapUsageReservation>;
@@ -0,0 +1,53 @@
1
+ import { releaseExpiredCapReservations, reserveCalendarCap, settleCalendarCapReservation, } from "./cap-reservation.js";
2
+ import { assertBelowHardCap, enforceCapAndMaybeNotify, } from "./enforce-cap.js";
3
+ // Shared by withCapEnforcement and the public reservation API so both enforce identically.
4
+ export async function reserveEnforcedCalendarCap(ctx, cap) {
5
+ const outsideTransaction = cap.outsideTransaction ?? true;
6
+ await releaseExpiredCapReservations(ctx, cap.capName, outsideTransaction);
7
+ if (cap.notify) {
8
+ await enforceCapAndMaybeNotify(ctx, {
9
+ capName: cap.capName,
10
+ periodStartIso: cap.periodStartIso,
11
+ limit: cap.limit,
12
+ profile: cap.profile,
13
+ notify: cap.notify,
14
+ amount: cap.amount,
15
+ markSoftWarnedOutsideTransaction: outsideTransaction,
16
+ });
17
+ }
18
+ return reserveCalendarCap(ctx, {
19
+ capName: cap.capName,
20
+ amount: cap.amount,
21
+ periodStartIso: cap.periodStartIso,
22
+ guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + cap.amount - 1, cap),
23
+ }, outsideTransaction);
24
+ }
25
+ export async function reserveCalendarCapUsage(ctx, spec) {
26
+ const outsideTransaction = spec.outsideTransaction ?? true;
27
+ const reservation = await reserveEnforcedCalendarCap(ctx, spec);
28
+ let state = "open";
29
+ return {
30
+ amount: spec.amount,
31
+ counterValue: reservation.counterValue,
32
+ async settle(actualAmount) {
33
+ if (state !== "open") {
34
+ throw new Error(`cap-counter: reservation for "${spec.capName}" is already ${state}`);
35
+ }
36
+ state = "settled";
37
+ try {
38
+ await settleCalendarCapReservation(ctx, reservation.reservationId, { capName: spec.capName, periodStartIso: spec.periodStartIso, actualAmount }, outsideTransaction);
39
+ }
40
+ catch (error) {
41
+ state = "open";
42
+ throw error;
43
+ }
44
+ },
45
+ async release() {
46
+ // skip: release after settle, or a second release, is a no-op by contract.
47
+ if (state !== "open")
48
+ return;
49
+ await reservation.release();
50
+ state = "released";
51
+ },
52
+ };
53
+ }
@@ -1,12 +1,21 @@
1
- import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
1
  import type { ReservationHandle } from "@cosmicdrift/kumiko-types/handlers";
3
- import { type BookCapUsageOptions, type ReadRollingCapUsageOptions } from "./book-cap-usage.js";
2
+ import { type BookCapUsageOptions, type CapCounterContext, type ReadRollingCapUsageOptions } from "./book-cap-usage.js";
3
+ export type CalendarCapReservation = ReservationHandle & {
4
+ readonly reservationId: string;
5
+ readonly counterValue: number;
6
+ };
4
7
  export type ReserveCalendarCapOptions = Required<Pick<BookCapUsageOptions, "capName" | "periodStartIso" | "amount">> & Pick<BookCapUsageOptions, "guardCurrentValue">;
5
- export declare function reserveCalendarCap(ctx: HandlerContext, options: ReserveCalendarCapOptions): Promise<ReservationHandle>;
8
+ export declare function reserveCalendarCap(ctx: CapCounterContext, options: ReserveCalendarCapOptions, outsideTransaction?: boolean): Promise<CalendarCapReservation>;
6
9
  export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
7
10
  readonly amount: number;
8
11
  readonly guardCurrentValue?: (currentValue: number) => void;
9
12
  };
10
- export declare function reserveRollingCap(ctx: HandlerContext, options: ReserveRollingCapOptions): Promise<ReservationHandle>;
11
- export declare function releaseCapReservation(ctx: HandlerContext, id: string): Promise<void>;
12
- export declare function releaseExpiredCapReservations(ctx: HandlerContext, capName: string): Promise<void>;
13
+ export declare function reserveRollingCap(ctx: CapCounterContext, options: ReserveRollingCapOptions, outsideTransaction?: boolean): Promise<ReservationHandle>;
14
+ export declare function releaseCapReservation(ctx: CapCounterContext, id: string, outsideTransaction?: boolean): Promise<void>;
15
+ export type SettleCalendarCapOptions = {
16
+ readonly capName: string;
17
+ readonly periodStartIso: string;
18
+ readonly actualAmount: number;
19
+ };
20
+ export declare function settleCalendarCapReservation(ctx: CapCounterContext, id: string, options: SettleCalendarCapOptions, outsideTransaction?: boolean): Promise<void>;
21
+ export declare function releaseExpiredCapReservations(ctx: CapCounterContext, capName: string, outsideTransaction?: boolean): Promise<void>;
@@ -4,14 +4,15 @@ import { appendEventInTenantDb, getStreamVersionInTenantDb, } from "@cosmicdrift
4
4
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
6
  import { rollingCapAggregateId } from "./aggregate-id.js";
7
- import { applyCapDeltaOn, readRollingCapUsage, requireOutsideTransactionDb, retryCounterWriteOnVersionConflict, retryOnStreamVersionConflict, } from "./book-cap-usage.js";
7
+ import { applyCapDeltaOn, readRollingCapUsage, resolveCapCounterScope, retryCounterWriteOnVersionConflict, retryOnStreamVersionConflict, } from "./book-cap-usage.js";
8
8
  import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CAP_RESERVATION_TTL_MINUTES, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
9
9
  import { capReservationsTable } from "./tables.js";
10
10
  const NOTHING_TO_RELEASE = { isSuccess: true, data: {} };
11
- async function insertReservationRow(txDb, id, draft) {
11
+ async function insertReservationRow(txDb, user, id, draft) {
12
12
  const now = Temporal.Now.instant();
13
13
  await txDb.insertOne(capReservationsTable, {
14
14
  id,
15
+ tenantId: user.tenantId,
15
16
  capName: draft.capName,
16
17
  kind: draft.kind,
17
18
  periodStartIso: draft.periodStartIso ?? null,
@@ -20,22 +21,31 @@ async function insertReservationRow(txDb, id, draft) {
20
21
  createdAt: now,
21
22
  });
22
23
  }
23
- function toHandle(ctx, id) {
24
+ function toHandle(ctx, id, outsideTransaction) {
24
25
  return {
25
- release: () => releaseCapReservation(ctx, id),
26
+ release: () => releaseCapReservation(ctx, id, outsideTransaction),
26
27
  confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
27
28
  };
28
29
  }
29
30
  // The counter increment and the reservation row commit together, so a booked amount never exists
30
31
  // without a row that can give it back.
31
- export async function reserveCalendarCap(ctx, options) {
32
- const outsideDb = requireOutsideTransactionDb(ctx);
32
+ export async function reserveCalendarCap(ctx, options, outsideTransaction = true) {
33
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
33
34
  const id = generateId();
34
- const reserved = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, async (txDb) => {
35
- const booked = await applyCapDeltaOn(txDb, ctx.user, options, "add");
35
+ // The last attempt is the one that committed, so this holds the value its increment started from.
36
+ let valueBeforeBooking = 0;
37
+ const observedOptions = {
38
+ ...options,
39
+ guardCurrentValue: (currentValue) => {
40
+ options.guardCurrentValue?.(currentValue);
41
+ valueBeforeBooking = currentValue;
42
+ },
43
+ };
44
+ const reserved = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
45
+ const booked = await applyCapDeltaOn(txDb, user, observedOptions, "add");
36
46
  if (!booked.isSuccess)
37
47
  return booked;
38
- await insertReservationRow(txDb, id, {
48
+ await insertReservationRow(txDb, user, id, {
39
49
  capName: options.capName,
40
50
  kind: "calendar",
41
51
  periodStartIso: options.periodStartIso,
@@ -45,7 +55,11 @@ export async function reserveCalendarCap(ctx, options) {
45
55
  }));
46
56
  if (!reserved.isSuccess)
47
57
  throw reraiseAsKumikoError(reserved.error);
48
- return toHandle(ctx, id);
58
+ return {
59
+ ...toHandle(ctx, id, outsideTransaction),
60
+ reservationId: id,
61
+ counterValue: valueBeforeBooking + options.amount,
62
+ };
49
63
  }
50
64
  function rollingEvent(user, capName, amount, expectedVersion, type) {
51
65
  return {
@@ -59,23 +73,23 @@ function rollingEvent(user, capName, amount, expectedVersion, type) {
59
73
  }
60
74
  // The append is version-guarded: a booker that read the usage before another one appended loses the
61
75
  // race and repeats the whole attempt, so the hard-cap check always sees the stream it appends to.
62
- export async function reserveRollingCap(ctx, options) {
63
- const outsideDb = requireOutsideTransactionDb(ctx);
76
+ export async function reserveRollingCap(ctx, options, outsideTransaction = true) {
77
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
64
78
  const id = generateId();
65
- const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
66
- await retryOnStreamVersionConflict(() => runInOwnTransaction(outsideDb, async (txDb) => {
79
+ const aggregateId = rollingCapAggregateId(user.tenantId, options.capName);
80
+ await retryOnStreamVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
67
81
  const version = await getStreamVersionInTenantDb(txDb, aggregateId);
68
- const usage = await readRollingCapUsage(txDb, ctx.user.tenantId, options);
82
+ const usage = await readRollingCapUsage(txDb, user.tenantId, options);
69
83
  options.guardCurrentValue?.(usage);
70
- await appendEventInTenantDb(txDb, rollingEvent(ctx.user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN));
71
- await insertReservationRow(txDb, id, {
84
+ await appendEventInTenantDb(txDb, rollingEvent(user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN));
85
+ await insertReservationRow(txDb, user, id, {
72
86
  capName: options.capName,
73
87
  kind: "rolling",
74
88
  amount: options.amount,
75
89
  });
76
90
  return NOTHING_TO_RELEASE;
77
91
  }));
78
- return toHandle(ctx, id);
92
+ return toHandle(ctx, id, outsideTransaction);
79
93
  }
80
94
  // Deleting the row takes its row lock inside the handler transaction, so a release started after a
81
95
  // failed COMMIT waits for the real outcome and finds the row gone when the commit went through.
@@ -94,39 +108,74 @@ async function giveBackRolling(txDb, user, row) {
94
108
  return NOTHING_TO_RELEASE;
95
109
  }
96
110
  async function giveBackReservation(txDb, user, id) {
97
- const row = await txDb.fetchOne(capReservationsTable, { id });
111
+ const ownRow = { id, tenantId: user.tenantId };
112
+ const row = await txDb.fetchOne(capReservationsTable, ownRow);
98
113
  if (!row)
99
114
  return NOTHING_TO_RELEASE;
100
- // Waits for a confirm still holding the row; zero rows back means that confirm committed.
101
- const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id });
102
- if (claimed.length === 0)
115
+ const claimed = await claimReservationRow(txDb, user, row);
116
+ if (!claimed)
103
117
  return NOTHING_TO_RELEASE;
104
118
  const given = row.kind === "rolling"
105
119
  ? await giveBackRolling(txDb, user, row)
106
120
  : await giveBackCalendar(txDb, user, row);
107
121
  if (!given.isSuccess)
108
122
  return given;
109
- await txDb.deleteMany(capReservationsTable, { id });
123
+ await txDb.deleteMany(capReservationsTable, ownRow);
110
124
  return given;
111
125
  }
112
- export async function releaseCapReservation(ctx, id) {
113
- const outsideDb = requireOutsideTransactionDb(ctx);
114
- const released = await retryOnStreamVersionConflict(() => retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, (txDb) => giveBackReservation(txDb, ctx.user, id))));
126
+ // Waits for a confirm still holding the row; false means that confirm (or another release) won.
127
+ async function claimReservationRow(txDb, user, row) {
128
+ const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id: row.id, tenantId: user.tenantId });
129
+ return claimed.length > 0;
130
+ }
131
+ export async function releaseCapReservation(ctx, id, outsideTransaction = true) {
132
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
133
+ const released = await retryOnStreamVersionConflict(() => retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, (txDb) => giveBackReservation(txDb, user, id))));
115
134
  if (!released.isSuccess)
116
135
  throw reraiseAsKumikoError(released.error);
117
136
  }
137
+ function bookCalendarDelta(txDb, user, period, delta) {
138
+ if (delta === 0)
139
+ return Promise.resolve({ isSuccess: true, data: {} });
140
+ return applyCapDeltaOn(txDb, user, { ...period, amount: Math.abs(delta) }, delta > 0 ? "add" : "subtract");
141
+ }
142
+ // Replaces the reserved amount by the actual one in a single transaction. When the row is already
143
+ // gone (swept or released), the reserved amount was given back, so the full actual amount is booked.
144
+ export async function settleCalendarCapReservation(ctx, id, options, outsideTransaction = true) {
145
+ if (!Number.isInteger(options.actualAmount) || options.actualAmount < 0) {
146
+ throw new Error(`cap-counter: settle amount must be a non-negative integer, got ${options.actualAmount}`);
147
+ }
148
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
149
+ const settled = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
150
+ const row = await txDb.fetchOne(capReservationsTable, {
151
+ id,
152
+ tenantId: user.tenantId,
153
+ });
154
+ const claimed = row ? await claimReservationRow(txDb, user, row) : false;
155
+ if (!row || !claimed) {
156
+ return bookCalendarDelta(txDb, user, options, options.actualAmount);
157
+ }
158
+ const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount - row.amount);
159
+ if (!booked.isSuccess)
160
+ return booked;
161
+ await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
162
+ return booked;
163
+ }));
164
+ if (!settled.isSuccess)
165
+ throw reraiseAsKumikoError(settled.error);
166
+ }
118
167
  // A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
119
168
  // capacity stays counted.
120
- export async function releaseExpiredCapReservations(ctx, capName) {
121
- const outsideDb = requireOutsideTransactionDb(ctx);
122
- const expired = await outsideDb.selectMany(capReservationsTable, {
123
- tenantId: ctx.user.tenantId,
169
+ export async function releaseExpiredCapReservations(ctx, capName, outsideTransaction = true) {
170
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
171
+ const expired = await writeDb.selectMany(capReservationsTable, {
172
+ tenantId: user.tenantId,
124
173
  capName,
125
174
  expiresAt: { lt: Temporal.Now.instant() },
126
175
  });
127
176
  for (const { id } of expired) {
128
177
  try {
129
- await releaseCapReservation(ctx, id);
178
+ await releaseCapReservation(ctx, id, outsideTransaction);
130
179
  }
131
180
  catch (error) {
132
181
  ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });
@@ -1,6 +1,6 @@
1
- import { type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
1
  import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
+ import { type CapCounterContext } from "./book-cap-usage.js";
4
4
  /**
5
5
  * Cap-Toleranz-Profile — pro Cap-Typ asymmetrisch. Quelle: Memory
6
6
  * `project_pricing_byok_caps` §3 + `docs/plans/marketing/produkt/
@@ -79,7 +79,7 @@ export declare function assertBelowHardCap(valueSeenByLastUnit: number, cap: {
79
79
  * (apps-count) need stricter serialization at the create-handler
80
80
  * level (e.g. uniqueness-index on apps.tenantId+slot-number).
81
81
  */
82
- export declare function enforceCap(ctx: HandlerContext, options: {
82
+ export declare function enforceCap(ctx: CapCounterContext, options: {
83
83
  readonly capName: string;
84
84
  readonly periodStartIso: string;
85
85
  readonly limit: number;
@@ -110,7 +110,7 @@ export declare function enforceCap(ctx: HandlerContext, options: {
110
110
  * Projection mit pre-aggregierten daily-buckets. Heute nicht
111
111
  * vorgezogen.
112
112
  */
113
- export declare function enforceRollingCap(ctx: HandlerContext, options: {
113
+ export declare function enforceRollingCap(ctx: CapCounterContext, options: {
114
114
  readonly capName: string;
115
115
  readonly windowDays: number;
116
116
  readonly limit: number;
@@ -162,7 +162,7 @@ export type SoftHitNotifier = (info: {
162
162
  * verzweigen (z.B. UI-Toast bei soft-hit zusätzlich zur
163
163
  * Email-Notification).
164
164
  */
165
- export declare function enforceCapAndMaybeNotify(ctx: HandlerContext, options: {
165
+ export declare function enforceCapAndMaybeNotify(ctx: CapCounterContext, options: {
166
166
  readonly capName: string;
167
167
  readonly periodStartIso: string;
168
168
  readonly limit: number;
@@ -181,7 +181,7 @@ export declare function enforceCapAndMaybeNotify(ctx: HandlerContext, options: {
181
181
  * oder er ruft `enforceRollingCapAndMaybeNotify` nur einmal pro
182
182
  * Tag/Stunde auf (Hourly-Cron statt pro Request).
183
183
  */
184
- export declare function enforceRollingCapAndMaybeNotify(ctx: HandlerContext, options: {
184
+ export declare function enforceRollingCapAndMaybeNotify(ctx: CapCounterContext, options: {
185
185
  readonly capName: string;
186
186
  readonly windowDays: number;
187
187
  readonly limit: number;
@@ -1,7 +1,7 @@
1
1
  import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
- import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage.js";
4
+ import { markCapSoftWarned, readRollingCapUsage, resolveCapCounterScope, } from "./book-cap-usage.js";
5
5
  import { capCounterEntity } from "./entity.js";
6
6
  const { table } = createEntityExecutor("cap-counter", capCounterEntity);
7
7
  export const CAP_TOLERANCES = {
@@ -45,16 +45,14 @@ export function assertBelowHardCap(valueSeenByLastUnit, cap) {
45
45
  * level (e.g. uniqueness-index on apps.tenantId+slot-number).
46
46
  */
47
47
  export async function enforceCap(ctx, options) {
48
- if (!ctx.db) {
49
- throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
50
- }
48
+ const { user, readDb } = resolveCapCounterScope(ctx, false);
51
49
  const amount = options.amount ?? 1;
52
50
  if (!Number.isInteger(amount) || amount < 1) {
53
51
  throw new Error(`cap-counter.enforceCap: amount must be a positive integer, got ${options.amount}`);
54
52
  }
55
53
  const tolerance = CAP_TOLERANCES[options.profile];
56
54
  const softThreshold = options.limit * tolerance.soft;
57
- const rows = await ctx.db.selectMany(table, { capName: options.capName, periodStart: options.periodStartIso }, { limit: 1 });
55
+ const rows = await readDb.selectMany(table, { tenantId: user.tenantId, capName: options.capName, periodStart: options.periodStartIso }, { limit: 1 });
58
56
  const row = rows[0];
59
57
  const storedValue = row ? row["value"] : 0; // @cast-boundary db-row
60
58
  // The last of `amount` units sees this value before its own increment.
@@ -94,16 +92,11 @@ export async function enforceCap(ctx, options) {
94
92
  * vorgezogen.
95
93
  */
96
94
  export async function enforceRollingCap(ctx, options) {
97
- if (!ctx.db) {
98
- throw new Error("cap-counter.enforceRollingCap: ctx.db missing — run inside a handler context");
99
- }
100
- if (!ctx.user?.tenantId) {
101
- throw new Error("cap-counter.enforceRollingCap: ctx.user.tenantId missing — required to compute aggregate-id");
102
- }
95
+ const { user, readDb } = resolveCapCounterScope(ctx, false);
103
96
  const tolerance = CAP_TOLERANCES[options.profile];
104
97
  const softThreshold = options.limit * tolerance.soft;
105
98
  const hardThreshold = options.limit * tolerance.hard;
106
- const value = await readRollingCapUsage(ctx.db, ctx.user.tenantId, {
99
+ const value = await readRollingCapUsage(readDb, user.tenantId, {
107
100
  capName: options.capName,
108
101
  windowDays: options.windowDays,
109
102
  });
@@ -184,14 +177,11 @@ export async function enforceCapAndMaybeNotify(ctx, options) {
184
177
  ...(options.amount !== undefined && { amount: options.amount }),
185
178
  });
186
179
  if (result.state === "soft-hit" && result.crossed) {
187
- if (!ctx.user?.tenantId) {
188
- throw new Error("cap-counter.enforceCapAndMaybeNotify: ctx.user.tenantId missing — required for notification");
189
- }
190
180
  await options.notify({
191
181
  capName: options.capName,
192
182
  value: result.value,
193
183
  limit: options.limit,
194
- tenantId: ctx.user.tenantId,
184
+ tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
195
185
  });
196
186
  // Flip the soft-warned flag so the same period doesn't re-notify.
197
187
  const marked = await markCapSoftWarned(ctx, {
@@ -222,14 +212,11 @@ export async function enforceRollingCapAndMaybeNotify(ctx, options) {
222
212
  profile: options.profile,
223
213
  });
224
214
  if (result.state === "soft-hit") {
225
- if (!ctx.user?.tenantId) {
226
- throw new Error("cap-counter.enforceRollingCapAndMaybeNotify: ctx.user.tenantId missing — required for notification");
227
- }
228
215
  await options.notify({
229
216
  capName: options.capName,
230
217
  value: result.value,
231
218
  limit: options.limit,
232
- tenantId: ctx.user.tenantId,
219
+ tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
233
220
  });
234
221
  }
235
222
  return result;
@@ -1,5 +1,6 @@
1
1
  export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
2
2
  export { type BookCapUsageOptions, bookCapUsage, type MarkCapSoftWarnedOptions, markCapSoftWarned, type ReadRollingCapUsageOptions, readRollingCapUsage, } from "./book-cap-usage.js";
3
+ export { type CalendarCapUsageReservation, type CalendarCapUsageReservationSpec, reserveCalendarCapUsage, } from "./calendar-cap-usage-reservation.js";
3
4
  export { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_QN, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
4
5
  export { CAP_TOLERANCES, CapExceededError, type CapToleranceProfile, type CapToleranceProfileName, currentCalendarMonthStartIso, type EnforceCapResult, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, type SoftHitNotifier, type StockCapResult, } from "./enforce-cap.js";
5
6
  export { capCounterEntity } from "./entity.js";
@@ -1,6 +1,7 @@
1
1
  // Public API of the cap-counter bundled-feature.
2
2
  export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
3
3
  export { bookCapUsage, markCapSoftWarned, readRollingCapUsage, } from "./book-cap-usage.js";
4
+ export { reserveCalendarCapUsage, } from "./calendar-cap-usage-reservation.js";
4
5
  export { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_QN, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
5
6
  export { CAP_TOLERANCES, CapExceededError, currentCalendarMonthStartIso, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, } from "./enforce-cap.js";
6
7
  export { capCounterEntity } from "./entity.js";
@@ -23,8 +23,9 @@
23
23
  //
24
24
  // No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
25
25
  // (enforce-cap.ts).
26
- import { releaseExpiredCapReservations, reserveCalendarCap, reserveRollingCap, } from "./cap-reservation.js";
27
- import { assertBelowHardCap, enforceCapAndMaybeNotify, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
26
+ import { reserveEnforcedCalendarCap } from "./calendar-cap-usage-reservation.js";
27
+ import { releaseExpiredCapReservations, reserveRollingCap } from "./cap-reservation.js";
28
+ import { assertBelowHardCap, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
28
29
  import { chainReservations } from "./reservation-chain.js";
29
30
  /**
30
31
  * Wrap a write-handler with calendar-period cap-enforcement.
@@ -41,23 +42,7 @@ export function withCapEnforcement(handler, capResolver) {
41
42
  ...handler,
42
43
  reserveBeforeTransaction: chainReservations(handler.reserveBeforeTransaction, async (event, ctx) => {
43
44
  const cap = await capResolver(event, ctx);
44
- await releaseExpiredCapReservations(ctx, cap.capName);
45
- await enforceCapAndMaybeNotify(ctx, {
46
- capName: cap.capName,
47
- periodStartIso: cap.periodStartIso,
48
- limit: cap.limit,
49
- profile: cap.profile,
50
- notify: cap.notify,
51
- markSoftWarnedOutsideTransaction: true,
52
- ...(cap.amount !== undefined && { amount: cap.amount }),
53
- });
54
- const amount = cap.amount ?? 1;
55
- return reserveCalendarCap(ctx, {
56
- capName: cap.capName,
57
- amount,
58
- periodStartIso: cap.periodStartIso,
59
- guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
60
- });
45
+ return reserveEnforcedCalendarCap(ctx, { ...cap, amount: cap.amount ?? 1 });
61
46
  }),
62
47
  };
63
48
  }
@@ -229,7 +229,6 @@ async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunn
229
229
  if (ctx.registry.features.has("notes-history")) {
230
230
  await eraseNoteBodiesReachingSubject({
231
231
  db: crossTenantSubjectRunner,
232
- registry: ctx.registry,
233
232
  subject,
234
233
  erasedBy: audit.erasedBy,
235
234
  requestId: audit.requestId,
@@ -4,13 +4,14 @@ import { fieldDefinitionAggregateId } from "../aggregate-id.js";
4
4
  import { DEFAULT_FIELD_DEFINITION_WRITE_ROLES } from "../constants.js";
5
5
  import { fieldDefinitionExecutor } from "../executor.js";
6
6
  import { buildFieldDefinitionColumns } from "../lib/field-definition-row.js";
7
- import { updateFieldPayloadSchema } from "../schemas.js";
7
+ import { fieldTypeSchema, updateFieldPayloadSchema } from "../schemas.js";
8
8
  export function createUpdateTenantFieldHandler(opts = {}) {
9
9
  return {
10
10
  name: "update-tenant-field",
11
11
  schema: updateFieldPayloadSchema,
12
12
  access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
13
- description: "Replaces a tenant-owned custom-field definition with a complete new spec while refusing any change to the field's type; use it to relabel or re-constrain an existing field rather than to create or remove one.",
13
+ description: "Replaces a tenant-owned custom-field definition with a complete new spec; serializedField.type may be omitted, in which case the stored type applies, and a different type is refused. Use it to relabel or re-constrain an existing field rather than to create or remove one.",
14
+ excludedFields: ["type"],
14
15
  handler: async (event, ctx) => {
15
16
  const payload = event.payload; // @cast-boundary engine-payload
16
17
  const tenantId = event.user.tenantId;
@@ -22,16 +23,24 @@ export function createUpdateTenantFieldHandler(opts = {}) {
22
23
  if (!existing) {
23
24
  return failNotFound("field-definition", aggregateId);
24
25
  }
25
- if (existing["type"] !== payload.serializedField.type) {
26
+ const storedType = fieldTypeSchema.safeParse(existing["type"]);
27
+ if (!storedType.success) {
28
+ throw new Error(`update-tenant-field: stored definition ${aggregateId} has an invalid type`);
29
+ }
30
+ const requestedType = payload.serializedField.type;
31
+ if (requestedType !== undefined && requestedType !== storedType.data) {
26
32
  return failUnprocessable("field_type_immutable", {
27
33
  entityName: payload.entityName,
28
34
  fieldKey: payload.fieldKey,
29
- currentType: existing["type"],
30
- requestedType: payload.serializedField.type,
35
+ currentType: storedType.data,
36
+ requestedType,
31
37
  });
32
38
  }
33
39
  // entityName/fieldKey sind die Identität — nicht Teil der changes.
34
- const { entityName: _entityName, fieldKey: _fieldKey, ...changes } = buildFieldDefinitionColumns(payload);
40
+ const { entityName: _entityName, fieldKey: _fieldKey, ...changes } = buildFieldDefinitionColumns({
41
+ ...payload,
42
+ serializedField: { ...payload.serializedField, type: storedType.data },
43
+ });
35
44
  return fieldDefinitionExecutor.update({ id: aggregateId, changes }, event.user, ctx.db, {
36
45
  skipOptimisticLock: true,
37
46
  });
@@ -1,4 +1,13 @@
1
1
  import * as z from "zod";
2
+ export declare const fieldTypeSchema: z.ZodEnum<{
3
+ boolean: "boolean";
4
+ date: "date";
5
+ embedded: "embedded";
6
+ enum: "enum";
7
+ money: "money";
8
+ number: "number";
9
+ text: "text";
10
+ }>;
2
11
  export declare const customFieldAccessSchema: z.ZodOptional<z.ZodObject<{
3
12
  read: z.ZodOptional<z.ZodArray<z.ZodString>>;
4
13
  write: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -8,15 +17,6 @@ export declare const defineFieldPayloadSchema: z.ZodObject<{
8
17
  entityName: z.ZodString;
9
18
  fieldKey: z.ZodString;
10
19
  serializedField: z.ZodObject<{
11
- type: z.ZodEnum<{
12
- boolean: "boolean";
13
- date: "date";
14
- embedded: "embedded";
15
- enum: "enum";
16
- money: "money";
17
- number: "number";
18
- text: "text";
19
- }>;
20
20
  fieldAccess: z.ZodOptional<z.ZodObject<{
21
21
  read: z.ZodOptional<z.ZodArray<z.ZodString>>;
22
22
  write: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -28,6 +28,15 @@ export declare const defineFieldPayloadSchema: z.ZodObject<{
28
28
  delete: "delete";
29
29
  }>;
30
30
  }, z.core.$strip>>;
31
+ type: z.ZodEnum<{
32
+ boolean: "boolean";
33
+ date: "date";
34
+ embedded: "embedded";
35
+ enum: "enum";
36
+ money: "money";
37
+ number: "number";
38
+ text: "text";
39
+ }>;
31
40
  }, z.core.$loose>;
32
41
  required: z.ZodDefault<z.ZodBoolean>;
33
42
  searchable: z.ZodDefault<z.ZodBoolean>;
@@ -38,16 +47,11 @@ export type DefineFieldPayload = z.infer<typeof defineFieldPayloadSchema>;
38
47
  export declare const updateFieldPayloadSchema: z.ZodObject<{
39
48
  entityName: z.ZodString;
40
49
  fieldKey: z.ZodString;
50
+ required: z.ZodDefault<z.ZodBoolean>;
51
+ searchable: z.ZodDefault<z.ZodBoolean>;
52
+ displayOrder: z.ZodDefault<z.ZodNumber>;
53
+ label: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
41
54
  serializedField: z.ZodObject<{
42
- type: z.ZodEnum<{
43
- boolean: "boolean";
44
- date: "date";
45
- embedded: "embedded";
46
- enum: "enum";
47
- money: "money";
48
- number: "number";
49
- text: "text";
50
- }>;
51
55
  fieldAccess: z.ZodOptional<z.ZodObject<{
52
56
  read: z.ZodOptional<z.ZodArray<z.ZodString>>;
53
57
  write: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -59,13 +63,18 @@ export declare const updateFieldPayloadSchema: z.ZodObject<{
59
63
  delete: "delete";
60
64
  }>;
61
65
  }, z.core.$strip>>;
66
+ type: z.ZodOptional<z.ZodEnum<{
67
+ boolean: "boolean";
68
+ date: "date";
69
+ embedded: "embedded";
70
+ enum: "enum";
71
+ money: "money";
72
+ number: "number";
73
+ text: "text";
74
+ }>>;
62
75
  }, z.core.$loose>;
63
- required: z.ZodDefault<z.ZodBoolean>;
64
- searchable: z.ZodDefault<z.ZodBoolean>;
65
- displayOrder: z.ZodDefault<z.ZodNumber>;
66
- label: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
67
76
  }, z.core.$strip>;
68
- export type UpdateFieldPayload = DefineFieldPayload;
77
+ export type UpdateFieldPayload = z.infer<typeof updateFieldPayloadSchema>;
69
78
  export declare const deleteFieldPayloadSchema: z.ZodObject<{
70
79
  entityName: z.ZodString;
71
80
  fieldKey: z.ZodString;
@@ -1,7 +1,7 @@
1
1
  import * as z from "zod";
2
2
  import { SUPPORTED_FIELD_TYPES } from "./constants.js";
3
3
  // Field-Type-Validator — pinnt valid type-Werte für fieldDefinition.
4
- const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
4
+ export const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
5
5
  // Per-field access-control. When set, `set/clear-custom-field` handlers
6
6
  // require the calling user to hold at least one of the listed roles —
7
7
  // in addition to the handler-level RBAC. Absent or empty `write` means
@@ -33,9 +33,7 @@ export const customFieldAccessSchema = z
33
33
  // Everything else stays loose pending B2's per-type discriminated-union —
34
34
  // EXCEPT `sensitive`, which is explicitly rejected below: custom fields
35
35
  // carry supplemental business data only, PII support was removed (#972).
36
- const serializedFieldSchema = z
37
- .looseObject({
38
- type: fieldTypeSchema,
36
+ const serializedFieldBaseShape = {
39
37
  fieldAccess: customFieldAccessSchema,
40
38
  // `retention` (T1.5d): per-field expiry. The retention-cron strips
41
39
  // values whose host-row `modified_at` is older than `keepFor`. Strategy
@@ -50,9 +48,19 @@ const serializedFieldSchema = z
50
48
  strategy: z.enum(["delete", "anonymize"]),
51
49
  })
52
50
  .optional(),
53
- })
51
+ };
52
+ const rejectSensitive = [
53
+ (v) => !("sensitive" in v),
54
+ "custom fields don't support PII — `sensitive` was removed (#972). Model personal data as a schema entity field with a pii/userOwned/tenantOwned annotation instead.",
55
+ ];
56
+ const serializedFieldSchema = z
57
+ .looseObject({ type: fieldTypeSchema, ...serializedFieldBaseShape })
54
58
  .refine((v) => typeof v["type"] === "string", "serializedField must have a string `type`")
55
- .refine((v) => !("sensitive" in v), "custom fields don't support PII — `sensitive` was removed (#972). Model personal data as a schema entity field with a pii/userOwned/tenantOwned annotation instead.");
59
+ .refine(...rejectSensitive);
60
+ // Update may omit `type`: it is immutable, so the handler takes it from the stored definition.
61
+ const updateSerializedFieldSchema = z
62
+ .looseObject({ type: fieldTypeSchema.optional(), ...serializedFieldBaseShape })
63
+ .refine(...rejectSensitive);
56
64
  // i18n-labels — `{ de: "...", en: "...", ... }`. Mindestens ein Eintrag.
57
65
  const labelSchema = z.record(z.string().min(2).max(8), z.string().min(1));
58
66
  // Payload für `define-tenant-field` und `define-system-field` write-handler.
@@ -69,11 +77,13 @@ export const defineFieldPayloadSchema = z.object({
69
77
  displayOrder: z.number().int().min(0).default(0),
70
78
  label: labelSchema.optional(),
71
79
  });
72
- // Payload für `update-tenant-field` — bewusst dieselbe Shape wie define
73
- // (Vollersatz-Semantik: der Edit-Screen schickt den kompletten neuen Stand;
74
- // entityName+fieldKey sind die Identität, type ist immutable und wird im
75
- // Handler gegen den Bestand geprüft).
76
- export const updateFieldPayloadSchema = defineFieldPayloadSchema;
80
+ // Payload for `update-tenant-field`: full replacement like define (the edit
81
+ // screen sends the complete new state; entityName+fieldKey are the identity),
82
+ // except that `serializedField.type` may be omitted. type is immutable; a
83
+ // differing value is refused in the handler.
84
+ export const updateFieldPayloadSchema = defineFieldPayloadSchema.extend({
85
+ serializedField: updateSerializedFieldSchema,
86
+ });
77
87
  // Payload für `delete-tenant-field` / `delete-system-field`.
78
88
  export const deleteFieldPayloadSchema = z.object({
79
89
  entityName: z.string().min(1).max(64),
@@ -25,6 +25,14 @@ const DEFAULT_CACHE_MAX_ENTRIES = 10_000;
25
25
  // complete/failed; either way the cache entry has no value. Falls back
26
26
  // to DB-lookup if actually needed.
27
27
  const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
28
+ // Run rows hang on SYSTEM_TENANT_ID and a null trigger is a cron/system run;
29
+ // job-run-table.ts declares whenAbsent: "tenant" for exactly that case, so the
30
+ // logger must resolve the same subject the entity chain would (fw#3654).
31
+ function jobRunSubject(triggeredById) {
32
+ return triggeredById === null
33
+ ? { kind: "tenant", tenantId: SYSTEM_TENANT_ID }
34
+ : { kind: "user", userId: triggeredById };
35
+ }
28
36
  // Same per-subject encryption as encryptFailureError/encryptStartedPayload,
29
37
  // applied to a single value — shared so payload/message/error all go
30
38
  // through the identical erased-key fallback. A key erased right before (or
@@ -33,9 +41,9 @@ const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
33
41
  // sentinel like decryptPiiValueForSubject does on the read side, instead of
34
42
  // throwing and dropping the rest of the batch — or, for onJobStart, the
35
43
  // whole run row.
36
- async function encryptOrSentinel(kms, subjectUserId, value, field) {
44
+ async function encryptOrSentinel(kms, subject, value, field) {
37
45
  try {
38
- return await encryptPiiValueForSubject(kms, { kind: "user", userId: subjectUserId }, value, { requestId: "jobs:job-run-logger" }, field);
46
+ return await encryptPiiValueForSubject(kms, subject, value, { requestId: "jobs:job-run-logger" }, field);
39
47
  }
40
48
  catch (e) {
41
49
  if (!(e instanceof KeyErasedError))
@@ -48,19 +56,18 @@ async function encryptOrSentinel(kms, subjectUserId, value, field) {
48
56
  // jobRunLogsTable — that table is unmanaged/direct-write, so there is no
49
57
  // event-piiFields catalog to lean on here either. "message" is the AAD
50
58
  // field name and must match the field passed to decryptStoredPii on read
51
- // (detail.query.ts) or decrypt fails loud. Same skip rules as the payload:
52
- // null subject (system/cron runs) and absent KMS (rollout mode) both stay
53
- // plaintext. Bounded concurrency (#2307) — unbounded Promise.all would fire
54
- // one getOrCreateDek per log line at once against the KMS adapter's pool.
59
+ // (detail.query.ts) or decrypt fails loud. Same skip rule as the payload:
60
+ // absent KMS (rollout mode) stays plaintext. Bounded concurrency (#2307) —
61
+ // unbounded Promise.all would fire one getOrCreateDek per log line at once
62
+ // against the KMS adapter's pool.
55
63
  async function encryptLogMessages(logs, triggeredById) {
56
- if (triggeredById === null)
57
- return [...logs];
58
64
  const kms = configuredPiiSubjectKms();
59
65
  if (!kms)
60
66
  return [...logs];
67
+ const subject = jobRunSubject(triggeredById);
61
68
  return mapWithConcurrency(logs, KMS_POOL_CONCURRENCY, async (log) => ({
62
69
  ...log,
63
- message: await encryptOrSentinel(kms, triggeredById, log.message, "message"),
70
+ message: await encryptOrSentinel(kms, subject, log.message, "message"),
64
71
  }));
65
72
  }
66
73
  // Mirrors encryptFailureError for the failure-path `error` column (#2307):
@@ -70,29 +77,26 @@ async function encryptLogMessages(logs, triggeredById) {
70
77
  // separate, per-row batch path outside onJobFailed) applies the identical
71
78
  // per-subject encryption instead of writing `error` in the clear.
72
79
  export async function encryptFailureError(error, triggeredById) {
73
- if (triggeredById === null)
74
- return error;
75
80
  const kms = configuredPiiSubjectKms();
76
81
  if (!kms)
77
82
  return error;
78
- return encryptOrSentinel(kms, triggeredById, error, "error");
83
+ return encryptOrSentinel(kms, jobRunSubject(triggeredById), error, "error");
79
84
  }
80
85
  // The run-started payload can carry arbitrary user data; triggeredById
81
86
  // names its owning user. No event-PII catalog involved (#2243 removed the
82
87
  // jobRun r.defineEvent registrations, so there is nothing to catalog) —
83
88
  // the subject is known statically, so we encrypt directly. A null subject
84
- // (system cron runs, recipient-less triggers) stays plaintext: there is no
85
- // user key to shred, mirroring the previous event-pii catalog's own skip
86
- // rule. Absent KMS adapter stays plaintext too (rollout mode, unchanged).
89
+ // (system cron runs, recipient-less triggers) encrypts under the system
90
+ // tenant key. Absent KMS adapter stays plaintext (rollout mode, unchanged).
87
91
  // A key already erased by the time the trigger lands (#2307) must not fail
88
92
  // the whole run-start write — sentinel like the log/error paths.
89
93
  async function encryptStartedPayload(payload, triggeredById) {
90
- if (payload === null || triggeredById === null)
94
+ if (payload === null)
91
95
  return payload;
92
96
  const kms = configuredPiiSubjectKms();
93
97
  if (!kms)
94
98
  return payload;
95
- return encryptOrSentinel(kms, triggeredById, payload, "payload");
99
+ return encryptOrSentinel(kms, jobRunSubject(triggeredById), payload, "payload");
96
100
  }
97
101
  // pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
98
102
  // framework's other fixed advisory-lock keys.
@@ -29,11 +29,18 @@ export const jobRunEntity = createEntity({
29
29
  status: createTextField({ required: true, personal: false, reason: "system_metadata" }),
30
30
  // The run-started payload can carry arbitrary caller-supplied data,
31
31
  // encrypted by job-run-logger.ts under the triggering user's DEK (see
32
- // jobs-pii-kms.integration.test.ts) — same subject as `triggeredById`.
33
- payload: createTextField({ personal: { of: "triggeredById" }, find: "none" }),
32
+ // jobs-pii-kms.integration.test.ts) — same subject as `triggeredById`;
33
+ // cron runs have none and use the system tenant key (whenAbsent).
34
+ payload: createTextField({
35
+ personal: { of: "triggeredById", whenAbsent: "tenant" },
36
+ find: "none",
37
+ }),
34
38
  // Failure messages can echo payload content back — same subject/DEK as
35
39
  // `payload` (job-run-logger.ts encryptFailureError).
36
- error: createTextField({ personal: { of: "triggeredById" }, find: "none" }),
40
+ error: createTextField({
41
+ personal: { of: "triggeredById", whenAbsent: "tenant" },
42
+ find: "none",
43
+ }),
37
44
  attempt: createNumberField({ required: true, default: 1, integer: true }),
38
45
  startedAt: createTimestampField({ required: true }),
39
46
  finishedAt: createTimestampField(),
@@ -67,9 +67,10 @@ export function createNoteEntryEntity(access, parents) {
67
67
  }),
68
68
  // Stamped once at write time, never re-resolved later — the history
69
69
  // needs the name as it was then, not whatever the account is called now.
70
+ // whenAbsent: authorless notes (system-written) encrypt under the tenant key.
70
71
  authorName: createTextField({
71
72
  maxLength: 200,
72
- personal: { of: "authorId" },
73
+ personal: { of: "authorId", whenAbsent: "tenant" },
73
74
  find: "none",
74
75
  }),
75
76
  body: createLongTextField({
@@ -1,10 +1,9 @@
1
1
  import { type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
2
2
  import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
- import type { Registry, UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
4
4
  export declare const noteEntryExportHook: UserDataExportHook;
5
5
  export declare function eraseNoteBodiesReachingSubject(args: {
6
6
  readonly db: DbRunner | TenantDb;
7
- readonly registry: Registry;
8
7
  readonly subject: SubjectId;
9
8
  readonly erasedBy: string;
10
9
  readonly requestId: string;
@@ -5,9 +5,7 @@
5
5
  // export-only, erasure via crypto-shredding.
6
6
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
7
  import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
8
- import { resolveRetentionPolicyForTenant, resolveTenantRetentionPreset, } from "../data-retention/index.js";
9
8
  import { noteEntryTable, noteMentionTable } from "../notes-history/index.js";
10
- import { policyToStrategy } from "../user-data-rights/index.js";
11
9
  // note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
12
10
  // per-user content, so the export filters by authorId directly.
13
11
  export const noteEntryExportHook = async (ctx) => {
@@ -56,10 +54,9 @@ async function selectNotesReachingSubject(db, subject) {
56
54
  // @-mentions the subject (`note-mention`, fw#2787) or hangs on it as host.
57
55
  // Free-text mentions are NOT detected — the documented gap in entity.ts.
58
56
  //
59
- // Per reached note the HOST entity's retention strategy (entityType, resolved
60
- // for the note's own tenant — the manual forget-subject path runs cross-tenant)
61
- // is consulted first: anonymize/blockDelete win over erasure, same rule the
62
- // automated forget-cleanup applies to every other entity.
57
+ // Reached bodies are shredded regardless of the host entity's retention strategy:
58
+ // the note text is free-form PII, and a host's anonymize/blockDelete protects the
59
+ // row, not its content, just like the host's own PII fields get anonymized.
63
60
  //
64
61
  // Without a mounted KMS this erases nothing: record-owned fields fall back to
65
62
  // plaintext framework-wide (see pii-field-encryption.ts), so apps that need
@@ -70,36 +67,7 @@ export async function eraseNoteBodiesReachingSubject(args) {
70
67
  if (!kms)
71
68
  return;
72
69
  const notes = await selectNotesReachingSubject(args.db, args.subject);
73
- const dataRetentionMounted = args.registry.features.has("data-retention");
74
- const presetByTenant = new Map();
75
- const strategyByTenantAndEntityType = new Map();
76
70
  for (const note of notes) {
77
- const strategyKey = `${note.tenantId}:${note.entityType}`;
78
- let strategy = strategyByTenantAndEntityType.get(strategyKey);
79
- if (strategy === undefined) {
80
- let tenantPreset = presetByTenant.get(note.tenantId);
81
- if (tenantPreset === undefined) {
82
- tenantPreset = await resolveTenantRetentionPreset({
83
- db: args.db,
84
- registry: args.registry,
85
- tenantId: note.tenantId,
86
- });
87
- presetByTenant.set(note.tenantId, tenantPreset);
88
- }
89
- const hostPolicy = await resolveRetentionPolicyForTenant({
90
- db: args.db,
91
- registry: args.registry,
92
- tenantId: note.tenantId,
93
- entityName: note.entityType,
94
- preloadedTenantPreset: tenantPreset,
95
- // Without data-retention there is no override table to read.
96
- ...(!dataRetentionMounted && { preloadedOverride: null }),
97
- });
98
- strategy = policyToStrategy(hostPolicy.policy?.strategy ?? null);
99
- strategyByTenantAndEntityType.set(strategyKey, strategy);
100
- }
101
- if (strategy === "anonymize")
102
- continue;
103
71
  await kms.eraseKey({ kind: "record", entity: "note-entry", id: note.id }, {
104
72
  requestId: args.requestId,
105
73
  userId: args.erasedBy,
@@ -110,7 +78,6 @@ export async function eraseNoteBodiesReachingSubject(args) {
110
78
  export const noteEntryDeleteHook = async (ctx) => {
111
79
  await eraseNoteBodiesReachingSubject({
112
80
  db: ctx.db,
113
- registry: ctx.registry,
114
81
  subject: { kind: "user", userId: ctx.userId },
115
82
  erasedBy: ctx.userId,
116
83
  requestId: "notes-history-user-data:forget-reached-notes",
@@ -8,7 +8,7 @@ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engi
8
8
  import { noteEntryDeleteHook, noteEntryExportHook, noteMentionDeleteHook, noteMentionExportHook, } from "./hooks.js";
9
9
  export { eraseNoteBodiesReachingSubject } from "./hooks.js";
10
10
  export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
11
- r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.");
11
+ r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (regardless of the host entity's retention strategy: the note text is free PII, the host's anonymize/blockDelete protects the row, not the content), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.");
12
12
  // user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
13
13
  // is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
14
14
  // would throw an "effectively disabled" boot warning even though the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.357.0",
3
+ "version": "0.359.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -516,12 +516,12 @@
516
516
  }
517
517
  },
518
518
  "dependencies": {
519
- "@cosmicdrift/kumiko-dispatcher-live": "0.357.0",
520
- "@cosmicdrift/kumiko-framework": "0.357.0",
521
- "@cosmicdrift/kumiko-headless": "0.357.0",
522
- "@cosmicdrift/kumiko-renderer": "0.357.0",
523
- "@cosmicdrift/kumiko-renderer-web": "0.357.0",
524
- "@cosmicdrift/kumiko-types": "0.357.0",
519
+ "@cosmicdrift/kumiko-dispatcher-live": "0.359.0",
520
+ "@cosmicdrift/kumiko-framework": "0.359.0",
521
+ "@cosmicdrift/kumiko-headless": "0.359.0",
522
+ "@cosmicdrift/kumiko-renderer": "0.359.0",
523
+ "@cosmicdrift/kumiko-renderer-web": "0.359.0",
524
+ "@cosmicdrift/kumiko-types": "0.359.0",
525
525
  "@mollie/api-client": "^4.5.0",
526
526
  "@node-rs/argon2": "^2.0.2",
527
527
  "@types/mailparser": "^3.4.6",
@@ -1050,8 +1050,8 @@
1050
1050
  ],
1051
1051
  "devDependencies": {
1052
1052
  "@testing-library/user-event": "^14.6.1",
1053
- "@cosmicdrift/kumiko-locale-de": "0.357.0",
1054
- "@cosmicdrift/kumiko-locale-es": "0.357.0",
1053
+ "@cosmicdrift/kumiko-locale-de": "0.359.0",
1054
+ "@cosmicdrift/kumiko-locale-es": "0.359.0",
1055
1055
  "jsqr": "^1.4.0"
1056
1056
  }
1057
1057
  }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.358.0",
4
+ "type": "improvement",
5
+ "title": "Reserve and settle cap usage outside the dispatcher",
6
+ "detail": "reserveCalendarCapUsage(ctx, { capName, periodStartIso, limit, profile, amount, notify? }) runs the same sweep, soft-hit notification and guarded hard-cap reservation as withCapEnforcement and returns a handle. settle(actualAmount) replaces the reserved amount by the actual one in one transaction (and books the full actual amount if the reservation was swept or released meanwhile); release() gives it back. Parallel callers cannot exceed the hard cap. It works with a HandlerContext (also r.systemScope() handlers) and a JobContext. Pattern: `const r = await reserveCalendarCapUsage(ctx, {...}); try { ...; await r.settle(actual) } catch (e) { await r.release(); throw e }`. bookCapUsage, markCapSoftWarned, releaseCapUsage and the enforceCap family accept a JobContext as well.",
7
+ "migration": "withCapEnforcement and bookCapUsage now work on r.systemScope() handlers; before, they failed on the fail-closed ctx.db. Reservation rows carry the tenant of ctx.user explicitly, and a context whose tenant-mode db belongs to another tenant than ctx.user is rejected with access_denied. enforceCap now filters the counter by ctx.user.tenantId, so a system-mode db no longer reads another tenant's counter."
8
+ },
2
9
  {
3
10
  "version": "0.352.0",
4
11
  "type": "breaking",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.359.0",
4
+ "type": "improvement",
5
+ "title": "update-tenant-field keeps the stored type when serializedField.type is omitted",
6
+ "detail": "custom-fields: update-tenant-field accepts serializedField without type and keeps the stored type; a different type is still refused with 422 field_type_immutable. The handler declares excludedFields [\"type\"]."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.358.0",
4
+ "type": "fix",
5
+ "title": "forget-subject shreds note bodies even when the host entity has anonymize or blockDelete retention",
6
+ "detail": "Note bodies that @-mention the forgotten subject or hang on it as host entity were skipped when the host entity's retention strategy was anonymize or blockDelete, so the note text stayed readable while the host's own PII fields were anonymized. The note text is free-form PII, and a retention strategy protects the row, not its content, so these bodies are now always crypto-shredded. This applies to the manual `forget-subject` command and to the automated Art. 17 delete hook."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "breaking",