@cosmicdrift/kumiko-bundled-features 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 (70) hide show
  1. package/dist/cap-counter/book-cap-usage.d.ts +5 -1
  2. package/dist/cap-counter/book-cap-usage.js +65 -44
  3. package/dist/cap-counter/cap-reservation.d.ts +12 -0
  4. package/dist/cap-counter/cap-reservation.js +135 -0
  5. package/dist/cap-counter/constants.d.ts +3 -0
  6. package/dist/cap-counter/constants.js +6 -0
  7. package/dist/cap-counter/feature.js +7 -2
  8. package/dist/cap-counter/handlers/increment-rolling.write.d.ts +5 -0
  9. package/dist/cap-counter/handlers/increment-rolling.write.js +4 -0
  10. package/dist/cap-counter/index.d.ts +4 -2
  11. package/dist/cap-counter/index.js +3 -1
  12. package/dist/cap-counter/payload-field.d.ts +1 -0
  13. package/dist/cap-counter/payload-field.js +11 -0
  14. package/dist/cap-counter/reservation-chain.d.ts +2 -0
  15. package/dist/cap-counter/reservation-chain.js +44 -0
  16. package/dist/cap-counter/stock-cap-guard.d.ts +3 -0
  17. package/dist/cap-counter/tables.d.ts +3 -0
  18. package/dist/cap-counter/tables.js +36 -0
  19. package/dist/cap-counter/value-cap-guard.d.ts +18 -0
  20. package/dist/cap-counter/value-cap-guard.js +33 -0
  21. package/dist/cap-counter/with-cap-enforcement.d.ts +5 -7
  22. package/dist/cap-counter/with-cap-enforcement.js +35 -51
  23. package/dist/channel-discord/feature.js +1 -0
  24. package/dist/channel-slack/feature.js +1 -0
  25. package/dist/channel-teams/feature.js +1 -0
  26. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
  27. package/dist/delivery/attempt-log-retention.d.ts +9 -0
  28. package/dist/delivery/attempt-log-retention.js +23 -0
  29. package/dist/delivery/attempt-log.d.ts +1 -1
  30. package/dist/delivery/attempt-log.js +21 -2
  31. package/dist/delivery/constants.d.ts +1 -1
  32. package/dist/delivery/constants.js +1 -1
  33. package/dist/delivery/events.d.ts +1 -0
  34. package/dist/delivery/events.js +2 -0
  35. package/dist/delivery/feature.d.ts +1 -0
  36. package/dist/delivery/feature.js +39 -3
  37. package/dist/delivery/index.d.ts +2 -1
  38. package/dist/delivery/index.js +2 -1
  39. package/dist/delivery/mask-recipient-address.d.ts +1 -0
  40. package/dist/delivery/mask-recipient-address.js +41 -0
  41. package/dist/delivery/public-names.d.ts +2 -0
  42. package/dist/delivery/public-names.js +3 -0
  43. package/dist/delivery/types.d.ts +1 -0
  44. package/dist/delivery/types.js +6 -2
  45. package/dist/ledger/entity.d.ts +3 -0
  46. package/dist/renderer-foundation/types.d.ts +1 -0
  47. package/dist/renderer-simple/feature.js +4 -6
  48. package/dist/renderer-simple/index.d.ts +1 -0
  49. package/dist/renderer-simple/mail-blocks.d.ts +21 -0
  50. package/dist/renderer-simple/mail-blocks.js +107 -0
  51. package/dist/renderer-simple/simple-renderer.js +21 -3
  52. package/dist/template-resolver/table.d.ts +8 -0
  53. package/dist/template-resolver/user-content-table.d.ts +4 -0
  54. package/dist/tenant/invitation-table.d.ts +2 -0
  55. package/dist/tenant/schema/tenant.d.ts +2 -0
  56. package/dist/tier-engine/index.d.ts +1 -0
  57. package/dist/tier-engine/index.js +1 -0
  58. package/dist/tier-engine/tier-option-gate.d.ts +32 -0
  59. package/dist/tier-engine/tier-option-gate.js +62 -0
  60. package/dist/user/schema/user.d.ts +2 -0
  61. package/dist/user-data-rights/schema/export-job.d.ts +2 -0
  62. package/dist/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.js +25 -6
  63. package/dist/waitlist/entity.d.ts +2 -0
  64. package/package.json +10 -10
  65. package/src/cap-counter/changes.json +28 -0
  66. package/src/delivery/changes.json +12 -0
  67. package/src/derivatives-sharp/changes.json +6 -0
  68. package/src/legal-pages/changes.json +6 -0
  69. package/src/renderer-simple/changes.json +6 -0
  70. package/src/tier-engine/changes.json +6 -0
