@stamprally/server 0.9.0 → 0.11.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,20 +2,36 @@ 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
+ interface UserClaimRecord {
6
+ readonly rallyId: string;
7
+ readonly userId: string;
8
+ readonly rewardId: string;
9
+ readonly ticketNumber: string;
10
+ readonly timestamp: number;
11
+ }
5
12
  interface ServerPersistenceAdapter {
6
- acquireLock(lockKey: string, ttlMs: number): Promise<boolean>;
7
- releaseLock(lockKey: string): Promise<void>;
8
- decrementRewardStock(rewardId: string): Promise<{
13
+ /** Runs the callback in a storage-native transaction when supported. */
14
+ runTransaction?<T>(rallyId: string, operation: (transaction: ServerPersistenceAdapter) => Promise<T>): Promise<T>;
15
+ acquireLock(rallyId: string, lockKey: string, ttlMs: number): Promise<boolean>;
16
+ releaseLock(rallyId: string, lockKey: string): Promise<void>;
17
+ getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
18
+ decrementRewardStock(rallyId: string, rewardId: string): Promise<{
9
19
  readonly success: boolean;
10
20
  readonly remainingStock: number;
11
21
  }>;
12
- incrementRewardStock(rewardId: string): Promise<void>;
13
- getIdempotentResult<T>(key: string): Promise<T | null>;
14
- saveIdempotentResult<T>(key: string, result: T, ttlMs: number): Promise<void>;
22
+ restoreRewardStock?(rallyId: string, rewardId: string, count?: number): Promise<void>;
23
+ rollbackUserState?(rallyId: string, userId: string, previousState: UserRallyState | null): Promise<void>;
24
+ rollbackUserClaim?(rallyId: string, userId: string, rewardId: string, ticketNumber: string): Promise<void>;
25
+ getIdempotentResult<T>(rallyId: string, key: string): Promise<T | null>;
26
+ saveIdempotentResult<T>(rallyId: string, key: string, result: T, ttlMs: number): Promise<void>;
15
27
  getUserClaimCount(rallyId: string, userId: string, rewardId: string): Promise<number>;
28
+ recordUserClaim(params: UserClaimRecord): Promise<void>;
29
+ recordClaim?(params: UserClaimRecord): Promise<void>;
30
+ incrementUserClaimCount?(rallyId: string, userId: string, rewardId: string): Promise<void>;
16
31
  getUserState(rallyId: string, userId: string): Promise<UserRallyState | null>;
17
32
  saveUserState(rallyId: string, userId: string, state: UserRallyState): Promise<void>;
18
33
  recordAuditLog(log: AuditLog): Promise<void>;
34
+ removeAuditLog?(auditId: string): Promise<void>;
19
35
  }
20
36
  interface InMemoryServerPersistenceOptions {
21
37
  readonly stocks?: Readonly<Record<string, number>>;
@@ -23,21 +39,31 @@ interface InMemoryServerPersistenceOptions {
23
39
  declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
24
40
  #private;
25
41
  constructor(options?: InMemoryServerPersistenceOptions);
26
- acquireLock(key: string, ttlMs: number): Promise<boolean>;
27
- releaseLock(key: string): Promise<void>;
28
- decrementRewardStock(rewardId: string): Promise<{
42
+ acquireLock(rallyId: string, key: string, ttlMs: number): Promise<boolean>;
43
+ releaseLock(rallyId: string, key: string): Promise<void>;
44
+ getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
45
+ decrementRewardStock(rallyId: string, rewardId: string): Promise<{
29
46
  success: boolean;
30
47
  remainingStock: number;
31
48
  }>;
32
- incrementRewardStock(rewardId: string): Promise<void>;
33
- getIdempotentResult<T>(key: string): Promise<T | null>;
34
- saveIdempotentResult<T>(key: string, result: T, ttlMs: number): Promise<void>;
49
+ restoreRewardStock(rallyId: string, rewardId: string, count?: number): Promise<void>;
50
+ rollbackUserState(rallyId: string, userId: string, previousState: UserRallyState | null): Promise<void>;
51
+ getIdempotentResult<T>(rallyId: string, key: string): Promise<T | null>;
52
+ saveIdempotentResult<T>(rallyId: string, key: string, result: T, ttlMs: number): Promise<void>;
35
53
  getUserClaimCount(rallyId: string, userId: string, rewardId: string): Promise<number>;
36
54
  getUserState(rallyId: string, userId: string): Promise<UserRallyState | null>;
37
55
  saveUserState(rallyId: string, userId: string, state: UserRallyState): Promise<void>;
38
56
  recordAuditLog(log: AuditLog): Promise<void>;
57
+ removeAuditLog(auditId: string): Promise<void>;
39
58
  getAuditLogs(): ReadonlyArray<AuditLog>;
40
- recordClaim(rallyId: string, userId: string, rewardId: string): void;
59
+ recordUserClaim(params: UserClaimRecord): Promise<void>;
60
+ rollbackUserClaim(rallyId: string, userId: string, rewardId: string, ticketNumber: string): Promise<void>;
61
+ incrementUserClaimCount(rallyId: string, userId: string, rewardId: string): Promise<void>;
62
+ getClaimRecords(): ReadonlyArray<UserClaimRecord>;
63
+ /** @deprecated Use recordUserClaim with a ticket number and timestamp. */
64
+ recordClaim(params: UserClaimRecord): Promise<void>;
65
+ recordClaim(rallyId: string, userId: string, rewardId: string): Promise<void>;
66
+ runTransaction<T>(_rallyId: string, operation: (transaction: ServerPersistenceAdapter) => Promise<T>): Promise<T>;
41
67
  }
42
68
 
43
69
  type ErrorResponse = {
@@ -110,4 +136,4 @@ interface ServerOptions {
110
136
  readonly now?: () => string;
111
137
  }
112
138
 
113
- export { type AuditLog, type CheckInRequest, type CheckInResponse, type ClaimRewardRequest, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type ServerOptions, type ServerPersistenceAdapter, StampRallyServer };
139
+ export { type AuditLog, type CheckInRequest, type CheckInResponse, type ClaimRewardRequest, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type ServerOptions, type ServerPersistenceAdapter, StampRallyServer, type UserClaimRecord };
package/dist/index.d.ts CHANGED
@@ -2,20 +2,36 @@ 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
+ interface UserClaimRecord {
6
+ readonly rallyId: string;
7
+ readonly userId: string;
8
+ readonly rewardId: string;
9
+ readonly ticketNumber: string;
10
+ readonly timestamp: number;
11
+ }
5
12
  interface ServerPersistenceAdapter {
6
- acquireLock(lockKey: string, ttlMs: number): Promise<boolean>;
7
- releaseLock(lockKey: string): Promise<void>;
8
- decrementRewardStock(rewardId: string): Promise<{
13
+ /** Runs the callback in a storage-native transaction when supported. */
14
+ runTransaction?<T>(rallyId: string, operation: (transaction: ServerPersistenceAdapter) => Promise<T>): Promise<T>;
15
+ acquireLock(rallyId: string, lockKey: string, ttlMs: number): Promise<boolean>;
16
+ releaseLock(rallyId: string, lockKey: string): Promise<void>;
17
+ getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
18
+ decrementRewardStock(rallyId: string, rewardId: string): Promise<{
9
19
  readonly success: boolean;
10
20
  readonly remainingStock: number;
11
21
  }>;
12
- incrementRewardStock(rewardId: string): Promise<void>;
13
- getIdempotentResult<T>(key: string): Promise<T | null>;
14
- saveIdempotentResult<T>(key: string, result: T, ttlMs: number): Promise<void>;
22
+ restoreRewardStock?(rallyId: string, rewardId: string, count?: number): Promise<void>;
23
+ rollbackUserState?(rallyId: string, userId: string, previousState: UserRallyState | null): Promise<void>;
24
+ rollbackUserClaim?(rallyId: string, userId: string, rewardId: string, ticketNumber: string): Promise<void>;
25
+ getIdempotentResult<T>(rallyId: string, key: string): Promise<T | null>;
26
+ saveIdempotentResult<T>(rallyId: string, key: string, result: T, ttlMs: number): Promise<void>;
15
27
  getUserClaimCount(rallyId: string, userId: string, rewardId: string): Promise<number>;
28
+ recordUserClaim(params: UserClaimRecord): Promise<void>;
29
+ recordClaim?(params: UserClaimRecord): Promise<void>;
30
+ incrementUserClaimCount?(rallyId: string, userId: string, rewardId: string): Promise<void>;
16
31
  getUserState(rallyId: string, userId: string): Promise<UserRallyState | null>;
17
32
  saveUserState(rallyId: string, userId: string, state: UserRallyState): Promise<void>;
18
33
  recordAuditLog(log: AuditLog): Promise<void>;
34
+ removeAuditLog?(auditId: string): Promise<void>;
19
35
  }
20
36
  interface InMemoryServerPersistenceOptions {
21
37
  readonly stocks?: Readonly<Record<string, number>>;
@@ -23,21 +39,31 @@ interface InMemoryServerPersistenceOptions {
23
39
  declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
24
40
  #private;
25
41
  constructor(options?: InMemoryServerPersistenceOptions);
26
- acquireLock(key: string, ttlMs: number): Promise<boolean>;
27
- releaseLock(key: string): Promise<void>;
28
- decrementRewardStock(rewardId: string): Promise<{
42
+ acquireLock(rallyId: string, key: string, ttlMs: number): Promise<boolean>;
43
+ releaseLock(rallyId: string, key: string): Promise<void>;
44
+ getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
45
+ decrementRewardStock(rallyId: string, rewardId: string): Promise<{
29
46
  success: boolean;
30
47
  remainingStock: number;
31
48
  }>;
32
- incrementRewardStock(rewardId: string): Promise<void>;
33
- getIdempotentResult<T>(key: string): Promise<T | null>;
34
- saveIdempotentResult<T>(key: string, result: T, ttlMs: number): Promise<void>;
49
+ restoreRewardStock(rallyId: string, rewardId: string, count?: number): Promise<void>;
50
+ rollbackUserState(rallyId: string, userId: string, previousState: UserRallyState | null): Promise<void>;
51
+ getIdempotentResult<T>(rallyId: string, key: string): Promise<T | null>;
52
+ saveIdempotentResult<T>(rallyId: string, key: string, result: T, ttlMs: number): Promise<void>;
35
53
  getUserClaimCount(rallyId: string, userId: string, rewardId: string): Promise<number>;
36
54
  getUserState(rallyId: string, userId: string): Promise<UserRallyState | null>;
37
55
  saveUserState(rallyId: string, userId: string, state: UserRallyState): Promise<void>;
38
56
  recordAuditLog(log: AuditLog): Promise<void>;
57
+ removeAuditLog(auditId: string): Promise<void>;
39
58
  getAuditLogs(): ReadonlyArray<AuditLog>;
40
- recordClaim(rallyId: string, userId: string, rewardId: string): void;
59
+ recordUserClaim(params: UserClaimRecord): Promise<void>;
60
+ rollbackUserClaim(rallyId: string, userId: string, rewardId: string, ticketNumber: string): Promise<void>;
61
+ incrementUserClaimCount(rallyId: string, userId: string, rewardId: string): Promise<void>;
62
+ getClaimRecords(): ReadonlyArray<UserClaimRecord>;
63
+ /** @deprecated Use recordUserClaim with a ticket number and timestamp. */
64
+ recordClaim(params: UserClaimRecord): Promise<void>;
65
+ recordClaim(rallyId: string, userId: string, rewardId: string): Promise<void>;
66
+ runTransaction<T>(_rallyId: string, operation: (transaction: ServerPersistenceAdapter) => Promise<T>): Promise<T>;
41
67
  }
42
68
 
43
69
  type ErrorResponse = {
@@ -110,4 +136,4 @@ interface ServerOptions {
110
136
  readonly now?: () => string;
111
137
  }
112
138
 
113
- export { type AuditLog, type CheckInRequest, type CheckInResponse, type ClaimRewardRequest, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type ServerOptions, type ServerPersistenceAdapter, StampRallyServer };
139
+ export { type AuditLog, type CheckInRequest, type CheckInResponse, type ClaimRewardRequest, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type ServerOptions, type ServerPersistenceAdapter, StampRallyServer, type UserClaimRecord };
package/dist/index.js CHANGED
@@ -6,42 +6,73 @@ var InMemoryServerPersistenceAdapter = class {
6
6
  #idempotent = /* @__PURE__ */ new Map();
7
7
  #states = /* @__PURE__ */ new Map();
8
8
  #stocks;
9
+ #stockDefaults;
9
10
  #claims = /* @__PURE__ */ new Map();
11
+ #claimRecords = [];
10
12
  #auditLogs = [];
11
13
  constructor(options = {}) {
12
- this.#stocks = new Map(Object.entries(options.stocks ?? {}));
14
+ this.#stocks = /* @__PURE__ */ new Map();
15
+ this.#stockDefaults = /* @__PURE__ */ new Map();
16
+ for (const [key, stock] of Object.entries(options.stocks ?? {})) {
17
+ if (key.includes(":")) this.#stocks.set(key, stock);
18
+ else this.#stockDefaults.set(key, stock);
19
+ }
20
+ }
21
+ #key(rallyId, key) {
22
+ return `${rallyId}:${key}`;
23
+ }
24
+ #stockKey(rallyId, rewardId) {
25
+ const scopedKey = `${rallyId}:${rewardId}`;
26
+ if (this.#stocks.has(scopedKey)) return scopedKey;
27
+ const defaultStock = this.#stockDefaults.get(rewardId);
28
+ if (defaultStock !== void 0) {
29
+ this.#stocks.set(scopedKey, defaultStock);
30
+ return scopedKey;
31
+ }
32
+ return scopedKey;
13
33
  }
14
- async acquireLock(key, ttlMs) {
34
+ async acquireLock(rallyId, key, ttlMs) {
35
+ const scopedKey = this.#key(rallyId, key);
15
36
  const now2 = Date.now();
16
- const until = this.#locks.get(key);
37
+ const until = this.#locks.get(scopedKey);
17
38
  if (until !== void 0 && until > now2) return false;
18
- this.#locks.set(key, now2 + Math.max(1, ttlMs));
39
+ this.#locks.set(scopedKey, now2 + Math.max(1, ttlMs));
19
40
  return true;
20
41
  }
21
- async releaseLock(key) {
22
- this.#locks.delete(key);
42
+ async releaseLock(rallyId, key) {
43
+ this.#locks.delete(this.#key(rallyId, key));
44
+ }
45
+ async getRewardStock(rallyId, rewardId) {
46
+ return this.#stocks.get(this.#stockKey(rallyId, rewardId)) ?? null;
23
47
  }
24
- async decrementRewardStock(rewardId) {
25
- const current = this.#stocks.get(rewardId);
48
+ async decrementRewardStock(rallyId, rewardId) {
49
+ const key = this.#stockKey(rallyId, rewardId);
50
+ const current = this.#stocks.get(key);
26
51
  if (current === void 0) return { success: true, remainingStock: Number.POSITIVE_INFINITY };
27
52
  if (current <= 0) return { success: false, remainingStock: 0 };
28
- this.#stocks.set(rewardId, current - 1);
53
+ this.#stocks.set(key, current - 1);
29
54
  return { success: true, remainingStock: current - 1 };
30
55
  }
31
- async incrementRewardStock(rewardId) {
32
- const current = this.#stocks.get(rewardId);
33
- if (current !== void 0) this.#stocks.set(rewardId, current + 1);
56
+ async restoreRewardStock(rallyId, rewardId, count = 1) {
57
+ const key = this.#stockKey(rallyId, rewardId);
58
+ const current = this.#stocks.get(key);
59
+ if (current !== void 0) this.#stocks.set(key, current + Math.max(0, count));
60
+ }
61
+ async rollbackUserState(rallyId, userId, previousState) {
62
+ const key = `${rallyId}:${userId}`;
63
+ if (previousState === null) this.#states.delete(key);
64
+ else this.#states.set(key, structuredClone(previousState));
34
65
  }
35
- async getIdempotentResult(key) {
36
- const value = this.#idempotent.get(key);
66
+ async getIdempotentResult(rallyId, key) {
67
+ const value = this.#idempotent.get(this.#key(rallyId, key));
37
68
  if (value === void 0 || value.expiresAt <= Date.now()) {
38
- this.#idempotent.delete(key);
69
+ this.#idempotent.delete(this.#key(rallyId, key));
39
70
  return null;
40
71
  }
41
72
  return structuredClone(value.value);
42
73
  }
43
- async saveIdempotentResult(key, result, ttlMs) {
44
- this.#idempotent.set(key, {
74
+ async saveIdempotentResult(rallyId, key, result, ttlMs) {
75
+ this.#idempotent.set(this.#key(rallyId, key), {
45
76
  value: structuredClone(result),
46
77
  expiresAt: Date.now() + Math.max(1, ttlMs)
47
78
  });
@@ -59,13 +90,72 @@ var InMemoryServerPersistenceAdapter = class {
59
90
  async recordAuditLog(log) {
60
91
  this.#auditLogs.push(structuredClone(log));
61
92
  }
93
+ async removeAuditLog(auditId) {
94
+ const index = this.#auditLogs.findIndex((log) => log.id === auditId);
95
+ if (index >= 0) this.#auditLogs.splice(index, 1);
96
+ }
62
97
  getAuditLogs() {
63
98
  return structuredClone(this.#auditLogs);
64
99
  }
65
- recordClaim(rallyId, userId, rewardId) {
100
+ async recordUserClaim(params) {
101
+ const { rallyId, userId, rewardId } = params;
102
+ const key = `${rallyId}:${userId}:${rewardId}`;
103
+ this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
104
+ this.#claimRecords.push(structuredClone(params));
105
+ }
106
+ async rollbackUserClaim(rallyId, userId, rewardId, ticketNumber) {
107
+ const key = `${rallyId}:${userId}:${rewardId}`;
108
+ const count = this.#claims.get(key) ?? 0;
109
+ if (count <= 1) this.#claims.delete(key);
110
+ else this.#claims.set(key, count - 1);
111
+ const index = this.#claimRecords.findIndex(
112
+ (record) => record.rallyId === rallyId && record.userId === userId && record.rewardId === rewardId && record.ticketNumber === ticketNumber
113
+ );
114
+ if (index >= 0) this.#claimRecords.splice(index, 1);
115
+ }
116
+ async incrementUserClaimCount(rallyId, userId, rewardId) {
66
117
  const key = `${rallyId}:${userId}:${rewardId}`;
67
118
  this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
68
119
  }
120
+ getClaimRecords() {
121
+ return structuredClone(this.#claimRecords);
122
+ }
123
+ recordClaim(paramsOrRallyId, userId, rewardId) {
124
+ const params = typeof paramsOrRallyId === "string" ? {
125
+ rallyId: paramsOrRallyId,
126
+ userId: userId ?? "",
127
+ rewardId: rewardId ?? "",
128
+ ticketNumber: "",
129
+ timestamp: Date.now()
130
+ } : paramsOrRallyId;
131
+ return this.recordUserClaim(params);
132
+ }
133
+ async runTransaction(_rallyId, operation) {
134
+ const snapshot = {
135
+ stocks: new Map(this.#stocks),
136
+ idempotent: new Map(this.#idempotent),
137
+ states: new Map(this.#states),
138
+ claims: new Map(this.#claims),
139
+ claimRecords: structuredClone(this.#claimRecords),
140
+ auditLogs: structuredClone(this.#auditLogs)
141
+ };
142
+ try {
143
+ return await operation(this);
144
+ } catch (error) {
145
+ this.#stocks.clear();
146
+ for (const [key, value] of snapshot.stocks) this.#stocks.set(key, value);
147
+ this.#idempotent.clear();
148
+ for (const [key, value] of snapshot.idempotent)
149
+ this.#idempotent.set(key, structuredClone(value));
150
+ this.#states.clear();
151
+ for (const [key, value] of snapshot.states) this.#states.set(key, structuredClone(value));
152
+ this.#claims.clear();
153
+ for (const [key, value] of snapshot.claims) this.#claims.set(key, value);
154
+ this.#claimRecords.splice(0, this.#claimRecords.length, ...snapshot.claimRecords);
155
+ this.#auditLogs.splice(0, this.#auditLogs.length, ...snapshot.auditLogs);
156
+ throw error;
157
+ }
158
+ }
69
159
  };
70
160
  function json(body, status = 200) {
71
161
  return new Response(JSON.stringify(body), {
@@ -177,10 +267,17 @@ var StampRallyServer = class {
177
267
  }
178
268
  async checkIn(request) {
179
269
  const key = `check-in:${request.rallyId}:${request.userId}:${request.idempotencyKey}`;
180
- const previous = await this.#persistence.getIdempotentResult(key);
270
+ const previous = await this.#persistence.getIdempotentResult(
271
+ request.rallyId,
272
+ key
273
+ );
181
274
  if (previous !== null) return previous;
182
275
  const lockKey = `state:${request.rallyId}:${request.userId}`;
183
- if (!await this.#persistence.acquireLock(lockKey, this.#options.lockTtlMs ?? 5e3))
276
+ if (!await this.#persistence.acquireLock(
277
+ request.rallyId,
278
+ lockKey,
279
+ this.#options.lockTtlMs ?? 5e3
280
+ ))
184
281
  return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
185
282
  const timestamp = now(this.#options, request.now);
186
283
  try {
@@ -264,12 +361,20 @@ var StampRallyServer = class {
264
361
  timestamp
265
362
  );
266
363
  } finally {
267
- await this.#persistence.releaseLock(lockKey);
364
+ await this.#persistence.releaseLock(request.rallyId, lockKey);
268
365
  }
269
366
  }
270
367
  async claimReward(request) {
368
+ if (this.#persistence.runTransaction !== void 0)
369
+ return this.#persistence.runTransaction(
370
+ request.rallyId,
371
+ (transaction) => this.#claimRewardMutation(request, transaction)
372
+ );
373
+ return this.#claimRewardMutation(request, this.#persistence);
374
+ }
375
+ async #claimRewardMutation(request, persistence) {
271
376
  const key = `claim:${request.rallyId}:${request.userId}:${request.rewardId}:${request.idempotencyKey}`;
272
- const previous = await this.#persistence.getIdempotentResult(key);
377
+ const previous = await persistence.getIdempotentResult(request.rallyId, key);
273
378
  if (previous !== null) return previous;
274
379
  const reward = this.#config.rewards.find((item) => item.id === request.rewardId);
275
380
  if (reward === void 0)
@@ -277,18 +382,24 @@ var StampRallyServer = class {
277
382
  key,
278
383
  { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
279
384
  request,
280
- now(this.#options, request.now)
385
+ now(this.#options, request.now),
386
+ persistence
281
387
  );
282
388
  const lockKey = `reward:${request.rallyId}:${reward.id}`;
283
- if (!await this.#persistence.acquireLock(lockKey, this.#options.lockTtlMs ?? 5e3))
389
+ if (!await persistence.acquireLock(request.rallyId, lockKey, this.#options.lockTtlMs ?? 5e3))
284
390
  return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
285
391
  const timestamp = now(this.#options, request.now);
286
392
  let decremented = false;
393
+ let previousState = null;
394
+ let recordedTicket = null;
395
+ let successAuditId = null;
287
396
  try {
288
- const checked = await this.#persistence.getIdempotentResult(key);
397
+ const checked = await persistence.getIdempotentResult(request.rallyId, key);
289
398
  if (checked !== null) return checked;
290
- const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
291
- const claimCount = await this.#persistence.getUserClaimCount(
399
+ const storedState = await persistence.getUserState(request.rallyId, request.userId);
400
+ previousState = storedState;
401
+ const current = storedState ?? initialState(this.#config, request.userId, timestamp);
402
+ const claimCount = await persistence.getUserClaimCount(
292
403
  request.rallyId,
293
404
  request.userId,
294
405
  reward.id
@@ -311,15 +422,17 @@ var StampRallyServer = class {
311
422
  key,
312
423
  { ok: false, code: local.error.code, message: "Reward cannot be claimed." },
313
424
  request,
314
- timestamp
425
+ timestamp,
426
+ persistence
315
427
  );
316
- const stock = await this.#persistence.decrementRewardStock(reward.id);
428
+ const stock = await persistence.decrementRewardStock(request.rallyId, reward.id);
317
429
  if (!stock.success)
318
430
  return this.#rememberClaim(
319
431
  key,
320
432
  { ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
321
433
  request,
322
- timestamp
434
+ timestamp,
435
+ persistence
323
436
  );
324
437
  decremented = true;
325
438
  const next = {
@@ -328,41 +441,59 @@ var StampRallyServer = class {
328
441
  updatedAt: timestamp
329
442
  };
330
443
  try {
331
- await this.#persistence.saveUserState(request.rallyId, request.userId, next);
444
+ await persistence.saveUserState(request.rallyId, request.userId, next);
332
445
  const response = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
333
- await this.#persistence.recordAuditLog(
334
- audit(
335
- request.rallyId,
336
- request.userId,
337
- "CLAIM_REWARD",
338
- reward.id,
339
- request.idempotencyKey,
340
- "SUCCESS",
341
- timestamp
342
- )
446
+ recordedTicket = local.value.claimTicketNumber ?? "";
447
+ await persistence.recordUserClaim({
448
+ rallyId: request.rallyId,
449
+ userId: request.userId,
450
+ rewardId: reward.id,
451
+ ticketNumber: recordedTicket,
452
+ timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp)
453
+ });
454
+ const successAudit = audit(
455
+ request.rallyId,
456
+ request.userId,
457
+ "CLAIM_REWARD",
458
+ reward.id,
459
+ request.idempotencyKey,
460
+ "SUCCESS",
461
+ timestamp
343
462
  );
344
- await this.#persistence.saveIdempotentResult(
463
+ successAuditId = successAudit.id;
464
+ await persistence.recordAuditLog(successAudit);
465
+ await persistence.saveIdempotentResult(
466
+ request.rallyId,
345
467
  key,
346
468
  response,
347
469
  this.#options.idempotencyTtlMs ?? 864e5
348
470
  );
349
- if (this.#persistence instanceof Object && "recordClaim" in this.#persistence && typeof this.#persistence.recordClaim === "function")
350
- this.#persistence.recordClaim(request.rallyId, request.userId, reward.id);
351
471
  return response;
352
472
  } catch (error) {
353
- await this.#persistence.incrementRewardStock(reward.id);
473
+ if (recordedTicket !== null && persistence.rollbackUserClaim !== void 0)
474
+ await persistence.rollbackUserClaim(
475
+ request.rallyId,
476
+ request.userId,
477
+ reward.id,
478
+ recordedTicket
479
+ );
480
+ if (persistence.rollbackUserState !== void 0)
481
+ await persistence.rollbackUserState(request.rallyId, request.userId, previousState);
482
+ if (successAuditId !== null && persistence.removeAuditLog !== void 0)
483
+ await persistence.removeAuditLog(successAuditId);
484
+ await this.#restoreRewardStock(persistence, request.rallyId, reward.id);
354
485
  decremented = false;
355
486
  throw error;
356
487
  }
357
488
  } catch (error) {
358
- if (decremented) await this.#persistence.incrementRewardStock(reward.id);
489
+ if (decremented) await this.#restoreRewardStock(persistence, request.rallyId, reward.id);
359
490
  return {
360
491
  ok: false,
361
492
  code: "PERSISTENCE_FAILED",
362
493
  message: error instanceof Error ? error.message : "Reward claim failed."
363
494
  };
364
495
  } finally {
365
- await this.#persistence.releaseLock(lockKey);
496
+ await persistence.releaseLock(request.rallyId, lockKey);
366
497
  }
367
498
  }
368
499
  async sync(rallyId, userId) {
@@ -395,14 +526,15 @@ var StampRallyServer = class {
395
526
  )
396
527
  );
397
528
  await this.#persistence.saveIdempotentResult(
529
+ rallyId,
398
530
  key,
399
531
  result,
400
532
  this.#options.idempotencyTtlMs ?? 864e5
401
533
  );
402
534
  return result;
403
535
  }
404
- async #rememberClaim(key, result, request, timestamp) {
405
- await this.#persistence.recordAuditLog(
536
+ async #rememberClaim(key, result, request, timestamp, persistence = this.#persistence) {
537
+ await persistence.recordAuditLog(
406
538
  audit(
407
539
  request.rallyId,
408
540
  request.userId,
@@ -414,13 +546,18 @@ var StampRallyServer = class {
414
546
  result.ok ? void 0 : result.code
415
547
  )
416
548
  );
417
- await this.#persistence.saveIdempotentResult(
549
+ await persistence.saveIdempotentResult(
550
+ request.rallyId,
418
551
  key,
419
552
  result,
420
553
  this.#options.idempotencyTtlMs ?? 864e5
421
554
  );
422
555
  return result;
423
556
  }
557
+ async #restoreRewardStock(persistence, rallyId, rewardId) {
558
+ if (persistence.restoreRewardStock !== void 0)
559
+ await persistence.restoreRewardStock(rallyId, rewardId);
560
+ }
424
561
  };
425
562
 
426
563
  export { InMemoryServerPersistenceAdapter, StampRallyServer };