@cosmicdrift/kumiko-samples 0.350.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/audit/handlers/list.query.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/email-templates.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/reset-token.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/unlock-token.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/verification-token.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm.write.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/constants.ts +3 -17
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/feature.ts +2 -9
- package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +3 -11
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +96 -55
- 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/enforce-cap.ts +1 -9
- 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/handlers/log.query.ts +1 -1
- 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/feature-toggles/handlers/set.write.ts +1 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +1 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +1 -1
- package/packages/bundled-features/src/form-draft/lookup.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +1 -1
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +1 -1
- package/packages/bundled-features/src/jobs/job-instant.ts +1 -1
- 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/metrics/compute.ts +1 -1
- package/packages/bundled-features/src/metrics/feature.ts +1 -1
- package/packages/bundled-features/src/metrics/types.ts +1 -1
- package/packages/bundled-features/src/metrics/window.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/expiry.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/handlers/revoke.write.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +1 -1
- 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/sessions/handlers/revoke-all-for-user.write.ts +1 -1
- package/packages/bundled-features/src/sessions/handlers/revoke-all-others.write.ts +1 -1
- package/packages/bundled-features/src/sessions/handlers/revoke.write.ts +1 -1
- package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -1
- package/packages/bundled-features/src/shared/grace-period.ts +3 -10
- package/packages/bundled-features/src/shared/row-bound-grant.ts +1 -1
- package/packages/bundled-features/src/shared/signed-token.ts +1 -1
- package/packages/bundled-features/src/subscription-mollie/verify-webhook.ts +1 -1
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +1 -1
- package/packages/bundled-features/src/template-resolver/api.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/by-tenant.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +1 -1
- package/packages/bundled-features/src/template-resolver/table.ts +1 -1
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +1 -1
- package/packages/bundled-features/src/tenant/invited-membership.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/grant.ts +1 -1
- 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/db/queries/export-jobs.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +3 -9
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +1 -1
- package/samples/apps/marketing-demo/package.json +1 -0
- package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
- package/samples/apps/marketing-demo/src/app/seed.ts +1 -1
- 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/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/src/app/seed.ts +1 -1
- package/samples/recipes/files-post-processing/package.json +1 -1
- package/samples/recipes/seed-migration/README.md +13 -1
|
@@ -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",
|
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
1
|
import { createEntityExecutor, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
4
|
import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage.js";
|
|
5
5
|
import { capCounterEntity } from "./entity.js";
|
|
6
6
|
|
|
7
|
-
// Temporal globally provided by the framework's polyfill init
|
|
8
|
-
// (ensureTemporalPolyfill() in time/polyfill.ts, called from
|
|
9
|
-
// setupTestStack/boot). Importing from "temporal-polyfill" gives us
|
|
10
|
-
// the polyfill-package types which don't quite match drizzle's
|
|
11
|
-
// `instant()`-customType (temporal-spec narrowing of `until(...).sign`).
|
|
12
|
-
// Mirror the audit-handler pattern: rely on the global ambient
|
|
13
|
-
// declaration from temporal-spec.
|
|
14
|
-
|
|
15
7
|
const { table } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
16
8
|
|
|
17
9
|
// =============================================================================
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PreTransactionReservation,
|
|
3
|
+
ReservationHandle,
|
|
4
|
+
} from "@cosmicdrift/kumiko-types/handlers";
|
|
5
|
+
|
|
6
|
+
type ReservationOutcome = Awaited<ReturnType<PreTransactionReservation>>;
|
|
7
|
+
|
|
8
|
+
function toHandle(outcome: ReservationOutcome): ReservationHandle {
|
|
9
|
+
if (outcome === undefined)
|
|
10
|
+
return { release: async () => {}, confirmInTransaction: async () => {} };
|
|
11
|
+
if (typeof outcome === "function") {
|
|
12
|
+
return { release: outcome, confirmInTransaction: async () => {} };
|
|
13
|
+
}
|
|
14
|
+
return outcome;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Runs `inner` first, then `own`; a failing `own` gives the inner reservation back before it
|
|
18
|
+
// throws. Release goes in reverse order, confirm covers both.
|
|
19
|
+
export function chainReservations(
|
|
20
|
+
inner: PreTransactionReservation | undefined,
|
|
21
|
+
own: PreTransactionReservation,
|
|
22
|
+
): PreTransactionReservation {
|
|
23
|
+
if (!inner) return own;
|
|
24
|
+
return async (event, ctx) => {
|
|
25
|
+
const innerHandle = toHandle(await inner(event, ctx));
|
|
26
|
+
let ownHandle: ReservationHandle;
|
|
27
|
+
try {
|
|
28
|
+
ownHandle = toHandle(await own(event, ctx));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
try {
|
|
31
|
+
await innerHandle.release();
|
|
32
|
+
} catch (releaseError) {
|
|
33
|
+
ctx.log?.error("releasing the inner cap reservation failed", { error: releaseError });
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
release: async () => {
|
|
39
|
+
try {
|
|
40
|
+
await ownHandle.release();
|
|
41
|
+
} finally {
|
|
42
|
+
await innerHandle.release();
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
confirmInTransaction: async (confirm) => {
|
|
46
|
+
await innerHandle.confirmInTransaction(confirm);
|
|
47
|
+
await ownHandle.confirmInTransaction(confirm);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -24,6 +24,9 @@ export type CapLimitContext = {
|
|
|
24
24
|
readonly config?: ConfigAccessor;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
// What the cap guards need from a write handler's ctx.
|
|
28
|
+
export type CapGuardContext = CapLimitContext & { readonly db: TenantDb };
|
|
29
|
+
|
|
27
30
|
export type StockCapSpec<TCaps> = {
|
|
28
31
|
readonly table: SchemaTable | EntityTableMeta;
|
|
29
32
|
readonly limit: (caps: TCaps) => number;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineUnmanagedTable,
|
|
3
|
+
type EntityTableMeta,
|
|
4
|
+
index,
|
|
5
|
+
instant,
|
|
6
|
+
integer,
|
|
7
|
+
table as pgTable,
|
|
8
|
+
text,
|
|
9
|
+
uuid,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
|
|
12
|
+
const CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX =
|
|
13
|
+
"store_cap_reservations_tenant_id_cap_name_expires_at_idx";
|
|
14
|
+
|
|
15
|
+
// One row per capacity that was booked before a handler transaction and is not settled yet. The
|
|
16
|
+
// handler transaction deletes it on its own commit (confirm); a release or the TTL sweep deletes it
|
|
17
|
+
// together with the counter decrement. Unmanaged: the rows are a coordination aid, not state with
|
|
18
|
+
// an event history.
|
|
19
|
+
export const capReservationsTable = pgTable(
|
|
20
|
+
"store_cap_reservations",
|
|
21
|
+
{
|
|
22
|
+
id: uuid("id").primaryKey(),
|
|
23
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
24
|
+
capName: text("cap_name").notNull(),
|
|
25
|
+
kind: text("kind").notNull().$type<"calendar" | "rolling">(),
|
|
26
|
+
// Calendar only: raw string the counter's aggregate id was derived from; a normalized instant could point at another counter.
|
|
27
|
+
periodStartIso: text("period_start_iso"),
|
|
28
|
+
amount: integer("amount").notNull(),
|
|
29
|
+
expiresAt: instant("expires_at").notNull(),
|
|
30
|
+
createdAt: instant("created_at").notNull(),
|
|
31
|
+
},
|
|
32
|
+
(t) => [index(CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX).on(t.tenantId, t.capName, t.expiresAt)],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const capReservationsTableMeta: EntityTableMeta = defineUnmanagedTable({
|
|
36
|
+
tableName: "store_cap_reservations",
|
|
37
|
+
indexes: [
|
|
38
|
+
{
|
|
39
|
+
name: CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX,
|
|
40
|
+
columns: ["tenant_id", "cap_name", "expires_at"],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
columns: [
|
|
44
|
+
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
45
|
+
{ name: "tenant_id", pgType: "uuid", notNull: true },
|
|
46
|
+
{ name: "cap_name", pgType: "text", notNull: true },
|
|
47
|
+
{ name: "kind", pgType: "text", notNull: true },
|
|
48
|
+
{ name: "period_start_iso", pgType: "text", notNull: false },
|
|
49
|
+
{ name: "amount", pgType: "integer", notNull: true },
|
|
50
|
+
{ name: "expires_at", pgType: "timestamptz", notNull: true },
|
|
51
|
+
{ name: "created_at", pgType: "timestamptz", notNull: true },
|
|
52
|
+
],
|
|
53
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
|
|
4
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import {
|
|
7
|
+
UnprocessableError,
|
|
8
|
+
type WriteFailure,
|
|
9
|
+
writeFailure,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
11
|
+
import { readPayloadField } from "./payload-field.js";
|
|
12
|
+
import type { CapGuardContext, CapLimitContext } from "./stock-cap-guard.js";
|
|
13
|
+
|
|
14
|
+
export type ValueCapSpec<TCaps> = {
|
|
15
|
+
readonly field: string;
|
|
16
|
+
/** `undefined` = no lower bound for this tier. */
|
|
17
|
+
readonly min?: (caps: TCaps) => number | undefined;
|
|
18
|
+
/** `undefined` = no upper bound for this tier. */
|
|
19
|
+
readonly max?: (caps: TCaps) => number | undefined;
|
|
20
|
+
readonly code: string;
|
|
21
|
+
readonly i18nKey: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ValueCapGuard<TCaps> = {
|
|
25
|
+
readonly checkValueCap: (
|
|
26
|
+
ctx: CapGuardContext,
|
|
27
|
+
value: unknown,
|
|
28
|
+
spec: ValueCapSpec<TCaps>,
|
|
29
|
+
) => Promise<WriteFailure | null>;
|
|
30
|
+
readonly withValueCap: (handler: WriteHandlerDef, spec: ValueCapSpec<TCaps>) => WriteHandlerDef;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function isOutOfBounds(value: number, min: number | undefined, max: number | undefined): boolean {
|
|
34
|
+
return (min !== undefined && value < min) || (max !== undefined && value > max);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createValueCapGuard<TCaps>(
|
|
38
|
+
resolveTierCaps: (db: TenantDb, context: CapLimitContext) => Promise<TCaps>,
|
|
39
|
+
): ValueCapGuard<TCaps> {
|
|
40
|
+
async function checkValueCap(
|
|
41
|
+
ctx: CapGuardContext,
|
|
42
|
+
value: unknown,
|
|
43
|
+
spec: ValueCapSpec<TCaps>,
|
|
44
|
+
): Promise<WriteFailure | null> {
|
|
45
|
+
// skip: an absent or non-numeric value is the schema's business, not a cap violation.
|
|
46
|
+
if (typeof value !== "number") return null;
|
|
47
|
+
const caps = await resolveTierCaps(ctx.db, ctx.config ? { config: ctx.config } : {});
|
|
48
|
+
const min = spec.min?.(caps);
|
|
49
|
+
const max = spec.max?.(caps);
|
|
50
|
+
if (!isOutOfBounds(value, min, max)) return null;
|
|
51
|
+
return writeFailure(
|
|
52
|
+
new UnprocessableError(spec.code, {
|
|
53
|
+
i18nKey: spec.i18nKey,
|
|
54
|
+
details: { field: spec.field, value, min, max },
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function withValueCap(handler: WriteHandlerDef, spec: ValueCapSpec<TCaps>): WriteHandlerDef {
|
|
60
|
+
return {
|
|
61
|
+
...handler,
|
|
62
|
+
handler: async (event, ctx) => {
|
|
63
|
+
const failure = await checkValueCap(ctx, readPayloadField(event.payload, spec.field), spec);
|
|
64
|
+
return failure ?? handler.handler(event, ctx);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { checkValueCap, withValueCap };
|
|
70
|
+
}
|