@cosmicdrift/kumiko-bundled-features 0.315.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.315.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.315.0",
137
- "@cosmicdrift/kumiko-framework": "0.315.0",
138
- "@cosmicdrift/kumiko-headless": "0.315.0",
139
- "@cosmicdrift/kumiko-renderer": "0.315.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.315.0",
141
- "@cosmicdrift/kumiko-types": "0.315.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.315.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.315.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.preload resets window.location to http://localhost/ after each test.)
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
- * Deterministic aggregate-id für ein cap-counter-Aggregate aus dem
18
- * Tripel (tenantId, capName, periodStart-as-iso). Pro Tenant + Cap +
19
- * Period existiert genau ein Aggregate.
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 { TenantDb } from "@cosmicdrift/kumiko-framework/db";
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
- const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
54
- if (existing.length === 0) {
55
- return executor.create(
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
- capName: parsed.capName,
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
- const currentRow = existing[0];
69
- if (!currentRow) {
70
- throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
106
+ if (options.outsideTransaction) {
107
+ const outsideDb = requireOutsideTransactionDb(ctx);
108
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptWrite));
71
109
  }
72
- const currentValue = currentRow["value"] as number; // @cast-boundary db-row
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
- const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
101
- if (existing.length === 0) {
102
- throw new Error(
103
- `cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${options.capName} period=${options.periodStartIso}`,
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; the second one's append fails with
22
- // version_conflict — caller retries (the dispatcher already handles
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: dispatch `cap-counter:write:increment` with `amount`
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 `test-setup/dom.preload.ts`
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.