@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.
- package/dist/cap-counter/book-cap-usage.d.ts +5 -1
- package/dist/cap-counter/book-cap-usage.js +65 -44
- package/dist/cap-counter/cap-reservation.d.ts +12 -0
- package/dist/cap-counter/cap-reservation.js +135 -0
- package/dist/cap-counter/constants.d.ts +3 -0
- package/dist/cap-counter/constants.js +6 -0
- package/dist/cap-counter/feature.js +7 -2
- package/dist/cap-counter/handlers/increment-rolling.write.d.ts +5 -0
- package/dist/cap-counter/handlers/increment-rolling.write.js +4 -0
- package/dist/cap-counter/index.d.ts +4 -2
- package/dist/cap-counter/index.js +3 -1
- package/dist/cap-counter/payload-field.d.ts +1 -0
- package/dist/cap-counter/payload-field.js +11 -0
- package/dist/cap-counter/reservation-chain.d.ts +2 -0
- package/dist/cap-counter/reservation-chain.js +44 -0
- package/dist/cap-counter/stock-cap-guard.d.ts +3 -0
- package/dist/cap-counter/tables.d.ts +3 -0
- package/dist/cap-counter/tables.js +36 -0
- package/dist/cap-counter/value-cap-guard.d.ts +18 -0
- package/dist/cap-counter/value-cap-guard.js +33 -0
- package/dist/cap-counter/with-cap-enforcement.d.ts +5 -7
- package/dist/cap-counter/with-cap-enforcement.js +35 -51
- package/dist/channel-discord/feature.js +1 -0
- package/dist/channel-slack/feature.js +1 -0
- package/dist/channel-teams/feature.js +1 -0
- package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
- package/dist/delivery/attempt-log-retention.d.ts +9 -0
- package/dist/delivery/attempt-log-retention.js +23 -0
- package/dist/delivery/attempt-log.d.ts +1 -1
- package/dist/delivery/attempt-log.js +21 -2
- package/dist/delivery/constants.d.ts +1 -1
- package/dist/delivery/constants.js +1 -1
- package/dist/delivery/events.d.ts +1 -0
- package/dist/delivery/events.js +2 -0
- package/dist/delivery/feature.d.ts +1 -0
- package/dist/delivery/feature.js +39 -3
- package/dist/delivery/index.d.ts +2 -1
- package/dist/delivery/index.js +2 -1
- package/dist/delivery/mask-recipient-address.d.ts +1 -0
- package/dist/delivery/mask-recipient-address.js +41 -0
- package/dist/delivery/public-names.d.ts +2 -0
- package/dist/delivery/public-names.js +3 -0
- package/dist/delivery/types.d.ts +1 -0
- package/dist/delivery/types.js +6 -2
- package/dist/ledger/entity.d.ts +3 -0
- package/dist/renderer-foundation/types.d.ts +1 -0
- package/dist/renderer-simple/feature.js +4 -6
- package/dist/renderer-simple/index.d.ts +1 -0
- package/dist/renderer-simple/mail-blocks.d.ts +21 -0
- package/dist/renderer-simple/mail-blocks.js +107 -0
- package/dist/renderer-simple/simple-renderer.js +21 -3
- package/dist/template-resolver/table.d.ts +8 -0
- package/dist/template-resolver/user-content-table.d.ts +4 -0
- package/dist/tenant/invitation-table.d.ts +2 -0
- package/dist/tenant/schema/tenant.d.ts +2 -0
- package/dist/tier-engine/index.d.ts +1 -0
- package/dist/tier-engine/index.js +1 -0
- package/dist/tier-engine/tier-option-gate.d.ts +32 -0
- package/dist/tier-engine/tier-option-gate.js +62 -0
- package/dist/user/schema/user.d.ts +2 -0
- package/dist/user-data-rights/schema/export-job.d.ts +2 -0
- package/dist/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.js +25 -6
- package/dist/waitlist/entity.d.ts +2 -0
- package/package.json +10 -10
- package/src/cap-counter/changes.json +28 -0
- package/src/delivery/changes.json +12 -0
- package/src/derivatives-sharp/changes.json +6 -0
- package/src/legal-pages/changes.json +6 -0
- package/src/renderer-simple/changes.json +6 -0
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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,
|
|
101
|
+
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)));
|
|
87
102
|
}
|
|
88
|
-
return retryCounterWriteOnVersionConflict(() =>
|
|
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
|
-
|
|
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
|
|
142
|
+
type,
|
|
130
143
|
createdAt: { gte: cutoff },
|
|
131
144
|
});
|
|
132
|
-
let
|
|
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
|
-
|
|
152
|
+
total += payload.amount;
|
|
140
153
|
}
|
|
141
154
|
}
|
|
142
|
-
return
|
|
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 {
|
|
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,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,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
|
+
}
|