@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +2 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
- package/packages/bundled-features/src/cap-counter/changes.json +28 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
- package/packages/bundled-features/src/cap-counter/index.ts +9 -0
- package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
- package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
- package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
- package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
- package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/constants.ts +1 -0
- package/packages/bundled-features/src/delivery/events.ts +3 -0
- package/packages/bundled-features/src/delivery/feature.ts +50 -2
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
- package/packages/bundled-features/src/delivery/public-names.ts +5 -0
- package/packages/bundled-features/src/delivery/types.ts +12 -2
- package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/README.md +3 -3
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
- package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/index.ts +7 -0
- package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
- package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
- package/samples/apps/styleguide/src/app/shell.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
- package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/recipes/files-post-processing/package.json +1 -1
- 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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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(() =>
|
|
166
|
+
return retryCounterWriteOnVersionConflict(() =>
|
|
167
|
+
runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)),
|
|
168
|
+
);
|
|
146
169
|
}
|
|
147
|
-
return retryCounterWriteOnVersionConflict(() =>
|
|
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
|
-
|
|
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
|
|
246
|
+
type,
|
|
223
247
|
createdAt: { gte: cutoff },
|
|
224
248
|
});
|
|
225
249
|
|
|
226
|
-
let
|
|
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
|
-
|
|
257
|
+
total += payload.amount;
|
|
234
258
|
}
|
|
235
259
|
}
|
|
260
|
+
return total;
|
|
261
|
+
}
|
|
236
262
|
|
|
237
|
-
|
|
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
|
+
}
|