@cosmicdrift/kumiko-samples 0.356.0 → 0.358.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 (49) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  4. package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
  5. package/packages/bundled-features/src/auth-mfa/screens.ts +8 -1
  6. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +71 -21
  7. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +99 -0
  8. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +143 -43
  9. package/packages/bundled-features/src/cap-counter/changes.json +7 -0
  10. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +18 -32
  11. package/packages/bundled-features/src/cap-counter/feature.ts +7 -1
  12. package/packages/bundled-features/src/cap-counter/index.ts +5 -0
  13. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -25
  14. package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
  15. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  16. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +15 -1
  17. package/packages/bundled-features/src/delivery/changes.json +6 -0
  18. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
  19. package/packages/bundled-features/src/delivery/constants.ts +5 -0
  20. package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
  21. package/packages/bundled-features/src/delivery/feature.ts +7 -1
  22. package/packages/bundled-features/src/delivery/jobs.ts +27 -1
  23. package/packages/bundled-features/src/delivery/testing.ts +1 -0
  24. package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -15
  25. package/packages/bundled-features/src/jobs/job-run-table.ts +10 -3
  26. package/packages/bundled-features/src/notes-history/changes.json +6 -0
  27. package/packages/bundled-features/src/notes-history/entity.ts +9 -7
  28. package/packages/bundled-features/src/notes-history-user-data/hooks.ts +84 -76
  29. package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
  30. package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
  31. package/packages/bundled-features/src/sessions/screens.ts +2 -0
  32. package/packages/bundled-features/src/tenant/changes.json +6 -0
  33. package/packages/bundled-features/src/tenant/constants.ts +6 -3
  34. package/packages/bundled-features/src/tenant/feature.ts +2 -0
  35. package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
  36. package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
  37. package/packages/bundled-features/src/tenant/screens.ts +9 -6
  38. package/packages/bundled-features/src/user/screens.ts +8 -1
  39. package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
  40. package/packages/bundled-features/src/waitlist/screens.ts +8 -1
  41. package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
  42. package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
  43. package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
  44. package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
  45. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  46. package/samples/apps/styleguide/src/features/widgets/feature.ts +1 -0
  47. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
  48. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
  49. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.356.0",
3
+ "version": "0.358.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.356.0",
3
+ "version": "0.358.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>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.357.0",
4
+ "type": "improvement",
5
+ "title": "MFA enable asks to confirm saved recovery codes, code field autocompletes one-time codes",
6
+ "detail": "secretMint `reveal.acknowledge` (i18n key) renders a checkbox on the reveal card that must be ticked before the confirm step or the acknowledge button can be used; the tick never reaches the payload. Text fields accept an `autoComplete` token that reaches the input. The MFA enable screen uses both: a \"saved my recovery codes\" gate and `one-time-code` on the code field"
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "breaking",
@@ -28,6 +28,7 @@ export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
28
28
  "mfa.enable.reveal.qr": { en: "Scan this QR code" },
29
29
  "mfa.enable.reveal.secret": { en: "Or enter this code manually" },
30
30
  "mfa.enable.reveal.recoveryCodes": { en: "Recovery codes" },
31
+ "mfa.enable.reveal.savedCodes": { en: "I have saved my recovery codes." },
31
32
 
32
33
  "mfa.enable.confirm.submit": { en: "Enable" },
33
34
  "mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
