@stamprally/server 0.11.0 → 0.12.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
@@ -9,9 +9,40 @@ interface UserClaimRecord {
9
9
  readonly ticketNumber: string;
10
10
  readonly timestamp: number;
11
11
  }
12
+ interface ClaimRewardTransactionParams {
13
+ readonly rallyId: string;
14
+ readonly userId: string;
15
+ readonly rewardId: string;
16
+ readonly ticketNumber: string;
17
+ readonly timestamp: number;
18
+ readonly idempotencyKey?: string;
19
+ readonly proofData?: unknown;
20
+ readonly idempotencyTtlMs?: number;
21
+ /** Used when the first claim is made before a user state has been stored. */
22
+ readonly initialUserState?: UserRallyState;
23
+ }
24
+ interface ClaimRewardTransactionMutation {
25
+ readonly nextStock: number | null;
26
+ readonly nextUserState: UserRallyState;
27
+ readonly auditLog: AuditLog;
28
+ /** The server response is persisted by the adapter with the idempotency key. */
29
+ readonly result?: unknown;
30
+ /** A domain rejection is committed as an audit/idempotency record without mutations. */
31
+ readonly error?: string;
32
+ }
12
33
  interface ServerPersistenceAdapter {
13
- /** Runs the callback in a storage-native transaction when supported. */
14
- runTransaction?<T>(rallyId: string, operation: (transaction: ServerPersistenceAdapter) => Promise<T>): Promise<T>;
34
+ /**
35
+ * Atomically commits stock, user state, claim count, audit log, and idempotency data.
36
+ * Adapters must roll back every write when the callback or any commit operation fails.
37
+ */
38
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
39
+ readonly stock: number | null;
40
+ readonly claimCount: number;
41
+ readonly userState: UserRallyState;
42
+ }) => ClaimRewardTransactionMutation): Promise<{
43
+ readonly success: boolean;
44
+ readonly error?: string;
45
+ }>;
15
46
  acquireLock(rallyId: string, lockKey: string, ttlMs: number): Promise<boolean>;
16
47
  releaseLock(rallyId: string, lockKey: string): Promise<void>;
17
48
  getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
@@ -19,9 +50,6 @@ interface ServerPersistenceAdapter {
19
50
  readonly success: boolean;
20
51
  readonly remainingStock: number;
21
52
  }>;
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
53
  getIdempotentResult<T>(rallyId: string, key: string): Promise<T | null>;
26
54
  saveIdempotentResult<T>(rallyId: string, key: string, result: T, ttlMs: number): Promise<void>;
27
55
  getUserClaimCount(rallyId: string, userId: string, rewardId: string): Promise<number>;
@@ -47,6 +75,14 @@ declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapt
47
75
  remainingStock: number;
48
76
  }>;
49
77
  restoreRewardStock(rallyId: string, rewardId: string, count?: number): Promise<void>;
78
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
79
+ readonly stock: number | null;
80
+ readonly claimCount: number;
81
+ readonly userState: UserRallyState;
82
+ }) => ClaimRewardTransactionMutation): Promise<{
83
+ readonly success: boolean;
84
+ readonly error?: string;
85
+ }>;
50
86
  rollbackUserState(rallyId: string, userId: string, previousState: UserRallyState | null): Promise<void>;
51
87
  getIdempotentResult<T>(rallyId: string, key: string): Promise<T | null>;
52
88
  saveIdempotentResult<T>(rallyId: string, key: string, result: T, ttlMs: number): Promise<void>;
@@ -136,4 +172,4 @@ interface ServerOptions {
136
172
  readonly now?: () => string;
137
173
  }
138
174
 
139
- export { type AuditLog, type CheckInRequest, type CheckInResponse, type ClaimRewardRequest, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type ServerOptions, type ServerPersistenceAdapter, StampRallyServer, type UserClaimRecord };
175
+ export { type AuditLog, type CheckInRequest, type CheckInResponse, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type ServerOptions, type ServerPersistenceAdapter, StampRallyServer, type UserClaimRecord };
package/dist/index.d.ts CHANGED
@@ -9,9 +9,40 @@ interface UserClaimRecord {
9
9
  readonly ticketNumber: string;
10
10
  readonly timestamp: number;
11
11
  }