@@ -1,5 +1,7 @@
1
1
  import { type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import { type HandlerContext, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type HandlerContext, type SessionUser, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
3
+ export declare function retryCounterWriteOnVersionConflict(writeAttempt: () => Promise<WriteResult>): Promise<WriteResult>;
4
+ export declare function retryOnStreamVersionConflict<T>(attempt: () => Promise<T>): Promise<T>;
3
5
  export type BookCapUsageOptions = {
4
6
  readonly capName: string;
5
7
  readonly periodStartIso: string;
@@ -7,8 +9,10 @@ export type BookCapUsageOptions = {
7
9
  readonly outsideTransaction?: boolean;
8
10
  readonly guardCurrentValue?: (currentValue: number) => void;
9
11
  };
12
+ export declare function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb;
10
13
  export declare function bookCapUsage(ctx: HandlerContext, options: BookCapUsageOptions): Promise<WriteResult>;
11
14
  export declare function releaseCapUsage(ctx: HandlerContext, options: Omit<BookCapUsageOptions, "guardCurrentValue">): Promise<WriteResult>;
15
+ export declare function applyCapDeltaOn(db: TenantDb, user: SessionUser, options: BookCapUsageOptions, direction: "add" | "subtract"): Promise<WriteResult>;
12
16
  export type MarkCapSoftWarnedOptions = {
13
17
  readonly capName: string;
14
18
  readonly periodStartIso: string;
@@ -1,11 +1,11 @@
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 { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
6
  import * as z from "zod";
7
7
  import { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
8
- import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN } from "./constants.js";
8
+ import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
9
9
  import { capCounterEntity } from "./entity.js";
10
10
  const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
11
11
  // periodStartIso feeds the aggregate id and Temporal.Instant.from(); a non-instant string would
@@ -26,7 +26,7 @@ const BACKOFF_JITTER_MS_PER_ATTEMPT = 5;
26
26
  function sleepMs(ms) {
27
27
  return new Promise((resolve) => setTimeout(resolve, ms));
28
28
  }
29
- async function retryCounterWriteOnVersionConflict(writeAttempt) {
29
+ export async function retryCounterWriteOnVersionConflict(writeAttempt) {
30
30
  let result = await writeAttempt();
31
31
  for (let attempt = 1; attempt < MAX_COUNTER_WRITE_ATTEMPTS && isLostCounterRace(result); attempt++) {
32
32
  await sleepMs(Math.random() * attempt * BACKOFF_JITTER_MS_PER_ATTEMPT);
@@ -34,7 +34,21 @@ async function retryCounterWriteOnVersionConflict(writeAttempt) {
34
34
  }
35
35
  return result;
36
36
  }
37
- function requireOutsideTransactionDb(ctx) {
37
+ // Event-store appends signal a lost race by throwing; the whole attempt (read, guard, append) is repeated.
38
+ export async function retryOnStreamVersionConflict(attempt) {
39
+ for (let tries = 1;; tries++) {
40
+ try {
41
+ return await attempt();
42
+ }
43
+ catch (error) {
44
+ if (!(error instanceof VersionConflictError) || tries >= MAX_COUNTER_WRITE_ATTEMPTS) {
45
+ throw error;
46
+ }
47
+ await sleepMs(Math.random() * tries * BACKOFF_JITTER_MS_PER_ATTEMPT);
48
+ }
49
+ }
50
+ }
51
+ export function requireOutsideTransactionDb(ctx) {
38
52
  if (!ctx.dbOutsideTransaction) {
39
53
  throw new Error("cap-counter.bookCapUsage: outsideTransaction requested but ctx.dbOutsideTransaction is undefined");
40
54
  }
@@ -47,45 +61,46 @@ export async function bookCapUsage(ctx, options) {
47
61
  export async function releaseCapUsage(ctx, options) {
48
62
  return applyCapDelta(ctx, options, "subtract");
49
63
  }
50
- async function applyCapDelta(ctx, options, direction) {
64
+ // One attempt against an already-open db handle; a lost race comes back as a version_conflict failure the caller retries.
65
+ export async function applyCapDeltaOn(db, user, options, direction) {
51
66
  const parsed = capBookingSchema.parse(options);
52
- const aggregateId = capCounterAggregateId(ctx.user.tenantId, parsed.capName, parsed.periodStartIso);
53
- async function attemptWrite(db) {
54
- const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
55
- if (existing.length === 0) {
56
- options.guardCurrentValue?.(0);
57
- if (direction === "subtract")
58
- return { isSuccess: true, data: {} };
59
- return executor.create({
60
- id: aggregateId,
61
- capName: parsed.capName,
62
- value: parsed.amount,
63
- periodStart: Temporal.Instant.from(parsed.periodStartIso),
64
- lastSoftWarnedAt: null,
65
- }, ctx.user, db);
66
- }
67
- const currentRow = existing[0];
68
- if (!currentRow) {
69
- throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
70
- }
71
- const currentValue = currentRow["value"]; // @cast-boundary db-row
72
- const currentVersion = currentRow["version"]; // @cast-boundary db-row
73
- options.guardCurrentValue?.(currentValue);
74
- return executor.update({
67
+ const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
68
+ const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
69
+ if (existing.length === 0) {
70
+ options.guardCurrentValue?.(0);
71
+ if (direction === "subtract")
72
+ return { isSuccess: true, data: {} };
73
+ return executor.create({
75
74
  id: aggregateId,
76
- version: currentVersion,
77
- changes: {
78
- value: direction === "add"
79
- ? currentValue + parsed.amount
80
- : Math.max(0, currentValue - parsed.amount),
81
- },
82
- }, ctx.user, db);
75
+ capName: parsed.capName,
76
+ value: parsed.amount,
77
+ periodStart: Temporal.Instant.from(parsed.periodStartIso),
78
+ lastSoftWarnedAt: null,
79
+ }, user, db);
80
+ }
81
+ const currentRow = existing[0];
82
+ if (!currentRow) {
83
+ throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
83
84
  }
85
+ const currentValue = currentRow["value"]; // @cast-boundary db-row
86
+ const currentVersion = currentRow["version"]; // @cast-boundary db-row
87
+ options.guardCurrentValue?.(currentValue);
88
+ return executor.update({
89
+ id: aggregateId,
90
+ version: currentVersion,
91
+ changes: {
92
+ value: direction === "add"
93
+ ? currentValue + parsed.amount
94
+ : Math.max(0, currentValue - parsed.amount),
95
+ },
96
+ }, user, db);
97
+ }
98
+ async function applyCapDelta(ctx, options, direction) {
84
99
  if (options.outsideTransaction) {
85
100
  const outsideDb = requireOutsideTransactionDb(ctx);
86
- return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptWrite));
101
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)));
87
102
  }
88
- return retryCounterWriteOnVersionConflict(() => attemptWrite(ctx.db));
103
+ return retryCounterWriteOnVersionConflict(() => applyCapDeltaOn(ctx.db, ctx.user, options, direction));
89
104
  }
90
105
  export async function markCapSoftWarned(ctx, options) {
91
106
  const parsed = capPeriodSchema.parse(options);
@@ -117,27 +132,33 @@ export async function markCapSoftWarned(ctx, options) {
117
132
  }
118
133
  return retryCounterWriteOnVersionConflict(() => attemptMark(ctx.db));
119
134
  }
120
- // Sums usage without throwing — for callers that only need the raw number
121
- // (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
122
- export async function readRollingCapUsage(db, tenantId, options) {
135
+ async function sumRollingEventAmounts(db, tenantId, options, type) {
123
136
  const aggregateId = rollingCapAggregateId(tenantId, options.capName);
124
137
  const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
125
138
  const rows = await db.selectMany(eventsTable, {
126
139
  tenantId,
127
140
  aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
128
141
  aggregateId,
129
- type: ROLLING_INCREMENTED_EVENT_QN,
142
+ type,
130
143
  createdAt: { gte: cutoff },
131
144
  });
132
- let value = 0;
145
+ let total = 0;
133
146
  for (const row of rows) {
134
147
  // @cast-boundary engine-payload — events.payload is jsonb (typed as
135
148
  // unknown by drizzle's $type<Record<string,unknown>>); narrowing the
136
149
  // shape here mirrors enforceRollingCap's own read-side contract.
137
150
  const payload = row["payload"];
138
151
  if (typeof payload.amount === "number") {
139
- value += payload.amount;
152
+ total += payload.amount;
140
153
  }
141
154
  }
142
- return value;
155
+ return total;
156
+ }
157
+ // Sums usage without throwing — for callers that only need the raw number
158
+ // (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
159
+ // Incremented minus released amounts inside the window; a release whose increment already left the window cannot push it below 0.
160
+ export async function readRollingCapUsage(db, tenantId, options) {
161
+ const incremented = await sumRollingEventAmounts(db, tenantId, options, ROLLING_INCREMENTED_EVENT_QN);
162
+ const released = await sumRollingEventAmounts(db, tenantId, options, ROLLING_RELEASED_EVENT_QN);
163
+ return Math.max(0, incremented - released);
143
164
  }
@@ -0,0 +1,12 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ReservationHandle } from "@cosmicdrift/kumiko-types/handlers";
3
+ import { type BookCapUsageOptions, type ReadRollingCapUsageOptions } from "./book-cap-usage.js";
4
+ export type ReserveCalendarCapOptions = Required<Pick<BookCapUsageOptions, "capName" | "periodStartIso" | "amount">> & Pick<BookCapUsageOptions, "guardCurrentValue">;
5
+ export declare function reserveCalendarCap(ctx: HandlerContext, options: ReserveCalendarCapOptions): Promise<ReservationHandle>;
6
+ export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
7
+ readonly amount: number;
8
+ readonly guardCurrentValue?: (currentValue: number) => void;
9
+ };
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>;
@@ -0,0 +1,135 @@
1
+ import { deleteMany, runInOwnTransaction } from "@cosmicdrift/kumiko-framework/db";
2
+ import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { appendEventInTenantDb, getStreamVersionInTenantDb, } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
+ import { rollingCapAggregateId } from "./aggregate-id.js";
7
+ import { applyCapDeltaOn, readRollingCapUsage, requireOutsideTransactionDb, retryCounterWriteOnVersionConflict, retryOnStreamVersionConflict, } from "./book-cap-usage.js";
8
+ import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CAP_RESERVATION_TTL_MINUTES, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
9
+ import { capReservationsTable } from "./tables.js";
10
+ const NOTHING_TO_RELEASE = { isSuccess: true, data: {} };
11
+ async function insertReservationRow(txDb, id, draft) {
12
+ const now = Temporal.Now.instant();
13
+ await txDb.insertOne(capReservationsTable, {
14
+ id,
15
+ capName: draft.capName,
16
+ kind: draft.kind,
17
+ periodStartIso: draft.periodStartIso ?? null,
18
+ amount: draft.amount,
19
+ expiresAt: now.add({ minutes: CAP_RESERVATION_TTL_MINUTES }),
20
+ createdAt: now,
21
+ });
22
+ }
23
+ function toHandle(ctx, id) {
24
+ return {
25
+ release: () => releaseCapReservation(ctx, id),
26
+ confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
27
+ };
28
+ }
29
+ // The counter increment and the reservation row commit together, so a booked amount never exists
30
+ // without a row that can give it back.
31
+ export async function reserveCalendarCap(ctx, options) {
32
+ const outsideDb = requireOutsideTransactionDb(ctx);
33
+ const id = generateId();
34
+ const reserved = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, async (txDb) => {
35
+ const booked = await applyCapDeltaOn(txDb, ctx.user, options, "add");
36
+ if (!booked.isSuccess)
37
+ return booked;
38
+ await insertReservationRow(txDb, id, {
39
+ capName: options.capName,
40
+ kind: "calendar",
41
+ periodStartIso: options.periodStartIso,
42
+ amount: options.amount,
43
+ });
44
+ return booked;
45
+ }));
46
+ if (!reserved.isSuccess)
47
+ throw reraiseAsKumikoError(reserved.error);
48
+ return toHandle(ctx, id);
49
+ }
50
+ function rollingEvent(user, capName, amount, expectedVersion, type) {
51
+ return {
52
+ aggregateId: rollingCapAggregateId(user.tenantId, capName),
53
+ aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
54
+ expectedVersion,
55
+ type,
56
+ payload: { capName, amount },
57
+ metadata: { userId: user.id },
58
+ };
59
+ }
60
+ // The append is version-guarded: a booker that read the usage before another one appended loses the
61
+ // 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);
64
+ const id = generateId();
65
+ const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
66
+ await retryOnStreamVersionConflict(() => runInOwnTransaction(outsideDb, async (txDb) => {
67
+ const version = await getStreamVersionInTenantDb(txDb, aggregateId);
68
+ const usage = await readRollingCapUsage(txDb, ctx.user.tenantId, options);
69
+ options.guardCurrentValue?.(usage);
70
+ await appendEventInTenantDb(txDb, rollingEvent(ctx.user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN));
71
+ await insertReservationRow(txDb, id, {
72
+ capName: options.capName,
73
+ kind: "rolling",
74
+ amount: options.amount,
75
+ });
76
+ return NOTHING_TO_RELEASE;
77
+ }));
78
+ return toHandle(ctx, id);
79
+ }
80
+ // Deleting the row takes its row lock inside the handler transaction, so a release started after a
81
+ // failed COMMIT waits for the real outcome and finds the row gone when the commit went through.
82
+ async function confirmCapReservation(confirm, id) {
83
+ await deleteMany(confirm.tx, capReservationsTable, { id, tenantId: confirm.tenantId });
84
+ }
85
+ async function giveBackCalendar(txDb, user, row) {
86
+ if (row.periodStartIso === null) {
87
+ throw new Error(`cap-counter: calendar reservation ${row.id} has no period to release against`);
88
+ }
89
+ return applyCapDeltaOn(txDb, user, { capName: row.capName, periodStartIso: row.periodStartIso, amount: row.amount }, "subtract");
90
+ }
91
+ async function giveBackRolling(txDb, user, row) {
92
+ const version = await getStreamVersionInTenantDb(txDb, rollingCapAggregateId(user.tenantId, row.capName));
93
+ await appendEventInTenantDb(txDb, rollingEvent(user, row.capName, row.amount, version, ROLLING_RELEASED_EVENT_QN));
94
+ return NOTHING_TO_RELEASE;
95
+ }
96
+ async function giveBackReservation(txDb, user, id) {
97
+ const row = await txDb.fetchOne(capReservationsTable, { id });
98
+ if (!row)
99
+ 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)
103
+ return NOTHING_TO_RELEASE;
104
+ const given = row.kind === "rolling"
105
+ ? await giveBackRolling(txDb, user, row)
106
+ : await giveBackCalendar(txDb, user, row);
107
+ if (!given.isSuccess)
108
+ return given;
109
+ await txDb.deleteMany(capReservationsTable, { id });
110
+ return given;
111
+ }
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))));
115
+ if (!released.isSuccess)
116
+ throw reraiseAsKumikoError(released.error);
117
+ }
118
+ // A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
119
+ // 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,
124
+ capName,
125
+ expiresAt: { lt: Temporal.Now.instant() },
126
+ });
127
+ for (const { id } of expired) {
128
+ try {
129
+ await releaseCapReservation(ctx, id);
130
+ }
131
+ catch (error) {
132
+ ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });
133
+ }
134
+ }
135
+ }
@@ -3,6 +3,9 @@ export declare const CAP_COUNTER_LIST_SCREEN_ID: "cap-list";
3
3
  export declare const CAP_COUNTER_ROLLING_AGGREGATE_TYPE: "cap-counter-rolling";
