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