@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/README.md CHANGED
@@ -13,6 +13,12 @@ const response = await server.handle(request);
13
13
 
14
14
  `ServerPersistenceAdapter` scopes locks, reward stock, idempotency records, user state, claim records, and audit logs by `rallyId`. Its `recordUserClaim` operation receives the rally, user, reward, issued ticket number, and timestamp. Use a transactional database or equivalent Redis primitives for multi-instance production deployments.
15
15
 
16
+ HTTP check-in and reward-claim responses include an operation status: `ACCEPTED`,
17
+ `REJECTED_PERMANENT`, or `RETRYABLE_ERROR`. Clients can pass the response directly
18
+ to an `OfflineQueue` sender; the queue removes accepted/permanent operations and
19
+ retains retryable failures. The SQL transaction contract and all-or-nothing example
20
+ are exported as `executeClaimRewardTransaction` from `src/examples/transaction.ts`.
21
+
16
22
  Hono can mount the handler directly because it accepts the same Web Standard request and response types.
17
23
 
18
24
  ## License
package/dist/index.cjs CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  var core = require('@stamprally/core');
4
4
 
5
+ // src/examples/transaction.ts
6
+ async function executeClaimRewardTransaction(database, store, params, mutation) {
7
+ try {
8
+ return await database.transaction(async (transaction) => {
9
+ const current = await store.readContext(transaction, params);
10
+ const next = mutation(current);
11
+ if (next.error !== void 0) {
12
+ await store.writeAudit(transaction, next.auditLog);
13
+ if (params.idempotencyKey !== void 0 && next.result !== void 0)
14
+ await store.writeIdempotency(transaction, params, next.result);
15
+ return { success: false, error: next.error };
16
+ }
17
+ if (next.nextStock !== null) await store.writeStock(transaction, params, next.nextStock);
18
+ await store.writeUserState(transaction, params, next.nextUserState);
19
+ await store.writeClaimRecord(transaction, params, next.nextUserState);
20
+ await store.writeAudit(transaction, next.auditLog);
21
+ if (params.idempotencyKey !== void 0 && next.result !== void 0)
22
+ await store.writeIdempotency(transaction, params, next.result);
23
+ return { success: true };
24
+ });
25
+ } catch (error) {
26
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
27
+ }
28
+ }
29
+
5
30
  // src/persistence.ts