4
4
  export declare const ROLLING_INCREMENTED_EVENT_SHORT: "rolling-incremented";
5
5
  export declare const ROLLING_INCREMENTED_EVENT_QN: "cap-counter:event:rolling-incremented";
6
+ export declare const ROLLING_RELEASED_EVENT_SHORT: "rolling-released";
7
+ export declare const ROLLING_RELEASED_EVENT_QN: "cap-counter:event:rolling-released";
8
+ export declare const CAP_RESERVATION_TTL_MINUTES = 60;
6
9
  export declare const CapCounterHandlers: {
7
10
  readonly increment: "cap-counter:write:increment";
8
11
  readonly incrementRolling: "cap-counter:write:increment-rolling";
@@ -16,6 +16,12 @@ export const CAP_COUNTER_ROLLING_AGGREGATE_TYPE = "cap-counter-rolling";
16
16
  // Beide MÜSSEN konsistent sein (drift-pin im feature-test).
17
17
  export const ROLLING_INCREMENTED_EVENT_SHORT = "rolling-incremented";
18
18
  export const ROLLING_INCREMENTED_EVENT_QN = "cap-counter:event:rolling-incremented";
19
+ // Gives back a reserved rolling amount; the window sum is incremented minus released.
20
+ export const ROLLING_RELEASED_EVENT_SHORT = "rolling-released";
21
+ export const ROLLING_RELEASED_EVENT_QN = "cap-counter:event:rolling-released";
22
+ // A reservation row older than this is treated as a lost release and given back before the next
23
+ // reservation for the same cap, which bounds the over-count a failed release can leave behind.
24
+ export const CAP_RESERVATION_TTL_MINUTES = 60;
19
25
  // Qualified write handler names (QN format: scope:type:name).
20
26
  export const CapCounterHandlers = {
21
27
  increment: "cap-counter:write:increment",
@@ -45,13 +45,14 @@
45
45
  // config, kein secrets, kein tenant-feature nötig. Tenant-Scoping kommt
46
46
  // vom Framework-Default (Base-Column tenantId).
47
47
  import { access, defineEntityListHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
48
- import { CAP_COUNTER_FEATURE, CAP_COUNTER_LIST_SCREEN_ID, ROLLING_INCREMENTED_EVENT_SHORT, } from "./constants.js";
48
+ import { CAP_COUNTER_FEATURE, CAP_COUNTER_LIST_SCREEN_ID, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
49
49
  import { capCounterEntity } from "./entity.js";
50
50
  import { getCounterQuery } from "./handlers/get-counter.query.js";
51
51
  import { incrementCapHandler } from "./handlers/increment.write.js";
52
- import { incrementRollingCapHandler, rollingIncrementedSchema, } from "./handlers/increment-rolling.write.js";
52
+ import { incrementRollingCapHandler, rollingIncrementedSchema, rollingReleasedSchema, } from "./handlers/increment-rolling.write.js";
53
53
  import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write.js";
54
54
  import { CAP_COUNTER_I18N } from "./i18n.js";
55
+ import { capReservationsTableMeta } from "./tables.js";
55
56
  export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
56
57
  r.describe("Tracks per-tenant usage against configurable limits using two complementary storage models: calendar-period counters (one projection row per tenant/capName/period, reset implicitly by period rollover) and rolling-window counters (append-only event stream, no projection). Use `enforceCap` / `enforceRollingCap` (or the `withCapEnforcement` / `withRollingCapEnforcement` handler wrappers) in your write-handlers to check limits with soft-warn and hard-block tolerances; call `enforceCapAndMaybeNotify` when you also want to trigger a delivery notification on soft-threshold hits.");
57
58
  r.uiHints({
@@ -60,12 +61,16 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
60
61
  recommended: false,
61
62
  });
62
63
  r.entity("cap-counter", capCounterEntity);
64
+ r.storeTable(capReservationsTableMeta, {
65
+ reason: "coordination.cap_reservation_ledger",
66
+ });
63
67
  // Custom Domain-Event für Rolling-Counter. r.defineEvent registriert
64
68
  // das Schema beim Registry; ctx.unsafeAppendEvent im Handler nutzt
65
69
  // dasselbe Schema für Append-Time-Validation. QN nach Prefixing:
66
70
  // "cap-counter:event:rolling-incremented" (siehe
67
71
  // ROLLING_INCREMENTED_EVENT_QN).
68
72
  r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema, { piiFields: "none" });
73
+ r.defineEvent(ROLLING_RELEASED_EVENT_SHORT, rollingReleasedSchema, { piiFields: "none" });
69
74
  // Custom write-handlers.
70
75
  // - increment: Calendar-Period (CRUD via projection-row).
71
76
  // - increment-rolling: Rolling-Window (Custom-Event, no projection).
@@ -6,4 +6,9 @@ export declare const rollingIncrementedSchema: z.ZodObject<{
6
6
  capName: z.ZodString;
7
7
  amount: z.ZodDefault<z.ZodNumber>;
8
8
  }, z.core.$strip>;
9
+ /** Payload of the event that gives a reserved rolling amount back. */
10
+ export declare const rollingReleasedSchema: z.ZodObject<{
11
+ capName: z.ZodString;
12
+ amount: z.ZodNumber;
13
+ }, z.core.$strip>;
9
14
  export declare const incrementRollingCapHandler: WriteHandlerDef;
@@ -31,6 +31,10 @@ const incrementRollingSchema = z.object({
31
31
  /** Schema des emittierten Custom-Events. Identisch zum Input-Schema:
32
32
  * der Caller zahlt amount, wir hängen es 1:1 an den Stream. */
33
33
  export const rollingIncrementedSchema = incrementRollingSchema;
34
+ /** Payload of the event that gives a reserved rolling amount back. */
35
+ export const rollingReleasedSchema = incrementRollingSchema.extend({
36
+ amount: z.number().int().positive(),
37
+ });
34
38
  // Rolling-Increment-Handler — append-only. Race-frei: zwei parallele
35
39
  // Increments für (tenant, cap) hängen sich am selben aggregate-stream
36
40
  // in unterschiedlichen versions auf, das event-store ordert.
@@ -1,8 +1,10 @@
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 { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, } from "./constants.js";
3
+ 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
4
  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
5
  export { capCounterEntity } from "./entity.js";
6
6
  export { capCounterFeature } from "./feature.js";
7
- export { type CapLimitContext, createStockCapGuard, type StockCapGuard, type StockCapSpec, } from "./stock-cap-guard.js";
7
+ export { readPayloadField } from "./payload-field.js";
8
+ export { type CapGuardContext, type CapLimitContext, createStockCapGuard, type StockCapGuard, type StockCapSpec, } from "./stock-cap-guard.js";
9
+ export { createValueCapGuard, type ValueCapGuard, type ValueCapSpec, } from "./value-cap-guard.js";
8
10
  export { type CalendarCapDef, type CalendarCapResolver, type RollingCapDef, type RollingCapResolver, withCapEnforcement, withRollingCapEnforcement, } from "./with-cap-enforcement.js";
@@ -1,9 +1,11 @@
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 { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, } from "./constants.js";
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";
5
5
  export { CAP_TOLERANCES, CapExceededError, currentCalendarMonthStartIso, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, } from "./enforce-cap.js";
6
6
  export { capCounterEntity } from "./entity.js";
7
7
  export { capCounterFeature } from "./feature.js";
8
+ export { readPayloadField } from "./payload-field.js";
8
9
  export { createStockCapGuard, } from "./stock-cap-guard.js";
10
+ export { createValueCapGuard, } from "./value-cap-guard.js";
9
11
  export { withCapEnforcement, withRollingCapEnforcement, } from "./with-cap-enforcement.js";
@@ -0,0 +1 @@
1
+ export declare function readPayloadField(payload: unknown, field: string): unknown;
@@ -0,0 +1,11 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
2
+ // Create payloads are flat, entity update payloads carry the edited fields under `changes`
3
+ // (`{ id, version, changes }`), so a guard that follows one field has to look at both.
4
+ export function readPayloadField(payload, field) {
5
+ if (!isPlainObject(payload))
6
+ return undefined;
7
+ if (field in payload)
8
+ return payload[field];
9
+ const { changes } = payload;
10
+ return isPlainObject(changes) ? changes[field] : undefined;
11
+ }
@@ -0,0 +1,2 @@
1
+ import type { PreTransactionReservation } from "@cosmicdrift/kumiko-types/handlers";
2
+ export declare function chainReservations(inner: PreTransactionReservation | undefined, own: PreTransactionReservation): PreTransactionReservation;
@@ -0,0 +1,44 @@
1
+ function toHandle(outcome) {
2
+ if (outcome === undefined)
3
+ return { release: async () => { }, confirmInTransaction: async () => { } };
4
+ if (typeof outcome === "function") {
5
+ return { release: outcome, confirmInTransaction: async () => { } };
6
+ }
7
+ return outcome;
8
+ }
9
+ // Runs `inner` first, then `own`; a failing `own` gives the inner reservation back before it
10
+ // throws. Release goes in reverse order, confirm covers both.
11
+ export function chainReservations(inner, own) {
12
+ if (!inner)
13
+ return own;
14
+ return async (event, ctx) => {
15
+ const innerHandle = toHandle(await inner(event, ctx));
16
+ let ownHandle;
17
+ try {
18
+ ownHandle = toHandle(await own(event, ctx));
19
+ }
20
+ catch (error) {
21
+ try {
22
+ await innerHandle.release();
23
+ }
24
+ catch (releaseError) {
25
+ ctx.log?.error("releasing the inner cap reservation failed", { error: releaseError });
26
+ }
27
+ throw error;
28
+ }
29
+ return {
30
+ release: async () => {
31
+ try {
32
+ await ownHandle.release();
33
+ }
34
+ finally {
35
+ await innerHandle.release();
36
+ }
37
+ },
38
+ confirmInTransaction: async (confirm) => {
39
+ await innerHandle.confirmInTransaction(confirm);
40
+ await ownHandle.confirmInTransaction(confirm);
41
+ },
42
+ };
43
+ };
44
+ }
@@ -4,6 +4,9 @@ import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  export type CapLimitContext = {
5
5
  readonly config?: ConfigAccessor;
6
6
  };
7
+ export type CapGuardContext = CapLimitContext & {
8
+ readonly db: TenantDb;
9
+ };
7
10
  export type StockCapSpec<TCaps> = {
8
11
  readonly table: SchemaTable | EntityTableMeta;
9
12
  readonly limit: (caps: TCaps) => number;
@@ -0,0 +1,3 @@
1
+ import { type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ export declare const capReservationsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
3
+ export declare const capReservationsTableMeta: EntityTableMeta;
@@ -0,0 +1,36 @@
1
+ import { defineUnmanagedTable, index, instant, integer, table as pgTable, text, uuid, } from "@cosmicdrift/kumiko-framework/db";
2
+ const CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX = "store_cap_reservations_tenant_id_cap_name_expires_at_idx";
3
+ // One row per capacity that was booked before a handler transaction and is not settled yet. The
4
+ // handler transaction deletes it on its own commit (confirm); a release or the TTL sweep deletes it
5
+ // together with the counter decrement. Unmanaged: the rows are a coordination aid, not state with
6
+ // an event history.
7
+ export const capReservationsTable = pgTable("store_cap_reservations", {
8
+ id: uuid("id").primaryKey(),
9
+ tenantId: uuid("tenant_id").notNull(),
10
+ capName: text("cap_name").notNull(),
11
+ kind: text("kind").notNull().$type(),
12
+ // Calendar only: raw string the counter's aggregate id was derived from; a normalized instant could point at another counter.
13
+ periodStartIso: text("period_start_iso"),
14
+ amount: integer("amount").notNull(),
15
+ expiresAt: instant("expires_at").notNull(),
16
+ createdAt: instant("created_at").notNull(),
17
+ }, (t) => [index(CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX).on(t.tenantId, t.capName, t.expiresAt)]);
18
+ export const capReservationsTableMeta = defineUnmanagedTable({
19
+ tableName: "store_cap_reservations",
20
+ indexes: [
21
+ {
22
+ name: CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX,
23
+ columns: ["tenant_id", "cap_name", "expires_at"],
24
+ },
25
+ ],
26
+ columns: [
27
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
28
+ { name: "tenant_id", pgType: "uuid", notNull: true },
29
+ { name: "cap_name", pgType: "text", notNull: true },
30
+ { name: "kind", pgType: "text", notNull: true },
31
+ { name: "period_start_iso", pgType: "text", notNull: false },
32
+ { name: "amount", pgType: "integer", notNull: true },
33
+ { name: "expires_at", pgType: "timestamptz", notNull: true },
34
+ { name: "created_at", pgType: "timestamptz", notNull: true },
35
+ ],
36
+ });
@@ -0,0 +1,18 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import type { CapGuardContext, CapLimitContext } from "./stock-cap-guard.js";
5
+ export type ValueCapSpec<TCaps> = {
6
+ readonly field: string;
7
+ /** `undefined` = no lower bound for this tier. */
8
+ readonly min?: (caps: TCaps) => number | undefined;
9
+ /** `undefined` = no upper bound for this tier. */
10
+ readonly max?: (caps: TCaps) => number | undefined;
11
+ readonly code: string;
12
+ readonly i18nKey: string;
13
+ };
14
+ export type ValueCapGuard<TCaps> = {
15
+ readonly checkValueCap: (ctx: CapGuardContext, value: unknown, spec: ValueCapSpec<TCaps>) => Promise<WriteFailure | null>;
16
+ readonly withValueCap: (handler: WriteHandlerDef, spec: ValueCapSpec<TCaps>) => WriteHandlerDef;
17
+ };
18
+ export declare function createValueCapGuard<TCaps>(resolveTierCaps: (db: TenantDb, context: CapLimitContext) => Promise<TCaps>): ValueCapGuard<TCaps>;
@@ -0,0 +1,33 @@
1
+ // Per-tier bounds on a single written number (e.g. a retention period or a page size),
2
+ // next to the stock-cap guard, which bounds how many rows may exist.
3
+ import { UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { readPayloadField } from "./payload-field.js";
5
+ function isOutOfBounds(value, min, max) {
6
+ return (min !== undefined && value < min) || (max !== undefined && value > max);
7
+ }
8
+ export function createValueCapGuard(resolveTierCaps) {
9
+ async function checkValueCap(ctx, value, spec) {
10
+ // skip: an absent or non-numeric value is the schema's business, not a cap violation.
11
+ if (typeof value !== "number")
12
+ return null;
13
+ const caps = await resolveTierCaps(ctx.db, ctx.config ? { config: ctx.config } : {});
14
+ const min = spec.min?.(caps);
15
+ const max = spec.max?.(caps);
16
+ if (!isOutOfBounds(value, min, max))
17
+ return null;
18
+ return writeFailure(new UnprocessableError(spec.code, {
19
+ i18nKey: spec.i18nKey,
20
+ details: { field: spec.field, value, min, max },
21
+ }));
22
+ }
23
+ function withValueCap(handler, spec) {
24
+ return {
25
+ ...handler,
26
+ handler: async (event, ctx) => {
27
+ const failure = await checkValueCap(ctx, readPayloadField(event.payload, spec.field), spec);
28
+ return failure ?? handler.handler(event, ctx);
29
+ },
30
+ };
31
+ }
32
+ return { checkValueCap, withValueCap };
33
+ }