@cosmicdrift/kumiko-bundled-features 0.316.0 → 0.317.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 +9 -9
- package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +1 -1
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +102 -0
- package/src/cap-counter/aggregate-id.ts +4 -17
- package/src/cap-counter/book-cap-usage.ts +73 -43
- package/src/cap-counter/changes.json +6 -0
- package/src/cap-counter/handlers/increment.write.ts +2 -3
- package/src/cap-counter/with-cap-enforcement.ts +4 -2
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.317.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>",
|
|
@@ -133,12 +133,12 @@
|
|
|
133
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.317.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.317.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.317.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.317.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.317.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.317.0",
|
|
142
142
|
"@mollie/api-client": "^4.5.0",
|
|
143
143
|
"@node-rs/argon2": "^2.0.2",
|
|
144
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.317.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.317.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -4,7 +4,7 @@ import { useUrlToken } from "../auth-form-primitives";
|
|
|
4
4
|
|
|
5
5
|
// #774: magic-link tokens must not linger in browser history / Referer.
|
|
6
6
|
// useUrlToken reads the token once, then scrubs the param via replaceState.
|
|
7
|
-
// (dom
|
|
7
|
+
// (preload/dom resets window.location to http://localhost/ after each test.)
|
|
8
8
|
describe("useUrlToken (magic-link history hygiene)", () => {
|
|
9
9
|
test("reads ?token= and strips it from the URL, keeping other params", () => {
|
|
10
10
|
window.history.replaceState(null, "", "http://localhost/reset?token=secret-abc&keep=1");
|
|
@@ -135,11 +135,47 @@ const bookOutsideTxThenFailHandler: WriteHandlerDef = {
|
|
|
135
135
|
const NEWSLETTER_TENANT_ONLY_QN = "newsletter:write:send-newsletter-tenant-only";
|
|
136
136
|
const BOOK_OUTSIDE_TX_QN = "newsletter:write:book-outside-tx-then-fail";
|
|
137
137
|
|
|
138
|
+
// =============================================================================
|
|
139
|
+
// Parallel-booking-race probes — return bookCapUsage's result
|
|
140
|
+
// directly so a lost optimistic-lock race surfaces as an HTTP error instead
|
|
141
|
+
// of being swallowed.
|
|
142
|
+
// =============================================================================
|
|
143
|
+
|
|
144
|
+
const PARALLEL_BOOKING_IN_TX_CAP_NAME = "parallel-booking-in-tx-cap";
|
|
145
|
+
const bookCapUsageInTxHandler: WriteHandlerDef = {
|
|
146
|
+
name: "book-cap-usage-in-tx",
|
|
147
|
+
schema: z.object({}),
|
|
148
|
+
access: { roles: ["TenantAdmin"] },
|
|
149
|
+
handler: (_event, ctx) =>
|
|
150
|
+
bookCapUsage(ctx, {
|
|
151
|
+
capName: PARALLEL_BOOKING_IN_TX_CAP_NAME,
|
|
152
|
+
periodStartIso: TENANT_ONLY_PERIOD,
|
|
153
|
+
}),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const PARALLEL_BOOKING_OUTSIDE_TX_CAP_NAME = "parallel-booking-outside-tx-cap";
|
|
157
|
+
const bookCapUsageOutsideTxHandler: WriteHandlerDef = {
|
|
158
|
+
name: "book-cap-usage-outside-tx",
|
|
159
|
+
schema: z.object({}),
|
|
160
|
+
access: { roles: ["TenantAdmin"] },
|
|
161
|
+
handler: (_event, ctx) =>
|
|
162
|
+
bookCapUsage(ctx, {
|
|
163
|
+
capName: PARALLEL_BOOKING_OUTSIDE_TX_CAP_NAME,
|
|
164
|
+
periodStartIso: TENANT_ONLY_PERIOD,
|
|
165
|
+
outsideTransaction: true,
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const BOOK_CAP_USAGE_IN_TX_QN = "newsletter:write:book-cap-usage-in-tx";
|
|
170
|
+
const BOOK_CAP_USAGE_OUTSIDE_TX_QN = "newsletter:write:book-cap-usage-outside-tx";
|
|
171
|
+
|
|
138
172
|
const newsletterFeature = defineFeature("newsletter", (r) => {
|
|
139
173
|
r.writeHandler(wrappedCalendar);
|
|
140
174
|
r.writeHandler(wrappedRolling);
|
|
141
175
|
r.writeHandler(wrappedCalendarTenantOnly);
|
|
142
176
|
r.writeHandler(bookOutsideTxThenFailHandler);
|
|
177
|
+
r.writeHandler(bookCapUsageInTxHandler);
|
|
178
|
+
r.writeHandler(bookCapUsageOutsideTxHandler);
|
|
143
179
|
});
|
|
144
180
|
|
|
145
181
|
// =============================================================================
|
|
@@ -410,3 +446,69 @@ describe("withCapEnforcement — calendar, TenantAdmin-only callers", () => {
|
|
|
410
446
|
expect(usageB).toBe(0);
|
|
411
447
|
});
|
|
412
448
|
});
|
|
449
|
+
|
|
450
|
+
// =============================================================================
|
|
451
|
+
// Parallel bookCapUsage calls for the same (tenant, cap, period)
|
|
452
|
+
// =============================================================================
|
|
453
|
+
|
|
454
|
+
const PARALLEL_BOOKINGS = 6;
|
|
455
|
+
|
|
456
|
+
describe("bookCapUsage — parallel bookings for the same period", () => {
|
|
457
|
+
test("in-tx: N concurrent bookings all succeed and the counter sums to N, then 2N", async () => {
|
|
458
|
+
const user = tenantAdminOnlyFor(2601);
|
|
459
|
+
|
|
460
|
+
await Promise.all(
|
|
461
|
+
Array.from({ length: PARALLEL_BOOKINGS }, () =>
|
|
462
|
+
stack.http.writeOk(BOOK_CAP_USAGE_IN_TX_QN, {}, user),
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
const afterCreateRace = await readCounter(
|
|
466
|
+
user,
|
|
467
|
+
PARALLEL_BOOKING_IN_TX_CAP_NAME,
|
|
468
|
+
TENANT_ONLY_PERIOD,
|
|
469
|
+
);
|
|
470
|
+
expect(afterCreateRace!["value"]).toBe(PARALLEL_BOOKINGS);
|
|
471
|
+
|
|
472
|
+
await Promise.all(
|
|
473
|
+
Array.from({ length: PARALLEL_BOOKINGS }, () =>
|
|
474
|
+
stack.http.writeOk(BOOK_CAP_USAGE_IN_TX_QN, {}, user),
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
const afterUpdateRace = await readCounter(
|
|
478
|
+
user,
|
|
479
|
+
PARALLEL_BOOKING_IN_TX_CAP_NAME,
|
|
480
|
+
TENANT_ONLY_PERIOD,
|
|
481
|
+
);
|
|
482
|
+
expect(afterUpdateRace!["value"]).toBe(PARALLEL_BOOKINGS * 2);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// N=6 stays below the default pool max (10): each outside-tx request holds
|
|
486
|
+
// both a handler-tx connection and a dbOutsideTransaction connection.
|
|
487
|
+
test("outside-tx: N concurrent bookings all succeed and the counter sums to N, then 2N", async () => {
|
|
488
|
+
const user = tenantAdminOnlyFor(2602);
|
|
489
|
+
|
|
490
|
+
await Promise.all(
|
|
491
|
+
Array.from({ length: PARALLEL_BOOKINGS }, () =>
|
|
492
|
+
stack.http.writeOk(BOOK_CAP_USAGE_OUTSIDE_TX_QN, {}, user),
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
const afterCreateRace = await readCounter(
|
|
496
|
+
user,
|
|
497
|
+
PARALLEL_BOOKING_OUTSIDE_TX_CAP_NAME,
|
|
498
|
+
TENANT_ONLY_PERIOD,
|
|
499
|
+
);
|
|
500
|
+
expect(afterCreateRace!["value"]).toBe(PARALLEL_BOOKINGS);
|
|
501
|
+
|
|
502
|
+
await Promise.all(
|
|
503
|
+
Array.from({ length: PARALLEL_BOOKINGS }, () =>
|
|
504
|
+
stack.http.writeOk(BOOK_CAP_USAGE_OUTSIDE_TX_QN, {}, user),
|
|
505
|
+
),
|
|
506
|
+
);
|
|
507
|
+
const afterUpdateRace = await readCounter(
|
|
508
|
+
user,
|
|
509
|
+
PARALLEL_BOOKING_OUTSIDE_TX_CAP_NAME,
|
|
510
|
+
TENANT_ONLY_PERIOD,
|
|
511
|
+
);
|
|
512
|
+
expect(afterUpdateRace!["value"]).toBe(PARALLEL_BOOKINGS * 2);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
@@ -14,23 +14,10 @@ const CAP_COUNTER_NAMESPACE = "9c1bf2a3-6e4d-4f5b-8a9c-2d3e4f5a6b7c";
|
|
|
14
14
|
const CAP_COUNTER_ROLLING_NAMESPACE = "8b2ad0c6-1f3e-4f7c-9b8a-3c4d5e6f7a8b";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* **Period-Semantik:**
|
|
22
|
-
* - Calendar-Month-Reset: neuer periodStart am 1. des Monats →
|
|
23
|
-
* neuer Aggregate-Stream. Vorherige Counter-Row bleibt für Audit.
|
|
24
|
-
* - Rolling-Window: periodStart wird NIE zurückgesetzt (z.B. fixed
|
|
25
|
-
* "1970-01-01" als Sentinel). Der Read filtert via Event-Store-
|
|
26
|
-
* Timestamp, nicht via Aggregate-Identity.
|
|
27
|
-
*
|
|
28
|
-
* **Aufruf-Pattern:** Caller (incrementCap-Helper) ruft das mit dem
|
|
29
|
-
* tenantId aus event.user.tenantId, dem capName und dem aktuellen
|
|
30
|
-
* Period-Start auf. Race-frei: zwei parallele Increments für denselben
|
|
31
|
-
* (tenant, cap, period) gehen auf denselben aggregate-Stream und werden
|
|
32
|
-
* vom event-store optimistic-lock serialisiert (version_conflict bei
|
|
33
|
-
* Race → Caller-side Retry).
|
|
17
|
+
* One aggregate per (tenantId, capName, periodStart-as-iso). A new calendar
|
|
18
|
+
* period yields a new stream, so the previous counter row stays for audit.
|
|
19
|
+
* Parallel bookings for the same triple share one stream; the event store's
|
|
20
|
+
* optimistic lock serializes them and bookCapUsage retries the loser.
|
|
34
21
|
*/
|
|
35
22
|
// @wrapper-known uuid-domain
|
|
36
23
|
export function capCounterAggregateId(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// In-process cap booking for the caller's own tenant; no SystemAdmin dispatch needed.
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import {
|
|
5
5
|
createEntityExecutor,
|
|
6
6
|
type HandlerContext,
|
|
@@ -22,6 +22,27 @@ const capBookingSchema = z.object({
|
|
|
22
22
|
amount: z.number().int().positive().default(1),
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
function isLostCounterRace(result: WriteResult): boolean {
|
|
26
|
+
return !result.isSuccess && result.error.code === "version_conflict";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Each attempt writes event + projection atomically, so a conflict means another booker just committed — bounded by how many bookers run at once (pool max, default 10); this ceiling only guards against non-convergence.
|
|
30
|
+
const MAX_COUNTER_WRITE_ATTEMPTS = 20;
|
|
31
|
+
|
|
32
|
+
async function retryCounterWriteOnVersionConflict(
|
|
33
|
+
writeAttempt: () => Promise<WriteResult>,
|
|
34
|
+
): Promise<WriteResult> {
|
|
35
|
+
let result: WriteResult = await writeAttempt();
|
|
36
|
+
for (
|
|
37
|
+
let attempt = 1;
|
|
38
|
+
attempt < MAX_COUNTER_WRITE_ATTEMPTS && isLostCounterRace(result);
|
|
39
|
+
attempt++
|
|
40
|
+
) {
|
|
41
|
+
result = await writeAttempt();
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
export type BookCapUsageOptions = {
|
|
26
47
|
readonly capName: string;
|
|
27
48
|
readonly periodStartIso: string;
|
|
@@ -43,43 +64,50 @@ export async function bookCapUsage(
|
|
|
43
64
|
options: BookCapUsageOptions,
|
|
44
65
|
): Promise<WriteResult> {
|
|
45
66
|
const parsed = capBookingSchema.parse(options);
|
|
46
|
-
const db = options.outsideTransaction ? requireOutsideTransactionDb(ctx) : ctx.db;
|
|
47
67
|
const aggregateId = capCounterAggregateId(
|
|
48
68
|
ctx.user.tenantId,
|
|
49
69
|
parsed.capName,
|
|
50
70
|
parsed.periodStartIso,
|
|
51
71
|
);
|
|
52
72
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
async function attemptWrite(db: TenantDb): Promise<WriteResult> {
|
|
74
|
+
const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
75
|
+
if (existing.length === 0) {
|
|
76
|
+
return executor.create(
|
|
77
|
+
{
|
|
78
|
+
id: aggregateId,
|
|
79
|
+
capName: parsed.capName,
|
|
80
|
+
value: parsed.amount,
|
|
81
|
+
periodStart: Temporal.Instant.from(parsed.periodStartIso),
|
|
82
|
+
lastSoftWarnedAt: null,
|
|
83
|
+
},
|
|
84
|
+
ctx.user,
|
|
85
|
+
db,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const currentRow = existing[0];
|
|
90
|
+
if (!currentRow) {
|
|
91
|
+
throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
|
|
92
|
+
}
|
|
93
|
+
const currentValue = currentRow["value"] as number; // @cast-boundary db-row
|
|
94
|
+
const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
|
|
95
|
+
return executor.update(
|
|
56
96
|
{
|
|
57
97
|
id: aggregateId,
|
|
58
|
-
|
|
59
|
-
value: parsed.amount,
|
|
60
|
-
periodStart: Temporal.Instant.from(parsed.periodStartIso),
|
|
61
|
-
lastSoftWarnedAt: null,
|
|
98
|
+
version: currentVersion,
|
|
99
|
+
changes: { value: currentValue + parsed.amount },
|
|
62
100
|
},
|
|
63
101
|
ctx.user,
|
|
64
102
|
db,
|
|
65
103
|
);
|
|
66
104
|
}
|
|
67
105
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
106
|
+
if (options.outsideTransaction) {
|
|
107
|
+
const outsideDb = requireOutsideTransactionDb(ctx);
|
|
108
|
+
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptWrite));
|
|
71
109
|
}
|
|
72
|
-
|
|
73
|
-
const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
|
|
74
|
-
return executor.update(
|
|
75
|
-
{
|
|
76
|
-
id: aggregateId,
|
|
77
|
-
version: currentVersion,
|
|
78
|
-
changes: { value: currentValue + parsed.amount },
|
|
79
|
-
},
|
|
80
|
-
ctx.user,
|
|
81
|
-
db,
|
|
82
|
-
);
|
|
110
|
+
return retryCounterWriteOnVersionConflict(() => attemptWrite(ctx.db));
|
|
83
111
|
}
|
|
84
112
|
|
|
85
113
|
export type MarkCapSoftWarnedOptions = {
|
|
@@ -97,27 +125,29 @@ export async function markCapSoftWarned(
|
|
|
97
125
|
options.periodStartIso,
|
|
98
126
|
);
|
|
99
127
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
return retryCounterWriteOnVersionConflict(async () => {
|
|
129
|
+
const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
130
|
+
if (existing.length === 0) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${options.capName} period=${options.periodStartIso}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const row = existing[0];
|
|
136
|
+
if (!row) {
|
|
137
|
+
throw new Error("cap-counter.markCapSoftWarned: row vanished between length-check and read");
|
|
138
|
+
}
|
|
139
|
+
const currentVersion = row["version"] as number; // @cast-boundary db-row
|
|
140
|
+
|
|
141
|
+
return executor.update(
|
|
142
|
+
{
|
|
143
|
+
id: aggregateId,
|
|
144
|
+
version: currentVersion,
|
|
145
|
+
changes: { lastSoftWarnedAt: Temporal.Now.instant() },
|
|
146
|
+
},
|
|
147
|
+
ctx.user,
|
|
148
|
+
ctx.db,
|
|
104
149
|
);
|
|
105
|
-
}
|
|
106
|
-
const row = existing[0];
|
|
107
|
-
if (!row) {
|
|
108
|
-
throw new Error("cap-counter.markCapSoftWarned: row vanished between length-check and read");
|
|
109
|
-
}
|
|
110
|
-
const currentVersion = row["version"] as number; // @cast-boundary db-row
|
|
111
|
-
|
|
112
|
-
return executor.update(
|
|
113
|
-
{
|
|
114
|
-
id: aggregateId,
|
|
115
|
-
version: currentVersion,
|
|
116
|
-
changes: { lastSoftWarnedAt: Temporal.Now.instant() },
|
|
117
|
-
},
|
|
118
|
-
ctx.user,
|
|
119
|
-
ctx.db,
|
|
120
|
-
);
|
|
150
|
+
});
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
export type ReadRollingCapUsageOptions = {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.317.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "bookCapUsage/markCapSoftWarned count parallel bookings instead of losing them",
|
|
6
|
+
"detail": "Both re-read and retry when they lose an optimistic-lock race, so parallel bookings for the same cap period all count instead of failing or getting silently dropped. The outsideTransaction path runs each attempt in its own transaction (runInOwnTransaction), keeping event append and projection update atomic. withCapEnforcement now throws a failed booking instead of discarding it."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.307.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -18,9 +18,8 @@ type IncrementPayload = z.infer<typeof incrementSchema>;
|
|
|
18
18
|
|
|
19
19
|
// increment-cap — atomic counter increment via the event-store's
|
|
20
20
|
// optimistic-lock. Two parallel increments for the same (tenant, cap,
|
|
21
|
-
// period) go to the same aggregate;
|
|
22
|
-
//
|
|
23
|
-
// that for write-handlers, see version_conflict-retry-policy).
|
|
21
|
+
// period) go to the same aggregate; parallel-safety comes from the
|
|
22
|
+
// re-read-and-retry loop inside bookCapUsage, not from this handler.
|
|
24
23
|
//
|
|
25
24
|
// **Two paths:**
|
|
26
25
|
// 1. Aggregate doesn't exist yet (first increment of the period) →
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
WriteEvent,
|
|
22
22
|
WriteHandlerDef,
|
|
23
23
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
|
+
import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
24
25
|
import { bookCapUsage } from "./book-cap-usage";
|
|
25
26
|
import { CapCounterHandlers } from "./constants";
|
|
26
27
|
import {
|
|
@@ -68,7 +69,7 @@ export type CalendarCapResolver = (
|
|
|
68
69
|
* 2. pre-call: `enforceCapAndMaybeNotify` — throws CapExceededError
|
|
69
70
|
* on hard-hit (handler never runs), notifies on soft-hit-crossing
|
|
70
71
|
* 3. invoke the wrapped handler
|
|
71
|
-
* 4. post-success:
|
|
72
|
+
* 4. post-success: book usage via `bookCapUsage` with `amount`
|
|
72
73
|
*
|
|
73
74
|
* The returned handler-def keeps the original name/schema/access
|
|
74
75
|
* untouched — only the handler-fn is wrapped. The dispatcher sees
|
|
@@ -101,11 +102,12 @@ export function withCapEnforcement(
|
|
|
101
102
|
// Post-success increment. Skip on failure so a failed write
|
|
102
103
|
// doesn't burn cap-quota. amount default 1.
|
|
103
104
|
if (result.isSuccess) {
|
|
104
|
-
await bookCapUsage(ctx, {
|
|
105
|
+
const booked = await bookCapUsage(ctx, {
|
|
105
106
|
capName: cap.capName,
|
|
106
107
|
amount: cap.amount ?? 1,
|
|
107
108
|
periodStartIso: cap.periodStartIso,
|
|
108
109
|
});
|
|
110
|
+
if (!booked.isSuccess) throw reraiseAsKumikoError(booked.error);
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
return result;
|
|
@@ -143,7 +143,7 @@ async function waitForDownloadReady(view: ReturnType<typeof render>): Promise<vo
|
|
|
143
143
|
|
|
144
144
|
// CI runs this file in its own `bun test` process (own ci.yml step), NOT in the
|
|
145
145
|
// shared `kumiko check` run — see bunfig.ci.toml pathIgnorePatterns. In the shared
|
|
146
|
-
// single-process happy-dom, the global `afterEach` from
|
|
146
|
+
// single-process happy-dom, the global `afterEach` from the preload/dom module
|
|
147
147
|
// plus accumulated global DOM/event state across ~30 prior DOM test files corrupts
|
|
148
148
|
// these in-flight renders (#457-class). A fresh process has no such accumulation.
|
|
149
149
|
// The QN-Drift-Pins + formatDate describes below are pure-logic and CI-stable.
|