@stamprally/server 0.11.0 → 0.13.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.js CHANGED
@@ -1,5 +1,30 @@
1
1
  import { reconcileRewardStates, consumeReward, evaluateConditionDetailed } from '@stamprally/core';
2
2
 
3
+ // src/examples/transaction.ts
4
+ async function executeClaimRewardTransaction(database, store, params, mutation) {
5
+ try {
6
+ return await database.transaction(async (transaction) => {
7
+ const current = await store.readContext(transaction, params);
8
+ const next = mutation(current);
9
+ if (next.error !== void 0) {
10
+ await store.writeAudit(transaction, next.auditLog);
11
+ if (params.idempotencyKey !== void 0 && next.result !== void 0)
12
+ await store.writeIdempotency(transaction, params, next.result);
13
+ return { success: false, error: next.error };
14
+ }
15
+ if (next.nextStock !== null) await store.writeStock(transaction, params, next.nextStock);
16
+ await store.writeUserState(transaction, params, next.nextUserState);
17
+ await store.writeClaimRecord(transaction, params, next.nextUserState);
18
+ await store.writeAudit(transaction, next.auditLog);
19
+ if (params.idempotencyKey !== void 0 && next.result !== void 0)
20
+ await store.writeIdempotency(transaction, params, next.result);
21
+ return { success: true };
22
+ });
23
+ } catch (error) {
24
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
25
+ }
26
+ }
27
+
3
28
  // src/persistence.ts
4
29
  var InMemoryServerPersistenceAdapter = class {
5
30
  #locks = /* @__PURE__ */ new Map();
@@ -58,6 +83,65 @@ var InMemoryServerPersistenceAdapter = class {
58
83
  const current = this.#stocks.get(key);
59
84
  if (current !== void 0) this.#stocks.set(key, current + Math.max(0, count));
60
85
  }
86
+ async executeClaimRewardTransaction(params, mutation) {
87
+ try {
88
+ return await this.runTransaction(params.rallyId, async () => {
89
+ const userState = await this.getUserState(params.rallyId, params.userId) ?? params.initialUserState;
90
+ if (userState === void 0)
91
+ return { success: false, error: "A user state is required for this transaction." };
92
+ const mutationResult = mutation({
93
+ stock: await this.getRewardStock(params.rallyId, params.rewardId),
94
+ claimCount: await this.getUserClaimCount(params.rallyId, params.userId, params.rewardId),
95
+ userState
96
+ });
97
+ const idempotencyKey = params.idempotencyKey;
98
+ if (mutationResult.error !== void 0) {
99
+ await this.recordAuditLog(mutationResult.auditLog);
100
+ if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
101
+ await this.saveIdempotentResult(
102
+ params.rallyId,
103
+ idempotencyKey,
104
+ mutationResult.result,
105
+ params.idempotencyTtlMs ?? 864e5
106
+ );
107
+ return { success: false, error: mutationResult.error };
108
+ }
109
+ const currentStock = await this.getRewardStock(params.rallyId, params.rewardId);
110
+ if (currentStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
111
+ throw new Error("The transaction produced an invalid stock value.");
112
+ if (currentStock === null && mutationResult.nextStock !== null)
113
+ throw new Error("The transaction changed an unlimited stock to a limited stock.");
114
+ if (mutationResult.nextStock !== null)
115
+ this.#stocks.set(
116
+ this.#stockKey(params.rallyId, params.rewardId),
117
+ mutationResult.nextStock
118
+ );
119
+ await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
120
+ const reward = mutationResult.nextUserState.rewards.find(
121
+ (item) => item.rewardId === params.rewardId
122
+ );
123
+ if (reward?.claimTicketNumber !== void 0)
124
+ await this.recordUserClaim({
125
+ rallyId: params.rallyId,
126
+ userId: params.userId,
127
+ rewardId: params.rewardId,
128
+ ticketNumber: reward.claimTicketNumber,
129
+ timestamp: params.timestamp
130
+ });
131
+ await this.recordAuditLog(mutationResult.auditLog);
132
+ if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
133
+ await this.saveIdempotentResult(
134
+ params.rallyId,
135
+ idempotencyKey,
136
+ mutationResult.result,
137
+ params.idempotencyTtlMs ?? 864e5
138
+ );
139
+ return { success: true };
140
+ });
141
+ } catch (error) {
142
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
143
+ }
144
+ }
61
145
  async rollbackUserState(rallyId, userId, previousState) {
62
146
  const key = `${rallyId}:${userId}`;
63
147
  if (previousState === null) this.#states.delete(key);
@@ -210,6 +294,10 @@ function audit(rallyId, userId, action, resourceId, key, status, timestamp, code
210
294
  ...code === void 0 ? {} : { metadata: { errorCode: code } }
211
295
  };
212
296
  }
297
+ function operationStatus(result) {
298
+ if (result.ok) return "ACCEPTED";
299
+ return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
300
+ }
213
301
  var StampRallyServer = class {
214
302
  #config;
215
303
  #persistence;
@@ -241,7 +329,10 @@ var StampRallyServer = class {
241
329
  400
242
330
  );
243
331
  const result = await this.checkIn({ ...body, userId });
244
- return json(result, result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422);
332
+ return json(
333
+ { ...result, status: operationStatus(result) },
334
+ result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422
335
+ );
245
336
  }
246
337
  async handleClaimReward(request) {
247
338
  const body = await this.#body(request);
@@ -256,7 +347,10 @@ var StampRallyServer = class {
256
347
  400
257
348
  );
258
349
  const result = await this.claimReward({ ...body, userId });
259
- return json(result, result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422);
350
+ return json(
351
+ { ...result, status: operationStatus(result) },
352
+ result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422
353
+ );
260
354
  }
261
355
  async handleSync(request) {
262
356
  const body = await this.#body(request);
@@ -365,16 +459,11 @@ var StampRallyServer = class {
365
459
  }
366
460
  }
367
461
  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
462
  const key = `claim:${request.rallyId}:${request.userId}:${request.rewardId}:${request.idempotencyKey}`;
377
- const previous = await persistence.getIdempotentResult(request.rallyId, key);
463
+ const previous = await this.#persistence.getIdempotentResult(
464
+ request.rallyId,
465
+ key
466
+ );
378
467
  if (previous !== null) return previous;