12
+ interface ClaimRewardTransactionParams {
13
+ readonly rallyId: string;
14
+ readonly userId: string;
15
+ readonly rewardId: string;
16
+ readonly ticketNumber: string;
17
+ readonly timestamp: number;
18
+ readonly idempotencyKey?: string;
19
+ readonly proofData?: unknown;
20
+ readonly idempotencyTtlMs?: number;
21
+ /** Used when the first claim is made before a user state has been stored. */
22
+ readonly initialUserState?: UserRallyState;
23
+ }
24
+ interface ClaimRewardTransactionMutation {
25
+ readonly nextStock: number | null;
26
+ readonly nextUserState: UserRallyState;
27
+ readonly auditLog: AuditLog;
28
+ /** The server response is persisted by the adapter with the idempotency key. */
29
+ readonly result?: unknown;
30
+ /** A domain rejection is committed as an audit/idempotency record without mutations. */
31
+ readonly error?: string;
32
+ }
12
33
  interface ServerPersistenceAdapter {
13
- /** Runs the callback in a storage-native transaction when supported. */
14
- runTransaction?<T>(rallyId: string, operation: (transaction: ServerPersistenceAdapter) => Promise<T>): Promise<T>;
34
+ /**
35
+ * Atomically commits stock, user state, claim count, audit log, and idempotency data.
36
+ * Adapters must roll back every write when the callback or any commit operation fails.
37
+ */
38
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
39
+ readonly stock: number | null;
40
+ readonly claimCount: number;
41
+ readonly userState: UserRallyState;
42
+ }) => ClaimRewardTransactionMutation): Promise<{
43
+ readonly success: boolean;
44
+ readonly error?: string;
45
+ }>;
15
46
  acquireLock(rallyId: string, lockKey: string, ttlMs: number): Promise<boolean>;
16
47
  releaseLock(rallyId: string, lockKey: string): Promise<void>;
17
48
  getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
@@ -19,9 +50,6 @@ interface ServerPersistenceAdapter {
19
50
  readonly success: boolean;
20
51
  readonly remainingStock: number;
21
52
  }>;
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
53
  getIdempotentResult<T>(rallyId: string, key: string): Promise<T | null>;
26
54
  saveIdempotentResult<T>(rallyId: string, key: string, result: T, ttlMs: number): Promise<void>;
27
55
  getUserClaimCount(rallyId: string, userId: string, rewardId: string): Promise<number>;
@@ -47,6 +75,14 @@ declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapt
47
75
  remainingStock: number;
48
76
  }>;
49
77
  restoreRewardStock(rallyId: string, rewardId: string, count?: number): Promise<void>;
78
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
79
+ readonly stock: number | null;
80
+ readonly claimCount: number;
81
+ readonly userState: UserRallyState;
82
+ }) => ClaimRewardTransactionMutation): Promise<{
83
+ readonly success: boolean;
84
+ readonly error?: string;
85
+ }>;
50
86
  rollbackUserState(rallyId: string, userId: string, previousState: UserRallyState | null): Promise<void>;
51
87
  getIdempotentResult<T>(rallyId: string, key: string): Promise<T | null>;
52
88
  saveIdempotentResult<T>(rallyId: string, key: string, result: T, ttlMs: number): Promise<void>;
@@ -136,4 +172,4 @@ interface ServerOptions {
136
172
  readonly now?: () => string;
137
173
  }
138
174
 
139
- export { type AuditLog, type CheckInRequest, type CheckInResponse, type ClaimRewardRequest, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type ServerOptions, type ServerPersistenceAdapter, StampRallyServer, type UserClaimRecord };
175
+ export { type AuditLog, type CheckInRequest, type CheckInResponse, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type ServerOptions, type ServerPersistenceAdapter, StampRallyServer, type UserClaimRecord };
package/dist/index.js CHANGED
@@ -58,6 +58,65 @@ var InMemoryServerPersistenceAdapter = class {
58
58
  const current = this.#stocks.get(key);
59
59
  if (current !== void 0) this.#stocks.set(key, current + Math.max(0, count));
60
60
  }
