@cosmicdrift/kumiko-samples 0.356.0 → 0.358.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 +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +8 -1
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +71 -21
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +99 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +143 -43
- package/packages/bundled-features/src/cap-counter/changes.json +7 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +18 -32
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -1
- package/packages/bundled-features/src/cap-counter/index.ts +5 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -25
- package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +15 -1
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
- package/packages/bundled-features/src/delivery/constants.ts +5 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
- package/packages/bundled-features/src/delivery/feature.ts +7 -1
- package/packages/bundled-features/src/delivery/jobs.ts +27 -1
- package/packages/bundled-features/src/delivery/testing.ts +1 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -15
- package/packages/bundled-features/src/jobs/job-run-table.ts +10 -3
- package/packages/bundled-features/src/notes-history/changes.json +6 -0
- package/packages/bundled-features/src/notes-history/entity.ts +9 -7
- package/packages/bundled-features/src/notes-history-user-data/hooks.ts +84 -76
- package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
- package/packages/bundled-features/src/sessions/screens.ts +2 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +6 -3
- package/packages/bundled-features/src/tenant/feature.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
- package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
- package/packages/bundled-features/src/tenant/screens.ts +9 -6
- package/packages/bundled-features/src/user/screens.ts +8 -1
- package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
- package/packages/bundled-features/src/waitlist/screens.ts +8 -1
- package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
- package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
- package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
- package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +1 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
- package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.358.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.358.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>",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "MFA enable asks to confirm saved recovery codes, code field autocompletes one-time codes",
|
|
6
|
+
"detail": "secretMint `reveal.acknowledge` (i18n key) renders a checkbox on the reveal card that must be ticked before the confirm step or the acknowledge button can be used; the tick never reaches the payload. Text fields accept an `autoComplete` token that reaches the input. The MFA enable screen uses both: a \"saved my recovery codes\" gate and `one-time-code` on the code field"
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -28,6 +28,7 @@ export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
|
|
|
28
28
|
"mfa.enable.reveal.qr": { en: "Scan this QR code" },
|
|
29
29
|
"mfa.enable.reveal.secret": { en: "Or enter this code manually" },
|
|
30
30
|
"mfa.enable.reveal.recoveryCodes": { en: "Recovery codes" },
|
|
31
|
+
"mfa.enable.reveal.savedCodes": { en: "I have saved my recovery codes." },
|
|
31
32
|
|
|
32
33
|
"mfa.enable.confirm.submit": { en: "Enable" },
|
|
33
34
|
"mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
|
|
@@ -34,6 +34,7 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
34
34
|
reveal: {
|
|
35
35
|
title: i18nKey("mfa.enable.reveal.title"),
|
|
36
36
|
warning: i18nKey("mfa.enable.reveal.warning"),
|
|
37
|
+
acknowledge: i18nKey("mfa.enable.reveal.savedCodes"),
|
|
37
38
|
fields: [
|
|
38
39
|
{
|
|
39
40
|
field: "otpauthUri",
|
|
@@ -58,7 +59,13 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
58
59
|
confirm: {
|
|
59
60
|
handler: AuthMfaHandlers.enableConfirm,
|
|
60
61
|
fields: {
|
|
61
|
-
code: {
|
|
62
|
+
code: {
|
|
63
|
+
type: "text",
|
|
64
|
+
required: true,
|
|
65
|
+
minLength: 6,
|
|
66
|
+
maxLength: 6,
|
|
67
|
+
autoComplete: "one-time-code",
|
|
68
|
+
},
|
|
62
69
|
},
|
|
63
70
|
layout: { sections: [{ fields: ["code"] }] },
|
|
64
71
|
carry: ["setupToken"],
|
|
@@ -4,10 +4,12 @@ import { runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framewor
|
|
|
4
4
|
import {
|
|
5
5
|
createEntityExecutor,
|
|
6
6
|
type HandlerContext,
|
|
7
|
+
type JobContext,
|
|
7
8
|
type SessionUser,
|
|
8
9
|
type TenantId,
|
|
9
10
|
type WriteResult,
|
|
10
11
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
11
13
|
import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
14
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
13
15
|
import * as z from "zod";
|
|
@@ -82,17 +84,69 @@ export type BookCapUsageOptions = {
|
|
|
82
84
|
readonly guardCurrentValue?: (currentValue: number) => void;
|
|
83
85
|
};
|
|
84
86
|
|
|
85
|
-
export
|
|
86
|
-
|
|
87
|
+
export type CapCounterContext = HandlerContext | JobContext;
|
|
88
|
+
|
|
89
|
+
export type CapCounterScope = {
|
|
90
|
+
readonly user: SessionUser;
|
|
91
|
+
readonly readDb: TenantDb;
|
|
92
|
+
readonly writeDb: TenantDb;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// A tenant-mode db pins every row it writes to its own tenant; counter and reservation row would
|
|
96
|
+
// silently land on different tenants if that is not the user's.
|
|
97
|
+
function assertDbServesUser(db: TenantDb, user: SessionUser): TenantDb {
|
|
98
|
+
if (db.mode === "tenant" && db.tenantId !== user.tenantId) {
|
|
99
|
+
throw new AccessDeniedError({
|
|
100
|
+
message: "cap-counter: the context's database belongs to a different tenant than its user",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return db;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Jobs write in place (their db is not transaction-bound); systemScope handlers reach both dbs via
|
|
107
|
+
// systemDb, because their ctx.db / ctx.dbOutsideTransaction are fail-closed proxies.
|
|
108
|
+
export function resolveCapCounterScope(
|
|
109
|
+
ctx: CapCounterContext,
|
|
110
|
+
outsideTransaction: boolean,
|
|
111
|
+
): CapCounterScope {
|
|
112
|
+
// A job context spread into a handler-shaped one (user set) is a handler context: its user, not
|
|
113
|
+
// the job's systemUser, names the metered tenant.
|
|
114
|
+
if (!("user" in ctx) || !ctx.user) {
|
|
115
|
+
const jobUser = "systemUser" in ctx ? ctx.systemUser : undefined;
|
|
116
|
+
if (!jobUser) {
|
|
117
|
+
throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
|
|
118
|
+
}
|
|
119
|
+
const db = assertDbServesUser(ctx.db, jobUser);
|
|
120
|
+
return { user: jobUser, readDb: db, writeDb: db };
|
|
121
|
+
}
|
|
122
|
+
const { user } = ctx;
|
|
123
|
+
if (!user.tenantId) {
|
|
124
|
+
throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
|
|
125
|
+
}
|
|
126
|
+
if (ctx.systemDb) {
|
|
127
|
+
const readDb = ctx.systemDb.assertTenantMatch(user.tenantId);
|
|
128
|
+
const writeDb = outsideTransaction
|
|
129
|
+
? ctx.systemDb.outsideTransaction.assertTenantMatch(user.tenantId)
|
|
130
|
+
: readDb;
|
|
131
|
+
return { user, readDb, writeDb };
|
|
132
|
+
}
|
|
133
|
+
const writeDb = outsideTransaction ? ctx.dbOutsideTransaction : ctx.db;
|
|
134
|
+
if (!writeDb) {
|
|
87
135
|
throw new Error(
|
|
88
|
-
|
|
136
|
+
outsideTransaction
|
|
137
|
+
? "cap-counter: outsideTransaction requested but ctx.dbOutsideTransaction is undefined"
|
|
138
|
+
: "cap-counter: ctx.db missing — run inside a handler context",
|
|
89
139
|
);
|
|
90
140
|
}
|
|
91
|
-
return
|
|
141
|
+
return {
|
|
142
|
+
user,
|
|
143
|
+
readDb: assertDbServesUser(ctx.db, user),
|
|
144
|
+
writeDb: assertDbServesUser(writeDb, user),
|
|
145
|
+
};
|
|
92
146
|
}
|
|
93
147
|
|
|
94
148
|
export async function bookCapUsage(
|
|
95
|
-
ctx:
|
|
149
|
+
ctx: CapCounterContext,
|
|
96
150
|
options: BookCapUsageOptions,
|
|
97
151
|
): Promise<WriteResult> {
|
|
98
152
|
return applyCapDelta(ctx, options, "add");
|
|
@@ -100,7 +154,7 @@ export async function bookCapUsage(
|
|
|
100
154
|
|
|
101
155
|
// Gives back a reservation whose operation failed afterwards; the counter never drops below 0.
|
|
102
156
|
export async function releaseCapUsage(
|
|
103
|
-
ctx:
|
|
157
|
+
ctx: CapCounterContext,
|
|
104
158
|
options: Omit<BookCapUsageOptions, "guardCurrentValue">,
|
|
105
159
|
): Promise<WriteResult> {
|
|
106
160
|
return applyCapDelta(ctx, options, "subtract");
|
|
@@ -157,18 +211,18 @@ export async function applyCapDeltaOn(
|
|
|
157
211
|
}
|
|
158
212
|
|
|
159
213
|
async function applyCapDelta(
|
|
160
|
-
ctx:
|
|
214
|
+
ctx: CapCounterContext,
|
|
161
215
|
options: BookCapUsageOptions,
|
|
162
216
|
direction: "add" | "subtract",
|
|
163
217
|
): Promise<WriteResult> {
|
|
218
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
|
|
164
219
|
if (options.outsideTransaction) {
|
|
165
|
-
const outsideDb = requireOutsideTransactionDb(ctx);
|
|
166
220
|
return retryCounterWriteOnVersionConflict(() =>
|
|
167
|
-
runInOwnTransaction(
|
|
221
|
+
runInOwnTransaction(writeDb, (txDb) => applyCapDeltaOn(txDb, user, options, direction)),
|
|
168
222
|
);
|
|
169
223
|
}
|
|
170
224
|
return retryCounterWriteOnVersionConflict(() =>
|
|
171
|
-
applyCapDeltaOn(
|
|
225
|
+
applyCapDeltaOn(writeDb, user, options, direction),
|
|
172
226
|
);
|
|
173
227
|
}
|
|
174
228
|
|
|
@@ -179,21 +233,18 @@ export type MarkCapSoftWarnedOptions = {
|
|
|
179
233
|
};
|
|
180
234
|
|
|
181
235
|
export async function markCapSoftWarned(
|
|
182
|
-
ctx:
|
|
236
|
+
ctx: CapCounterContext,
|
|
183
237
|
options: MarkCapSoftWarnedOptions,
|
|
184
238
|
): Promise<WriteResult> {
|
|
185
239
|
const parsed = capPeriodSchema.parse(options);
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
parsed.capName,
|
|
189
|
-
parsed.periodStartIso,
|
|
190
|
-
);
|
|
240
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
|
|
241
|
+
const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
|
|
191
242
|
|
|
192
243
|
async function attemptMark(db: TenantDb): Promise<WriteResult> {
|
|
193
244
|
const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
194
245
|
if (existing.length === 0) {
|
|
195
246
|
throw new Error(
|
|
196
|
-
`cap-counter: cannot mark-soft-warned, no counter found for tenant=${
|
|
247
|
+
`cap-counter: cannot mark-soft-warned, no counter found for tenant=${user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`,
|
|
197
248
|
);
|
|
198
249
|
}
|
|
199
250
|
const row = existing[0];
|
|
@@ -213,16 +264,15 @@ export async function markCapSoftWarned(
|
|
|
213
264
|
version: currentVersion,
|
|
214
265
|
changes: { lastSoftWarnedAt: Temporal.Now.instant() },
|
|
215
266
|
},
|
|
216
|
-
|
|
267
|
+
user,
|
|
217
268
|
db,
|
|
218
269
|
);
|
|
219
270
|
}
|
|
220
271
|
|
|
221
272
|
if (options.outsideTransaction) {
|
|
222
|
-
|
|
223
|
-
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
|
|
273
|
+
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, attemptMark));
|
|
224
274
|
}
|
|
225
|
-
return retryCounterWriteOnVersionConflict(() => attemptMark(
|
|
275
|
+
return retryCounterWriteOnVersionConflict(() => attemptMark(writeDb));
|
|
226
276
|
}
|
|
227
277
|
|
|
228
278
|
export type ReadRollingCapUsageOptions = {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { CapCounterContext } from "./book-cap-usage.js";
|
|
2
|
+
import {
|
|
3
|
+
type CalendarCapReservation,
|
|
4
|
+
releaseExpiredCapReservations,
|
|
5
|
+
reserveCalendarCap,
|
|
6
|
+
settleCalendarCapReservation,
|
|
7
|
+
} from "./cap-reservation.js";
|
|
8
|
+
import {
|
|
9
|
+
assertBelowHardCap,
|
|
10
|
+
enforceCapAndMaybeNotify,
|
|
11
|
+
type SoftHitNotifier,
|
|
12
|
+
} from "./enforce-cap.js";
|
|
13
|
+
import type { CalendarCapDef } from "./with-cap-enforcement.js";
|
|
14
|
+
|
|
15
|
+
export type CalendarCapUsageReservationSpec = Omit<CalendarCapDef, "amount" | "notify"> & {
|
|
16
|
+
readonly amount: number;
|
|
17
|
+
readonly notify?: SoftHitNotifier;
|
|
18
|
+
// Default true: the reservation must commit on its own, not roll back with the caller's transaction.
|
|
19
|
+
readonly outsideTransaction?: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type CalendarCapUsageReservation = {
|
|
23
|
+
readonly amount: number;
|
|
24
|
+
// Counter value right after this reservation was booked.
|
|
25
|
+
readonly counterValue: number;
|
|
26
|
+
// Replaces the reserved amount with the actual one; rejects a second settle and one after release.
|
|
27
|
+
settle(actualAmount: number): Promise<void>;
|
|
28
|
+
// Gives the reserved amount back; a no-op once settled.
|
|
29
|
+
release(): Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Shared by withCapEnforcement and the public reservation API so both enforce identically.
|
|
33
|
+
export async function reserveEnforcedCalendarCap(
|
|
34
|
+
ctx: CapCounterContext,
|
|
35
|
+
cap: CalendarCapUsageReservationSpec,
|
|
36
|
+
): Promise<CalendarCapReservation> {
|
|
37
|
+
const outsideTransaction = cap.outsideTransaction ?? true;
|
|
38
|
+
await releaseExpiredCapReservations(ctx, cap.capName, outsideTransaction);
|
|
39
|
+
if (cap.notify) {
|
|
40
|
+
await enforceCapAndMaybeNotify(ctx, {
|
|
41
|
+
capName: cap.capName,
|
|
42
|
+
periodStartIso: cap.periodStartIso,
|
|
43
|
+
limit: cap.limit,
|
|
44
|
+
profile: cap.profile,
|
|
45
|
+
notify: cap.notify,
|
|
46
|
+
amount: cap.amount,
|
|
47
|
+
markSoftWarnedOutsideTransaction: outsideTransaction,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return reserveCalendarCap(
|
|
51
|
+
ctx,
|
|
52
|
+
{
|
|
53
|
+
capName: cap.capName,
|
|
54
|
+
amount: cap.amount,
|
|
55
|
+
periodStartIso: cap.periodStartIso,
|
|
56
|
+
guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + cap.amount - 1, cap),
|
|
57
|
+
},
|
|
58
|
+
outsideTransaction,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type ReservationState = "open" | "settled" | "released";
|
|
63
|
+
|
|
64
|
+
export async function reserveCalendarCapUsage(
|
|
65
|
+
ctx: CapCounterContext,
|
|
66
|
+
spec: CalendarCapUsageReservationSpec,
|
|
67
|
+
): Promise<CalendarCapUsageReservation> {
|
|
68
|
+
const outsideTransaction = spec.outsideTransaction ?? true;
|
|
69
|
+
const reservation = await reserveEnforcedCalendarCap(ctx, spec);
|
|
70
|
+
let state: ReservationState = "open";
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
amount: spec.amount,
|
|
74
|
+
counterValue: reservation.counterValue,
|
|
75
|
+
async settle(actualAmount) {
|
|
76
|
+
if (state !== "open") {
|
|
77
|
+
throw new Error(`cap-counter: reservation for "${spec.capName}" is already ${state}`);
|
|
78
|
+
}
|
|
79
|
+
state = "settled";
|
|
80
|
+
try {
|
|
81
|
+
await settleCalendarCapReservation(
|
|
82
|
+
ctx,
|
|
83
|
+
reservation.reservationId,
|
|
84
|
+
{ capName: spec.capName, periodStartIso: spec.periodStartIso, actualAmount },
|
|
85
|
+
outsideTransaction,
|
|
86
|
+
);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
state = "open";
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
async release() {
|
|
93
|
+
// skip: release after settle, or a second release, is a no-op by contract.
|
|
94
|
+
if (state !== "open") return;
|
|
95
|
+
await reservation.release();
|
|
96
|
+
state = "released";
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -1,9 +1,5 @@
|
|
|
1
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";
|
|
2
|
+
import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
3
|
import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
4
|
import {
|
|
9
5
|
appendEventInTenantDb,
|
|
@@ -19,9 +15,10 @@ import { rollingCapAggregateId } from "./aggregate-id.js";
|
|
|
19
15
|
import {
|
|
20
16
|
applyCapDeltaOn,
|
|
21
17
|
type BookCapUsageOptions,
|
|
18
|
+
type CapCounterContext,
|
|
22
19
|
type ReadRollingCapUsageOptions,
|
|
23
20
|
readRollingCapUsage,
|
|
24
|
-
|
|
21
|
+
resolveCapCounterScope,
|
|
25
22
|
retryCounterWriteOnVersionConflict,
|
|
26
23
|
retryOnStreamVersionConflict,
|
|
27
24
|
} from "./book-cap-usage.js";
|
|
@@ -47,10 +44,16 @@ const NOTHING_TO_RELEASE: WriteResult = { isSuccess: true, data: {} };
|
|
|
47
44
|
type ReservationRowDraft = Pick<CapReservationRow, "capName" | "kind" | "amount"> &
|
|
48
45
|
Pick<Partial<CapReservationRow>, "periodStartIso">;
|
|
49
46
|
|
|
50
|
-
async function insertReservationRow(
|
|
47
|
+
async function insertReservationRow(
|
|
48
|
+
txDb: TenantDb,
|
|
49
|
+
user: SessionUser,
|
|
50
|
+
id: string,
|
|
51
|
+
draft: ReservationRowDraft,
|
|
52
|
+
) {
|
|
51
53
|
const now = Temporal.Now.instant();
|
|
52
54
|
await txDb.insertOne(capReservationsTable, {
|
|
53
55
|
id,
|
|
56
|
+
tenantId: user.tenantId,
|
|
54
57
|
capName: draft.capName,
|
|
55
58
|
kind: draft.kind,
|
|
56
59
|
periodStartIso: draft.periodStartIso ?? null,
|
|
@@ -60,9 +63,19 @@ async function insertReservationRow(txDb: TenantDb, id: string, draft: Reservati
|
|
|
60
63
|
});
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
export type CalendarCapReservation = ReservationHandle & {
|
|
67
|
+
readonly reservationId: string;
|
|
68
|
+
// Counter value right after this reservation was booked.
|
|
69
|
+
readonly counterValue: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function toHandle(
|
|
73
|
+
ctx: CapCounterContext,
|
|
74
|
+
id: string,
|
|
75
|
+
outsideTransaction: boolean,
|
|
76
|
+
): ReservationHandle {
|
|
64
77
|
return {
|
|
65
|
-
release: () => releaseCapReservation(ctx, id),
|
|
78
|
+
release: () => releaseCapReservation(ctx, id, outsideTransaction),
|
|
66
79
|
confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
|
|
67
80
|
};
|
|
68
81
|
}
|
|
@@ -75,16 +88,26 @@ export type ReserveCalendarCapOptions = Required<
|
|
|
75
88
|
// The counter increment and the reservation row commit together, so a booked amount never exists
|
|
76
89
|
// without a row that can give it back.
|
|
77
90
|
export async function reserveCalendarCap(
|
|
78
|
-
ctx:
|
|
91
|
+
ctx: CapCounterContext,
|
|
79
92
|
options: ReserveCalendarCapOptions,
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
outsideTransaction = true,
|
|
94
|
+
): Promise<CalendarCapReservation> {
|
|
95
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
82
96
|
const id = generateId();
|
|
97
|
+
// The last attempt is the one that committed, so this holds the value its increment started from.
|
|
98
|
+
let valueBeforeBooking = 0;
|
|
99
|
+
const observedOptions: ReserveCalendarCapOptions = {
|
|
100
|
+
...options,
|
|
101
|
+
guardCurrentValue: (currentValue) => {
|
|
102
|
+
options.guardCurrentValue?.(currentValue);
|
|
103
|
+
valueBeforeBooking = currentValue;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
83
106
|
const reserved = await retryCounterWriteOnVersionConflict(() =>
|
|
84
|
-
runInOwnTransaction(
|
|
85
|
-
const booked = await applyCapDeltaOn(txDb,
|
|
107
|
+
runInOwnTransaction(writeDb, async (txDb) => {
|
|
108
|
+
const booked = await applyCapDeltaOn(txDb, user, observedOptions, "add");
|
|
86
109
|
if (!booked.isSuccess) return booked;
|
|
87
|
-
await insertReservationRow(txDb, id, {
|
|
110
|
+
await insertReservationRow(txDb, user, id, {
|
|
88
111
|
capName: options.capName,
|
|
89
112
|
kind: "calendar",
|
|
90
113
|
periodStartIso: options.periodStartIso,
|
|
@@ -94,7 +117,11 @@ export async function reserveCalendarCap(
|
|
|
94
117
|
}),
|
|
95
118
|
);
|
|
96
119
|
if (!reserved.isSuccess) throw reraiseAsKumikoError(reserved.error);
|
|
97
|
-
return
|
|
120
|
+
return {
|
|
121
|
+
...toHandle(ctx, id, outsideTransaction),
|
|
122
|
+
reservationId: id,
|
|
123
|
+
counterValue: valueBeforeBooking + options.amount,
|
|
124
|
+
};
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
|
|
@@ -123,28 +150,23 @@ function rollingEvent(
|
|
|
123
150
|
// The append is version-guarded: a booker that read the usage before another one appended loses the
|
|
124
151
|
// race and repeats the whole attempt, so the hard-cap check always sees the stream it appends to.
|
|
125
152
|
export async function reserveRollingCap(
|
|
126
|
-
ctx:
|
|
153
|
+
ctx: CapCounterContext,
|
|
127
154
|
options: ReserveRollingCapOptions,
|
|
155
|
+
outsideTransaction = true,
|
|
128
156
|
): Promise<ReservationHandle> {
|
|
129
|
-
const
|
|
157
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
130
158
|
const id = generateId();
|
|
131
|
-
const aggregateId = rollingCapAggregateId(
|
|
159
|
+
const aggregateId = rollingCapAggregateId(user.tenantId, options.capName);
|
|
132
160
|
await retryOnStreamVersionConflict(() =>
|
|
133
|
-
runInOwnTransaction(
|
|
161
|
+
runInOwnTransaction(writeDb, async (txDb) => {
|
|
134
162
|
const version = await getStreamVersionInTenantDb(txDb, aggregateId);
|
|
135
|
-
const usage = await readRollingCapUsage(txDb,
|
|
163
|
+
const usage = await readRollingCapUsage(txDb, user.tenantId, options);
|
|
136
164
|
options.guardCurrentValue?.(usage);
|
|
137
165
|
await appendEventInTenantDb(
|
|
138
166
|
txDb,
|
|
139
|
-
rollingEvent(
|
|
140
|
-
ctx.user,
|
|
141
|
-
options.capName,
|
|
142
|
-
options.amount,
|
|
143
|
-
version,
|
|
144
|
-
ROLLING_INCREMENTED_EVENT_QN,
|
|
145
|
-
),
|
|
167
|
+
rollingEvent(user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN),
|
|
146
168
|
);
|
|
147
|
-
await insertReservationRow(txDb, id, {
|
|
169
|
+
await insertReservationRow(txDb, user, id, {
|
|
148
170
|
capName: options.capName,
|
|
149
171
|
kind: "rolling",
|
|
150
172
|
amount: options.amount,
|
|
@@ -152,7 +174,7 @@ export async function reserveRollingCap(
|
|
|
152
174
|
return NOTHING_TO_RELEASE;
|
|
153
175
|
}),
|
|
154
176
|
);
|
|
155
|
-
return toHandle(ctx, id);
|
|
177
|
+
return toHandle(ctx, id, outsideTransaction);
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
// Deleting the row takes its row lock inside the handler transaction, so a release started after a
|
|
@@ -201,45 +223,123 @@ async function giveBackReservation(
|
|
|
201
223
|
user: SessionUser,
|
|
202
224
|
id: string,
|
|
203
225
|
): Promise<WriteResult> {
|
|
204
|
-
const
|
|
226
|
+
const ownRow = { id, tenantId: user.tenantId };
|
|
227
|
+
const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, ownRow);
|
|
205
228
|
if (!row) return NOTHING_TO_RELEASE;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (claimed.length === 0) return NOTHING_TO_RELEASE;
|
|
229
|
+
const claimed = await claimReservationRow(txDb, user, row);
|
|
230
|
+
if (!claimed) return NOTHING_TO_RELEASE;
|
|
209
231
|
const given =
|
|
210
232
|
row.kind === "rolling"
|
|
211
233
|
? await giveBackRolling(txDb, user, row)
|
|
212
234
|
: await giveBackCalendar(txDb, user, row);
|
|
213
235
|
if (!given.isSuccess) return given;
|
|
214
|
-
await txDb.deleteMany(capReservationsTable,
|
|
236
|
+
await txDb.deleteMany(capReservationsTable, ownRow);
|
|
215
237
|
return given;
|
|
216
238
|
}
|
|
217
239
|
|
|
218
|
-
|
|
219
|
-
|
|
240
|
+
// Waits for a confirm still holding the row; false means that confirm (or another release) won.
|
|
241
|
+
async function claimReservationRow(
|
|
242
|
+
txDb: TenantDb,
|
|
243
|
+
user: SessionUser,
|
|
244
|
+
row: CapReservationRow,
|
|
245
|
+
): Promise<boolean> {
|
|
246
|
+
const claimed = await txDb.updateMany(
|
|
247
|
+
capReservationsTable,
|
|
248
|
+
{ expiresAt: row.expiresAt },
|
|
249
|
+
{ id: row.id, tenantId: user.tenantId },
|
|
250
|
+
);
|
|
251
|
+
return claimed.length > 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function releaseCapReservation(
|
|
255
|
+
ctx: CapCounterContext,
|
|
256
|
+
id: string,
|
|
257
|
+
outsideTransaction = true,
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
220
260
|
const released = await retryOnStreamVersionConflict(() =>
|
|
221
261
|
retryCounterWriteOnVersionConflict(() =>
|
|
222
|
-
runInOwnTransaction(
|
|
262
|
+
runInOwnTransaction(writeDb, (txDb) => giveBackReservation(txDb, user, id)),
|
|
223
263
|
),
|
|
224
264
|
);
|
|
225
265
|
if (!released.isSuccess) throw reraiseAsKumikoError(released.error);
|
|
226
266
|
}
|
|
227
267
|
|
|
268
|
+
export type SettleCalendarCapOptions = {
|
|
269
|
+
readonly capName: string;
|
|
270
|
+
readonly periodStartIso: string;
|
|
271
|
+
readonly actualAmount: number;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
function bookCalendarDelta(
|
|
275
|
+
txDb: TenantDb,
|
|
276
|
+
user: SessionUser,
|
|
277
|
+
period: Pick<SettleCalendarCapOptions, "capName" | "periodStartIso">,
|
|
278
|
+
delta: number,
|
|
279
|
+
): Promise<WriteResult> {
|
|
280
|
+
if (delta === 0) return Promise.resolve({ isSuccess: true, data: {} });
|
|
281
|
+
return applyCapDeltaOn(
|
|
282
|
+
txDb,
|
|
283
|
+
user,
|
|
284
|
+
{ ...period, amount: Math.abs(delta) },
|
|
285
|
+
delta > 0 ? "add" : "subtract",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Replaces the reserved amount by the actual one in a single transaction. When the row is already
|
|
290
|
+
// gone (swept or released), the reserved amount was given back, so the full actual amount is booked.
|
|
291
|
+
export async function settleCalendarCapReservation(
|
|
292
|
+
ctx: CapCounterContext,
|
|
293
|
+
id: string,
|
|
294
|
+
options: SettleCalendarCapOptions,
|
|
295
|
+
outsideTransaction = true,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
if (!Number.isInteger(options.actualAmount) || options.actualAmount < 0) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`cap-counter: settle amount must be a non-negative integer, got ${options.actualAmount}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
303
|
+
const settled = await retryCounterWriteOnVersionConflict(() =>
|
|
304
|
+
runInOwnTransaction(writeDb, async (txDb) => {
|
|
305
|
+
const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, {
|
|
306
|
+
id,
|
|
307
|
+
tenantId: user.tenantId,
|
|
308
|
+
});
|
|
309
|
+
const claimed = row ? await claimReservationRow(txDb, user, row) : false;
|
|
310
|
+
if (!row || !claimed) {
|
|
311
|
+
return bookCalendarDelta(txDb, user, options, options.actualAmount);
|
|
312
|
+
}
|
|
313
|
+
const booked = await bookCalendarDelta(
|
|
314
|
+
txDb,
|
|
315
|
+
user,
|
|
316
|
+
options,
|
|
317
|
+
options.actualAmount - row.amount,
|
|
318
|
+
);
|
|
319
|
+
if (!booked.isSuccess) return booked;
|
|
320
|
+
await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
|
|
321
|
+
return booked;
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
if (!settled.isSuccess) throw reraiseAsKumikoError(settled.error);
|
|
325
|
+
}
|
|
326
|
+
|
|
228
327
|
// A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
|
|
229
328
|
// capacity stays counted.
|
|
230
329
|
export async function releaseExpiredCapReservations(
|
|
231
|
-
ctx:
|
|
330
|
+
ctx: CapCounterContext,
|
|
232
331
|
capName: string,
|
|
332
|
+
outsideTransaction = true,
|
|
233
333
|
): Promise<void> {
|
|
234
|
-
const
|
|
235
|
-
const expired = await
|
|
236
|
-
tenantId:
|
|
334
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
335
|
+
const expired = await writeDb.selectMany<Pick<CapReservationRow, "id">>(capReservationsTable, {
|
|
336
|
+
tenantId: user.tenantId,
|
|
237
337
|
capName,
|
|
238
338
|
expiresAt: { lt: Temporal.Now.instant() },
|
|
239
339
|
});
|
|
240
340
|
for (const { id } of expired) {
|
|
241
341
|
try {
|
|
242
|
-
await releaseCapReservation(ctx, id);
|
|
342
|
+
await releaseCapReservation(ctx, id, outsideTransaction);
|
|
243
343
|
} catch (error) {
|
|
244
344
|
ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });
|
|
245
345
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.358.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Reserve and settle cap usage outside the dispatcher",
|
|
6
|
+
"detail": "reserveCalendarCapUsage(ctx, { capName, periodStartIso, limit, profile, amount, notify? }) runs the same sweep, soft-hit notification and guarded hard-cap reservation as withCapEnforcement and returns a handle. settle(actualAmount) replaces the reserved amount by the actual one in one transaction (and books the full actual amount if the reservation was swept or released meanwhile); release() gives it back. Parallel callers cannot exceed the hard cap. It works with a HandlerContext (also r.systemScope() handlers) and a JobContext. Pattern: `const r = await reserveCalendarCapUsage(ctx, {...}); try { ...; await r.settle(actual) } catch (e) { await r.release(); throw e }`. bookCapUsage, markCapSoftWarned, releaseCapUsage and the enforceCap family accept a JobContext as well.",
|
|
7
|
+
"migration": "withCapEnforcement and bookCapUsage now work on r.systemScope() handlers; before, they failed on the fail-closed ctx.db. Reservation rows carry the tenant of ctx.user explicitly, and a context whose tenant-mode db belongs to another tenant than ctx.user is rejected with access_denied. enforceCap now filters the counter by ctx.user.tenantId, so a system-mode db no longer reads another tenant's counter."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.352.0",
|
|
4
11
|
"type": "breaking",
|