@stamprally/server 0.16.0 → 0.17.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/dist/index.d.cts CHANGED
@@ -2,6 +2,16 @@ import * as _stamprally_core from '@stamprally/core';
2
2
  import { UserRallyState, AdminRallyConfig } from '@stamprally/core';
3
3
  export { UserRallyState } from '@stamprally/core';
4
4
 
5
+ /** Authentication already verified by the host application's middleware. */
6
+ interface TrustedAuthContext {
7
+ readonly authenticatedUserId: string;
8
+ readonly isAnonymous?: boolean;
9
+ readonly sessionId?: string;
10
+ readonly claims?: Record<string, unknown>;
11
+ }
12
+ /** @deprecated Use TrustedAuthContext. */
13
+ type AuthenticationContext = TrustedAuthContext;
14
+
5
15
  interface UserClaimRecord {
6
16
  readonly rallyId: string;
7
17
  readonly userId: string;
@@ -39,6 +49,16 @@ interface ClaimRewardTransactionMutation {
39
49
  /** A domain rejection is committed as an audit/idempotency record without mutations. */
40
50
  readonly error?: string;
41
51
  }
52
+ interface ClaimRewardMutationContext {
53
+ readonly rewardStock: number | null;
54
+ readonly sharedStock: number | null;
55
+ readonly primaryStock: number | null;
56
+ readonly secondaryStock: number | null;
57
+ /** @deprecated Use rewardStock. */
58
+ readonly stock: number | null;
59
+ readonly claimCount: number;
60
+ readonly userState: UserRallyState;
61
+ }
42
62
  interface CheckInTransactionParams {
43
63
  readonly rallyId: string;
44
64
  readonly userId: string;
@@ -67,12 +87,7 @@ interface ServerPersistenceAdapter {
67
87
  * Atomically commits stock, user state, claim count, audit log, and idempotency data.
68
88
  * Adapters must roll back every write when the callback or any commit operation fails.
69
89
  */
70
- executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
71
- readonly stock: number | null;
72
- readonly secondaryStock: number | null;
73
- readonly claimCount: number;
74
- readonly userState: UserRallyState;
75
- }) => ClaimRewardTransactionMutation): Promise<{
90
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
76
91
  readonly success: boolean;
77
92
  readonly error?: string;
78
93
  }>;
@@ -101,7 +116,7 @@ interface InMemoryServerPersistenceOptions {
101
116
  }
102
117
  declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
103
118
  #private;
104
- readonly supportsRewardStock = true;
119
+ readonly supportsRewardStock: boolean;
105
120
  constructor(options?: InMemoryServerPersistenceOptions);
106
121
  acquireLock(rallyId: string, key: string, ttlMs: number): Promise<boolean>;
107
122
  releaseLock(rallyId: string, key: string): Promise<void>;
@@ -111,12 +126,7 @@ declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapt
111
126
  remainingStock: number;
112
127
  }>;
113
128
  restoreRewardStock(rallyId: string, rewardId: string, count?: number): Promise<void>;
114
- executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
115
- readonly stock: number | null;
116
- readonly secondaryStock: number | null;
117
- readonly claimCount: number;
118
- readonly userState: UserRallyState;
119
- }) => ClaimRewardTransactionMutation): Promise<{
129
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
120
130
  readonly success: boolean;
121
131
  readonly error?: string;
122
132
  }>;