379
468
  const reward = this.#config.rewards.find((item) => item.id === request.rewardId);
380
469
  if (reward === void 0)
@@ -382,118 +471,125 @@ var StampRallyServer = class {
382
471
  key,
383
472
  { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
384
473
  request,
385
- now(this.#options, request.now),
386
- persistence
474
+ now(this.#options, request.now)
387
475
  );
388
476
  const lockKey = `reward:${request.rallyId}:${reward.id}`;
389
- if (!await persistence.acquireLock(request.rallyId, lockKey, this.#options.lockTtlMs ?? 5e3))
477
+ if (!await this.#persistence.acquireLock(
478
+ request.rallyId,
479
+ lockKey,
480
+ this.#options.lockTtlMs ?? 5e3
481
+ ))
390
482
  return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
391
483
  const timestamp = now(this.#options, request.now);
392
- let decremented = false;
393
- let previousState = null;
394
- let recordedTicket = null;
395
- let successAuditId = null;
396
484
  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(
485
+ const checked = await this.#persistence.getIdempotentResult(
403
486
  request.rallyId,
404
- request.userId,
405
- reward.id
487
+ key
406
488
  );
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({
489
+ if (checked !== null) return checked;
490
+ const responseHolder = { value: null };
491
+ const result = await this.#persistence.executeClaimRewardTransaction(
492
+ {
448
493
  rallyId: request.rallyId,
449
494
  userId: request.userId,
450
495
  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(
496
+ ticketNumber: request.idempotencyKey,
497
+ timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp),
498
+ idempotencyKey: key,
499
+ ...request.staffPasscode === void 0 ? {} : { proofData: request.staffPasscode },
500
+ ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
501
+ initialUserState: initialState(this.#config, request.userId, timestamp)
502
+ },
503
+ ({ stock, claimCount, userState }) => {
504
+ const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
505
+ rewardId: reward.id,
506
+ status: "LOCKED"
507
+ };
508
+ const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
509
+ const makeAudit = (status, code) => audit(
475
510
  request.rallyId,
476
511
  request.userId,
512
+ "CLAIM_REWARD",
477
513
  reward.id,
478
- recordedTicket
514
+ request.idempotencyKey,
515
+ status,
516
+ timestamp,
517
+ code
479
518
  );
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;
519
+ if (stock !== null && stock <= 0) {
520
+ responseHolder.value = {
521
+ ok: false,
522
+ code: "OUT_OF_STOCK",
523
+ message: "Reward is out of stock."
524
+ };
525
+ return {
526
+ nextStock: stock,
527
+ nextUserState: userState,
528
+ auditLog: makeAudit("REJECTED", "OUT_OF_STOCK"),
529
+ result: responseHolder.value,
530
+ error: "OUT_OF_STOCK"
531
+ };
532
+ }
533
+ const local = consumeReward({
534
+ reward,
535
+ currentState: currentReward,
536
+ now: timestamp,
537
+ userRedemptionCount: claimCount,
538
+ ...request.staffPasscode === void 0 ? {} : { inputPasscode: request.staffPasscode },
539
+ ...request.staffId === void 0 ? {} : { staffId: request.staffId }
540
+ });
541
+ if (!local.ok) {
542
+ responseHolder.value = {
543
+ ok: false,
544
+ code: local.error.code,
545
+ message: "Reward cannot be claimed."
546
+ };
547
+ return {
548
+ nextStock: stock,
549
+ nextUserState: userState,
550
+ auditLog: makeAudit("REJECTED", local.error.code),
551
+ result: responseHolder.value,
552
+ error: local.error.code
553
+ };
554
+ }
555
+ 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];
556
+ const next = {
557
+ ...userState,
558
+ rewards: nextRewards,
559
+ updatedAt: timestamp
560
+ };
561
+ responseHolder.value = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
562
+ return {
563
+ nextStock: stock === null ? null : stock - 1,
564
+ nextUserState: next,
565
+ auditLog: makeAudit("SUCCESS"),
566
+ result: responseHolder.value
567
+ };
568
+ }
569
+ );
570
+ const response = responseHolder.value;
571
+ if (!result.success) {
572
+ if (response !== null && !response.ok && response.code === result.error) return response;
573
+ return {
574
+ ok: false,
575
+ code: "PERSISTENCE_FAILED",
576
+ message: result.error ?? "Reward claim failed."
577
+ };
487
578
  }
579
+ if (response !== null && result.success) return response;
580
+ return {
581
+ ok: false,
582
+ code: "PERSISTENCE_FAILED",
583
+ message: result.error ?? "Reward claim failed."
584
+ };
488
585
  } catch (error) {
489
- if (decremented) await this.#restoreRewardStock(persistence, request.rallyId, reward.id);
490
586
  return {
491
587
  ok: false,
492
588
  code: "PERSISTENCE_FAILED",
493
589
  message: error instanceof Error ? error.message : "Reward claim failed."
494
590
  };
495
591
  } finally {
496
- await persistence.releaseLock(request.rallyId, lockKey);
592
+ await this.#persistence.releaseLock(request.rallyId, lockKey);
497
593
  }
498
594
  }
499
595
  async sync(rallyId, userId) {
@@ -533,8 +629,8 @@ var StampRallyServer = class {
533
629
  );
534
630
  return result;
535
631
  }
536
- async #rememberClaim(key, result, request, timestamp, persistence = this.#persistence) {
537
- await persistence.recordAuditLog(
632
+ async #rememberClaim(key, result, request, timestamp) {
633
+ await this.#persistence.recordAuditLog(
538
634
  audit(
539
635
  request.rallyId,
540
636
  request.userId,
@@ -546,7 +642,7 @@ var StampRallyServer = class {
546
642
  result.ok ? void 0 : result.code
547
643
  )
548
644
  );
549
- await persistence.saveIdempotentResult(
645
+ await this.#persistence.saveIdempotentResult(
550
646
  request.rallyId,
551
647
  key,
552
648
  result,
@@ -554,12 +650,8 @@ var StampRallyServer = class {
554
650
  );
555
651
  return result;
556
652
  }
557
- async #restoreRewardStock(persistence, rallyId, rewardId) {
558
- if (persistence.restoreRewardStock !== void 0)
559
- await persistence.restoreRewardStock(rallyId, rewardId);
560
- }
561
653
  };
562
654
 
563
- export { InMemoryServerPersistenceAdapter, StampRallyServer };
655
+ export { InMemoryServerPersistenceAdapter, StampRallyServer, executeClaimRewardTransaction };
564
656
  //# sourceMappingURL=index.js.map
565
657
  //# sourceMappingURL=index.js.map