6
31
  var InMemoryServerPersistenceAdapter = class {
7
32
  #locks = /* @__PURE__ */ new Map();
@@ -60,6 +85,65 @@ var InMemoryServerPersistenceAdapter = class {
60
85
  const current = this.#stocks.get(key);
61
86
  if (current !== void 0) this.#stocks.set(key, current + Math.max(0, count));
62
87
  }
88
+ async executeClaimRewardTransaction(params, mutation) {
89
+ try {
90
+ return await this.runTransaction(params.rallyId, async () => {
91
+ const userState = await this.getUserState(params.rallyId, params.userId) ?? params.initialUserState;
92
+ if (userState === void 0)
93
+ return { success: false, error: "A user state is required for this transaction." };
94
+ const mutationResult = mutation({
95
+ stock: await this.getRewardStock(params.rallyId, params.rewardId),
96
+ claimCount: await this.getUserClaimCount(params.rallyId, params.userId, params.rewardId),
97
+ userState
98
+ });
99
+ const idempotencyKey = params.idempotencyKey;
100
+ if (mutationResult.error !== void 0) {
101
+ await this.recordAuditLog(mutationResult.auditLog);
102
+ if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
103
+ await this.saveIdempotentResult(
104
+ params.rallyId,
105
+ idempotencyKey,
106
+ mutationResult.result,
107
+ params.idempotencyTtlMs ?? 864e5
108
+ );
109
+ return { success: false, error: mutationResult.error };
110
+ }
111
+ const currentStock = await this.getRewardStock(params.rallyId, params.rewardId);
112
+ if (currentStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
113
+ throw new Error("The transaction produced an invalid stock value.");
114
+ if (currentStock === null && mutationResult.nextStock !== null)
115
+ throw new Error("The transaction changed an unlimited stock to a limited stock.");
116
+ if (mutationResult.nextStock !== null)
117
+ this.#stocks.set(
118
+ this.#stockKey(params.rallyId, params.rewardId),
119
+ mutationResult.nextStock
120
+ );
121
+ await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
122
+ const reward = mutationResult.nextUserState.rewards.find(
123
+ (item) => item.rewardId === params.rewardId
124
+ );
125
+ if (reward?.claimTicketNumber !== void 0)
126
+ await this.recordUserClaim({
127
+ rallyId: params.rallyId,
128
+ userId: params.userId,
129
+ rewardId: params.rewardId,
130
+ ticketNumber: reward.claimTicketNumber,
131
+ timestamp: params.timestamp
132
+ });
133
+ await this.recordAuditLog(mutationResult.auditLog);
134
+ if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
135
+ await this.saveIdempotentResult(
136
+ params.rallyId,
137
+ idempotencyKey,
138
+ mutationResult.result,
139
+ params.idempotencyTtlMs ?? 864e5
140
+ );
141
+ return { success: true };
142
+ });
143
+ } catch (error) {
144
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
145
+ }
146
+ }
63
147
  async rollbackUserState(rallyId, userId, previousState) {
64
148
  const key = `${rallyId}:${userId}`;
65
149
  if (previousState === null) this.#states.delete(key);
@@ -212,6 +296,10 @@ function audit(rallyId, userId, action, resourceId, key, status, timestamp, code
212
296
  ...code === void 0 ? {} : { metadata: { errorCode: code } }
213
297
  };
214
298
  }
299
+ function operationStatus(result) {
300
+ if (result.ok) return "ACCEPTED";
301
+ return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
302
+ }
215
303
  var StampRallyServer = class {
216
304
  #config;
217
305
  #persistence;
@@ -243,7 +331,10 @@ var StampRallyServer = class {
243
331
  400
244
332
  );
245
333
  const result = await this.checkIn({ ...body, userId });
246
- return json(result, result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422);
334
+ return json(
335
+ { ...result, status: operationStatus(result) },
336
+ result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422
337
+ );
247
338
  }
248
339
  async handleClaimReward(request) {
249
340
  const body = await this.#body(request);
@@ -258,7 +349,10 @@ var StampRallyServer = class {
258
349
  400
259
350
  );
260
351
  const result = await this.claimReward({ ...body, userId });
261
- return json(result, result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422);
352
+ return json(
353
+ { ...result, status: operationStatus(result) },
354
+ result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422
355
+ );
262
356
  }
263
357
  async handleSync(request) {
264
358
  const body = await this.#body(request);
@@ -367,16 +461,11 @@ var StampRallyServer = class {
367
461
  }
368
462
  }
369
463
  async claimReward(request) {
370
- if (this.#persistence.runTransaction !== void 0)
371
- return this.#persistence.runTransaction(
372
- request.rallyId,
373
- (transaction) => this.#claimRewardMutation(request, transaction)
374
- );
375
- return this.#claimRewardMutation(request, this.#persistence);
376
- }
377
- async #claimRewardMutation(request, persistence) {
378
464
  const key = `claim:${request.rallyId}:${request.userId}:${request.rewardId}:${request.idempotencyKey}`;
379
- const previous = await persistence.getIdempotentResult(request.rallyId, key);
465
+ const previous = await this.#persistence.getIdempotentResult(
466
+ request.rallyId,
467
+ key
468
+ );
380
469
  if (previous !== null) return previous;
381
470
  const reward = this.#config.rewards.find((item) => item.id === request.rewardId);
382
471
  if (reward === void 0)
