@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/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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
407
|
-
reward.id
|
|
489
|
+
key
|
|
408
490
|
);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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:
|
|
454
|
-
timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
request.userId,
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
516
|
+
request.idempotencyKey,
|
|
517
|
+
status,
|
|
518
|
+
timestamp,
|
|
519
|
+
code
|
|
481
520
|
);
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
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
|