61
+ async executeClaimRewardTransaction(params, mutation) {
62
+ try {
63
+ return await this.runTransaction(params.rallyId, async () => {
64
+ const userState = await this.getUserState(params.rallyId, params.userId) ?? params.initialUserState;
65
+ if (userState === void 0)
66
+ return { success: false, error: "A user state is required for this transaction." };
67
+ const mutationResult = mutation({
68
+ stock: await this.getRewardStock(params.rallyId, params.rewardId),
69
+ claimCount: await this.getUserClaimCount(params.rallyId, params.userId, params.rewardId),
70
+ userState
71
+ });
72
+ const idempotencyKey = params.idempotencyKey;
73
+ if (mutationResult.error !== void 0) {
74
+ await this.recordAuditLog(mutationResult.auditLog);
75
+ if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
76
+ await this.saveIdempotentResult(
77
+ params.rallyId,
78
+ idempotencyKey,
79
+ mutationResult.result,
80
+ params.idempotencyTtlMs ?? 864e5
81
+ );
82
+ return { success: false, error: mutationResult.error };
83
+ }
84
+ const currentStock = await this.getRewardStock(params.rallyId, params.rewardId);
85
+ if (currentStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
86
+ throw new Error("The transaction produced an invalid stock value.");
87
+ if (currentStock === null && mutationResult.nextStock !== null)
88
+ throw new Error("The transaction changed an unlimited stock to a limited stock.");
89
+ if (mutationResult.nextStock !== null)
90
+ this.#stocks.set(
91
+ this.#stockKey(params.rallyId, params.rewardId),
92
+ mutationResult.nextStock
93
+ );
94
+ await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
95
+ const reward = mutationResult.nextUserState.rewards.find(
96
+ (item) => item.rewardId === params.rewardId
97
+ );
98
+ if (reward?.claimTicketNumber !== void 0)
99
+ await this.recordUserClaim({
100
+ rallyId: params.rallyId,
101
+ userId: params.userId,
102
+ rewardId: params.rewardId,
103
+ ticketNumber: reward.claimTicketNumber,
104
+ timestamp: params.timestamp
105
+ });
106
+ await this.recordAuditLog(mutationResult.auditLog);
107
+ if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
108
+ await this.saveIdempotentResult(
109
+ params.rallyId,
110
+ idempotencyKey,
111
+ mutationResult.result,
112
+ params.idempotencyTtlMs ?? 864e5
113
+ );
114
+ return { success: true };
115
+ });
116
+ } catch (error) {
117
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
118
+ }
119
+ }
61
120
  async rollbackUserState(rallyId, userId, previousState) {
62
121
  const key = `${rallyId}:${userId}`;
63
122
  if (previousState === null) this.#states.delete(key);
@@ -365,16 +424,11 @@ var StampRallyServer = class {
365
424
  }
366
425
  }
367
426
  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) {
376
427
  const key = `claim:${request.rallyId}:${request.userId}:${request.rewardId}:${request.idempotencyKey}`;
377
- const previous = await persistence.getIdempotentResult(request.rallyId, key);
428
+ const previous = await this.#persistence.getIdempotentResult(
429
+ request.rallyId,
430
+ key
431
+ );
378
432
  if (previous !== null) return previous;
379
433
  const reward = this.#config.rewards.find((item) => item.id === request.rewardId);
380
434
  if (reward === void 0)