@@ -175,11 +185,7 @@ interface SqlClaimRewardStore<Tx> {
175
185
  * while unexpected write failures reject the transaction and roll everything
176
186
  * back.
177
187
  */
178
- declare function executeClaimRewardTransaction<Tx>(database: SqlTransactionDatabase<Tx>, store: SqlClaimRewardStore<Tx>, params: ClaimRewardTransactionParams, mutation: (current: {
179
- readonly stock: number | null;
180
- readonly claimCount: number;
181
- readonly userState: UserRallyState;
182
- }) => ClaimRewardTransactionMutation): Promise<{
188
+ declare function executeClaimRewardTransaction<Tx>(database: SqlTransactionDatabase<Tx>, store: SqlClaimRewardStore<Tx>, params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
183
189
  readonly success: boolean;
184
190
  readonly error?: string;
185
191
  }>;
@@ -263,8 +269,8 @@ declare class StampRallyServer {
263
269
  handleCheckIn(request: Request): Promise<Response>;
264
270
  handleClaimReward(request: Request): Promise<Response>;
265
271
  handleSync(request: Request): Promise<Response>;
266
- checkIn(request: CheckInRequest): Promise<CheckInResponse>;
267
- claimReward(request: ClaimRewardRequest): Promise<ClaimResponse>;
272
+ checkIn(request: CheckInRequest, authContext?: TrustedAuthContext): Promise<CheckInResponse>;
273
+ claimReward(request: ClaimRewardRequest, authContext?: TrustedAuthContext): Promise<ClaimResponse>;
268
274
  sync(rallyId: string, userId: string): Promise<UserRallyState>;
269
275
  syncProgress(request: {
270
276
  readonly rallyId: string;
@@ -323,13 +329,10 @@ type CheckInResponse = {
323
329
  interface ServerOptions {
324
330
  readonly lockTtlMs?: number;
325
331
  readonly idempotencyTtlMs?: number;
326
- readonly authenticate?: (request: Request) => Promise<string | AuthenticationContext | null> | string | AuthenticationContext | null;
332
+ readonly authenticate?: (request: Request) => Promise<string | TrustedAuthContext | null> | string | TrustedAuthContext | null;
327
333
  readonly customValidators?: Readonly<Record<string, _stamprally_core.Validator>>;
328
334
  readonly now?: () => string;
329
335
  readonly anonymousPolicy?: "session_scoped" | "reject" | "shared_global_opt_in_insecure";
330
336
  }
331
- interface AuthenticationContext {
332
- readonly authenticatedUserId: string;
333
- }
334
337
 
335
- export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
338
+ export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardMutationContext, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type TrustedAuthContext, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,16 @@ import * as _stamprally_core from '@stamprally/core';
2
2
  import { UserRallyState, AdminRallyConfig } from '@stamprally/core';
3
3
  export { UserRallyState } from '@stamprally/core';
4
4
 
5
+ /** Authentication already verified by the host application's middleware. */
6
+ interface TrustedAuthContext {
7
+ readonly authenticatedUserId: string;
8
+ readonly isAnonymous?: boolean;
9
+ readonly sessionId?: string;
10
+ readonly claims?: Record<string, unknown>;
11
+ }
12
+ /** @deprecated Use TrustedAuthContext. */
13
+ type AuthenticationContext = TrustedAuthContext;
14
+
5
15
  interface UserClaimRecord {
6
16
  readonly rallyId: string;
7
17
  readonly userId: string;
@@ -39,6 +49,16 @@ interface ClaimRewardTransactionMutation {
39
49
  /** A domain rejection is committed as an audit/idempotency record without mutations. */
40
50
  readonly error?: string;
41
51
  }
52
+ interface ClaimRewardMutationContext {
53
+ readonly rewardStock: number | null;
54
+ readonly sharedStock: number | null;
55
+ readonly primaryStock: number | null;
56
+ readonly secondaryStock: number | null;
57
+ /** @deprecated Use rewardStock. */
58
+ readonly stock: number | null;
59
+ readonly claimCount: number;
60
+ readonly userState: UserRallyState;
61
+ }
42
62
  interface CheckInTransactionParams {
43
63
  readonly rallyId: string;
44
64
  readonly userId: string;
@@ -67,12 +87,7 @@ interface ServerPersistenceAdapter {
67
87
  * Atomically commits stock, user state, claim count, audit log, and idempotency data.
68
88
  * Adapters must roll back every write when the callback or any commit operation fails.
69
89
  */
70
- executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
71
- readonly stock: number | null;
72
- readonly secondaryStock: number | null;
73
- readonly claimCount: number;
74
- readonly userState: UserRallyState;
75
- }) => ClaimRewardTransactionMutation): Promise<{
90
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
76
91
  readonly success: boolean;
77
92
  readonly error?: string;
78
93
  }>;
@@ -101,7 +116,7 @@ interface InMemoryServerPersistenceOptions {
101
116
  }
102
117
  declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
103
118
  #private;
104
- readonly supportsRewardStock = true;
119
+ readonly supportsRewardStock: boolean;
105
120
  constructor(options?: InMemoryServerPersistenceOptions);
106
121
  acquireLock(rallyId: string, key: string, ttlMs: number): Promise<boolean>;
107
122
  releaseLock(rallyId: string, key: string): Promise<void>;
@@ -111,12 +126,7 @@ declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapt
111
126
  remainingStock: number;
112
127
  }>;
113
128
  restoreRewardStock(rallyId: string, rewardId: string, count?: number): Promise<void>;
114
- executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
115
- readonly stock: number | null;
116
- readonly secondaryStock: number | null;
117
- readonly claimCount: number;
118
- readonly userState: UserRallyState;
119
- }) => ClaimRewardTransactionMutation): Promise<{
129
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
120
130
  readonly success: boolean;
121
131
  readonly error?: string;
122
132
  }>;
@@ -175,11 +185,7 @@ interface SqlClaimRewardStore<Tx> {
175
185
  * while unexpected write failures reject the transaction and roll everything
176
186
  * back.
177
187
  */
178
- declare function executeClaimRewardTransaction<Tx>(database: SqlTransactionDatabase<Tx>, store: SqlClaimRewardStore<Tx>, params: ClaimRewardTransactionParams, mutation: (current: {
179
- readonly stock: number | null;
180
- readonly claimCount: number;
181
- readonly userState: UserRallyState;
182
- }) => ClaimRewardTransactionMutation): Promise<{
188
+ declare function executeClaimRewardTransaction<Tx>(database: SqlTransactionDatabase<Tx>, store: SqlClaimRewardStore<Tx>, params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
183
189
  readonly success: boolean;
184
190
  readonly error?: string;
185
191
  }>;
@@ -263,8 +269,8 @@ declare class StampRallyServer {
263
269
  handleCheckIn(request: Request): Promise<Response>;
264
270
  handleClaimReward(request: Request): Promise<Response>;
265
271
  handleSync(request: Request): Promise<Response>;
266
- checkIn(request: CheckInRequest): Promise<CheckInResponse>;
267
- claimReward(request: ClaimRewardRequest): Promise<ClaimResponse>;
272
+ checkIn(request: CheckInRequest, authContext?: TrustedAuthContext): Promise<CheckInResponse>;
273
+ claimReward(request: ClaimRewardRequest, authContext?: TrustedAuthContext): Promise<ClaimResponse>;
268
274
  sync(rallyId: string, userId: string): Promise<UserRallyState>;
269
275
  syncProgress(request: {
270
276
  readonly rallyId: string;
@@ -323,13 +329,10 @@ type CheckInResponse = {
323
329
  interface ServerOptions {
324
330
  readonly lockTtlMs?: number;
325
331
  readonly idempotencyTtlMs?: number;
326
- readonly authenticate?: (request: Request) => Promise<string | AuthenticationContext | null> | string | AuthenticationContext | null;
332
+ readonly authenticate?: (request: Request) => Promise<string | TrustedAuthContext | null> | string | TrustedAuthContext | null;
327
333
  readonly customValidators?: Readonly<Record<string, _stamprally_core.Validator>>;
328
334
  readonly now?: () => string;
329
335
  readonly anonymousPolicy?: "session_scoped" | "reject" | "shared_global_opt_in_insecure";
330
336
  }
331
- interface AuthenticationContext {
332
- readonly authenticatedUserId: string;
333
- }
334
337
 
335
- export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
338
+ export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardMutationContext, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type TrustedAuthContext, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
package/dist/index.js CHANGED
@@ -5,7 +5,16 @@ async function executeClaimRewardTransaction(database, store, params2, mutation2
5
5
  try {
6
6
  return await database.transaction(async (transaction) => {
7
7
  const current = await store.readContext(transaction, params2);
8
- const next = mutation2(current);
8
+ const secondaryStock = current.secondaryStock ?? null;
9
+ const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : current.stock;
10
+ const next = mutation2({
11
+ ...current,
12
+ rewardStock: rewardStock2,
13
+ sharedStock: params2.stockKey === "__shared__" ? current.stock : null,
14
+ primaryStock: rewardStock2,
15
+ secondaryStock,
16
+ stock: current.stock
17
+ });
9
18
  if (next.error !== void 0) {
10
19
  await store.writeAudit(transaction, next.auditLog);
11
20
  if (params2.idempotencyKey !== void 0 && next.result !== void 0)
@@ -13,7 +22,7 @@ async function executeClaimRewardTransaction(database, store, params2, mutation2
13
22
  return { success: false, error: next.error };
14
23
  }
15
24
  if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock === void 0)
16
- return { success: false, error: "INVENTORY_NOT_SUPPORTED" };
25
+ return { success: false, error: "INVENTORY_STORAGE_NOT_IMPLEMENTED" };
17
26
  if (next.nextStock !== null) await store.writeStock(transaction, params2, next.nextStock);
18
27
  if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock !== void 0) {
19
28
  if (next.nextSecondaryStock !== null)
@@ -144,9 +153,16 @@ var InMemoryServerPersistenceAdapter = class {
144
153
  params2.stockKey ?? params2.rewardId
145
154
  );
146
155
  const storedSecondaryStock = params2.secondaryStockKey === void 0 ? null : await this.getRewardStock(params2.rallyId, params2.secondaryStockKey);
156
+ const stock = storedStock ?? initialStock;
157
+ const secondaryStock = storedSecondaryStock ?? initialSecondaryStock;
158
+ const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : stock;
159
+ const sharedStock2 = params2.stockKey === "__shared__" ? stock : null;
147
160
  const mutationResult = mutation2({
148
- stock: storedStock ?? initialStock,
149
- secondaryStock: storedSecondaryStock ?? initialSecondaryStock,
161
+ rewardStock: rewardStock2,
162
+ sharedStock: sharedStock2,
163
+ primaryStock: rewardStock2,
164
+ secondaryStock,
165
+ stock,
150
166
  claimCount: await this.getUserClaimCount(params2.rallyId, params2.userId, params2.rewardId),
151
167
  userState
152
168
  });
@@ -678,7 +694,18 @@ function operationStatus(result) {
678
694
  if (result.ok) return "ACCEPTED";
679
695
  return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
680
696
  }
681
- function withDirectIdentity(request) {
697
+ function withDirectIdentity(request, authContext) {
698
+ if (authContext !== void 0) {
699
+ if (authContext.authenticatedUserId.trim() === "")
700
+ throw new RequestValidationException([
701
+ {
702
+ path: "authContext.authenticatedUserId",
703
+ message: "An authenticated userId is required.",
704
+ code: "REQUIRED"
705
+ }
706
+ ]);
707
+ return { ...request, userId: authContext.authenticatedUserId };
708
+ }
682
709
  if (request.userId !== void 0) return { ...request, userId: request.userId };
683
710
  if (request.anonymousSessionId !== void 0 && isUuidV4(request.anonymousSessionId))
684
711
  return { ...request, userId: request.anonymousSessionId };
@@ -808,8 +835,8 @@ var StampRallyServer = class {
808
835
  throw error;
809
836
  }
810
837
  }
811
- async checkIn(request) {
812
- const directRequest = withDirectIdentity(request);
838
+ async checkIn(request, authContext) {
839
+ const directRequest = withDirectIdentity(request, authContext);
813
840
  assertValidCheckInParams(directRequest, this.#config);
814
841
  const { userId } = directRequest;
815
842
  const key = `check-in:${request.rallyId}:${userId}:${request.idempotencyKey}`;
@@ -942,8 +969,8 @@ var StampRallyServer = class {
942
969
  await this.#persistence.releaseLock(request.rallyId, lockKey);
943
970
  }
944
971
  }
945
- async claimReward(request) {
946
- const directRequest = withDirectIdentity(request);
972
+ async claimReward(request, authContext) {
973
+ const directRequest = withDirectIdentity(request, authContext);
947
974
  assertValidClaimParams(directRequest, this.#config);
948
975
  const { userId } = directRequest;
949
976
  const key = `claim:${request.rallyId}:${userId}:${request.rewardId}:${request.idempotencyKey}`;
@@ -961,13 +988,14 @@ var StampRallyServer = class {
961
988
  now(this.#options)
962
989
  );
963
990
  const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
964
- if (rewardStock(this.#config, reward.id, reward.stockLimit) !== null && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function"))
991
+ const inventoryEnabled = plan.primaryInitial !== null || plan.secondaryInitial !== void 0 && plan.secondaryInitial !== null;
992
+ if (inventoryEnabled && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function" || typeof this.#persistence.executeClaimRewardTransaction !== "function"))
965
993
  return this.#rememberClaim(
966
994
  key,
967
995
  {
968
996
  ok: false,
969
- code: "INVENTORY_NOT_SUPPORTED",
970
- message: "This persistence adapter cannot store per-reward inventory."
997
+ code: "INVENTORY_STORAGE_NOT_IMPLEMENTED",
998
+ message: "This persistence adapter cannot atomically store inventory."
971
999
  },
972
1000
  directRequest,
973
1001
  now(this.#options)
@@ -1098,11 +1126,11 @@ var StampRallyServer = class {
1098
1126
  const response = responseHolder.value;
1099
1127
  if (!result.success) {
1100
1128
  if (response !== null && !response.ok && response.code === result.error) return response;
1101
- if (result.error === "INVENTORY_NOT_SUPPORTED")
1129
+ if (result.error === "INVENTORY_STORAGE_NOT_IMPLEMENTED")
1102
1130
  return {
1103
1131
  ok: false,
1104
- code: "INVENTORY_NOT_SUPPORTED",
1105
- message: "This persistence adapter cannot store per-reward inventory."
1132
+ code: "INVENTORY_STORAGE_NOT_IMPLEMENTED",
1133
+ message: "This persistence adapter cannot atomically store inventory."
1106
1134
  };
1107
1135
  return {
1108
1136
  ok: false,
@@ -1310,6 +1338,15 @@ async function runPersistenceAdapterComplianceTests(createAdapter) {
1310
1338
  await adapter.getRewardStock("compliance-rally", "reward") === 0,
1311
1339
  "per-reward stock was not decremented atomically"
1312
1340
  );
1341
+ const exhausted = await adapter.executeClaimRewardTransaction(
1342
+ params("carol", "boundary"),
1343
+ mutation
1344
+ );
1345
+ assert(!exhausted.success, "a zero-stock claim was accepted");
1346
+ assert(
1347
+ await adapter.getRewardStock("compliance-rally", "__shared__") === 0 && await adapter.getRewardStock("compliance-rally", "reward") === 0,
1348
+ "zero-stock rejection did not preserve both stock boundaries"
1349
+ );
1313
1350
  const idempotentAdapter = await createAdapter();
1314
1351
  const idempotentParams = params("alice", "same-key");
1315
1352
  const firstClaim = await idempotentAdapter.executeClaimRewardTransaction(
@@ -1325,6 +1362,10 @@ async function runPersistenceAdapterComplianceTests(createAdapter) {
1325
1362
  await idempotentAdapter.getRewardStock("compliance-rally", "__shared__") === 0,
1326
1363
  "idempotent retry decremented shared stock twice"
1327
1364
  );
1365
+ assert(
1366
+ await idempotentAdapter.getRewardStock("compliance-rally", "reward") === 0,
1367
+ "idempotent retry decremented per-reward stock twice"
1368
+ );
1328
1369
  const rollbackAdapter = await createAdapter();
1329
1370
  const rollbackParams = params("alice", "rollback");
1330
1371
  const rollback = await rollbackAdapter.executeClaimRewardTransaction(rollbackParams, () => {