@cosmicdrift/kumiko-samples 0.351.0 → 0.352.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 +2 -2
  3. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
  4. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
  5. package/packages/bundled-features/src/cap-counter/changes.json +28 -0
  6. package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
  7. package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
  8. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
  9. package/packages/bundled-features/src/cap-counter/index.ts +9 -0
  10. package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
  11. package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
  12. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
  13. package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
  14. package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
  15. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
  16. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  17. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  18. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  19. package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
  20. package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
  21. package/packages/bundled-features/src/delivery/changes.json +12 -0
  22. package/packages/bundled-features/src/delivery/constants.ts +1 -0
  23. package/packages/bundled-features/src/delivery/events.ts +3 -0
  24. package/packages/bundled-features/src/delivery/feature.ts +50 -2
  25. package/packages/bundled-features/src/delivery/index.ts +6 -0
  26. package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
  27. package/packages/bundled-features/src/delivery/public-names.ts +5 -0
  28. package/packages/bundled-features/src/delivery/types.ts +12 -2
  29. package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
  30. package/packages/bundled-features/src/legal-pages/README.md +3 -3
  31. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  32. package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
  33. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  34. package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
  35. package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
  36. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
  37. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
  38. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  39. package/packages/bundled-features/src/tier-engine/index.ts +7 -0
  40. package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
  41. package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
  42. package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
  43. package/samples/apps/styleguide/src/app/shell.tsx +1 -1
  44. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
  45. package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
  46. package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
  47. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  48. package/samples/recipes/files-post-processing/package.json +1 -1
  49. package/samples/recipes/seed-migration/README.md +13 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.351.0",
3
+ "version": "0.352.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.351.0",
3
+ "version": "0.352.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>",
@@ -164,7 +164,7 @@
164
164
  "nodemailer": "^10.0.13",
165
165
  "qrcode": "^1.5.4",
166
166
  "react": "^19.2.6",
167
- "sharp": "^0.35.4",
167
+ "sharp": "^0.35.5",
168
168
  "stripe": "^22.6.2",
169
169
  "tailwind-merge": "^3.6.0",
170
170
  "temporal-polyfill": "^0.3.2",