@@ -382,118 +436,125 @@ var StampRallyServer = class {
382
436
  key,
383
437
  { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
384
438
  request,
385
- now(this.#options, request.now),
386
- persistence
439
+ now(this.#options, request.now)
387
440
  );
388
441
  const lockKey = `reward:${request.rallyId}:${reward.id}`;
389
- if (!await persistence.acquireLock(request.rallyId, lockKey, this.#options.lockTtlMs ?? 5e3))
442
+ if (!await this.#persistence.acquireLock(
443
+ request.rallyId,
444
+ lockKey,
445
+ this.#options.lockTtlMs ?? 5e3
446
+ ))
390
447
  return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
391
448
  const timestamp = now(this.#options, request.now);
392
- let decremented = false;
393
- let previousState = null;
394
- let recordedTicket = null;
395
- let successAuditId = null;
396
449
  try {
397
- const checked = await persistence.getIdempotentResult(request.rallyId, key);
398
- if (checked !== null) return checked;
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(
450
+ const checked = await this.#persistence.getIdempotentResult(
403
451
  request.rallyId,
404
- request.userId,
405
- reward.id
452
+ key
406
453
  );
407
- const storedReward = current.rewards.find((item) => item.rewardId === reward.id) ?? {
408
- rewardId: reward.id,
409
- status: "LOCKED"
410
- };
411
- const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
412
- const local = consumeReward({
413
- reward,
414
- currentState: currentReward,
415
- now: timestamp,
416
- userRedemptionCount: claimCount,
417
- ...request.staffPasscode === void 0 ? {} : { inputPasscode: request.staffPasscode },
418
- ...request.staffId === void 0 ? {} : { staffId: request.staffId }
419
- });
420
- if (!local.ok)
421
- return this.#rememberClaim(
422
- key,
423
- { ok: false, code: local.error.code, message: "Reward cannot be claimed." },
424
- request,
425
- timestamp,
426
- persistence
427
- );
428
- const stock = await persistence.decrementRewardStock(request.rallyId, reward.id);
429
- if (!stock.success)
430
- return this.#rememberClaim(
431
- key,
432
- { ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
433
- request,
434
- timestamp,
435
- persistence
436
- );
437
- decremented = true;
438
- const next = {
439
- ...current,
440
- rewards: current.rewards.map((item) => item.rewardId === reward.id ? local.value : item),
441
- updatedAt: timestamp
442
- };
443
- try {
444
- await persistence.saveUserState(request.rallyId, request.userId, next);
445
- const response = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
446
- recordedTicket = local.value.claimTicketNumber ?? "";
447
- await persistence.recordUserClaim({
454
+ if (checked !== null) return checked;
455
+ const responseHolder = { value: null };
456
+ const result = await this.#persistence.executeClaimRewardTransaction(
457
+ {
448
458
  rallyId: request.rallyId,
449
459
  userId: request.userId,
450
460
  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
462
- );
463
- successAuditId = successAudit.id;
464
- await persistence.recordAuditLog(successAudit);
465
- await persistence.saveIdempotentResult(
466
- request.rallyId,
467
- key,
468
- response,
469
- this.#options.idempotencyTtlMs ?? 864e5
470
- );
471
- return response;
472
- } catch (error) {
473
- if (recordedTicket !== null && persistence.rollbackUserClaim !== void 0)
474
- await persistence.rollbackUserClaim(
461
+ ticketNumber: request.idempotencyKey,
462
+ timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp),
463
+ idempotencyKey: key,
464
+ ...request.staffPasscode === void 0 ? {} : { proofData: request.staffPasscode },
465
+ ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
466
+ initialUserState: initialState(this.#config, request.userId, timestamp)
467
+ },
468
+ ({ stock, claimCount, userState }) => {
469
+ const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
470
+ rewardId: reward.id,
471
+ status: "LOCKED"
472
+ };
473
+ const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
474
+ const makeAudit = (status, code) => audit(
475
475
  request.rallyId,
476
476
  request.userId,
477
+ "CLAIM_REWARD",
477
478
  reward.id,
478
- recordedTicket
479
+ request.idempotencyKey,
480
+ status,
481
+ timestamp,
482
+ code
479
483
  );
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);
485
- decremented = false;
486
- throw error;
484
+ if (stock !== null && stock <= 0) {
485
+ responseHolder.value = {
486
+ ok: false,
487
+ code: "OUT_OF_STOCK",
488
+ message: "Reward is out of stock."
489
+ };
490
+ return {
491
+ nextStock: stock,
492
+ nextUserState: userState,
493
+ auditLog: makeAudit("REJECTED", "OUT_OF_STOCK"),
494
+ result: responseHolder.value,
495
+ error: "OUT_OF_STOCK"
496
+ };
497
+ }
498
+ const local = consumeReward({
499
+ reward,
500
+ currentState: currentReward,
501
+ now: timestamp,
502
+ userRedemptionCount: claimCount,
503
+ ...request.staffPasscode === void 0 ? {} : { inputPasscode: request.staffPasscode },
504
+ ...request.staffId === void 0 ? {} : { staffId: request.staffId }
505
+ });
506
+ if (!local.ok) {
507
+ responseHolder.value = {
508
+ ok: false,
509
+ code: local.error.code,
510
+ message: "Reward cannot be claimed."
511
+ };
512
+ return {
513
+ nextStock: stock,
514
+ nextUserState: userState,
515
+ auditLog: makeAudit("REJECTED", local.error.code),
516
+ result: responseHolder.value,
517
+ error: local.error.code
518
+ };
519
+ }
520
+ const nextRewards = userState.rewards.some((item) => item.rewardId === reward.id) ? userState.rewards.map((item) => item.rewardId === reward.id ? local.value : item) : [...userState.rewards, local.value];
521
+ const next = {
522
+ ...userState,
523
+ rewards: nextRewards,
524
+ updatedAt: timestamp
525
+ };
526
+ responseHolder.value = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
527
+ return {
528
+ nextStock: stock === null ? null : stock - 1,
529
+ nextUserState: next,
530
+ auditLog: makeAudit("SUCCESS"),
531
+ result: responseHolder.value
532
+ };
533
+ }
534
+ );
535
+ const response = responseHolder.value;
536
+ if (!result.success) {
537
+ if (response !== null && !response.ok && response.code === result.error) return response;
538
+ return {
539
+ ok: false,
540
+ code: "PERSISTENCE_FAILED",
541
+ message: result.error ?? "Reward claim failed."
542
+ };
487
543
  }
544
+ if (response !== null && result.success) return response;
545
+ return {
546
+ ok: false,
547
+ code: "PERSISTENCE_FAILED",
548
+ message: result.error ?? "Reward claim failed."
549
+ };
488
550
  } catch (error) {
489
- if (decremented) await this.#restoreRewardStock(persistence, request.rallyId, reward.id);
490
551
  return {
491
552
  ok: false,
492
553
  code: "PERSISTENCE_FAILED",
493
554
  message: error instanceof Error ? error.message : "Reward claim failed."
494
555
  };
495
556
  } finally {
496
- await persistence.releaseLock(request.rallyId, lockKey);
557
+ await this.#persistence.releaseLock(request.rallyId, lockKey);
497
558
  }
498
559
  }
499
560
  async sync(rallyId, userId) {
@@ -533,8 +594,8 @@ var StampRallyServer = class {
533
594
  );
534
595
  return result;
535
596
  }
536
- async #rememberClaim(key, result, request, timestamp, persistence = this.#persistence) {
537
- await persistence.recordAuditLog(
597
+ async #rememberClaim(key, result, request, timestamp) {
598
+ await this.#persistence.recordAuditLog(
538
599
  audit(
539
600
  request.rallyId,
540
601
  request.userId,
@@ -546,7 +607,7 @@ var StampRallyServer = class {
546
607
  result.ok ? void 0 : result.code
547
608
  )
548
609
  );
549
- await persistence.saveIdempotentResult(
610
+ await this.#persistence.saveIdempotentResult(
550
611
  request.rallyId,
551
612
  key,
552
613
  result,
@@ -554,10 +615,6 @@ var StampRallyServer = class {
554
615
  );
555
616
  return result;
556
617
  }
557
- async #restoreRewardStock(persistence, rallyId, rewardId) {
558
- if (persistence.restoreRewardStock !== void 0)
559
- await persistence.restoreRewardStock(rallyId, rewardId);
560
- }
561
618
  };
562
619
 
563
620
  export { InMemoryServerPersistenceAdapter, StampRallyServer };