@@ -34,6 +34,7 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
34
34
  reveal: {
35
35
  title: i18nKey("mfa.enable.reveal.title"),
36
36
  warning: i18nKey("mfa.enable.reveal.warning"),
37
+ acknowledge: i18nKey("mfa.enable.reveal.savedCodes"),
37
38
  fields: [
38
39
  {
39
40
  field: "otpauthUri",
@@ -58,7 +59,13 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
58
59
  confirm: {
59
60
  handler: AuthMfaHandlers.enableConfirm,
60
61
  fields: {
61
- code: { type: "text", required: true, minLength: 6, maxLength: 6 },
62
+ code: {
63
+ type: "text",
64
+ required: true,
65
+ minLength: 6,
66
+ maxLength: 6,
67
+ autoComplete: "one-time-code",
68
+ },
62
69
  },
63
70
  layout: { sections: [{ fields: ["code"] }] },
64
71
  carry: ["setupToken"],
@@ -4,10 +4,12 @@ import { runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framewor
4
4
  import {
5
5
  createEntityExecutor,
6
6
  type HandlerContext,
7
+ type JobContext,
7
8
  type SessionUser,
8
9
  type TenantId,
9
10
  type WriteResult,
10
11
  } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
11
13
  import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
12
14
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
13
15
  import * as z from "zod";
@@ -82,17 +84,69 @@ export type BookCapUsageOptions = {
82
84
  readonly guardCurrentValue?: (currentValue: number) => void;
83
85
  };
84
86
 
85
- export function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb {
86
- if (!ctx.dbOutsideTransaction) {
87
+ export type CapCounterContext = HandlerContext | JobContext;
88
+
89
+ export type CapCounterScope = {
90
+ readonly user: SessionUser;
91
+ readonly readDb: TenantDb;
92
+ readonly writeDb: TenantDb;
93
+ };
94
+
95
+ // A tenant-mode db pins every row it writes to its own tenant; counter and reservation row would
96
+ // silently land on different tenants if that is not the user's.
97
+ function assertDbServesUser(db: TenantDb, user: SessionUser): TenantDb {
98
+ if (db.mode === "tenant" && db.tenantId !== user.tenantId) {
99
+ throw new AccessDeniedError({
100
+ message: "cap-counter: the context's database belongs to a different tenant than its user",
101
+ });
102
+ }
103
+ return db;
104
+ }
105
+
106
+ // Jobs write in place (their db is not transaction-bound); systemScope handlers reach both dbs via
107
+ // systemDb, because their ctx.db / ctx.dbOutsideTransaction are fail-closed proxies.
108
+ export function resolveCapCounterScope(
109
+ ctx: CapCounterContext,
110
+ outsideTransaction: boolean,
111
+ ): CapCounterScope {
112
+ // A job context spread into a handler-shaped one (user set) is a handler context: its user, not
113
+ // the job's systemUser, names the metered tenant.
114
+ if (!("user" in ctx) || !ctx.user) {
115
+ const jobUser = "systemUser" in ctx ? ctx.systemUser : undefined;
116
+ if (!jobUser) {
117
+ throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
118
+ }
119
+ const db = assertDbServesUser(ctx.db, jobUser);
120
+ return { user: jobUser, readDb: db, writeDb: db };
121
+ }
122
+ const { user } = ctx;
123
+ if (!user.tenantId) {
124
+ throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
125
+ }
126
+ if (ctx.systemDb) {
127
+ const readDb = ctx.systemDb.assertTenantMatch(user.tenantId);
128
+ const writeDb = outsideTransaction
129
+ ? ctx.systemDb.outsideTransaction.assertTenantMatch(user.tenantId)
130
+ : readDb;
131
+ return { user, readDb, writeDb };
132
+ }
133
+ const writeDb = outsideTransaction ? ctx.dbOutsideTransaction : ctx.db;
134
+ if (!writeDb) {
87
135
  throw new Error(
88
- "cap-counter.bookCapUsage: outsideTransaction requested but ctx.dbOutsideTransaction is undefined",
136
+ outsideTransaction
137
+ ? "cap-counter: outsideTransaction requested but ctx.dbOutsideTransaction is undefined"
138
+ : "cap-counter: ctx.db missing — run inside a handler context",
89
139
  );
90
140
  }
91
- return ctx.dbOutsideTransaction;
141
+ return {
142
+ user,
143
+ readDb: assertDbServesUser(ctx.db, user),
144
+ writeDb: assertDbServesUser(writeDb, user),
145
+ };
92
146
  }
93
147
 
94
148
  export async function bookCapUsage(
95
- ctx: HandlerContext,
149
+ ctx: CapCounterContext,
96
150
  options: BookCapUsageOptions,
97
151
  ): Promise<WriteResult> {
98
152
  return applyCapDelta(ctx, options, "add");
@@ -100,7 +154,7 @@ export async function bookCapUsage(
100
154
 
101
155
  // Gives back a reservation whose operation failed afterwards; the counter never drops below 0.
102
156
  export async function releaseCapUsage(
103
- ctx: HandlerContext,
157
+ ctx: CapCounterContext,
104
158
  options: Omit<BookCapUsageOptions, "guardCurrentValue">,
105
159
  ): Promise<WriteResult> {
106
160
  return applyCapDelta(ctx, options, "subtract");
@@ -157,18 +211,18 @@ export async function applyCapDeltaOn(
157
211
  }
158
212
 
159
213
  async function applyCapDelta(
160
- ctx: HandlerContext,
214
+ ctx: CapCounterContext,
161
215
  options: BookCapUsageOptions,
162
216
  direction: "add" | "subtract",
163
217
  ): Promise<WriteResult> {
218
+ const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
164
219
  if (options.outsideTransaction) {
165
- const outsideDb = requireOutsideTransactionDb(ctx);
166
220
  return retryCounterWriteOnVersionConflict(() =>
167
- runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)),
221
+ runInOwnTransaction(writeDb, (txDb) => applyCapDeltaOn(txDb, user, options, direction)),
168
222
  );
169
223
  }
170
224
  return retryCounterWriteOnVersionConflict(() =>
171
- applyCapDeltaOn(ctx.db, ctx.user, options, direction),
225
+ applyCapDeltaOn(writeDb, user, options, direction),
172
226
  );
173
227
  }
174
228
 
@@ -179,21 +233,18 @@ export type MarkCapSoftWarnedOptions = {
179
233
  };
180
234
 
181
235
  export async function markCapSoftWarned(
182
- ctx: HandlerContext,
236
+ ctx: CapCounterContext,
183
237
  options: MarkCapSoftWarnedOptions,
184
238
  ): Promise<WriteResult> {
185
239
  const parsed = capPeriodSchema.parse(options);
186
- const aggregateId = capCounterAggregateId(
187
- ctx.user.tenantId,
188
- parsed.capName,
189
- parsed.periodStartIso,
190
- );
240
+ const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
241
+ const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
191
242
 
192
243
  async function attemptMark(db: TenantDb): Promise<WriteResult> {
193
244
  const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
194
245
  if (existing.length === 0) {
195
246
  throw new Error(
196
- `cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`,
247
+ `cap-counter: cannot mark-soft-warned, no counter found for tenant=${user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`,
197
248
  );
198
249
  }
199
250
  const row = existing[0];
@@ -213,16 +264,15 @@ export async function markCapSoftWarned(
213
264
  version: currentVersion,
214
265
  changes: { lastSoftWarnedAt: Temporal.Now.instant() },
215
266
  },
216
- ctx.user,
267
+ user,
217
268
  db,
218
269
  );
219
270
  }
220
271
 
221
272
  if (options.outsideTransaction) {
222
- const outsideDb = requireOutsideTransactionDb(ctx);
223
- return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
273
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, attemptMark));
224
274
  }
225
- return retryCounterWriteOnVersionConflict(() => attemptMark(ctx.db));
275
+ return retryCounterWriteOnVersionConflict(() => attemptMark(writeDb));
226
276
  }
227
277
 
228
278
  export type ReadRollingCapUsageOptions = {
@@ -0,0 +1,99 @@
1
+ import type { CapCounterContext } from "./book-cap-usage.js";
2
+ import {
3
+ type CalendarCapReservation,
4
+ releaseExpiredCapReservations,
5
+ reserveCalendarCap,
6
+ settleCalendarCapReservation,
7
+ } from "./cap-reservation.js";
8
+ import {
9
+ assertBelowHardCap,
10
+ enforceCapAndMaybeNotify,
11
+ type SoftHitNotifier,
12
+ } from "./enforce-cap.js";
13
+ import type { CalendarCapDef } from "./with-cap-enforcement.js";
14
+
15
+ export type CalendarCapUsageReservationSpec = Omit<CalendarCapDef, "amount" | "notify"> & {
16
+ readonly amount: number;
17
+ readonly notify?: SoftHitNotifier;
18
+ // Default true: the reservation must commit on its own, not roll back with the caller's transaction.
19
+ readonly outsideTransaction?: boolean;
20
+ };
21
+
22
+ export type CalendarCapUsageReservation = {
23
+ readonly amount: number;
24
+ // Counter value right after this reservation was booked.
25
+ readonly counterValue: number;
26
+ // Replaces the reserved amount with the actual one; rejects a second settle and one after release.
27
+ settle(actualAmount: number): Promise<void>;
28
+ // Gives the reserved amount back; a no-op once settled.
29
+ release(): Promise<void>;
30
+ };
31
+
32
+ // Shared by withCapEnforcement and the public reservation API so both enforce identically.
33
+ export async function reserveEnforcedCalendarCap(
34
+ ctx: CapCounterContext,
35
+ cap: CalendarCapUsageReservationSpec,
36
+ ): Promise<CalendarCapReservation> {
37
+ const outsideTransaction = cap.outsideTransaction ?? true;
38
+ await releaseExpiredCapReservations(ctx, cap.capName, outsideTransaction);
39
+ if (cap.notify) {
40
+ await enforceCapAndMaybeNotify(ctx, {
41
+ capName: cap.capName,
42
+ periodStartIso: cap.periodStartIso,
43
+ limit: cap.limit,
44
+ profile: cap.profile,
45
+ notify: cap.notify,
46
+ amount: cap.amount,
47
+ markSoftWarnedOutsideTransaction: outsideTransaction,
48
+ });
49
+ }
50
+ return reserveCalendarCap(
51
+ ctx,
52
+ {
53
+ capName: cap.capName,
54
+ amount: cap.amount,
55
+ periodStartIso: cap.periodStartIso,
56
+ guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + cap.amount - 1, cap),
57
+ },
58
+ outsideTransaction,
59
+ );
60
+ }
61
+
62
+ type ReservationState = "open" | "settled" | "released";
63
+
64
+ export async function reserveCalendarCapUsage(
65
+ ctx: CapCounterContext,
66
+ spec: CalendarCapUsageReservationSpec,
67
+ ): Promise<CalendarCapUsageReservation> {
68
+ const outsideTransaction = spec.outsideTransaction ?? true;
69
+ const reservation = await reserveEnforcedCalendarCap(ctx, spec);
70
+ let state: ReservationState = "open";
71
+
72
+ return {
73
+ amount: spec.amount,
74
+ counterValue: reservation.counterValue,
75
+ async settle(actualAmount) {
76
+ if (state !== "open") {
77
+ throw new Error(`cap-counter: reservation for "${spec.capName}" is already ${state}`);
78
+ }
79
+ state = "settled";
80
+ try {
81
+ await settleCalendarCapReservation(
82
+ ctx,
83
+ reservation.reservationId,
84
+ { capName: spec.capName, periodStartIso: spec.periodStartIso, actualAmount },
85
+ outsideTransaction,
86
+ );
87
+ } catch (error) {
88
+ state = "open";
89
+ throw error;
90
+ }
91
+ },
92
+ async release() {
93
+ // skip: release after settle, or a second release, is a no-op by contract.
94
+ if (state !== "open") return;
95
+ await reservation.release();
96
+ state = "released";
97
+ },
98
+ };
99
+ }
@@ -1,9 +1,5 @@
1
1
  import { deleteMany, runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import type {
3
- HandlerContext,
4
- SessionUser,
5
- WriteResult,
6
- } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
7
3
  import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
8
4
  import {
9
5
  appendEventInTenantDb,
@@ -19,9 +15,10 @@ import { rollingCapAggregateId } from "./aggregate-id.js";
19
15
  import {
20
16
  applyCapDeltaOn,
21
17
  type BookCapUsageOptions,
18
+ type CapCounterContext,
22
19
  type ReadRollingCapUsageOptions,
23
20
  readRollingCapUsage,
24
- requireOutsideTransactionDb,
21
+ resolveCapCounterScope,
25
22
  retryCounterWriteOnVersionConflict,
26
23
  retryOnStreamVersionConflict,
27
24
  } from "./book-cap-usage.js";
@@ -47,10 +44,16 @@ const NOTHING_TO_RELEASE: WriteResult = { isSuccess: true, data: {} };
47
44
  type ReservationRowDraft = Pick<CapReservationRow, "capName" | "kind" | "amount"> &
48
45
  Pick<Partial<CapReservationRow>, "periodStartIso">;
49
46
 
50
- async function insertReservationRow(txDb: TenantDb, id: string, draft: ReservationRowDraft) {
47
+ async function insertReservationRow(
48
+ txDb: TenantDb,
49
+ user: SessionUser,
50
+ id: string,
51
+ draft: ReservationRowDraft,
52
+ ) {
51
53
  const now = Temporal.Now.instant();
52
54
  await txDb.insertOne(capReservationsTable, {
53
55
  id,
56
+ tenantId: user.tenantId,
54
57
  capName: draft.capName,
55
58
  kind: draft.kind,
56
59
  periodStartIso: draft.periodStartIso ?? null,
@@ -60,9 +63,19 @@ async function insertReservationRow(txDb: TenantDb, id: string, draft: Reservati
60
63
  });
61
64
  }
62
65
 
63
- function toHandle(ctx: HandlerContext, id: string): ReservationHandle {
66
+ export type CalendarCapReservation = ReservationHandle & {
67
+ readonly reservationId: string;
68
+ // Counter value right after this reservation was booked.
69
+ readonly counterValue: number;
70
+ };
71
+
72
+ function toHandle(
73
+ ctx: CapCounterContext,
74
+ id: string,
75
+ outsideTransaction: boolean,
76
+ ): ReservationHandle {
64
77
  return {
65
- release: () => releaseCapReservation(ctx, id),
78
+ release: () => releaseCapReservation(ctx, id, outsideTransaction),
66
79
  confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
67
80
  };
68
81
  }
@@ -75,16 +88,26 @@ export type ReserveCalendarCapOptions = Required<
75
88
  // The counter increment and the reservation row commit together, so a booked amount never exists
76
89
  // without a row that can give it back.
77
90
  export async function reserveCalendarCap(
78
- ctx: HandlerContext,
91
+ ctx: CapCounterContext,
79
92
  options: ReserveCalendarCapOptions,
80
- ): Promise<ReservationHandle> {
81
- const outsideDb = requireOutsideTransactionDb(ctx);
93
+ outsideTransaction = true,
94
+ ): Promise<CalendarCapReservation> {
95
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
82
96
  const id = generateId();
97
+ // The last attempt is the one that committed, so this holds the value its increment started from.
98
+ let valueBeforeBooking = 0;
99
+ const observedOptions: ReserveCalendarCapOptions = {
100
+ ...options,
101
+ guardCurrentValue: (currentValue) => {
102
+ options.guardCurrentValue?.(currentValue);
103
+ valueBeforeBooking = currentValue;
104
+ },
105
+ };
83
106
  const reserved = await retryCounterWriteOnVersionConflict(() =>
84
- runInOwnTransaction(outsideDb, async (txDb) => {
85
- const booked = await applyCapDeltaOn(txDb, ctx.user, options, "add");
107
+ runInOwnTransaction(writeDb, async (txDb) => {
108
+ const booked = await applyCapDeltaOn(txDb, user, observedOptions, "add");
86
109
  if (!booked.isSuccess) return booked;
87
- await insertReservationRow(txDb, id, {
110
+ await insertReservationRow(txDb, user, id, {
88
111
  capName: options.capName,
89
112
  kind: "calendar",
90
113
  periodStartIso: options.periodStartIso,
@@ -94,7 +117,11 @@ export async function reserveCalendarCap(
94
117
  }),
95
118
  );
96
119
  if (!reserved.isSuccess) throw reraiseAsKumikoError(reserved.error);
97
- return toHandle(ctx, id);
120
+ return {
121
+ ...toHandle(ctx, id, outsideTransaction),
122
+ reservationId: id,
123
+ counterValue: valueBeforeBooking + options.amount,
124
+ };
98
125
  }
99
126
 
100
127
  export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
@@ -123,28 +150,23 @@ function rollingEvent(
123
150
  // The append is version-guarded: a booker that read the usage before another one appended loses the
124
151
  // race and repeats the whole attempt, so the hard-cap check always sees the stream it appends to.
125
152
  export async function reserveRollingCap(
126
- ctx: HandlerContext,
153
+ ctx: CapCounterContext,
127
154
  options: ReserveRollingCapOptions,
155
+ outsideTransaction = true,
128
156
  ): Promise<ReservationHandle> {
129
- const outsideDb = requireOutsideTransactionDb(ctx);
157
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
130
158
  const id = generateId();
131
- const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
159
+ const aggregateId = rollingCapAggregateId(user.tenantId, options.capName);
132
160
  await retryOnStreamVersionConflict(() =>
133
- runInOwnTransaction(outsideDb, async (txDb) => {
161
+ runInOwnTransaction(writeDb, async (txDb) => {
134
162
  const version = await getStreamVersionInTenantDb(txDb, aggregateId);
135
- const usage = await readRollingCapUsage(txDb, ctx.user.tenantId, options);
163
+ const usage = await readRollingCapUsage(txDb, user.tenantId, options);
136
164
  options.guardCurrentValue?.(usage);
137
165
  await appendEventInTenantDb(
138
166
  txDb,
139
- rollingEvent(
140
- ctx.user,
141
- options.capName,
142
- options.amount,
143
- version,
144
- ROLLING_INCREMENTED_EVENT_QN,
145
- ),
167
+ rollingEvent(user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN),
146
168
  );
147
- await insertReservationRow(txDb, id, {
169
+ await insertReservationRow(txDb, user, id, {
148
170
  capName: options.capName,
149
171
  kind: "rolling",
150
172
  amount: options.amount,
@@ -152,7 +174,7 @@ export async function reserveRollingCap(
152
174
  return NOTHING_TO_RELEASE;
153
175
  }),
154
176
  );
155
- return toHandle(ctx, id);
177
+ return toHandle(ctx, id, outsideTransaction);
156
178
  }
157
179
 
158
180
  // Deleting the row takes its row lock inside the handler transaction, so a release started after a
@@ -201,45 +223,123 @@ async function giveBackReservation(
201
223
  user: SessionUser,
202
224
  id: string,
203
225
  ): Promise<WriteResult> {
204
- const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, { id });
226
+ const ownRow = { id, tenantId: user.tenantId };
227
+ const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, ownRow);
205
228
  if (!row) return NOTHING_TO_RELEASE;
206
- // Waits for a confirm still holding the row; zero rows back means that confirm committed.
207
- const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id });
208
- if (claimed.length === 0) return NOTHING_TO_RELEASE;
229
+ const claimed = await claimReservationRow(txDb, user, row);
230
+ if (!claimed) return NOTHING_TO_RELEASE;
209
231
  const given =
210
232
  row.kind === "rolling"
211
233
  ? await giveBackRolling(txDb, user, row)
212
234
  : await giveBackCalendar(txDb, user, row);
213
235
  if (!given.isSuccess) return given;
214
- await txDb.deleteMany(capReservationsTable, { id });
236
+ await txDb.deleteMany(capReservationsTable, ownRow);
215
237
  return given;
216
238
  }
217
239
 
218
- export async function releaseCapReservation(ctx: HandlerContext, id: string): Promise<void> {
219
- const outsideDb = requireOutsideTransactionDb(ctx);
240
+ // Waits for a confirm still holding the row; false means that confirm (or another release) won.
241
+ async function claimReservationRow(
242
+ txDb: TenantDb,
243
+ user: SessionUser,
244
+ row: CapReservationRow,
245
+ ): Promise<boolean> {
246
+ const claimed = await txDb.updateMany(
247
+ capReservationsTable,
248
+ { expiresAt: row.expiresAt },
249
+ { id: row.id, tenantId: user.tenantId },
250
+ );
251
+ return claimed.length > 0;
252
+ }
253
+
254
+ export async function releaseCapReservation(
255
+ ctx: CapCounterContext,
256
+ id: string,
257
+ outsideTransaction = true,
258
+ ): Promise<void> {
259
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
220
260
  const released = await retryOnStreamVersionConflict(() =>
221
261
  retryCounterWriteOnVersionConflict(() =>
222
- runInOwnTransaction(outsideDb, (txDb) => giveBackReservation(txDb, ctx.user, id)),
262
+ runInOwnTransaction(writeDb, (txDb) => giveBackReservation(txDb, user, id)),
223
263
  ),
224
264
  );
225
265
  if (!released.isSuccess) throw reraiseAsKumikoError(released.error);
226
266
  }
227
267
 
268
+ export type SettleCalendarCapOptions = {
269
+ readonly capName: string;
270
+ readonly periodStartIso: string;
271
+ readonly actualAmount: number;
272
+ };
273
+
274
+ function bookCalendarDelta(
275
+ txDb: TenantDb,
276
+ user: SessionUser,
277
+ period: Pick<SettleCalendarCapOptions, "capName" | "periodStartIso">,
278
+ delta: number,
279
+ ): Promise<WriteResult> {
280
+ if (delta === 0) return Promise.resolve({ isSuccess: true, data: {} });
281
+ return applyCapDeltaOn(
282
+ txDb,
283
+ user,
284
+ { ...period, amount: Math.abs(delta) },
285
+ delta > 0 ? "add" : "subtract",
286
+ );
287
+ }
288
+
289
+ // Replaces the reserved amount by the actual one in a single transaction. When the row is already
290
+ // gone (swept or released), the reserved amount was given back, so the full actual amount is booked.
291
+ export async function settleCalendarCapReservation(
292
+ ctx: CapCounterContext,
293
+ id: string,
294
+ options: SettleCalendarCapOptions,
295
+ outsideTransaction = true,
296
+ ): Promise<void> {
297
+ if (!Number.isInteger(options.actualAmount) || options.actualAmount < 0) {
298
+ throw new Error(
299
+ `cap-counter: settle amount must be a non-negative integer, got ${options.actualAmount}`,
300
+ );
301
+ }
302
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
303
+ const settled = await retryCounterWriteOnVersionConflict(() =>
304
+ runInOwnTransaction(writeDb, async (txDb) => {
305
+ const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, {
306
+ id,
307
+ tenantId: user.tenantId,
308
+ });
309
+ const claimed = row ? await claimReservationRow(txDb, user, row) : false;
310
+ if (!row || !claimed) {
311
+ return bookCalendarDelta(txDb, user, options, options.actualAmount);
312
+ }
313
+ const booked = await bookCalendarDelta(
314
+ txDb,
315
+ user,
316
+ options,
317
+ options.actualAmount - row.amount,
318
+ );
319
+ if (!booked.isSuccess) return booked;
320
+ await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
321
+ return booked;
322
+ }),
323
+ );
324
+ if (!settled.isSuccess) throw reraiseAsKumikoError(settled.error);
325
+ }
326
+
228
327
  // A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
229
328
  // capacity stays counted.
230
329
  export async function releaseExpiredCapReservations(
231
- ctx: HandlerContext,
330
+ ctx: CapCounterContext,
232
331
  capName: string,
332
+ outsideTransaction = true,
233
333
  ): Promise<void> {
234
- const outsideDb = requireOutsideTransactionDb(ctx);
235
- const expired = await outsideDb.selectMany<Pick<CapReservationRow, "id">>(capReservationsTable, {
236
- tenantId: ctx.user.tenantId,
334
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
335
+ const expired = await writeDb.selectMany<Pick<CapReservationRow, "id">>(capReservationsTable, {
336
+ tenantId: user.tenantId,
237
337
  capName,
238
338
  expiresAt: { lt: Temporal.Now.instant() },
239
339
  });
240
340
  for (const { id } of expired) {
241
341
  try {
242
- await releaseCapReservation(ctx, id);
342
+ await releaseCapReservation(ctx, id, outsideTransaction);
243
343
  } catch (error) {
244
344
  ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });
245
345
  }
@@ -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",