@stamprally/server 0.15.0 → 0.17.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 +1 -1
- package/dist/index.cjs +476 -134
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -30
- package/dist/index.d.ts +65 -30
- package/dist/index.js +472 -135
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,50 +1,61 @@
|
|
|
1
1
|
import { reconcileRewardStates, consumeReward, evaluateConditionDetailed } from '@stamprally/core';
|
|
2
2
|
|
|
3
3
|
// src/examples/transaction.ts
|
|
4
|
-
async function executeClaimRewardTransaction(database, store,
|
|
4
|
+
async function executeClaimRewardTransaction(database, store, params2, mutation2) {
|
|
5
5
|
try {
|
|
6
6
|
return await database.transaction(async (transaction) => {
|
|
7
|
-
const current = await store.readContext(transaction,
|
|
8
|
-
const
|
|
7
|
+
const current = await store.readContext(transaction, params2);
|
|
8
|
+
const secondaryStock = current.secondaryStock ?? null;
|
|
9
|
+
const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : current.stock;
|
|
10
|
+
const next = mutation2({
|
|
11
|
+
...current,
|
|
12
|
+
rewardStock: rewardStock2,
|
|
13
|
+
sharedStock: params2.stockKey === "__shared__" ? current.stock : null,
|
|
14
|
+
primaryStock: rewardStock2,
|
|
15
|
+
secondaryStock,
|
|
16
|
+
stock: current.stock
|
|
17
|
+
});
|
|
9
18
|
if (next.error !== void 0) {
|
|
10
19
|
await store.writeAudit(transaction, next.auditLog);
|
|
11
|
-
if (
|
|
12
|
-
await store.writeIdempotency(transaction,
|
|
20
|
+
if (params2.idempotencyKey !== void 0 && next.result !== void 0)
|
|
21
|
+
await store.writeIdempotency(transaction, params2, next.result);
|
|
13
22
|
return { success: false, error: next.error };
|
|
14
23
|
}
|
|
15
|
-
if (next.
|
|
24
|
+
if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock === void 0)
|
|
25
|
+
return { success: false, error: "INVENTORY_STORAGE_NOT_IMPLEMENTED" };
|
|
26
|
+
if (next.nextStock !== null) await store.writeStock(transaction, params2, next.nextStock);
|
|
16
27
|
if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock !== void 0) {
|
|
17
28
|
if (next.nextSecondaryStock !== null)
|
|
18
|
-
await store.writeSecondaryStock(transaction,
|
|
29
|
+
await store.writeSecondaryStock(transaction, params2, next.nextSecondaryStock);
|
|
19
30
|
}
|
|
20
|
-
await store.writeUserState(transaction,
|
|
21
|
-
await store.writeClaimRecord(transaction,
|
|
31
|
+
await store.writeUserState(transaction, params2, next.nextUserState);
|
|
32
|
+
await store.writeClaimRecord(transaction, params2, next.nextUserState);
|
|
22
33
|
await store.writeAudit(transaction, next.auditLog);
|
|
23
|
-
if (
|
|
24
|
-
await store.writeIdempotency(transaction,
|
|
34
|
+
if (params2.idempotencyKey !== void 0 && next.result !== void 0)
|
|
35
|
+
await store.writeIdempotency(transaction, params2, next.result);
|
|
25
36
|
return { success: true };
|
|
26
37
|
});
|
|
27
38
|
} catch (error) {
|
|
28
39
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
29
40
|
}
|
|
30
41
|
}
|
|
31
|
-
async function executeCheckInTransaction(database, store,
|
|
42
|
+
async function executeCheckInTransaction(database, store, params2, mutation2, current) {
|
|
32
43
|
try {
|
|
33
44
|
return await database.transaction(async (transaction) => {
|
|
34
45
|
const userState = current ?? (store.readUserState === void 0 ? (() => {
|
|
35
46
|
throw new Error("A current user state or readUserState implementation is required.");
|
|
36
|
-
})() : await store.readUserState(transaction,
|
|
37
|
-
const next =
|
|
47
|
+
})() : await store.readUserState(transaction, params2));
|
|
48
|
+
const next = mutation2({ userState });
|
|
38
49
|
if (next.error !== void 0) {
|
|
39
50
|
await store.writeAudit(transaction, next.auditLog);
|
|
40
|
-
if (
|
|
41
|
-
await store.writeIdempotency(transaction,
|
|
51
|
+
if (params2.idempotencyKey !== void 0 && next.result !== void 0)
|
|
52
|
+
await store.writeIdempotency(transaction, params2, next.result);
|
|
42
53
|
return { success: false, error: next.error };
|
|
43
54
|
}
|
|
44
|
-
await store.writeUserState(transaction,
|
|
55
|
+
await store.writeUserState(transaction, params2, next.nextUserState);
|
|
45
56
|
await store.writeAudit(transaction, next.auditLog);
|
|
46
|
-
if (
|
|
47
|
-
await store.writeIdempotency(transaction,
|
|
57
|
+
if (params2.idempotencyKey !== void 0 && next.result !== void 0)
|
|
58
|
+
await store.writeIdempotency(transaction, params2, next.result);
|
|
48
59
|
return { success: true };
|
|
49
60
|
});
|
|
50
61
|
} catch (error) {
|
|
@@ -64,6 +75,7 @@ async function executeRedisTransaction(redis, queue) {
|
|
|
64
75
|
|
|
65
76
|
// src/persistence.ts
|
|
66
77
|
var InMemoryServerPersistenceAdapter = class {
|
|
78
|
+
supportsRewardStock = true;
|
|
67
79
|
#locks = /* @__PURE__ */ new Map();
|
|
68
80
|
#idempotent = /* @__PURE__ */ new Map();
|
|
69
81
|
#states = /* @__PURE__ */ new Map();
|
|
@@ -72,6 +84,7 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
72
84
|
#claims = /* @__PURE__ */ new Map();
|
|
73
85
|
#claimRecords = [];
|
|
74
86
|
#auditLogs = [];
|
|
87
|
+
#transactionTails = /* @__PURE__ */ new Map();
|
|
75
88
|
constructor(options = {}) {
|
|
76
89
|
this.#stocks = /* @__PURE__ */ new Map();
|
|
77
90
|
this.#stockDefaults = /* @__PURE__ */ new Map();
|
|
@@ -120,51 +133,67 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
120
133
|
const current = this.#stocks.get(key);
|
|
121
134
|
if (current !== void 0) this.#stocks.set(key, current + Math.max(0, count));
|
|
122
135
|
}
|
|
123
|
-
async executeClaimRewardTransaction(
|
|
136
|
+
async executeClaimRewardTransaction(params2, mutation2) {
|
|
124
137
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
138
|
+
const initialStock = params2.initialStock !== void 0 ? params2.initialStock : params2.stockKey === "__shared__" ? params2.sharedStockLimit : params2.rewardStockLimit;
|
|
139
|
+
const initialSecondaryStock = params2.initialSecondaryStock !== void 0 ? params2.initialSecondaryStock : params2.rewardStockLimit;
|
|
140
|
+
return await this.runTransaction(params2.rallyId, async () => {
|
|
141
|
+
if (params2.idempotencyKey !== void 0) {
|
|
142
|
+
const previous = await this.getIdempotentResult(
|
|
143
|
+
params2.rallyId,
|
|
144
|
+
params2.idempotencyKey
|
|
145
|
+
);
|
|
146
|
+
if (previous !== null) return { success: true };
|
|
147
|
+
}
|
|
148
|
+
const userState = await this.getUserState(params2.rallyId, params2.userId) ?? params2.initialUserState;
|
|
127
149
|
if (userState === void 0)
|
|
128
150
|
return { success: false, error: "A user state is required for this transaction." };
|
|
129
151
|
const storedStock = await this.getRewardStock(
|
|
130
|
-
|
|
131
|
-
|
|
152
|
+
params2.rallyId,
|
|
153
|
+
params2.stockKey ?? params2.rewardId
|
|
132
154
|
);
|
|
133
|
-
const storedSecondaryStock =
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
155
|
+
const storedSecondaryStock = params2.secondaryStockKey === void 0 ? null : await this.getRewardStock(params2.rallyId, params2.secondaryStockKey);
|
|
156
|
+
const stock = storedStock ?? initialStock;
|
|
157
|
+
const secondaryStock = storedSecondaryStock ?? initialSecondaryStock;
|
|
158
|
+
const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : stock;
|
|
159
|
+
const sharedStock2 = params2.stockKey === "__shared__" ? stock : null;
|
|
160
|
+
const mutationResult = mutation2({
|
|
161
|
+
rewardStock: rewardStock2,
|
|
162
|
+
sharedStock: sharedStock2,
|
|
163
|
+
primaryStock: rewardStock2,
|
|
164
|
+
secondaryStock,
|
|
165
|
+
stock,
|
|
166
|
+
claimCount: await this.getUserClaimCount(params2.rallyId, params2.userId, params2.rewardId),
|
|
138
167
|
userState
|
|
139
168
|
});
|
|
140
|
-
const idempotencyKey =
|
|
169
|
+
const idempotencyKey = params2.idempotencyKey;
|
|
141
170
|
if (mutationResult.error !== void 0) {
|
|
142
171
|
await this.recordAuditLog(mutationResult.auditLog);
|
|
143
172
|
if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
|
|
144
173
|
await this.saveIdempotentResult(
|
|
145
|
-
|
|
174
|
+
params2.rallyId,
|
|
146
175
|
idempotencyKey,
|
|
147
176
|
mutationResult.result,
|
|
148
|
-
|
|
177
|
+
params2.idempotencyTtlMs ?? 864e5
|
|
149
178
|
);
|
|
150
179
|
return { success: false, error: mutationResult.error };
|
|
151
180
|
}
|
|
152
181
|
const currentStock = await this.getRewardStock(
|
|
153
|
-
|
|
154
|
-
|
|
182
|
+
params2.rallyId,
|
|
183
|
+
params2.stockKey ?? params2.rewardId
|
|
155
184
|
);
|
|
156
|
-
const effectiveStock = currentStock ??
|
|
157
|
-
if (currentStock === null &&
|
|
185
|
+
const effectiveStock = currentStock ?? initialStock;
|
|
186
|
+
if (currentStock === null && initialStock !== void 0 && initialStock !== null)
|
|
158
187
|
this.#stocks.set(
|
|
159
|
-
this.#stockKey(
|
|
160
|
-
|
|
188
|
+
this.#stockKey(params2.rallyId, params2.stockKey ?? params2.rewardId),
|
|
189
|
+
initialStock
|
|
161
190
|
);
|
|
162
|
-
const currentSecondaryStock =
|
|
163
|
-
const effectiveSecondaryStock = currentSecondaryStock ??
|
|
164
|
-
if (currentSecondaryStock === null &&
|
|
191
|
+
const currentSecondaryStock = params2.secondaryStockKey === void 0 ? null : await this.getRewardStock(params2.rallyId, params2.secondaryStockKey);
|
|
192
|
+
const effectiveSecondaryStock = currentSecondaryStock ?? initialSecondaryStock;
|
|
193
|
+
if (currentSecondaryStock === null && params2.secondaryStockKey !== void 0 && initialSecondaryStock !== void 0 && initialSecondaryStock !== null)
|
|
165
194
|
this.#stocks.set(
|
|
166
|
-
this.#stockKey(
|
|
167
|
-
|
|
195
|
+
this.#stockKey(params2.rallyId, params2.secondaryStockKey),
|
|
196
|
+
initialSecondaryStock
|
|
168
197
|
);
|
|
169
198
|
if (effectiveStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
|
|
170
199
|
throw new Error("The transaction produced an invalid stock value.");
|
|
@@ -172,10 +201,10 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
172
201
|
throw new Error("The transaction changed an unlimited stock to a limited stock.");
|
|
173
202
|
if (mutationResult.nextStock !== null)
|
|
174
203
|
this.#stocks.set(
|
|
175
|
-
this.#stockKey(
|
|
204
|
+
this.#stockKey(params2.rallyId, params2.stockKey ?? params2.rewardId),
|
|
176
205
|
mutationResult.nextStock
|
|
177
206
|
);
|
|
178
|
-
if (
|
|
207
|
+
if (params2.secondaryStockKey !== void 0 && mutationResult.nextSecondaryStock !== void 0) {
|
|
179
208
|
if (effectiveSecondaryStock !== null && (mutationResult.nextSecondaryStock === null || mutationResult.nextSecondaryStock < 0))
|
|
180
209
|
throw new Error("The transaction produced an invalid secondary stock value.");
|
|
181
210
|
if (effectiveSecondaryStock === null && mutationResult.nextSecondaryStock !== null)
|
|
@@ -184,29 +213,29 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
184
213
|
);
|
|
185
214
|
if (mutationResult.nextSecondaryStock !== null)
|
|
186
215
|
this.#stocks.set(
|
|
187
|
-
this.#stockKey(
|
|
216
|
+
this.#stockKey(params2.rallyId, params2.secondaryStockKey),
|
|
188
217
|
mutationResult.nextSecondaryStock
|
|
189
218
|
);
|
|
190
219
|
}
|
|
191
|
-
await this.saveUserState(
|
|
220
|
+
await this.saveUserState(params2.rallyId, params2.userId, mutationResult.nextUserState);
|
|
192
221
|
const reward = mutationResult.nextUserState.rewards.find(
|
|
193
|
-
(item) => item.rewardId ===
|
|
222
|
+
(item) => item.rewardId === params2.rewardId
|
|
194
223
|
);
|
|
195
224
|
if (reward?.claimTicketNumber !== void 0)
|
|
196
225
|
await this.recordUserClaim({
|
|
197
|
-
rallyId:
|
|
198
|
-
userId:
|
|
199
|
-
rewardId:
|
|
226
|
+
rallyId: params2.rallyId,
|
|
227
|
+
userId: params2.userId,
|
|
228
|
+
rewardId: params2.rewardId,
|
|
200
229
|
ticketNumber: reward.claimTicketNumber,
|
|
201
|
-
timestamp:
|
|
230
|
+
timestamp: params2.timestamp
|
|
202
231
|
});
|
|
203
232
|
await this.recordAuditLog(mutationResult.auditLog);
|
|
204
233
|
if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
|
|
205
234
|
await this.saveIdempotentResult(
|
|
206
|
-
|
|
235
|
+
params2.rallyId,
|
|
207
236
|
idempotencyKey,
|
|
208
237
|
mutationResult.result,
|
|
209
|
-
|
|
238
|
+
params2.idempotencyTtlMs ?? 864e5
|
|
210
239
|
);
|
|
211
240
|
return { success: true };
|
|
212
241
|
});
|
|
@@ -214,32 +243,32 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
214
243
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
215
244
|
}
|
|
216
245
|
}
|
|
217
|
-
async executeCheckInTransaction(
|
|
246
|
+
async executeCheckInTransaction(params2, mutation2) {
|
|
218
247
|
try {
|
|
219
|
-
return await this.runTransaction(
|
|
220
|
-
const userState = await this.getUserState(
|
|
248
|
+
return await this.runTransaction(params2.rallyId, async () => {
|
|
249
|
+
const userState = await this.getUserState(params2.rallyId, params2.userId) ?? params2.initialUserState;
|
|
221
250
|
if (userState === void 0)
|
|
222
251
|
return { success: false, error: "A user state is required for this transaction." };
|
|
223
|
-
const mutationResult =
|
|
252
|
+
const mutationResult = mutation2({ userState });
|
|
224
253
|
if (mutationResult.error !== void 0) {
|
|
225
254
|
await this.recordAuditLog(mutationResult.auditLog);
|
|
226
|
-
if (
|
|
255
|
+
if (params2.idempotencyKey !== void 0 && mutationResult.result !== void 0)
|
|
227
256
|
await this.saveIdempotentResult(
|
|
228
|
-
|
|
229
|
-
|
|
257
|
+
params2.rallyId,
|
|
258
|
+
params2.idempotencyKey,
|
|
230
259
|
mutationResult.result,
|
|
231
|
-
|
|
260
|
+
params2.idempotencyTtlMs ?? 864e5
|
|
232
261
|
);
|
|
233
262
|
return { success: false, error: mutationResult.error };
|
|
234
263
|
}
|
|
235
|
-
await this.saveUserState(
|
|
264
|
+
await this.saveUserState(params2.rallyId, params2.userId, mutationResult.nextUserState);
|
|
236
265
|
await this.recordAuditLog(mutationResult.auditLog);
|
|
237
|
-
if (
|
|
266
|
+
if (params2.idempotencyKey !== void 0 && mutationResult.result !== void 0)
|
|
238
267
|
await this.saveIdempotentResult(
|
|
239
|
-
|
|
240
|
-
|
|
268
|
+
params2.rallyId,
|
|
269
|
+
params2.idempotencyKey,
|
|
241
270
|
mutationResult.result,
|
|
242
|
-
|
|
271
|
+
params2.idempotencyTtlMs ?? 864e5
|
|
243
272
|
);
|
|
244
273
|
return { success: true };
|
|
245
274
|
});
|
|
@@ -273,8 +302,8 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
273
302
|
const value = this.#states.get(`${rallyId}:${userId}`);
|
|
274
303
|
return value === void 0 ? null : structuredClone(value);
|
|
275
304
|
}
|
|
276
|
-
async saveUserState(rallyId, userId,
|
|
277
|
-
this.#states.set(`${rallyId}:${userId}`, structuredClone(
|
|
305
|
+
async saveUserState(rallyId, userId, state2) {
|
|
306
|
+
this.#states.set(`${rallyId}:${userId}`, structuredClone(state2));
|
|
278
307
|
}
|
|
279
308
|
async recordAuditLog(log) {
|
|
280
309
|
this.#auditLogs.push(structuredClone(log));
|
|
@@ -286,11 +315,11 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
286
315
|
getAuditLogs() {
|
|
287
316
|
return structuredClone(this.#auditLogs);
|
|
288
317
|
}
|
|
289
|
-
async recordUserClaim(
|
|
290
|
-
const { rallyId, userId, rewardId } =
|
|
318
|
+
async recordUserClaim(params2) {
|
|
319
|
+
const { rallyId, userId, rewardId } = params2;
|
|
291
320
|
const key = `${rallyId}:${userId}:${rewardId}`;
|
|
292
321
|
this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
|
|
293
|
-
this.#claimRecords.push(structuredClone(
|
|
322
|
+
this.#claimRecords.push(structuredClone(params2));
|
|
294
323
|
}
|
|
295
324
|
async rollbackUserClaim(rallyId, userId, rewardId, ticketNumber) {
|
|
296
325
|
const key = `${rallyId}:${userId}:${rewardId}`;
|
|
@@ -310,44 +339,64 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
310
339
|
return structuredClone(this.#claimRecords);
|
|
311
340
|
}
|
|
312
341
|
recordClaim(paramsOrRallyId, userId, rewardId) {
|
|
313
|
-
const
|
|
342
|
+
const params2 = typeof paramsOrRallyId === "string" ? {
|
|
314
343
|
rallyId: paramsOrRallyId,
|
|
315
344
|
userId: userId ?? "",
|
|
316
345
|
rewardId: rewardId ?? "",
|
|
317
346
|
ticketNumber: "",
|
|
318
347
|
timestamp: Date.now()
|
|
319
348
|
} : paramsOrRallyId;
|
|
320
|
-
return this.recordUserClaim(
|
|
349
|
+
return this.recordUserClaim(params2);
|
|
321
350
|
}
|
|
322
|
-
async runTransaction(
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
this.#idempotent.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
async runTransaction(rallyId, operation) {
|
|
352
|
+
const previous = this.#transactionTails.get(rallyId) ?? Promise.resolve();
|
|
353
|
+
const current = previous.then(async () => {
|
|
354
|
+
const snapshot = {
|
|
355
|
+
stocks: new Map(this.#stocks),
|
|
356
|
+
idempotent: new Map(this.#idempotent),
|
|
357
|
+
states: new Map(this.#states),
|
|
358
|
+
claims: new Map(this.#claims),
|
|
359
|
+
claimRecords: structuredClone(this.#claimRecords),
|
|
360
|
+
auditLogs: structuredClone(this.#auditLogs)
|
|
361
|
+
};
|
|
362
|
+
try {
|
|
363
|
+
return await operation(this);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
this.#stocks.clear();
|
|
366
|
+
for (const [key, value] of snapshot.stocks) this.#stocks.set(key, value);
|
|
367
|
+
this.#idempotent.clear();
|
|
368
|
+
for (const [key, value] of snapshot.idempotent)
|
|
369
|
+
this.#idempotent.set(key, structuredClone(value));
|
|
370
|
+
this.#states.clear();
|
|
371
|
+
for (const [key, value] of snapshot.states) this.#states.set(key, structuredClone(value));
|
|
372
|
+
this.#claims.clear();
|
|
373
|
+
for (const [key, value] of snapshot.claims) this.#claims.set(key, value);
|
|
374
|
+
this.#claimRecords.splice(0, this.#claimRecords.length, ...snapshot.claimRecords);
|
|
375
|
+
this.#auditLogs.splice(0, this.#auditLogs.length, ...snapshot.auditLogs);
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
this.#transactionTails.set(
|
|
380
|
+
rallyId,
|
|
381
|
+
current.then(
|
|
382
|
+
() => void 0,
|
|
383
|
+
() => void 0
|
|
384
|
+
)
|
|
385
|
+
);
|
|
386
|
+
return current;
|
|
347
387
|
}
|
|
348
388
|
};
|
|
349
389
|
|
|
350
390
|
// src/security.ts
|
|
391
|
+
var RequestValidationException = class extends Error {
|
|
392
|
+
code = "VALIDATION_FAILED";
|
|
393
|
+
errors;
|
|
394
|
+
constructor(errors2) {
|
|
395
|
+
super("Request validation failed.");
|
|
396
|
+
this.name = "RequestValidationException";
|
|
397
|
+
this.errors = errors2;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
351
400
|
function errors(...items) {
|
|
352
401
|
return { success: false, errors: items };
|
|
353
402
|
}
|
|
@@ -461,7 +510,7 @@ function validateClaimRewardRequest(value) {
|
|
|
461
510
|
});
|
|
462
511
|
if (value.staffId !== void 0 && !nonEmpty(value.staffId))
|
|
463
512
|
return errors({ path: "staffId", message: "staffId must be non-empty.", code: "INVALID_TYPE" });
|
|
464
|
-
if (value.now !== void 0 && !
|
|
513
|
+
if (value.now !== void 0 && !dateInput(value.now))
|
|
465
514
|
return errors({
|
|
466
515
|
path: "now",
|
|
467
516
|
message: "now must be an ISO 8601 date or positive timestamp.",
|
|
@@ -469,6 +518,76 @@ function validateClaimRewardRequest(value) {
|
|
|
469
518
|
});
|
|
470
519
|
return { success: true, data: value };
|
|
471
520
|
}
|
|
521
|
+
function uuid(value) {
|
|
522
|
+
return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
523
|
+
}
|
|
524
|
+
function identityErrors(value) {
|
|
525
|
+
const result = [];
|
|
526
|
+
if (value.userId === void 0 && value.anonymousSessionId === void 0)
|
|
527
|
+
result.push({
|
|
528
|
+
path: "userId",
|
|
529
|
+
message: "An authenticated userId or anonymousSessionId is required.",
|
|
530
|
+
code: "REQUIRED"
|
|
531
|
+
});
|
|
532
|
+
if (value.userId !== void 0 && !nonEmpty(value.userId))
|
|
533
|
+
result.push({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
|
|
534
|
+
if (value.anonymousSessionId !== void 0 && !uuid(value.anonymousSessionId))
|
|
535
|
+
result.push({
|
|
536
|
+
path: "anonymousSessionId",
|
|
537
|
+
message: "anonymousSessionId must be a UUID v4.",
|
|
538
|
+
code: "INVALID_FORMAT"
|
|
539
|
+
});
|
|
540
|
+
if (value.userId !== void 0 && value.anonymousSessionId !== void 0 && value.userId !== value.anonymousSessionId)
|
|
541
|
+
result.push({
|
|
542
|
+
path: "anonymousSessionId",
|
|
543
|
+
message: "userId and anonymousSessionId must identify the same session.",
|
|
544
|
+
code: "IDENTITY_MISMATCH"
|
|
545
|
+
});
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
function directErrors(value, config, kind) {
|
|
549
|
+
const validated = kind === "check-in" ? validateCheckInRequest(value) : validateClaimRewardRequest(value);
|
|
550
|
+
if (!validated.success) return validated.errors;
|
|
551
|
+
const errors2 = [];
|
|
552
|
+
if (validated.data.rallyId !== config.id)
|
|
553
|
+
errors2.push({
|
|
554
|
+
path: "rallyId",
|
|
555
|
+
message: "The rally does not match this server.",
|
|
556
|
+
code: "INVALID_VALUE"
|
|
557
|
+
});
|
|
558
|
+
const resourceId = kind === "check-in" ? validated.data.spotId : validated.data.rewardId;
|
|
559
|
+
const exists = kind === "check-in" ? config.spots.some((spot) => spot.id === resourceId) : config.rewards.some((reward) => reward.id === resourceId);
|
|
560
|
+
if (!exists)
|
|
561
|
+
errors2.push({
|
|
562
|
+
path: kind === "check-in" ? "spotId" : "rewardId",
|
|
563
|
+
message: `${kind === "check-in" ? "Spot" : "Reward"} was not found.`,
|
|
564
|
+
code: kind === "check-in" ? "SPOT_NOT_FOUND" : "REWARD_NOT_FOUND"
|
|
565
|
+
});
|
|
566
|
+
errors2.push(...identityErrors(validated.data));
|
|
567
|
+
return errors2;
|
|
568
|
+
}
|
|
569
|
+
function assertValidCheckInParams(value, config) {
|
|
570
|
+
const errors2 = directErrors(value, config, "check-in");
|
|
571
|
+
if (errors2.length > 0) throw new RequestValidationException(errors2);
|
|
572
|
+
}
|
|
573
|
+
function assertValidClaimParams(value, config) {
|
|
574
|
+
const errors2 = directErrors(value, config, "claim");
|
|
575
|
+
if (errors2.length > 0) throw new RequestValidationException(errors2);
|
|
576
|
+
}
|
|
577
|
+
function assertValidSyncParams(value, config) {
|
|
578
|
+
const validated = validateSyncRequest(value);
|
|
579
|
+
const errors2 = validated.success ? [
|
|
580
|
+
...validated.data.rallyId !== config.id ? [
|
|
581
|
+
{
|
|
582
|
+
path: "rallyId",
|
|
583
|
+
message: "The rally does not match this server.",
|
|
584
|
+
code: "INVALID_VALUE"
|
|
585
|
+
}
|
|
586
|
+
] : []
|
|
587
|
+
] : [...validated.errors];
|
|
588
|
+
if (validated.success) errors2.push(...identityErrors(validated.data));
|
|
589
|
+
if (errors2.length > 0) throw new RequestValidationException(errors2);
|
|
590
|
+
}
|
|
472
591
|
function validateSyncRequest(value) {
|
|
473
592
|
const required = requiredErrors(value, ["rallyId"]);
|
|
474
593
|
if (required.length > 0) return errors(...required);
|
|
@@ -476,7 +595,10 @@ function validateSyncRequest(value) {
|
|
|
476
595
|
return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
|
|
477
596
|
if (value.userId !== void 0 && !nonEmpty(value.userId))
|
|
478
597
|
return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
|
|
479
|
-
return {
|
|
598
|
+
return {
|
|
599
|
+
success: true,
|
|
600
|
+
data: value
|
|
601
|
+
};
|
|
480
602
|
}
|
|
481
603
|
function json(body, status = 200) {
|
|
482
604
|
return new Response(JSON.stringify(body), {
|
|
@@ -572,6 +694,29 @@ function operationStatus(result) {
|
|
|
572
694
|
if (result.ok) return "ACCEPTED";
|
|
573
695
|
return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
|
|
574
696
|
}
|
|
697
|
+
function withDirectIdentity(request, authContext) {
|
|
698
|
+
if (authContext !== void 0) {
|
|
699
|
+
if (authContext.authenticatedUserId.trim() === "")
|
|
700
|
+
throw new RequestValidationException([
|
|
701
|
+
{
|
|
702
|
+
path: "authContext.authenticatedUserId",
|
|
703
|
+
message: "An authenticated userId is required.",
|
|
704
|
+
code: "REQUIRED"
|
|
705
|
+
}
|
|
706
|
+
]);
|
|
707
|
+
return { ...request, userId: authContext.authenticatedUserId };
|
|
708
|
+
}
|
|
709
|
+
if (request.userId !== void 0) return { ...request, userId: request.userId };
|
|
710
|
+
if (request.anonymousSessionId !== void 0 && isUuidV4(request.anonymousSessionId))
|
|
711
|
+
return { ...request, userId: request.anonymousSessionId };
|
|
712
|
+
throw new RequestValidationException([
|
|
713
|
+
{
|
|
714
|
+
path: "userId",
|
|
715
|
+
message: "An authenticated userId or anonymousSessionId is required.",
|
|
716
|
+
code: "REQUIRED"
|
|
717
|
+
}
|
|
718
|
+
]);
|
|
719
|
+
}
|
|
575
720
|
var StampRallyServer = class {
|
|
576
721
|
#config;
|
|
577
722
|
#persistence;
|
|
@@ -607,7 +752,18 @@ var StampRallyServer = class {
|
|
|
607
752
|
{ ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
|
|
608
753
|
401
|
|
609
754
|
);
|
|
610
|
-
const
|
|
755
|
+
const sessionId = request.headers.get("x-anonymous-session-id");
|
|
756
|
+
let result;
|
|
757
|
+
try {
|
|
758
|
+
result = await this.checkIn({
|
|
759
|
+
...body.data,
|
|
760
|
+
userId,
|
|
761
|
+
...sessionId === null ? {} : { anonymousSessionId: sessionId }
|
|
762
|
+
});
|
|
763
|
+
} catch (error) {
|
|
764
|
+
if (error instanceof RequestValidationException) return validationResponse(error.errors);
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
611
767
|
return json(
|
|
612
768
|
{ ...result, status: operationStatus(result) },
|
|
613
769
|
result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422
|
|
@@ -630,7 +786,18 @@ var StampRallyServer = class {
|
|
|
630
786
|
{ ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
|
|
631
787
|
401
|
|
632
788
|
);
|
|
633
|
-
const
|
|
789
|
+
const sessionId = request.headers.get("x-anonymous-session-id");
|
|
790
|
+
let result;
|
|
791
|
+
try {
|
|
792
|
+
result = await this.claimReward({
|
|
793
|
+
...body.data,
|
|
794
|
+
userId,
|
|
795
|
+
...sessionId === null ? {} : { anonymousSessionId: sessionId }
|
|
796
|
+
});
|
|
797
|
+
} catch (error) {
|
|
798
|
+
if (error instanceof RequestValidationException) return validationResponse(error.errors);
|
|
799
|
+
throw error;
|
|
800
|
+
}
|
|
634
801
|
return json(
|
|
635
802
|
{ ...result, status: operationStatus(result) },
|
|
636
803
|
result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422
|
|
@@ -653,16 +820,32 @@ var StampRallyServer = class {
|
|
|
653
820
|
{ ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
|
|
654
821
|
401
|
|
655
822
|
);
|
|
656
|
-
|
|
823
|
+
const sessionId = request.headers.get("x-anonymous-session-id");
|
|
824
|
+
try {
|
|
825
|
+
return json({
|
|
826
|
+
ok: true,
|
|
827
|
+
state: await this.syncProgress({
|
|
828
|
+
rallyId: body.data.rallyId,
|
|
829
|
+
userId,
|
|
830
|
+
...sessionId === null ? {} : { anonymousSessionId: sessionId }
|
|
831
|
+
})
|
|
832
|
+
});
|
|
833
|
+
} catch (error) {
|
|
834
|
+
if (error instanceof RequestValidationException) return validationResponse(error.errors);
|
|
835
|
+
throw error;
|
|
836
|
+
}
|
|
657
837
|
}
|
|
658
|
-
async checkIn(request) {
|
|
659
|
-
const
|
|
838
|
+
async checkIn(request, authContext) {
|
|
839
|
+
const directRequest = withDirectIdentity(request, authContext);
|
|
840
|
+
assertValidCheckInParams(directRequest, this.#config);
|
|
841
|
+
const { userId } = directRequest;
|
|
842
|
+
const key = `check-in:${request.rallyId}:${userId}:${request.idempotencyKey}`;
|
|
660
843
|
const previous = await this.#persistence.getIdempotentResult(
|
|
661
844
|
request.rallyId,
|
|
662
845
|
key
|
|
663
846
|
);
|
|
664
847
|
if (previous !== null) return previous;
|
|
665
|
-
const lockKey = `state:${request.rallyId}:${
|
|
848
|
+
const lockKey = `state:${request.rallyId}:${userId}`;
|
|
666
849
|
if (!await this.#persistence.acquireLock(
|
|
667
850
|
request.rallyId,
|
|
668
851
|
lockKey,
|
|
@@ -671,11 +854,11 @@ var StampRallyServer = class {
|
|
|
671
854
|
return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
|
|
672
855
|
const timestamp = now(this.#options);
|
|
673
856
|
try {
|
|
674
|
-
const current = await this.#persistence.getUserState(request.rallyId,
|
|
857
|
+
const current = await this.#persistence.getUserState(request.rallyId, userId) ?? initialState(this.#config, userId, timestamp);
|
|
675
858
|
const responseHolder = { value: null };
|
|
676
859
|
const makeAudit = (status, code) => audit(
|
|
677
860
|
request.rallyId,
|
|
678
|
-
|
|
861
|
+
userId,
|
|
679
862
|
"CHECK_IN",
|
|
680
863
|
request.spotId,
|
|
681
864
|
request.idempotencyKey,
|
|
@@ -691,7 +874,7 @@ var StampRallyServer = class {
|
|
|
691
874
|
message: "Spot was not found."
|
|
692
875
|
};
|
|
693
876
|
return await this.#rememberCheckInTransaction(
|
|
694
|
-
|
|
877
|
+
directRequest,
|
|
695
878
|
timestamp,
|
|
696
879
|
key,
|
|
697
880
|
current,
|
|
@@ -715,7 +898,7 @@ var StampRallyServer = class {
|
|
|
715
898
|
};
|
|
716
899
|
if (current.records.some((record2) => record2.stampId === request.spotId))
|
|
717
900
|
return await this.#rememberCheckInTransaction(
|
|
718
|
-
|
|
901
|
+
directRequest,
|
|
719
902
|
timestamp,
|
|
720
903
|
key,
|
|
721
904
|
current,
|
|
@@ -725,7 +908,7 @@ var StampRallyServer = class {
|
|
|
725
908
|
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
726
909
|
if (spot.prerequisites?.some((id) => !acquired.has(id)))
|
|
727
910
|
return await this.#rememberCheckInTransaction(
|
|
728
|
-
|
|
911
|
+
directRequest,
|
|
729
912
|
timestamp,
|
|
730
913
|
key,
|
|
731
914
|
current,
|
|
@@ -740,7 +923,7 @@ var StampRallyServer = class {
|
|
|
740
923
|
{ rallyId: request.rallyId, spotId: request.spotId, state: current }
|
|
741
924
|
))
|
|
742
925
|
return await this.#rememberCheckInTransaction(
|
|
743
|
-
|
|
926
|
+
directRequest,
|
|
744
927
|
timestamp,
|
|
745
928
|
key,
|
|
746
929
|
current,
|
|
@@ -762,7 +945,7 @@ var StampRallyServer = class {
|
|
|
762
945
|
const transaction = await this.#persistence.executeCheckInTransaction(
|
|
763
946
|
{
|
|
764
947
|
rallyId: request.rallyId,
|
|
765
|
-
userId
|
|
948
|
+
userId,
|
|
766
949
|
spotId: request.spotId,
|
|
767
950
|
timestamp: timestampMillis(timestamp),
|
|
768
951
|
idempotencyKey: key,
|
|
@@ -786,8 +969,11 @@ var StampRallyServer = class {
|
|
|
786
969
|
await this.#persistence.releaseLock(request.rallyId, lockKey);
|
|
787
970
|
}
|
|
788
971
|
}
|
|
789
|
-
async claimReward(request) {
|
|
790
|
-
const
|
|
972
|
+
async claimReward(request, authContext) {
|
|
973
|
+
const directRequest = withDirectIdentity(request, authContext);
|
|
974
|
+
assertValidClaimParams(directRequest, this.#config);
|
|
975
|
+
const { userId } = directRequest;
|
|
976
|
+
const key = `claim:${request.rallyId}:${userId}:${request.rewardId}:${request.idempotencyKey}`;
|
|
791
977
|
const previous = await this.#persistence.getIdempotentResult(
|
|
792
978
|
request.rallyId,
|
|
793
979
|
key
|
|
@@ -798,7 +984,20 @@ var StampRallyServer = class {
|
|
|
798
984
|
return this.#rememberClaim(
|
|
799
985
|
key,
|
|
800
986
|
{ ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
|
|
801
|
-
|
|
987
|
+
directRequest,
|
|
988
|
+
now(this.#options)
|
|
989
|
+
);
|
|
990
|
+
const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
|
|
991
|
+
const inventoryEnabled = plan.primaryInitial !== null || plan.secondaryInitial !== void 0 && plan.secondaryInitial !== null;
|
|
992
|
+
if (inventoryEnabled && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function" || typeof this.#persistence.executeClaimRewardTransaction !== "function"))
|
|
993
|
+
return this.#rememberClaim(
|
|
994
|
+
key,
|
|
995
|
+
{
|
|
996
|
+
ok: false,
|
|
997
|
+
code: "INVENTORY_STORAGE_NOT_IMPLEMENTED",
|
|
998
|
+
message: "This persistence adapter cannot atomically store inventory."
|
|
999
|
+
},
|
|
1000
|
+
directRequest,
|
|
802
1001
|
now(this.#options)
|
|
803
1002
|
);
|
|
804
1003
|
const lockKey = this.#config.inventoryMode === "shared" ? "inventory:shared" : `reward:${request.rallyId}:${reward.id}`;
|
|
@@ -809,7 +1008,6 @@ var StampRallyServer = class {
|
|
|
809
1008
|
))
|
|
810
1009
|
return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
|
|
811
1010
|
const timestamp = now(this.#options);
|
|
812
|
-
const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
|
|
813
1011
|
try {
|
|
814
1012
|
const checked = await this.#persistence.getIdempotentResult(
|
|
815
1013
|
request.rallyId,
|
|
@@ -820,10 +1018,12 @@ var StampRallyServer = class {
|
|
|
820
1018
|
const result = await this.#persistence.executeClaimRewardTransaction(
|
|
821
1019
|
{
|
|
822
1020
|
rallyId: request.rallyId,
|
|
823
|
-
userId
|
|
1021
|
+
userId,
|
|
824
1022
|
rewardId: reward.id,
|
|
825
1023
|
stockKey: plan.primaryKey,
|
|
826
1024
|
...plan.secondaryKey === void 0 ? {} : { secondaryStockKey: plan.secondaryKey },
|
|
1025
|
+
rewardStockLimit: rewardStock(this.#config, reward.id, reward.stockLimit),
|
|
1026
|
+
sharedStockLimit: this.#config.inventoryMode === "shared" ? sharedStock(this.#config) : null,
|
|
827
1027
|
initialStock: plan.primaryInitial,
|
|
828
1028
|
...plan.secondaryInitial === void 0 ? {} : { initialSecondaryStock: plan.secondaryInitial },
|
|
829
1029
|
ticketNumber: request.idempotencyKey,
|
|
@@ -831,7 +1031,7 @@ var StampRallyServer = class {
|
|
|
831
1031
|
idempotencyKey: key,
|
|
832
1032
|
...request.staffPasscode === void 0 ? {} : { proofData: request.staffPasscode },
|
|
833
1033
|
...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
|
|
834
|
-
initialUserState: initialState(this.#config,
|
|
1034
|
+
initialUserState: initialState(this.#config, userId, timestamp)
|
|
835
1035
|
},
|
|
836
1036
|
({ stock, secondaryStock, claimCount, userState }) => {
|
|
837
1037
|
const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
|
|
@@ -841,7 +1041,7 @@ var StampRallyServer = class {
|
|
|
841
1041
|
const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
|
|
842
1042
|
const makeAudit = (status, code) => audit(
|
|
843
1043
|
request.rallyId,
|
|
844
|
-
|
|
1044
|
+
userId,
|
|
845
1045
|
"CLAIM_REWARD",
|
|
846
1046
|
reward.id,
|
|
847
1047
|
request.idempotencyKey,
|
|
@@ -926,6 +1126,12 @@ var StampRallyServer = class {
|
|
|
926
1126
|
const response = responseHolder.value;
|
|
927
1127
|
if (!result.success) {
|
|
928
1128
|
if (response !== null && !response.ok && response.code === result.error) return response;
|
|
1129
|
+
if (result.error === "INVENTORY_STORAGE_NOT_IMPLEMENTED")
|
|
1130
|
+
return {
|
|
1131
|
+
ok: false,
|
|
1132
|
+
code: "INVENTORY_STORAGE_NOT_IMPLEMENTED",
|
|
1133
|
+
message: "This persistence adapter cannot atomically store inventory."
|
|
1134
|
+
};
|
|
929
1135
|
return {
|
|
930
1136
|
ok: false,
|
|
931
1137
|
code: "PERSISTENCE_FAILED",
|
|
@@ -949,28 +1155,34 @@ var StampRallyServer = class {
|
|
|
949
1155
|
}
|
|
950
1156
|
}
|
|
951
1157
|
async sync(rallyId, userId) {
|
|
952
|
-
|
|
953
|
-
|
|
1158
|
+
assertValidSyncParams({ rallyId, userId }, this.#config);
|
|
1159
|
+
const state2 = await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
|
|
1160
|
+
return this.#attachInventory(state2);
|
|
1161
|
+
}
|
|
1162
|
+
async syncProgress(request) {
|
|
1163
|
+
const directRequest = withDirectIdentity(request);
|
|
1164
|
+
assertValidSyncParams(directRequest, this.#config);
|
|
1165
|
+
return this.sync(directRequest.rallyId, directRequest.userId);
|
|
954
1166
|
}
|
|
955
|
-
async #attachInventory(
|
|
1167
|
+
async #attachInventory(state2) {
|
|
956
1168
|
const rewardRemaining = {};
|
|
957
1169
|
for (const reward of this.#config.rewards) {
|
|
958
1170
|
const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
|
|
959
1171
|
if (plan.secondaryKey !== void 0) {
|
|
960
|
-
const stock = await this.#persistence.getRewardStock(
|
|
1172
|
+
const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.secondaryKey);
|
|
961
1173
|
const remaining = stock ?? plan.secondaryInitial ?? null;
|
|
962
1174
|
if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
|
|
963
1175
|
} else if (plan.primaryKey !== "__shared__") {
|
|
964
|
-
const stock = await this.#persistence.getRewardStock(
|
|
1176
|
+
const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.primaryKey);
|
|
965
1177
|
const remaining = stock ?? plan.primaryInitial;
|
|
966
1178
|
if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
|
|
967
1179
|
}
|
|
968
1180
|
}
|
|
969
1181
|
const shared = sharedStock(this.#config);
|
|
970
|
-
const storedShared = await this.#persistence.getRewardStock(
|
|
1182
|
+
const storedShared = await this.#persistence.getRewardStock(state2.rallyId, "__shared__");
|
|
971
1183
|
const sharedRemaining = this.#config.inventoryMode === "shared" && shared !== null ? Math.max(0, storedShared ?? shared) : void 0;
|
|
972
1184
|
return {
|
|
973
|
-
...
|
|
1185
|
+
...state2,
|
|
974
1186
|
...Object.keys(rewardRemaining).length === 0 && sharedRemaining === void 0 ? {} : {
|
|
975
1187
|
inventory: {
|
|
976
1188
|
...sharedRemaining === void 0 ? {} : { sharedRemaining },
|
|
@@ -995,13 +1207,14 @@ var StampRallyServer = class {
|
|
|
995
1207
|
const authenticatedUserId = identity.authenticatedUserId;
|
|
996
1208
|
return authenticatedUserId.length > 0 ? authenticatedUserId : null;
|
|
997
1209
|
}
|
|
998
|
-
|
|
1210
|
+
const policy = this.#options.anonymousPolicy ?? "session_scoped";
|
|
1211
|
+
if (policy === "reject") return null;
|
|
999
1212
|
const sessionId = request.headers.get("X-Anonymous-Session-Id");
|
|
1000
|
-
if (sessionId !== null
|
|
1001
|
-
if (
|
|
1213
|
+
if (sessionId !== null) return isUuidV4(sessionId) ? sessionId : null;
|
|
1214
|
+
if (policy === "session_scoped") return null;
|
|
1002
1215
|
return "anonymous";
|
|
1003
1216
|
}
|
|
1004
|
-
async #rememberCheckInTransaction(request, timestamp, key, current,
|
|
1217
|
+
async #rememberCheckInTransaction(request, timestamp, key, current, mutation2, responseHolder) {
|
|
1005
1218
|
const transaction = await this.#persistence.executeCheckInTransaction(
|
|
1006
1219
|
{
|
|
1007
1220
|
rallyId: request.rallyId,
|
|
@@ -1012,9 +1225,9 @@ var StampRallyServer = class {
|
|
|
1012
1225
|
...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
|
|
1013
1226
|
initialUserState: current
|
|
1014
1227
|
},
|
|
1015
|
-
() =>
|
|
1228
|
+
() => mutation2
|
|
1016
1229
|
);
|
|
1017
|
-
if (responseHolder.value !== null && (transaction.success || transaction.error ===
|
|
1230
|
+
if (responseHolder.value !== null && (transaction.success || transaction.error === mutation2.error))
|
|
1018
1231
|
return responseHolder.value;
|
|
1019
1232
|
return {
|
|
1020
1233
|
ok: false,
|
|
@@ -1045,6 +1258,130 @@ var StampRallyServer = class {
|
|
|
1045
1258
|
}
|
|
1046
1259
|
};
|
|
1047
1260
|
|
|
1048
|
-
|
|
1261
|
+
// src/testing/compliance.ts
|
|
1262
|
+
var state = (userId) => ({
|
|
1263
|
+
rallyId: "compliance-rally",
|
|
1264
|
+
userId,
|
|
1265
|
+
records: [],
|
|
1266
|
+
rewards: [{ rewardId: "reward", status: "AVAILABLE" }],
|
|
1267
|
+
updatedAt: "2026-01-01T00:00:00.000Z"
|
|
1268
|
+
});
|
|
1269
|
+
function audit2(idempotencyKey, userId) {
|
|
1270
|
+
return {
|
|
1271
|
+
id: `audit-${idempotencyKey}`,
|
|
1272
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
1273
|
+
rallyId: "compliance-rally",
|
|
1274
|
+
userId,
|
|
1275
|
+
action: "CLAIM_REWARD",
|
|
1276
|
+
resourceId: "reward",
|
|
1277
|
+
status: "SUCCESS",
|
|
1278
|
+
idempotencyKey
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
function params(userId, idempotencyKey) {
|
|
1282
|
+
return {
|
|
1283
|
+
rallyId: "compliance-rally",
|
|
1284
|
+
userId,
|
|
1285
|
+
rewardId: "reward",
|
|
1286
|
+
ticketNumber: `ticket-${idempotencyKey}`,
|
|
1287
|
+
timestamp: Date.parse("2026-01-01T00:00:00.000Z"),
|
|
1288
|
+
idempotencyKey,
|
|
1289
|
+
rewardStockLimit: 1,
|
|
1290
|
+
sharedStockLimit: 1,
|
|
1291
|
+
stockKey: "__shared__",
|
|
1292
|
+
secondaryStockKey: "reward",
|
|
1293
|
+
initialStock: 1,
|
|
1294
|
+
initialSecondaryStock: 1,
|
|
1295
|
+
initialUserState: state(userId)
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
function mutation(current) {
|
|
1299
|
+
if (current.stock === 0 || current.secondaryStock === 0)
|
|
1300
|
+
return {
|
|
1301
|
+
nextStock: current.stock,
|
|
1302
|
+
nextSecondaryStock: current.secondaryStock,
|
|
1303
|
+
nextUserState: current.userState,
|
|
1304
|
+
auditLog: audit2("rejected", current.userState.userId ?? "unknown"),
|
|
1305
|
+
error: "OUT_OF_STOCK"
|
|
1306
|
+
};
|
|
1307
|
+
return {
|
|
1308
|
+
nextStock: current.stock === null ? null : current.stock - 1,
|
|
1309
|
+
nextSecondaryStock: current.secondaryStock === null ? null : current.secondaryStock - 1,
|
|
1310
|
+
nextUserState: {
|
|
1311
|
+
...current.userState,
|
|
1312
|
+
rewards: [{ rewardId: "reward", status: "CONSUMED" }],
|
|
1313
|
+
updatedAt: "2026-01-01T00:00:00.000Z"
|
|
1314
|
+
},
|
|
1315
|
+
auditLog: audit2("success", current.userState.userId ?? "unknown"),
|
|
1316
|
+
result: { ok: true }
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
function assert(condition, message) {
|
|
1320
|
+
if (!condition) throw new Error(`Persistence adapter compliance failed: ${message}`);
|
|
1321
|
+
}
|
|
1322
|
+
async function runPersistenceAdapterComplianceTests(createAdapter) {
|
|
1323
|
+
const adapter = await createAdapter();
|
|
1324
|
+
assert(
|
|
1325
|
+
adapter.supportsRewardStock !== false,
|
|
1326
|
+
"the adapter must explicitly support reward stock for this suite"
|
|
1327
|
+
);
|
|
1328
|
+
const [first, second] = await Promise.all([
|
|
1329
|
+
adapter.executeClaimRewardTransaction(params("alice", "race-a"), mutation),
|
|
1330
|
+
adapter.executeClaimRewardTransaction(params("bob", "race-b"), mutation)
|
|
1331
|
+
]);
|
|
1332
|
+
assert([first.success, second.success].filter(Boolean).length === 1, "race was not serialized");
|
|
1333
|
+
assert(
|
|
1334
|
+
await adapter.getRewardStock("compliance-rally", "__shared__") === 0,
|
|
1335
|
+
"shared stock was not decremented atomically"
|
|
1336
|
+
);
|
|
1337
|
+
assert(
|
|
1338
|
+
await adapter.getRewardStock("compliance-rally", "reward") === 0,
|
|
1339
|
+
"per-reward stock was not decremented atomically"
|
|
1340
|
+
);
|
|
1341
|
+
const exhausted = await adapter.executeClaimRewardTransaction(
|
|
1342
|
+
params("carol", "boundary"),
|
|
1343
|
+
mutation
|
|
1344
|
+
);
|
|
1345
|
+
assert(!exhausted.success, "a zero-stock claim was accepted");
|
|
1346
|
+
assert(
|
|
1347
|
+
await adapter.getRewardStock("compliance-rally", "__shared__") === 0 && await adapter.getRewardStock("compliance-rally", "reward") === 0,
|
|
1348
|
+
"zero-stock rejection did not preserve both stock boundaries"
|
|
1349
|
+
);
|
|
1350
|
+
const idempotentAdapter = await createAdapter();
|
|
1351
|
+
const idempotentParams = params("alice", "same-key");
|
|
1352
|
+
const firstClaim = await idempotentAdapter.executeClaimRewardTransaction(
|
|
1353
|
+
idempotentParams,
|
|
1354
|
+
mutation
|
|
1355
|
+
);
|
|
1356
|
+
const secondClaim = await idempotentAdapter.executeClaimRewardTransaction(
|
|
1357
|
+
idempotentParams,
|
|
1358
|
+
mutation
|
|
1359
|
+
);
|
|
1360
|
+
assert(firstClaim.success && secondClaim.success, "idempotent claim did not remain successful");
|
|
1361
|
+
assert(
|
|
1362
|
+
await idempotentAdapter.getRewardStock("compliance-rally", "__shared__") === 0,
|
|
1363
|
+
"idempotent retry decremented shared stock twice"
|
|
1364
|
+
);
|
|
1365
|
+
assert(
|
|
1366
|
+
await idempotentAdapter.getRewardStock("compliance-rally", "reward") === 0,
|
|
1367
|
+
"idempotent retry decremented per-reward stock twice"
|
|
1368
|
+
);
|
|
1369
|
+
const rollbackAdapter = await createAdapter();
|
|
1370
|
+
const rollbackParams = params("alice", "rollback");
|
|
1371
|
+
const rollback = await rollbackAdapter.executeClaimRewardTransaction(rollbackParams, () => {
|
|
1372
|
+
throw new Error("forced rollback");
|
|
1373
|
+
});
|
|
1374
|
+
assert(!rollback.success, "a failed mutation was committed");
|
|
1375
|
+
assert(
|
|
1376
|
+
await rollbackAdapter.getRewardStock("compliance-rally", "__shared__") === null,
|
|
1377
|
+
"rollback changed shared stock"
|
|
1378
|
+
);
|
|
1379
|
+
assert(
|
|
1380
|
+
await rollbackAdapter.getRewardStock("compliance-rally", "reward") === null,
|
|
1381
|
+
"rollback changed per-reward stock"
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
export { InMemoryServerPersistenceAdapter, RequestValidationException, StampRallyServer, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
|
|
1049
1386
|
//# sourceMappingURL=index.js.map
|
|
1050
1387
|
//# sourceMappingURL=index.js.map
|