@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 +6 -0
- package/dist/index.cjs +201 -108
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +82 -6
- package/dist/index.d.ts +82 -6
- package/dist/index.js +201 -109
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
405
|
-
reward.id
|
|
487
|
+
key
|
|
406
488
|
);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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:
|
|
452
|
-
timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp)
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
request.userId,
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
514
|
+
request.idempotencyKey,
|
|
515
|
+
status,
|
|
516
|
+
timestamp,
|
|
517
|
+
code
|
|
479
518
|
);
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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
|