@@ -384,118 +473,125 @@ var StampRallyServer = class {
384
473
  key,
385
474
  { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
386
475
  request,
387
- now(this.#options, request.now),
388
- persistence
476
+ now(this.#options, request.now)
389
477
  );
390
478
  const lockKey = `reward:${request.rallyId}:${reward.id}`;
391
- if (!await persistence.acquireLock(request.rallyId, lockKey, this.#options.lockTtlMs ?? 5e3))
479
+ if (!await this.#persistence.acquireLock(
480
+ request.rallyId,
481
+ lockKey,
482
+ this.#options.lockTtlMs ?? 5e3
483
+ ))
392
484
  return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
393
485
  const timestamp = now(this.#options, request.now);
394
- let decremented = false;
395
- let previousState = null;
396
- let recordedTicket = null;
397
- let successAuditId = null;
398
486
  try {
399
- const checked = await persistence.getIdempotentResult(request.rallyId, key);
400
- if (checked !== null) return checked;
401
- const storedState = await persistence.getUserState(request.rallyId, request.userId);
402
- previousState = storedState;
403
- const current = storedState ?? initialState(this.#config, request.userId, timestamp);
404
- const claimCount = await persistence.getUserClaimCount(
487
+ const checked = await this.#persistence.getIdempotentResult(
405
488
  request.rallyId,
406
- request.userId,
407
- reward.id
489
+ key
408
490
  );
409
- const storedReward = current.rewards.find((item) => item.rewardId === reward.id) ?? {
410
- rewardId: reward.id,
411
- status: "LOCKED"
412
- };
413
- const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
414
- const local = core.consumeReward({
415
- reward,
416
- currentState: currentReward,
417
- now: timestamp,
418
- userRedemptionCount: claimCount,
419
- ...request.staffPasscode === void 0 ? {} : { inputPasscode: request.staffPasscode },
420
- ...request.staffId === void 0 ? {} : { staffId: request.staffId }
421
- });
422
- if (!local.ok)
423
- return this.#rememberClaim(
424
- key,
425
- { ok: false, code: local.error.code, message: "Reward cannot be claimed." },
426
- request,
427
- timestamp,
428
- persistence
429
- );
430
- const stock = await persistence.decrementRewardStock(request.rallyId, reward.id);
431
- if (!stock.success)
432
- return this.#rememberClaim(
433
- key,
434
- { ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
435
- request,
436
- timestamp,
437
- persistence
438
- );
439
- decremented = true;
440
- const next = {
441
- ...current,
442
- rewards: current.rewards.map((item) => item.rewardId === reward.id ? local.value : item),
443
- updatedAt: timestamp
444
- };
445
- try {
446
- await persistence.saveUserState(request.rallyId, request.userId, next);
447
- const response = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
448
- recordedTicket = local.value.claimTicketNumber ?? "";
449
- await persistence.recordUserClaim({
491
+ if (checked !== null) return checked;
492
+ const responseHolder = { value: null };
493
+ const result = await this.#persistence.executeClaimRewardTransaction(
494
+ {
450
495
  rallyId: request.rallyId,
451
496
  userId: request.userId,
452
497
  rewardId: reward.id,
453
- ticketNumber: recordedTicket,
454
- timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp)
455
- });
456
- const successAudit = audit(
457
- request.rallyId,
458
- request.userId,
459
- "CLAIM_REWARD",
460
- reward.id,
461
- request.idempotencyKey,
462
- "SUCCESS",
463
- timestamp
464
- );
465
- successAuditId = successAudit.id;
466
- await persistence.recordAuditLog(successAudit);
467
- await persistence.saveIdempotentResult(
468
- request.rallyId,
469
- key,
470
- response,
471
- this.#options.idempotencyTtlMs ?? 864e5
472
- );
473
- return response;
474
- } catch (error) {
475
- if (recordedTicket !== null && persistence.rollbackUserClaim !== void 0)
476
- await persistence.rollbackUserClaim(
498
+ ticketNumber: request.idempotencyKey,
499
+ timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp),
500
+ idempotencyKey: key,
501
+ ...request.staffPasscode === void 0 ? {} : { proofData: request.staffPasscode },
502
+ ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
503
+ initialUserState: initialState(this.#config, request.userId, timestamp)
504
+ },
505
+ ({ stock, claimCount, userState }) => {
506
+ const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
507
+ rewardId: reward.id,
508
+ status: "LOCKED"
509
+ };
510
+ const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
511
+ const makeAudit = (status, code) => audit(
477
512
  request.rallyId,
478
513
  request.userId,
514
+ "CLAIM_REWARD",
479
515
  reward.id,
480
- recordedTicket
516
+ request.idempotencyKey,
517
+ status,
518
+ timestamp,
519
+ code
481
520
  );
482
- if (persistence.rollbackUserState !== void 0)
483
- await persistence.rollbackUserState(request.rallyId, request.userId, previousState);
484
- if (successAuditId !== null && persistence.removeAuditLog !== void 0)
485
- await persistence.removeAuditLog(successAuditId);
486
- await this.#restoreRewardStock(persistence, request.rallyId, reward.id);
487
- decremented = false;
488
- throw error;
521
+ if (stock !== null && stock <= 0) {
522
+ responseHolder.value = {
523
+ ok: false,
524
+ code: "OUT_OF_STOCK",
525
+ message: "Reward is out of stock."
526
+ };
527
+ return {
528
+ nextStock: stock,
529
+ nextUserState: userState,
530
+ auditLog: makeAudit("REJECTED", "OUT_OF_STOCK"),
531
+ result: responseHolder.value,
532
+ error: "OUT_OF_STOCK"
533
+ };
534
+ }
535
+ const local = core.consumeReward({
536
+ reward,
537
+ currentState: currentReward,
538
+ now: timestamp,
539
+ userRedemptionCount: claimCount,
540
+ ...request.staffPasscode === void 0 ? {} : { inputPasscode: request.staffPasscode },
541
+ ...request.staffId === void 0 ? {} : { staffId: request.staffId }
542
+ });
543
+ if (!local.ok) {
544
+ responseHolder.value = {
545
+ ok: false,
546
+ code: local.error.code,
547
+ message: "Reward cannot be claimed."
548
+ };
549
+ return {
550
+ nextStock: stock,
551
+ nextUserState: userState,
552
+ auditLog: makeAudit("REJECTED", local.error.code),
553
+ result: responseHolder.value,
554
+ error: local.error.code
555
+ };
556
+ }
557
+ 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];
558
+ const next = {
559
+ ...userState,
560
+ rewards: nextRewards,
561
+ updatedAt: timestamp
562
+ };
563
+ responseHolder.value = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
564
+ return {
565
+ nextStock: stock === null ? null : stock - 1,
566
+ nextUserState: next,
567
+ auditLog: makeAudit("SUCCESS"),
568
+ result: responseHolder.value
569
+ };
570
+ }
571
+ );
572
+ const response = responseHolder.value;
573
+ if (!result.success) {
574
+ if (response !== null && !response.ok && response.code === result.error) return response;
575
+ return {
576
+ ok: false,
577
+ code: "PERSISTENCE_FAILED",
578
+ message: result.error ?? "Reward claim failed."
579
+ };
489
580
  }
581
+ if (response !== null && result.success) return response;
582
+ return {
583
+ ok: false,
584
+ code: "PERSISTENCE_FAILED",
585
+ message: result.error ?? "Reward claim failed."
586
+ };
490
587
  } catch (error) {
491
- if (decremented) await this.#restoreRewardStock(persistence, request.rallyId, reward.id);
492
588
  return {
493
589
  ok: false,
494
590
  code: "PERSISTENCE_FAILED",
495
591
  message: error instanceof Error ? error.message : "Reward claim failed."
496
592
  };
497
593
  } finally {
498
- await persistence.releaseLock(request.rallyId, lockKey);
594
+ await this.#persistence.releaseLock(request.rallyId, lockKey);
499
595
  }
500
596
  }
501
597
  async sync(rallyId, userId) {
@@ -535,8 +631,8 @@ var StampRallyServer = class {
535
631
  );
536
632
  return result;
537
633
  }
538
- async #rememberClaim(key, result, request, timestamp, persistence = this.#persistence) {
539
- await persistence.recordAuditLog(
634
+ async #rememberClaim(key, result, request, timestamp) {
635
+ await this.#persistence.recordAuditLog(
540
636
  audit(
541
637
  request.rallyId,
542
638
  request.userId,
@@ -548,7 +644,7 @@ var StampRallyServer = class {
548
644
  result.ok ? void 0 : result.code
549
645
  )
550
646
  );
551
- await persistence.saveIdempotentResult(
647
+ await this.#persistence.saveIdempotentResult(
552
648
  request.rallyId,
553
649
  key,
554
650
  result,
@@ -556,13 +652,10 @@ var StampRallyServer = class {
556
652
  );
557
653
  return result;
558
654
  }
559
- async #restoreRewardStock(persistence, rallyId, rewardId) {
560
- if (persistence.restoreRewardStock !== void 0)
561
- await persistence.restoreRewardStock(rallyId, rewardId);
562
- }
563
655
  };
564
656
 
565
657
  exports.InMemoryServerPersistenceAdapter = InMemoryServerPersistenceAdapter;
566
658
  exports.StampRallyServer = StampRallyServer;
659
+ exports.executeClaimRewardTransaction = executeClaimRewardTransaction;
567
660
  //# sourceMappingURL=index.cjs.map
568
661
  //# sourceMappingURL=index.cjs.map