@@ -4,14 +4,19 @@ import { runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framewor
4
4
  import {
5
5
  createEntityExecutor,
6
6
  type HandlerContext,
7
+ type SessionUser,
7
8
  type TenantId,
8
9
  type WriteResult,
9
10
  } from "@cosmicdrift/kumiko-framework/engine";
10
- import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
11
12
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
12
13
  import * as z from "zod";
13
14
  import { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
14
- import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN } from "./constants.js";
15
+ import {
16
+ CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
17
+ ROLLING_INCREMENTED_EVENT_QN,
18
+ ROLLING_RELEASED_EVENT_QN,
19
+ } from "./constants.js";
15
20
  import { capCounterEntity } from "./entity.js";
16
21
 
17
22
  const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
@@ -39,7 +44,7 @@ function sleepMs(ms: number): Promise<void> {
39
44
  return new Promise((resolve) => setTimeout(resolve, ms));
40
45
  }
41
46
 
42
- async function retryCounterWriteOnVersionConflict(
47
+ export async function retryCounterWriteOnVersionConflict(
43
48
  writeAttempt: () => Promise<WriteResult>,
44
49
  ): Promise<WriteResult> {
45
50
  let result: WriteResult = await writeAttempt();
@@ -54,6 +59,20 @@ async function retryCounterWriteOnVersionConflict(
54
59
  return result;
55
60
  }
56
61
 
62
+ // Event-store appends signal a lost race by throwing; the whole attempt (read, guard, append) is repeated.
63
+ export async function retryOnStreamVersionConflict<T>(attempt: () => Promise<T>): Promise<T> {
64
+ for (let tries = 1; ; tries++) {
65
+ try {
66
+ return await attempt();
67
+ } catch (error) {
68
+ if (!(error instanceof VersionConflictError) || tries >= MAX_COUNTER_WRITE_ATTEMPTS) {
69
+ throw error;
70
+ }
71
+ await sleepMs(Math.random() * tries * BACKOFF_JITTER_MS_PER_ATTEMPT);
72
+ }
73
+ }
74
+ }
75
+
57
76
  export type BookCapUsageOptions = {
58
77
  readonly capName: string;
59
78
  readonly periodStartIso: string;
@@ -63,7 +82,7 @@ export type BookCapUsageOptions = {
63
82
  readonly guardCurrentValue?: (currentValue: number) => void;
64
83
  };
65
84
 
66
- function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb {
85
+ export function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb {
67
86
  if (!ctx.dbOutsideTransaction) {
68
87
  throw new Error(
69
88
  "cap-counter.bookCapUsage: outsideTransaction requested but ctx.dbOutsideTransaction is undefined",
@@ -87,64 +106,70 @@ export async function releaseCapUsage(
87
106
  return applyCapDelta(ctx, options, "subtract");
88
107
  }
89
108
 
90
- async function applyCapDelta(
91
- ctx: HandlerContext,
109
+ // One attempt against an already-open db handle; a lost race comes back as a version_conflict failure the caller retries.
110
+ export async function applyCapDeltaOn(
111
+ db: TenantDb,
112
+ user: SessionUser,
92
113
  options: BookCapUsageOptions,
93
114
  direction: "add" | "subtract",
94
115
  ): Promise<WriteResult> {
95
116
  const parsed = capBookingSchema.parse(options);
96
- const aggregateId = capCounterAggregateId(
97
- ctx.user.tenantId,
98
- parsed.capName,
99
- parsed.periodStartIso,
100
- );
101
-
102
- async function attemptWrite(db: TenantDb): Promise<WriteResult> {
103
- const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
104
- if (existing.length === 0) {
105
- options.guardCurrentValue?.(0);
106
- if (direction === "subtract") return { isSuccess: true, data: {} };
107
- return executor.create(
108
- {
109
- id: aggregateId,
110
- capName: parsed.capName,
111
- value: parsed.amount,
112
- periodStart: Temporal.Instant.from(parsed.periodStartIso),
113
- lastSoftWarnedAt: null,
114
- },
115
- ctx.user,
116
- db,
117
- );
118
- }
117
+ const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
119
118
 
120
- const currentRow = existing[0];
121
- if (!currentRow) {
122
- throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
123
- }
124
- const currentValue = currentRow["value"] as number; // @cast-boundary db-row
125
- const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
126
- options.guardCurrentValue?.(currentValue);
127
- return executor.update(
119
+ const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
120
+ if (existing.length === 0) {
121
+ options.guardCurrentValue?.(0);
122
+ if (direction === "subtract") return { isSuccess: true, data: {} };
123
+ return executor.create(
128
124
  {
129
125
  id: aggregateId,
130
- version: currentVersion,
131
- changes: {
132
- value:
133
- direction === "add"
134
- ? currentValue + parsed.amount
135
- : Math.max(0, currentValue - parsed.amount),
136
- },
126
+ capName: parsed.capName,
127
+ value: parsed.amount,
128
+ periodStart: Temporal.Instant.from(parsed.periodStartIso),
129
+ lastSoftWarnedAt: null,
137
130
  },
138
- ctx.user,
131
+ user,
139
132
  db,
140
133
  );
141
134
  }
142
135
 
136
+ const currentRow = existing[0];
137
+ if (!currentRow) {
138
+ throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
139
+ }
140
+ const currentValue = currentRow["value"] as number; // @cast-boundary db-row
141
+ const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
142
+ options.guardCurrentValue?.(currentValue);
143
+ return executor.update(
144
+ {
145
+ id: aggregateId,
146
+ version: currentVersion,
147
+ changes: {
148
+ value:
149
+ direction === "add"
150
+ ? currentValue + parsed.amount
151
+ : Math.max(0, currentValue - parsed.amount),
152
+ },
153
+ },
154
+ user,
155
+ db,
156
+ );
157
+ }
158
+
159
+ async function applyCapDelta(
160
+ ctx: HandlerContext,
161
+ options: BookCapUsageOptions,
162
+ direction: "add" | "subtract",
163
+ ): Promise<WriteResult> {
143
164
  if (options.outsideTransaction) {
144
165
  const outsideDb = requireOutsideTransactionDb(ctx);
145
- return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptWrite));
166
+ return retryCounterWriteOnVersionConflict(() =>
167
+ runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)),
168
+ );
146
169
  }
147
- return retryCounterWriteOnVersionConflict(() => attemptWrite(ctx.db));
170
+ return retryCounterWriteOnVersionConflict(() =>
171
+ applyCapDeltaOn(ctx.db, ctx.user, options, direction),
172
+ );
148
173
  }
149
174
 
150
175
  export type MarkCapSoftWarnedOptions = {
@@ -205,12 +230,11 @@ export type ReadRollingCapUsageOptions = {
205
230
  readonly windowDays: number;
206
231
  };
207
232
 
208
- // Sums usage without throwing — for callers that only need the raw number
209
- // (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
210
- export async function readRollingCapUsage(
233
+ async function sumRollingEventAmounts(
211
234
  db: TenantDb,
212
235
  tenantId: TenantId,
213
236
  options: ReadRollingCapUsageOptions,
237
+ type: string,
214
238
  ): Promise<number> {
215
239
  const aggregateId = rollingCapAggregateId(tenantId, options.capName);
216
240
  const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
@@ -219,20 +243,37 @@ export async function readRollingCapUsage(
219
243
  tenantId,
220
244
  aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
221
245
  aggregateId,
222
- type: ROLLING_INCREMENTED_EVENT_QN,
246
+ type,
223
247
  createdAt: { gte: cutoff },
224
248
  });
225
249
 
226
- let value = 0;
250
+ let total = 0;
227
251
  for (const row of rows) {
228
252
  // @cast-boundary engine-payload — events.payload is jsonb (typed as
229
253
  // unknown by drizzle's $type<Record<string,unknown>>); narrowing the
230
254
  // shape here mirrors enforceRollingCap's own read-side contract.
231
255
  const payload = row["payload"] as { amount?: number };
232
256
  if (typeof payload.amount === "number") {
233
- value += payload.amount;
257
+ total += payload.amount;
234
258
  }
235
259
  }
260
+ return total;
261
+ }
236
262
 
237
- return value;
263
+ // Sums usage without throwing — for callers that only need the raw number
264
+ // (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
265
+ // Incremented minus released amounts inside the window; a release whose increment already left the window cannot push it below 0.
266
+ export async function readRollingCapUsage(
267
+ db: TenantDb,
268
+ tenantId: TenantId,
269
+ options: ReadRollingCapUsageOptions,
270
+ ): Promise<number> {
271
+ const incremented = await sumRollingEventAmounts(
272
+ db,
273
+ tenantId,
274
+ options,
275
+ ROLLING_INCREMENTED_EVENT_QN,
276
+ );
277
+ const released = await sumRollingEventAmounts(db, tenantId, options, ROLLING_RELEASED_EVENT_QN);
278
+ return Math.max(0, incremented - released);
238
279
  }
@@ -0,0 +1,247 @@
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";
7
+ import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
8
+ import {
9
+ appendEventInTenantDb,
10
+ getStreamVersionInTenantDb,
11
+ } from "@cosmicdrift/kumiko-framework/event-store";
12
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
13
+ import type {
14
+ ReservationConfirmContext,
15
+ ReservationHandle,
16
+ } from "@cosmicdrift/kumiko-types/handlers";
17
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
18
+ import { rollingCapAggregateId } from "./aggregate-id.js";
19
+ import {
20
+ applyCapDeltaOn,
21
+ type BookCapUsageOptions,
22
+ type ReadRollingCapUsageOptions,
23
+ readRollingCapUsage,
24
+ requireOutsideTransactionDb,
25
+ retryCounterWriteOnVersionConflict,
26
+ retryOnStreamVersionConflict,
27
+ } from "./book-cap-usage.js";
28
+ import {
29
+ CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
30
+ CAP_RESERVATION_TTL_MINUTES,
31
+ ROLLING_INCREMENTED_EVENT_QN,
32
+ ROLLING_RELEASED_EVENT_QN,
33
+ } from "./constants.js";
34
+ import { capReservationsTable } from "./tables.js";
35
+
36
+ type CapReservationRow = {
37
+ readonly id: string;
38
+ readonly capName: string;
39
+ readonly kind: "calendar" | "rolling";
40
+ readonly periodStartIso: string | null;
41
+ readonly amount: number;
42
+ readonly expiresAt: Temporal.Instant;
43
+ };
44
+
45
+ const NOTHING_TO_RELEASE: WriteResult = { isSuccess: true, data: {} };
46
+
47
+ type ReservationRowDraft = Pick<CapReservationRow, "capName" | "kind" | "amount"> &
48
+ Pick<Partial<CapReservationRow>, "periodStartIso">;
49
+
50
+ async function insertReservationRow(txDb: TenantDb, id: string, draft: ReservationRowDraft) {
51
+ const now = Temporal.Now.instant();
52
+ await txDb.insertOne(capReservationsTable, {
53
+ id,
54
+ capName: draft.capName,
55
+ kind: draft.kind,
56
+ periodStartIso: draft.periodStartIso ?? null,
57
+ amount: draft.amount,
58
+ expiresAt: now.add({ minutes: CAP_RESERVATION_TTL_MINUTES }),
59
+ createdAt: now,
60
+ });
61
+ }
62
+
63
+ function toHandle(ctx: HandlerContext, id: string): ReservationHandle {
64
+ return {
65
+ release: () => releaseCapReservation(ctx, id),
66
+ confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
67
+ };
68
+ }
69
+
70
+ export type ReserveCalendarCapOptions = Required<
71
+ Pick<BookCapUsageOptions, "capName" | "periodStartIso" | "amount">
72
+ > &
73
+ Pick<BookCapUsageOptions, "guardCurrentValue">;
74
+
75
+ // The counter increment and the reservation row commit together, so a booked amount never exists
76
+ // without a row that can give it back.
77
+ export async function reserveCalendarCap(
78
+ ctx: HandlerContext,
79
+ options: ReserveCalendarCapOptions,
80
+ ): Promise<ReservationHandle> {
81
+ const outsideDb = requireOutsideTransactionDb(ctx);
82
+ const id = generateId();
83
+ const reserved = await retryCounterWriteOnVersionConflict(() =>
84
+ runInOwnTransaction(outsideDb, async (txDb) => {
85
+ const booked = await applyCapDeltaOn(txDb, ctx.user, options, "add");
86
+ if (!booked.isSuccess) return booked;
87
+ await insertReservationRow(txDb, id, {
88
+ capName: options.capName,
89
+ kind: "calendar",
90
+ periodStartIso: options.periodStartIso,
91
+ amount: options.amount,
92
+ });
93
+ return booked;
94
+ }),
95
+ );
96
+ if (!reserved.isSuccess) throw reraiseAsKumikoError(reserved.error);
97
+ return toHandle(ctx, id);
98
+ }
99
+
100
+ export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
101
+ readonly amount: number;
102
+ // Runs on every attempt with the usage seen by that attempt's append.
103
+ readonly guardCurrentValue?: (currentValue: number) => void;
104
+ };
105
+
106
+ function rollingEvent(
107
+ user: SessionUser,
108
+ capName: string,
109
+ amount: number,
110
+ expectedVersion: number,
111
+ type: string,
112
+ ) {
113
+ return {
114
+ aggregateId: rollingCapAggregateId(user.tenantId, capName),
115
+ aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
116
+ expectedVersion,
117
+ type,
118
+ payload: { capName, amount },
119
+ metadata: { userId: user.id },
120
+ };
121
+ }
122
+
123
+ // The append is version-guarded: a booker that read the usage before another one appended loses the
124
+ // race and repeats the whole attempt, so the hard-cap check always sees the stream it appends to.
125
+ export async function reserveRollingCap(
126
+ ctx: HandlerContext,
127
+ options: ReserveRollingCapOptions,
128
+ ): Promise<ReservationHandle> {
129
+ const outsideDb = requireOutsideTransactionDb(ctx);
130
+ const id = generateId();
131
+ const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
132
+ await retryOnStreamVersionConflict(() =>
133
+ runInOwnTransaction(outsideDb, async (txDb) => {
134
+ const version = await getStreamVersionInTenantDb(txDb, aggregateId);
135
+ const usage = await readRollingCapUsage(txDb, ctx.user.tenantId, options);
136
+ options.guardCurrentValue?.(usage);
137
+ await appendEventInTenantDb(
138
+ txDb,
139
+ rollingEvent(
140
+ ctx.user,
141
+ options.capName,
142
+ options.amount,
143
+ version,
144
+ ROLLING_INCREMENTED_EVENT_QN,
145
+ ),
146
+ );
147
+ await insertReservationRow(txDb, id, {
148
+ capName: options.capName,
149
+ kind: "rolling",
150
+ amount: options.amount,
151
+ });
152
+ return NOTHING_TO_RELEASE;
153
+ }),
154
+ );
155
+ return toHandle(ctx, id);
156
+ }
157
+
158
+ // Deleting the row takes its row lock inside the handler transaction, so a release started after a
159
+ // failed COMMIT waits for the real outcome and finds the row gone when the commit went through.
160
+ async function confirmCapReservation(
161
+ confirm: ReservationConfirmContext,
162
+ id: string,
163
+ ): Promise<void> {
164
+ await deleteMany(confirm.tx, capReservationsTable, { id, tenantId: confirm.tenantId });
165
+ }
166
+
167
+ async function giveBackCalendar(
168
+ txDb: TenantDb,
169
+ user: SessionUser,
170
+ row: CapReservationRow,
171
+ ): Promise<WriteResult> {
172
+ if (row.periodStartIso === null) {
173
+ throw new Error(`cap-counter: calendar reservation ${row.id} has no period to release against`);
174
+ }
175
+ return applyCapDeltaOn(
176
+ txDb,
177
+ user,
178
+ { capName: row.capName, periodStartIso: row.periodStartIso, amount: row.amount },
179
+ "subtract",
180
+ );
181
+ }
182
+
183
+ async function giveBackRolling(
184
+ txDb: TenantDb,
185
+ user: SessionUser,
186
+ row: CapReservationRow,
187
+ ): Promise<WriteResult> {
188
+ const version = await getStreamVersionInTenantDb(
189
+ txDb,
190
+ rollingCapAggregateId(user.tenantId, row.capName),
191
+ );
192
+ await appendEventInTenantDb(
193
+ txDb,
194
+ rollingEvent(user, row.capName, row.amount, version, ROLLING_RELEASED_EVENT_QN),
195
+ );
196
+ return NOTHING_TO_RELEASE;
197
+ }
198
+
199
+ async function giveBackReservation(
200
+ txDb: TenantDb,
201
+ user: SessionUser,
202
+ id: string,
203
+ ): Promise<WriteResult> {
204
+ const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, { id });
205
+ 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;
209
+ const given =
210
+ row.kind === "rolling"
211
+ ? await giveBackRolling(txDb, user, row)
212
+ : await giveBackCalendar(txDb, user, row);
213
+ if (!given.isSuccess) return given;
214
+ await txDb.deleteMany(capReservationsTable, { id });
215
+ return given;
216
+ }
217
+
218
+ export async function releaseCapReservation(ctx: HandlerContext, id: string): Promise<void> {
219
+ const outsideDb = requireOutsideTransactionDb(ctx);
220
+ const released = await retryOnStreamVersionConflict(() =>
221
+ retryCounterWriteOnVersionConflict(() =>
222
+ runInOwnTransaction(outsideDb, (txDb) => giveBackReservation(txDb, ctx.user, id)),
223
+ ),
224
+ );
225
+ if (!released.isSuccess) throw reraiseAsKumikoError(released.error);
226
+ }
227
+
228
+ // A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
229
+ // capacity stays counted.
230
+ export async function releaseExpiredCapReservations(
231
+ ctx: HandlerContext,
232
+ capName: string,
233
+ ): Promise<void> {
234
+ const outsideDb = requireOutsideTransactionDb(ctx);
235
+ const expired = await outsideDb.selectMany<Pick<CapReservationRow, "id">>(capReservationsTable, {
236
+ tenantId: ctx.user.tenantId,
237
+ capName,
238
+ expiresAt: { lt: Temporal.Now.instant() },
239
+ });
240
+ for (const { id } of expired) {
241
+ try {
242
+ await releaseCapReservation(ctx, id);
243
+ } catch (error) {
244
+ ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });
245
+ }
246
+ }
247
+ }
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "breaking",
5
+ "title": "Calendar cap reservations are stored in the new store_cap_reservations table",
6
+ "detail": "Capped writes now insert and delete rows in store_cap_reservations, so they fail until the table exists.",
7
+ "migration": "Generate and apply a migration with `kumiko-schema generate` — capped writes fail without the new store_cap_reservations table"
8
+ },
9
+ {
10
+ "version": "0.352.0",
11
+ "type": "improvement",
12
+ "title": "Reservations expire after 60 minutes and are swept before the next reserve of the same cap"
13
+ },
14
+ {
15
+ "version": "0.352.0",
16
+ "type": "fix",
17
+ "title": "withCapEnforcement keeps the wrapped handler's settings (rateLimit, additionalRateLimits) and composes with an inner reserveBeforeTransaction"
18
+ },
19
+ {
20
+ "version": "0.352.0",
21
+ "type": "improvement",
22
+ "title": "withRollingCapEnforcement reserves before the handler transaction and releases through the new rolling-released event, so rejected or failed writes no longer consume rolling cap"
23
+ },
24
+ {
25
+ "version": "0.352.0",
26
+ "type": "improvement",
27
+ "title": "createValueCapGuard rejects a written number outside the min and max of the tenant's tier",
28
+ "detail": "A number written by a handler can be bounded per tier\n`createValueCapGuard(resolveTierCaps)` returns `checkValueCap` and `withValueCap`. The spec names the payload field, optional `min` and `max` functions over the tier caps (`undefined` means unbounded), an error code and an i18n key. The value is read from the payload or, for entity updates, from `changes`; an absent or non-numeric value passes. A value below the minimum or above the maximum is rejected with `UnprocessableError` and `details: { field, value, min, max }`. `withValueCap` spreads the wrapped handler, so it composes with `withCapEnforcement`."
29
+ },
2
30
  {
3
31
  "version": "0.348.0",
4
32
  "type": "fix",
@@ -20,6 +20,14 @@ export const CAP_COUNTER_ROLLING_AGGREGATE_TYPE = "cap-counter-rolling" as const
20
20
  export const ROLLING_INCREMENTED_EVENT_SHORT = "rolling-incremented" as const;
21
21
  export const ROLLING_INCREMENTED_EVENT_QN = "cap-counter:event:rolling-incremented" as const;
22
22
 
23
+ // Gives back a reserved rolling amount; the window sum is incremented minus released.
24
+ export const ROLLING_RELEASED_EVENT_SHORT = "rolling-released" as const;
25
+ export const ROLLING_RELEASED_EVENT_QN = "cap-counter:event:rolling-released" as const;
26
+
27
+ // A reservation row older than this is treated as a lost release and given back before the next
28
+ // reservation for the same cap, which bounds the over-count a failed release can leave behind.
29
+ export const CAP_RESERVATION_TTL_MINUTES = 60;
30
+
23
31
  // Qualified write handler names (QN format: scope:type:name).
24
32
  export const CapCounterHandlers = {
25
33
  increment: "cap-counter:write:increment",
@@ -54,6 +54,7 @@ import {
54
54
  CAP_COUNTER_FEATURE,
55
55
  CAP_COUNTER_LIST_SCREEN_ID,
56
56
  ROLLING_INCREMENTED_EVENT_SHORT,
57
+ ROLLING_RELEASED_EVENT_SHORT,
57
58
  } from "./constants.js";
58
59
  import { capCounterEntity } from "./entity.js";
59
60
  import { getCounterQuery } from "./handlers/get-counter.query.js";
@@ -61,9 +62,11 @@ import { incrementCapHandler } from "./handlers/increment.write.js";
61
62
  import {
62
63
  incrementRollingCapHandler,
63
64
  rollingIncrementedSchema,
65
+ rollingReleasedSchema,
64
66
  } from "./handlers/increment-rolling.write.js";
65
67
  import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write.js";
66
68
  import { CAP_COUNTER_I18N } from "./i18n.js";
69
+ import { capReservationsTableMeta } from "./tables.js";
67
70
 
68
71
  export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
69
72
  r.describe(
@@ -75,6 +78,9 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
75
78
  recommended: false,
76
79
  });
77
80
  r.entity("cap-counter", capCounterEntity);
81
+ r.storeTable(capReservationsTableMeta, {
82
+ reason: "coordination.cap_reservation_ledger",
83
+ });
78
84
 
79
85
  // Custom Domain-Event für Rolling-Counter. r.defineEvent registriert
80
86
  // das Schema beim Registry; ctx.unsafeAppendEvent im Handler nutzt
@@ -82,6 +88,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
82
88
  // "cap-counter:event:rolling-incremented" (siehe
83
89
  // ROLLING_INCREMENTED_EVENT_QN).
84
90
  r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema, { piiFields: "none" });
91
+ r.defineEvent(ROLLING_RELEASED_EVENT_SHORT, rollingReleasedSchema, { piiFields: "none" });
85
92
 
86
93
  // Custom write-handlers.
87
94
  // - increment: Calendar-Period (CRUD via projection-row).
@@ -37,6 +37,11 @@ type IncrementRollingPayload = z.infer<typeof incrementRollingSchema>;
37
37
  * der Caller zahlt amount, wir hängen es 1:1 an den Stream. */
38
38
  export const rollingIncrementedSchema = incrementRollingSchema;
39
39
 
40
+ /** Payload of the event that gives a reserved rolling amount back. */
41
+ export const rollingReleasedSchema = incrementRollingSchema.extend({
42
+ amount: z.number().int().positive(),
43
+ });
44
+
40
45
  // Rolling-Increment-Handler — append-only. Race-frei: zwei parallele
41
46
  // Increments für (tenant, cap) hängen sich am selben aggregate-stream
42
47
  // in unterschiedlichen versions auf, das event-store ordert.
@@ -16,6 +16,8 @@ export {
16
16
  CapCounterQueries,
17
17
  ROLLING_INCREMENTED_EVENT_QN,
18
18
  ROLLING_INCREMENTED_EVENT_SHORT,
19
+ ROLLING_RELEASED_EVENT_QN,
20
+ ROLLING_RELEASED_EVENT_SHORT,
19
21
  } from "./constants.js";
20
22
  export {
21
23
  CAP_TOLERANCES,
@@ -34,12 +36,19 @@ export {
34
36
  } from "./enforce-cap.js";
35
37
  export { capCounterEntity } from "./entity.js";
36
38
  export { capCounterFeature } from "./feature.js";
39
+ export { readPayloadField } from "./payload-field.js";
37
40
  export {
41
+ type CapGuardContext,
38
42
  type CapLimitContext,
39
43
  createStockCapGuard,
40
44
  type StockCapGuard,
41
45
  type StockCapSpec,
42
46
  } from "./stock-cap-guard.js";
47
+ export {
48
+ createValueCapGuard,
49
+ type ValueCapGuard,
50
+ type ValueCapSpec,
51
+ } from "./value-cap-guard.js";
43
52
  export {
44
53
  type CalendarCapDef,
45
54
  type CalendarCapResolver,
@@ -0,0 +1,10 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
2
+
3
+ // Create payloads are flat, entity update payloads carry the edited fields under `changes`
4
+ // (`{ id, version, changes }`), so a guard that follows one field has to look at both.
5
+ export function readPayloadField(payload: unknown, field: string): unknown {
6
+ if (!isPlainObject(payload)) return undefined;
7
+ if (field in payload) return payload[field];
8
+ const { changes } = payload;
9
+ return isPlainObject(changes) ? changes[field] : undefined;
10
+ }