@stamprally/server 0.15.0 → 0.16.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 +433 -132
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -9
- package/dist/index.d.ts +41 -9
- package/dist/index.js +429 -133
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -3,50 +3,52 @@
|
|
|
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 next =
|
|
9
|
+
const current = await store.readContext(transaction, params2);
|
|
10
|
+
const next = mutation2(current);
|
|
11
11
|
if (next.error !== void 0) {
|
|
12
12
|
await store.writeAudit(transaction, next.auditLog);
|
|
13
|
-
if (
|
|
14
|
-
await store.writeIdempotency(transaction,
|
|
13
|
+
if (params2.idempotencyKey !== void 0 && next.result !== void 0)
|
|
14
|
+
await store.writeIdempotency(transaction, params2, next.result);
|
|
15
15
|
return { success: false, error: next.error };
|
|
16
16
|
}
|
|
17
|
-
if (next.
|
|
17
|
+
if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock === void 0)
|
|
18
|
+
return { success: false, error: "INVENTORY_NOT_SUPPORTED" };
|
|
19
|
+
if (next.nextStock !== null) await store.writeStock(transaction, params2, next.nextStock);
|
|
18
20
|
if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock !== void 0) {
|
|
19
21
|
if (next.nextSecondaryStock !== null)
|
|
20
|
-
await store.writeSecondaryStock(transaction,
|
|
22
|
+
await store.writeSecondaryStock(transaction, params2, next.nextSecondaryStock);
|
|
21
23
|
}
|
|
22
|
-
await store.writeUserState(transaction,
|
|
23
|
-
await store.writeClaimRecord(transaction,
|
|
24
|
+
await store.writeUserState(transaction, params2, next.nextUserState);
|
|
25
|
+
await store.writeClaimRecord(transaction, params2, next.nextUserState);
|
|
24
26
|
await store.writeAudit(transaction, next.auditLog);
|
|
25
|
-
if (
|
|
26
|
-
await store.writeIdempotency(transaction,
|
|
27
|
+
if (params2.idempotencyKey !== void 0 && next.result !== void 0)
|
|
28
|
+
await store.writeIdempotency(transaction, params2, next.result);
|
|
27
29
|
return { success: true };
|
|
28
30
|
});
|
|
29
31
|
} catch (error) {
|
|
30
32
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
|
-
async function executeCheckInTransaction(database, store,
|
|
35
|
+
async function executeCheckInTransaction(database, store, params2, mutation2, current) {
|
|
34
36
|
try {
|
|
35
37
|
return await database.transaction(async (transaction) => {
|
|
36
38
|
const userState = current ?? (store.readUserState === void 0 ? (() => {
|
|
37
39
|
throw new Error("A current user state or readUserState implementation is required.");
|
|
38
|
-
})() : await store.readUserState(transaction,
|
|
39
|
-
const next =
|
|
40
|
+
})() : await store.readUserState(transaction, params2));
|
|
41
|
+
const next = mutation2({ userState });
|
|
40
42
|
if (next.error !== void 0) {
|
|
41
43
|
await store.writeAudit(transaction, next.auditLog);
|
|
42
|
-
if (
|
|
43
|
-
await store.writeIdempotency(transaction,
|
|
44
|
+
if (params2.idempotencyKey !== void 0 && next.result !== void 0)
|
|
45
|
+
await store.writeIdempotency(transaction, params2, next.result);
|
|
44
46
|
return { success: false, error: next.error };
|
|
45
47
|
}
|
|
46
|
-
await store.writeUserState(transaction,
|
|
48
|
+
await store.writeUserState(transaction, params2, next.nextUserState);
|
|
47
49
|
await store.writeAudit(transaction, next.auditLog);
|
|
48
|
-
if (
|
|
49
|
-
await store.writeIdempotency(transaction,
|
|
50
|
+
if (params2.idempotencyKey !== void 0 && next.result !== void 0)
|
|
51
|
+
await store.writeIdempotency(transaction, params2, next.result);
|
|
50
52
|
return { success: true };
|
|
51
53
|
});
|
|
52
54
|
} catch (error) {
|
|
@@ -66,6 +68,7 @@ async function executeRedisTransaction(redis, queue) {
|
|
|
66
68
|
|
|
67
69
|
// src/persistence.ts
|
|
68
70
|
var InMemoryServerPersistenceAdapter = class {
|
|
71
|
+
supportsRewardStock = true;
|
|
69
72
|
#locks = /* @__PURE__ */ new Map();
|
|
70
73
|
#idempotent = /* @__PURE__ */ new Map();
|
|
71
74
|
#states = /* @__PURE__ */ new Map();
|
|
@@ -74,6 +77,7 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
74
77
|
#claims = /* @__PURE__ */ new Map();
|
|
75
78
|
#claimRecords = [];
|
|
76
79
|
#auditLogs = [];
|
|
80
|
+
#transactionTails = /* @__PURE__ */ new Map();
|
|
77
81
|
constructor(options = {}) {
|
|
78
82
|
this.#stocks = /* @__PURE__ */ new Map();
|
|
79
83
|
this.#stockDefaults = /* @__PURE__ */ new Map();
|
|
@@ -122,51 +126,60 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
122
126
|
const current = this.#stocks.get(key);
|
|
123
127
|
if (current !== void 0) this.#stocks.set(key, current + Math.max(0, count));
|
|
124
128
|
}
|
|
125
|
-
async executeClaimRewardTransaction(
|
|
129
|
+
async executeClaimRewardTransaction(params2, mutation2) {
|
|
126
130
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
const initialStock = params2.initialStock !== void 0 ? params2.initialStock : params2.stockKey === "__shared__" ? params2.sharedStockLimit : params2.rewardStockLimit;
|
|
132
|
+
const initialSecondaryStock = params2.initialSecondaryStock !== void 0 ? params2.initialSecondaryStock : params2.rewardStockLimit;
|
|
133
|
+
return await this.runTransaction(params2.rallyId, async () => {
|
|
134
|
+
if (params2.idempotencyKey !== void 0) {
|
|
135
|
+
const previous = await this.getIdempotentResult(
|
|
136
|
+
params2.rallyId,
|
|
137
|
+
params2.idempotencyKey
|
|
138
|
+
);
|
|
139
|
+
if (previous !== null) return { success: true };
|
|
140
|
+
}
|
|
141
|
+
const userState = await this.getUserState(params2.rallyId, params2.userId) ?? params2.initialUserState;
|
|
129
142
|
if (userState === void 0)
|
|
130
143
|
return { success: false, error: "A user state is required for this transaction." };
|
|
131
144
|
const storedStock = await this.getRewardStock(
|
|
132
|
-
|
|
133
|
-
|
|
145
|
+
params2.rallyId,
|
|
146
|
+
params2.stockKey ?? params2.rewardId
|
|
134
147
|
);
|
|
135
|
-
const storedSecondaryStock =
|
|
136
|
-
const mutationResult =
|
|
137
|
-
stock: storedStock ??
|
|
138
|
-
secondaryStock: storedSecondaryStock ??
|
|
139
|
-
claimCount: await this.getUserClaimCount(
|
|
148
|
+
const storedSecondaryStock = params2.secondaryStockKey === void 0 ? null : await this.getRewardStock(params2.rallyId, params2.secondaryStockKey);
|
|
149
|
+
const mutationResult = mutation2({
|
|
150
|
+
stock: storedStock ?? initialStock,
|
|
151
|
+
secondaryStock: storedSecondaryStock ?? initialSecondaryStock,
|
|
152
|
+
claimCount: await this.getUserClaimCount(params2.rallyId, params2.userId, params2.rewardId),
|
|
140
153
|
userState
|
|
141
154
|
});
|
|
142
|
-
const idempotencyKey =
|
|
155
|
+
const idempotencyKey = params2.idempotencyKey;
|
|
143
156
|
if (mutationResult.error !== void 0) {
|
|
144
157
|
await this.recordAuditLog(mutationResult.auditLog);
|
|
145
158
|
if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
|
|
146
159
|
await this.saveIdempotentResult(
|
|
147
|
-
|
|
160
|
+
params2.rallyId,
|
|
148
161
|
idempotencyKey,
|
|
149
162
|
mutationResult.result,
|
|
150
|
-
|
|
163
|
+
params2.idempotencyTtlMs ?? 864e5
|
|
151
164
|
);
|
|
152
165
|
return { success: false, error: mutationResult.error };
|
|
153
166
|
}
|
|
154
167
|
const currentStock = await this.getRewardStock(
|
|
155
|
-
|
|
156
|
-
|
|
168
|
+
params2.rallyId,
|
|
169
|
+
params2.stockKey ?? params2.rewardId
|
|
157
170
|
);
|
|
158
|
-
const effectiveStock = currentStock ??
|
|
159
|
-
if (currentStock === null &&
|
|
171
|
+
const effectiveStock = currentStock ?? initialStock;
|
|
172
|
+
if (currentStock === null && initialStock !== void 0 && initialStock !== null)
|
|
160
173
|
this.#stocks.set(
|
|
161
|
-
this.#stockKey(
|
|
162
|
-
|
|
174
|
+
this.#stockKey(params2.rallyId, params2.stockKey ?? params2.rewardId),
|
|
175
|
+
initialStock
|
|
163
176
|
);
|
|
164
|
-
const currentSecondaryStock =
|
|
165
|
-
const effectiveSecondaryStock = currentSecondaryStock ??
|
|
166
|
-
if (currentSecondaryStock === null &&
|
|
177
|
+
const currentSecondaryStock = params2.secondaryStockKey === void 0 ? null : await this.getRewardStock(params2.rallyId, params2.secondaryStockKey);
|
|
178
|
+
const effectiveSecondaryStock = currentSecondaryStock ?? initialSecondaryStock;
|
|
179
|
+
if (currentSecondaryStock === null && params2.secondaryStockKey !== void 0 && initialSecondaryStock !== void 0 && initialSecondaryStock !== null)
|
|
167
180
|
this.#stocks.set(
|
|
168
|
-
this.#stockKey(
|
|
169
|
-
|
|
181
|
+
this.#stockKey(params2.rallyId, params2.secondaryStockKey),
|
|
182
|
+
initialSecondaryStock
|
|
170
183
|
);
|
|
171
184
|
if (effectiveStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
|
|
172
185
|
throw new Error("The transaction produced an invalid stock value.");
|
|
@@ -174,10 +187,10 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
174
187
|
throw new Error("The transaction changed an unlimited stock to a limited stock.");
|
|
175
188
|
if (mutationResult.nextStock !== null)
|
|
176
189
|
this.#stocks.set(
|
|
177
|
-
this.#stockKey(
|
|
190
|
+
this.#stockKey(params2.rallyId, params2.stockKey ?? params2.rewardId),
|
|
178
191
|
mutationResult.nextStock
|
|
179
192
|
);
|
|
180
|
-
if (
|
|
193
|
+
if (params2.secondaryStockKey !== void 0 && mutationResult.nextSecondaryStock !== void 0) {
|
|
181
194
|
if (effectiveSecondaryStock !== null && (mutationResult.nextSecondaryStock === null || mutationResult.nextSecondaryStock < 0))
|
|
182
195
|
throw new Error("The transaction produced an invalid secondary stock value.");
|
|
183
196
|
if (effectiveSecondaryStock === null && mutationResult.nextSecondaryStock !== null)
|
|
@@ -186,29 +199,29 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
186
199
|
);
|
|
187
200
|
if (mutationResult.nextSecondaryStock !== null)
|
|
188
201
|
this.#stocks.set(
|
|
189
|
-
this.#stockKey(
|
|
202
|
+
this.#stockKey(params2.rallyId, params2.secondaryStockKey),
|
|
190
203
|
mutationResult.nextSecondaryStock
|
|
191
204
|
);
|
|
192
205
|
}
|
|
193
|
-
await this.saveUserState(
|
|
206
|
+
await this.saveUserState(params2.rallyId, params2.userId, mutationResult.nextUserState);
|
|
194
207
|
const reward = mutationResult.nextUserState.rewards.find(
|
|
195
|
-
(item) => item.rewardId ===
|
|
208
|
+
(item) => item.rewardId === params2.rewardId
|
|
196
209
|
);
|
|
197
210
|
if (reward?.claimTicketNumber !== void 0)
|
|
198
211
|
await this.recordUserClaim({
|
|
199
|
-
rallyId:
|
|
200
|
-
userId:
|
|
201
|
-
rewardId:
|
|
212
|
+
rallyId: params2.rallyId,
|
|
213
|
+
userId: params2.userId,
|
|
214
|
+
rewardId: params2.rewardId,
|
|
202
215
|
ticketNumber: reward.claimTicketNumber,
|
|
203
|
-
timestamp:
|
|
216
|
+
timestamp: params2.timestamp
|
|
204
217
|
});
|
|
205
218
|
await this.recordAuditLog(mutationResult.auditLog);
|
|
206
219
|
if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
|
|
207
220
|
await this.saveIdempotentResult(
|
|
208
|
-
|
|
221
|
+
params2.rallyId,
|
|
209
222
|
idempotencyKey,
|
|
210
223
|
mutationResult.result,
|
|
211
|
-
|
|
224
|
+
params2.idempotencyTtlMs ?? 864e5
|
|
212
225
|
);
|
|
213
226
|
return { success: true };
|
|
214
227
|
});
|
|
@@ -216,32 +229,32 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
216
229
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
217
230
|
}
|
|
218
231
|
}
|
|
219
|
-
async executeCheckInTransaction(
|
|
232
|
+
async executeCheckInTransaction(params2, mutation2) {
|
|
220
233
|
try {
|
|
221
|
-
return await this.runTransaction(
|
|
222
|
-
const userState = await this.getUserState(
|
|
234
|
+
return await this.runTransaction(params2.rallyId, async () => {
|
|
235
|
+
const userState = await this.getUserState(params2.rallyId, params2.userId) ?? params2.initialUserState;
|
|
223
236
|
if (userState === void 0)
|
|
224
237
|
return { success: false, error: "A user state is required for this transaction." };
|
|
225
|
-
const mutationResult =
|
|
238
|
+
const mutationResult = mutation2({ userState });
|
|
226
239
|
if (mutationResult.error !== void 0) {
|
|
227
240
|
await this.recordAuditLog(mutationResult.auditLog);
|
|
228
|
-
if (
|
|
241
|
+
if (params2.idempotencyKey !== void 0 && mutationResult.result !== void 0)
|
|
229
242
|
await this.saveIdempotentResult(
|
|
230
|
-
|
|
231
|
-
|
|
243
|
+
params2.rallyId,
|
|
244
|
+
params2.idempotencyKey,
|
|
232
245
|
mutationResult.result,
|
|
233
|
-
|
|
246
|
+
params2.idempotencyTtlMs ?? 864e5
|
|
234
247
|
);
|
|
235
248
|
return { success: false, error: mutationResult.error };
|
|
236
249
|
}
|
|
237
|
-
await this.saveUserState(
|
|
250
|
+
await this.saveUserState(params2.rallyId, params2.userId, mutationResult.nextUserState);
|
|
238
251
|
await this.recordAuditLog(mutationResult.auditLog);
|
|
239
|
-
if (
|
|
252
|
+
if (params2.idempotencyKey !== void 0 && mutationResult.result !== void 0)
|
|
240
253
|
await this.saveIdempotentResult(
|
|
241
|
-
|
|
242
|
-
|
|
254
|
+
params2.rallyId,
|
|
255
|
+
params2.idempotencyKey,
|
|
243
256
|
mutationResult.result,
|
|
244
|
-
|
|
257
|
+
params2.idempotencyTtlMs ?? 864e5
|
|
245
258
|
);
|
|
246
259
|
return { success: true };
|
|
247
260
|
});
|
|
@@ -275,8 +288,8 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
275
288
|
const value = this.#states.get(`${rallyId}:${userId}`);
|
|
276
289
|
return value === void 0 ? null : structuredClone(value);
|
|
277
290
|
}
|
|
278
|
-
async saveUserState(rallyId, userId,
|
|
279
|
-
this.#states.set(`${rallyId}:${userId}`, structuredClone(
|
|
291
|
+
async saveUserState(rallyId, userId, state2) {
|
|
292
|
+
this.#states.set(`${rallyId}:${userId}`, structuredClone(state2));
|
|
280
293
|
}
|
|
281
294
|
async recordAuditLog(log) {
|
|
282
295
|
this.#auditLogs.push(structuredClone(log));
|
|
@@ -288,11 +301,11 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
288
301
|
getAuditLogs() {
|
|
289
302
|
return structuredClone(this.#auditLogs);
|
|
290
303
|
}
|
|
291
|
-
async recordUserClaim(
|
|
292
|
-
const { rallyId, userId, rewardId } =
|
|
304
|
+
async recordUserClaim(params2) {
|
|
305
|
+
const { rallyId, userId, rewardId } = params2;
|
|
293
306
|
const key = `${rallyId}:${userId}:${rewardId}`;
|
|
294
307
|
this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
|
|
295
|
-
this.#claimRecords.push(structuredClone(
|
|
308
|
+
this.#claimRecords.push(structuredClone(params2));
|
|
296
309
|
}
|
|
297
310
|
async rollbackUserClaim(rallyId, userId, rewardId, ticketNumber) {
|
|
298
311
|
const key = `${rallyId}:${userId}:${rewardId}`;
|
|
@@ -312,44 +325,64 @@ var InMemoryServerPersistenceAdapter = class {
|
|
|
312
325
|
return structuredClone(this.#claimRecords);
|
|
313
326
|
}
|
|
314
327
|
recordClaim(paramsOrRallyId, userId, rewardId) {
|
|
315
|
-
const
|
|
328
|
+
const params2 = typeof paramsOrRallyId === "string" ? {
|
|
316
329
|
rallyId: paramsOrRallyId,
|
|
317
330
|
userId: userId ?? "",
|
|
318
331
|
rewardId: rewardId ?? "",
|
|
319
332
|
ticketNumber: "",
|
|
320
333
|
timestamp: Date.now()
|
|
321
334
|
} : paramsOrRallyId;
|
|
322
|
-
return this.recordUserClaim(
|
|
335
|
+
return this.recordUserClaim(params2);
|
|
323
336
|
}
|
|
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
|
-
|
|
337
|
+
async runTransaction(rallyId, operation) {
|
|
338
|
+
const previous = this.#transactionTails.get(rallyId) ?? Promise.resolve();
|
|
339
|
+
const current = previous.then(async () => {
|
|
340
|
+
const snapshot = {
|
|
341
|
+
stocks: new Map(this.#stocks),
|
|
342
|
+
idempotent: new Map(this.#idempotent),
|
|
343
|
+
states: new Map(this.#states),
|
|
344
|
+
claims: new Map(this.#claims),
|
|
345
|
+
claimRecords: structuredClone(this.#claimRecords),
|
|
346
|
+
auditLogs: structuredClone(this.#auditLogs)
|
|
347
|
+
};
|
|
348
|
+
try {
|
|
349
|
+
return await operation(this);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
this.#stocks.clear();
|
|
352
|
+
for (const [key, value] of snapshot.stocks) this.#stocks.set(key, value);
|
|
353
|
+
this.#idempotent.clear();
|
|
354
|
+
for (const [key, value] of snapshot.idempotent)
|
|
355
|
+
this.#idempotent.set(key, structuredClone(value));
|
|
356
|
+
this.#states.clear();
|
|
357
|
+
for (const [key, value] of snapshot.states) this.#states.set(key, structuredClone(value));
|
|
358
|
+
this.#claims.clear();
|
|
359
|
+
for (const [key, value] of snapshot.claims) this.#claims.set(key, value);
|
|
360
|
+
this.#claimRecords.splice(0, this.#claimRecords.length, ...snapshot.claimRecords);
|
|
361
|
+
this.#auditLogs.splice(0, this.#auditLogs.length, ...snapshot.auditLogs);
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
this.#transactionTails.set(
|
|
366
|
+
rallyId,
|
|
367
|
+
current.then(
|
|
368
|
+
() => void 0,
|
|
369
|
+
() => void 0
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
return current;
|
|
349
373
|
}
|
|
350
374
|
};
|
|
351
375
|
|
|
352
376
|
// src/security.ts
|
|
377
|
+
var RequestValidationException = class extends Error {
|
|
378
|
+
code = "VALIDATION_FAILED";
|
|
379
|
+
errors;
|
|
380
|
+
constructor(errors2) {
|
|
381
|
+
super("Request validation failed.");
|
|
382
|
+
this.name = "RequestValidationException";
|
|
383
|
+
this.errors = errors2;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
353
386
|
function errors(...items) {
|
|
354
387
|
return { success: false, errors: items };
|
|
355
388
|
}
|
|
@@ -463,7 +496,7 @@ function validateClaimRewardRequest(value) {
|
|
|
463
496
|
});
|
|
464
497
|
if (value.staffId !== void 0 && !nonEmpty(value.staffId))
|
|
465
498
|
return errors({ path: "staffId", message: "staffId must be non-empty.", code: "INVALID_TYPE" });
|
|
466
|
-
if (value.now !== void 0 && !
|
|
499
|
+
if (value.now !== void 0 && !dateInput(value.now))
|
|
467
500
|
return errors({
|
|
468
501
|
path: "now",
|
|
469
502
|
message: "now must be an ISO 8601 date or positive timestamp.",
|
|
@@ -471,6 +504,76 @@ function validateClaimRewardRequest(value) {
|
|
|
471
504
|
});
|
|
472
505
|
return { success: true, data: value };
|
|
473
506
|
}
|
|
507
|
+
function uuid(value) {
|
|
508
|
+
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);
|
|
509
|
+
}
|
|
510
|
+
function identityErrors(value) {
|
|
511
|
+
const result = [];
|
|
512
|
+
if (value.userId === void 0 && value.anonymousSessionId === void 0)
|
|
513
|
+
result.push({
|
|
514
|
+
path: "userId",
|
|
515
|
+
message: "An authenticated userId or anonymousSessionId is required.",
|
|
516
|
+
code: "REQUIRED"
|
|
517
|
+
});
|
|
518
|
+
if (value.userId !== void 0 && !nonEmpty(value.userId))
|
|
519
|
+
result.push({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
|
|
520
|
+
if (value.anonymousSessionId !== void 0 && !uuid(value.anonymousSessionId))
|
|
521
|
+
result.push({
|
|
522
|
+
path: "anonymousSessionId",
|
|
523
|
+
message: "anonymousSessionId must be a UUID v4.",
|
|
524
|
+
code: "INVALID_FORMAT"
|
|
525
|
+
});
|
|
526
|
+
if (value.userId !== void 0 && value.anonymousSessionId !== void 0 && value.userId !== value.anonymousSessionId)
|
|
527
|
+
result.push({
|
|
528
|
+
path: "anonymousSessionId",
|
|
529
|
+
message: "userId and anonymousSessionId must identify the same session.",
|
|
530
|
+
code: "IDENTITY_MISMATCH"
|
|
531
|
+
});
|
|
532
|
+
return result;
|
|
533
|
+
}
|
|
534
|
+
function directErrors(value, config, kind) {
|
|
535
|
+
const validated = kind === "check-in" ? validateCheckInRequest(value) : validateClaimRewardRequest(value);
|
|
536
|
+
if (!validated.success) return validated.errors;
|
|
537
|
+
const errors2 = [];
|
|
538
|
+
if (validated.data.rallyId !== config.id)
|
|
539
|
+
errors2.push({
|
|
540
|
+
path: "rallyId",
|
|
541
|
+
message: "The rally does not match this server.",
|
|
542
|
+
code: "INVALID_VALUE"
|
|
543
|
+
});
|
|
544
|
+
const resourceId = kind === "check-in" ? validated.data.spotId : validated.data.rewardId;
|
|
545
|
+
const exists = kind === "check-in" ? config.spots.some((spot) => spot.id === resourceId) : config.rewards.some((reward) => reward.id === resourceId);
|
|
546
|
+
if (!exists)
|
|
547
|
+
errors2.push({
|
|
548
|
+
path: kind === "check-in" ? "spotId" : "rewardId",
|
|
549
|
+
message: `${kind === "check-in" ? "Spot" : "Reward"} was not found.`,
|
|
550
|
+
code: kind === "check-in" ? "SPOT_NOT_FOUND" : "REWARD_NOT_FOUND"
|
|
551
|
+
});
|
|
552
|
+
errors2.push(...identityErrors(validated.data));
|
|
553
|
+
return errors2;
|
|
554
|
+
}
|
|
555
|
+
function assertValidCheckInParams(value, config) {
|
|
556
|
+
const errors2 = directErrors(value, config, "check-in");
|
|
557
|
+
if (errors2.length > 0) throw new RequestValidationException(errors2);
|
|
558
|
+
}
|
|
559
|
+
function assertValidClaimParams(value, config) {
|
|
560
|
+
const errors2 = directErrors(value, config, "claim");
|
|
561
|
+
if (errors2.length > 0) throw new RequestValidationException(errors2);
|
|
562
|
+
}
|
|
563
|
+
function assertValidSyncParams(value, config) {
|
|
564
|
+
const validated = validateSyncRequest(value);
|
|
565
|
+
const errors2 = validated.success ? [
|
|
566
|
+
...validated.data.rallyId !== config.id ? [
|
|
567
|
+
{
|
|
568
|
+
path: "rallyId",
|
|
569
|
+
message: "The rally does not match this server.",
|
|
570
|
+
code: "INVALID_VALUE"
|
|
571
|
+
}
|
|
572
|
+
] : []
|
|
573
|
+
] : [...validated.errors];
|
|
574
|
+
if (validated.success) errors2.push(...identityErrors(validated.data));
|
|
575
|
+
if (errors2.length > 0) throw new RequestValidationException(errors2);
|
|
576
|
+
}
|
|
474
577
|
function validateSyncRequest(value) {
|
|
475
578
|
const required = requiredErrors(value, ["rallyId"]);
|
|
476
579
|
if (required.length > 0) return errors(...required);
|
|
@@ -478,7 +581,10 @@ function validateSyncRequest(value) {
|
|
|
478
581
|
return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
|
|
479
582
|
if (value.userId !== void 0 && !nonEmpty(value.userId))
|
|
480
583
|
return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
|
|
481
|
-
return {
|
|
584
|
+
return {
|
|
585
|
+
success: true,
|
|
586
|
+
data: value
|
|
587
|
+
};
|
|
482
588
|
}
|
|
483
589
|
function json(body, status = 200) {
|
|
484
590
|
return new Response(JSON.stringify(body), {
|
|
@@ -574,6 +680,18 @@ function operationStatus(result) {
|
|
|
574
680
|
if (result.ok) return "ACCEPTED";
|
|
575
681
|
return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
|
|
576
682
|
}
|
|
683
|
+
function withDirectIdentity(request) {
|
|
684
|
+
if (request.userId !== void 0) return { ...request, userId: request.userId };
|
|
685
|
+
if (request.anonymousSessionId !== void 0 && isUuidV4(request.anonymousSessionId))
|
|
686
|
+
return { ...request, userId: request.anonymousSessionId };
|
|
687
|
+
throw new RequestValidationException([
|
|
688
|
+
{
|
|
689
|
+
path: "userId",
|
|
690
|
+
message: "An authenticated userId or anonymousSessionId is required.",
|
|
691
|
+
code: "REQUIRED"
|
|
692
|
+
}
|
|
693
|
+
]);
|
|
694
|
+
}
|
|
577
695
|
var StampRallyServer = class {
|
|
578
696
|
#config;
|
|
579
697
|
#persistence;
|
|
@@ -609,7 +727,18 @@ var StampRallyServer = class {
|
|
|
609
727
|
{ ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
|
|
610
728
|
401
|
|
611
729
|
);
|
|
612
|
-
const
|
|
730
|
+
const sessionId = request.headers.get("x-anonymous-session-id");
|
|
731
|
+
let result;
|
|
732
|
+
try {
|
|
733
|
+
result = await this.checkIn({
|
|
734
|
+
...body.data,
|
|
735
|
+
userId,
|
|
736
|
+
...sessionId === null ? {} : { anonymousSessionId: sessionId }
|
|
737
|
+
});
|
|
738
|
+
} catch (error) {
|
|
739
|
+
if (error instanceof RequestValidationException) return validationResponse(error.errors);
|
|
740
|
+
throw error;
|
|
741
|
+
}
|
|
613
742
|
return json(
|
|
614
743
|
{ ...result, status: operationStatus(result) },
|
|
615
744
|
result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422
|
|
@@ -632,7 +761,18 @@ var StampRallyServer = class {
|
|
|
632
761
|
{ ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
|
|
633
762
|
401
|
|
634
763
|
);
|
|
635
|
-
const
|
|
764
|
+
const sessionId = request.headers.get("x-anonymous-session-id");
|
|
765
|
+
let result;
|
|
766
|
+
try {
|
|
767
|
+
result = await this.claimReward({
|
|
768
|
+
...body.data,
|
|
769
|
+
userId,
|
|
770
|
+
...sessionId === null ? {} : { anonymousSessionId: sessionId }
|
|
771
|
+
});
|
|
772
|
+
} catch (error) {
|
|
773
|
+
if (error instanceof RequestValidationException) return validationResponse(error.errors);
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
636
776
|
return json(
|
|
637
777
|
{ ...result, status: operationStatus(result) },
|
|
638
778
|
result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422
|
|
@@ -655,16 +795,32 @@ var StampRallyServer = class {
|
|
|
655
795
|
{ ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
|
|
656
796
|
401
|
|
657
797
|
);
|
|
658
|
-
|
|
798
|
+
const sessionId = request.headers.get("x-anonymous-session-id");
|
|
799
|
+
try {
|
|
800
|
+
return json({
|
|
801
|
+
ok: true,
|
|
802
|
+
state: await this.syncProgress({
|
|
803
|
+
rallyId: body.data.rallyId,
|
|
804
|
+
userId,
|
|
805
|
+
...sessionId === null ? {} : { anonymousSessionId: sessionId }
|
|
806
|
+
})
|
|
807
|
+
});
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (error instanceof RequestValidationException) return validationResponse(error.errors);
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
659
812
|
}
|
|
660
813
|
async checkIn(request) {
|
|
661
|
-
const
|
|
814
|
+
const directRequest = withDirectIdentity(request);
|
|
815
|
+
assertValidCheckInParams(directRequest, this.#config);
|
|
816
|
+
const { userId } = directRequest;
|
|
817
|
+
const key = `check-in:${request.rallyId}:${userId}:${request.idempotencyKey}`;
|
|
662
818
|
const previous = await this.#persistence.getIdempotentResult(
|
|
663
819
|
request.rallyId,
|
|
664
820
|
key
|
|
665
821
|
);
|
|
666
822
|
if (previous !== null) return previous;
|
|
667
|
-
const lockKey = `state:${request.rallyId}:${
|
|
823
|
+
const lockKey = `state:${request.rallyId}:${userId}`;
|
|
668
824
|
if (!await this.#persistence.acquireLock(
|
|
669
825
|
request.rallyId,
|
|
670
826
|
lockKey,
|
|
@@ -673,11 +829,11 @@ var StampRallyServer = class {
|
|
|
673
829
|
return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
|
|
674
830
|
const timestamp = now(this.#options);
|
|
675
831
|
try {
|
|
676
|
-
const current = await this.#persistence.getUserState(request.rallyId,
|
|
832
|
+
const current = await this.#persistence.getUserState(request.rallyId, userId) ?? initialState(this.#config, userId, timestamp);
|
|
677
833
|
const responseHolder = { value: null };
|
|
678
834
|
const makeAudit = (status, code) => audit(
|
|
679
835
|
request.rallyId,
|
|
680
|
-
|
|
836
|
+
userId,
|
|
681
837
|
"CHECK_IN",
|
|
682
838
|
request.spotId,
|
|
683
839
|
request.idempotencyKey,
|
|
@@ -693,7 +849,7 @@ var StampRallyServer = class {
|
|
|
693
849
|
message: "Spot was not found."
|
|
694
850
|
};
|
|
695
851
|
return await this.#rememberCheckInTransaction(
|
|
696
|
-
|
|
852
|
+
directRequest,
|
|
697
853
|
timestamp,
|
|
698
854
|
key,
|
|
699
855
|
current,
|
|
@@ -717,7 +873,7 @@ var StampRallyServer = class {
|
|
|
717
873
|
};
|
|
718
874
|
if (current.records.some((record2) => record2.stampId === request.spotId))
|
|
719
875
|
return await this.#rememberCheckInTransaction(
|
|
720
|
-
|
|
876
|
+
directRequest,
|
|
721
877
|
timestamp,
|
|
722
878
|
key,
|
|
723
879
|
current,
|
|
@@ -727,7 +883,7 @@ var StampRallyServer = class {
|
|
|
727
883
|
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
728
884
|
if (spot.prerequisites?.some((id) => !acquired.has(id)))
|
|
729
885
|
return await this.#rememberCheckInTransaction(
|
|
730
|
-
|
|
886
|
+
directRequest,
|
|
731
887
|
timestamp,
|
|
732
888
|
key,
|
|
733
889
|
current,
|
|
@@ -742,7 +898,7 @@ var StampRallyServer = class {
|
|
|
742
898
|
{ rallyId: request.rallyId, spotId: request.spotId, state: current }
|
|
743
899
|
))
|
|
744
900
|
return await this.#rememberCheckInTransaction(
|
|
745
|
-
|
|
901
|
+
directRequest,
|
|
746
902
|
timestamp,
|
|
747
903
|
key,
|
|
748
904
|
current,
|
|
@@ -764,7 +920,7 @@ var StampRallyServer = class {
|
|
|
764
920
|
const transaction = await this.#persistence.executeCheckInTransaction(
|
|
765
921
|
{
|
|
766
922
|
rallyId: request.rallyId,
|
|
767
|
-
userId
|
|
923
|
+
userId,
|
|
768
924
|
spotId: request.spotId,
|
|
769
925
|
timestamp: timestampMillis(timestamp),
|
|
770
926
|
idempotencyKey: key,
|
|
@@ -789,7 +945,10 @@ var StampRallyServer = class {
|
|
|
789
945
|
}
|
|
790
946
|
}
|
|
791
947
|
async claimReward(request) {
|
|
792
|
-
const
|
|
948
|
+
const directRequest = withDirectIdentity(request);
|
|
949
|
+
assertValidClaimParams(directRequest, this.#config);
|
|
950
|
+
const { userId } = directRequest;
|
|
951
|
+
const key = `claim:${request.rallyId}:${userId}:${request.rewardId}:${request.idempotencyKey}`;
|
|
793
952
|
const previous = await this.#persistence.getIdempotentResult(
|
|
794
953
|
request.rallyId,
|
|
795
954
|
key
|
|
@@ -800,7 +959,19 @@ var StampRallyServer = class {
|
|
|
800
959
|
return this.#rememberClaim(
|
|
801
960
|
key,
|
|
802
961
|
{ ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
|
|
803
|
-
|
|
962
|
+
directRequest,
|
|
963
|
+
now(this.#options)
|
|
964
|
+
);
|
|
965
|
+
const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
|
|
966
|
+
if (rewardStock(this.#config, reward.id, reward.stockLimit) !== null && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function"))
|
|
967
|
+
return this.#rememberClaim(
|
|
968
|
+
key,
|
|
969
|
+
{
|
|
970
|
+
ok: false,
|
|
971
|
+
code: "INVENTORY_NOT_SUPPORTED",
|
|
972
|
+
message: "This persistence adapter cannot store per-reward inventory."
|
|
973
|
+
},
|
|
974
|
+
directRequest,
|
|
804
975
|
now(this.#options)
|
|
805
976
|
);
|
|
806
977
|
const lockKey = this.#config.inventoryMode === "shared" ? "inventory:shared" : `reward:${request.rallyId}:${reward.id}`;
|
|
@@ -811,7 +982,6 @@ var StampRallyServer = class {
|
|
|
811
982
|
))
|
|
812
983
|
return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
|
|
813
984
|
const timestamp = now(this.#options);
|
|
814
|
-
const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
|
|
815
985
|
try {
|
|
816
986
|
const checked = await this.#persistence.getIdempotentResult(
|
|
817
987
|
request.rallyId,
|
|
@@ -822,10 +992,12 @@ var StampRallyServer = class {
|
|
|
822
992
|
const result = await this.#persistence.executeClaimRewardTransaction(
|
|
823
993
|
{
|
|
824
994
|
rallyId: request.rallyId,
|
|
825
|
-
userId
|
|
995
|
+
userId,
|
|
826
996
|
rewardId: reward.id,
|
|
827
997
|
stockKey: plan.primaryKey,
|
|
828
998
|
...plan.secondaryKey === void 0 ? {} : { secondaryStockKey: plan.secondaryKey },
|
|
999
|
+
rewardStockLimit: rewardStock(this.#config, reward.id, reward.stockLimit),
|
|
1000
|
+
sharedStockLimit: this.#config.inventoryMode === "shared" ? sharedStock(this.#config) : null,
|
|
829
1001
|
initialStock: plan.primaryInitial,
|
|
830
1002
|
...plan.secondaryInitial === void 0 ? {} : { initialSecondaryStock: plan.secondaryInitial },
|
|
831
1003
|
ticketNumber: request.idempotencyKey,
|
|
@@ -833,7 +1005,7 @@ var StampRallyServer = class {
|
|
|
833
1005
|
idempotencyKey: key,
|
|
834
1006
|
...request.staffPasscode === void 0 ? {} : { proofData: request.staffPasscode },
|
|
835
1007
|
...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
|
|
836
|
-
initialUserState: initialState(this.#config,
|
|
1008
|
+
initialUserState: initialState(this.#config, userId, timestamp)
|
|
837
1009
|
},
|
|
838
1010
|
({ stock, secondaryStock, claimCount, userState }) => {
|
|
839
1011
|
const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
|
|
@@ -843,7 +1015,7 @@ var StampRallyServer = class {
|
|
|
843
1015
|
const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
|
|
844
1016
|
const makeAudit = (status, code) => audit(
|
|
845
1017
|
request.rallyId,
|
|
846
|
-
|
|
1018
|
+
userId,
|
|
847
1019
|
"CLAIM_REWARD",
|
|
848
1020
|
reward.id,
|
|
849
1021
|
request.idempotencyKey,
|
|
@@ -928,6 +1100,12 @@ var StampRallyServer = class {
|
|
|
928
1100
|
const response = responseHolder.value;
|
|
929
1101
|
if (!result.success) {
|
|
930
1102
|
if (response !== null && !response.ok && response.code === result.error) return response;
|
|
1103
|
+
if (result.error === "INVENTORY_NOT_SUPPORTED")
|
|
1104
|
+
return {
|
|
1105
|
+
ok: false,
|
|
1106
|
+
code: "INVENTORY_NOT_SUPPORTED",
|
|
1107
|
+
message: "This persistence adapter cannot store per-reward inventory."
|
|
1108
|
+
};
|
|
931
1109
|
return {
|
|
932
1110
|
ok: false,
|
|
933
1111
|
code: "PERSISTENCE_FAILED",
|
|
@@ -951,28 +1129,34 @@ var StampRallyServer = class {
|
|
|
951
1129
|
}
|
|
952
1130
|
}
|
|
953
1131
|
async sync(rallyId, userId) {
|
|
954
|
-
|
|
955
|
-
|
|
1132
|
+
assertValidSyncParams({ rallyId, userId }, this.#config);
|
|
1133
|
+
const state2 = await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
|
|
1134
|
+
return this.#attachInventory(state2);
|
|
1135
|
+
}
|
|
1136
|
+
async syncProgress(request) {
|
|
1137
|
+
const directRequest = withDirectIdentity(request);
|
|
1138
|
+
assertValidSyncParams(directRequest, this.#config);
|
|
1139
|
+
return this.sync(directRequest.rallyId, directRequest.userId);
|
|
956
1140
|
}
|
|
957
|
-
async #attachInventory(
|
|
1141
|
+
async #attachInventory(state2) {
|
|
958
1142
|
const rewardRemaining = {};
|
|
959
1143
|
for (const reward of this.#config.rewards) {
|
|
960
1144
|
const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
|
|
961
1145
|
if (plan.secondaryKey !== void 0) {
|
|
962
|
-
const stock = await this.#persistence.getRewardStock(
|
|
1146
|
+
const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.secondaryKey);
|
|
963
1147
|
const remaining = stock ?? plan.secondaryInitial ?? null;
|
|
964
1148
|
if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
|
|
965
1149
|
} else if (plan.primaryKey !== "__shared__") {
|
|
966
|
-
const stock = await this.#persistence.getRewardStock(
|
|
1150
|
+
const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.primaryKey);
|
|
967
1151
|
const remaining = stock ?? plan.primaryInitial;
|
|
968
1152
|
if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
|
|
969
1153
|
}
|
|
970
1154
|
}
|
|
971
1155
|
const shared = sharedStock(this.#config);
|
|
972
|
-
const storedShared = await this.#persistence.getRewardStock(
|
|
1156
|
+
const storedShared = await this.#persistence.getRewardStock(state2.rallyId, "__shared__");
|
|
973
1157
|
const sharedRemaining = this.#config.inventoryMode === "shared" && shared !== null ? Math.max(0, storedShared ?? shared) : void 0;
|
|
974
1158
|
return {
|
|
975
|
-
...
|
|
1159
|
+
...state2,
|
|
976
1160
|
...Object.keys(rewardRemaining).length === 0 && sharedRemaining === void 0 ? {} : {
|
|
977
1161
|
inventory: {
|
|
978
1162
|
...sharedRemaining === void 0 ? {} : { sharedRemaining },
|
|
@@ -997,13 +1181,14 @@ var StampRallyServer = class {
|
|
|
997
1181
|
const authenticatedUserId = identity.authenticatedUserId;
|
|
998
1182
|
return authenticatedUserId.length > 0 ? authenticatedUserId : null;
|
|
999
1183
|
}
|
|
1000
|
-
|
|
1184
|
+
const policy = this.#options.anonymousPolicy ?? "session_scoped";
|
|
1185
|
+
if (policy === "reject") return null;
|
|
1001
1186
|
const sessionId = request.headers.get("X-Anonymous-Session-Id");
|
|
1002
|
-
if (sessionId !== null
|
|
1003
|
-
if (
|
|
1187
|
+
if (sessionId !== null) return isUuidV4(sessionId) ? sessionId : null;
|
|
1188
|
+
if (policy === "session_scoped") return null;
|
|
1004
1189
|
return "anonymous";
|
|
1005
1190
|
}
|
|
1006
|
-
async #rememberCheckInTransaction(request, timestamp, key, current,
|
|
1191
|
+
async #rememberCheckInTransaction(request, timestamp, key, current, mutation2, responseHolder) {
|
|
1007
1192
|
const transaction = await this.#persistence.executeCheckInTransaction(
|
|
1008
1193
|
{
|
|
1009
1194
|
rallyId: request.rallyId,
|
|
@@ -1014,9 +1199,9 @@ var StampRallyServer = class {
|
|
|
1014
1199
|
...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
|
|
1015
1200
|
initialUserState: current
|
|
1016
1201
|
},
|
|
1017
|
-
() =>
|
|
1202
|
+
() => mutation2
|
|
1018
1203
|
);
|
|
1019
|
-
if (responseHolder.value !== null && (transaction.success || transaction.error ===
|
|
1204
|
+
if (responseHolder.value !== null && (transaction.success || transaction.error === mutation2.error))
|
|
1020
1205
|
return responseHolder.value;
|
|
1021
1206
|
return {
|
|
1022
1207
|
ok: false,
|
|
@@ -1047,11 +1232,127 @@ var StampRallyServer = class {
|
|
|
1047
1232
|
}
|
|
1048
1233
|
};
|
|
1049
1234
|
|
|
1235
|
+
// src/testing/compliance.ts
|
|
1236
|
+
var state = (userId) => ({
|
|
1237
|
+
rallyId: "compliance-rally",
|
|
1238
|
+
userId,
|
|
1239
|
+
records: [],
|
|
1240
|
+
rewards: [{ rewardId: "reward", status: "AVAILABLE" }],
|
|
1241
|
+
updatedAt: "2026-01-01T00:00:00.000Z"
|
|
1242
|
+
});
|
|
1243
|
+
function audit2(idempotencyKey, userId) {
|
|
1244
|
+
return {
|
|
1245
|
+
id: `audit-${idempotencyKey}`,
|
|
1246
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
1247
|
+
rallyId: "compliance-rally",
|
|
1248
|
+
userId,
|
|
1249
|
+
action: "CLAIM_REWARD",
|
|
1250
|
+
resourceId: "reward",
|
|
1251
|
+
status: "SUCCESS",
|
|
1252
|
+
idempotencyKey
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
function params(userId, idempotencyKey) {
|
|
1256
|
+
return {
|
|
1257
|
+
rallyId: "compliance-rally",
|
|
1258
|
+
userId,
|
|
1259
|
+
rewardId: "reward",
|
|
1260
|
+
ticketNumber: `ticket-${idempotencyKey}`,
|
|
1261
|
+
timestamp: Date.parse("2026-01-01T00:00:00.000Z"),
|
|
1262
|
+
idempotencyKey,
|
|
1263
|
+
rewardStockLimit: 1,
|
|
1264
|
+
sharedStockLimit: 1,
|
|
1265
|
+
stockKey: "__shared__",
|
|
1266
|
+
secondaryStockKey: "reward",
|
|
1267
|
+
initialStock: 1,
|
|
1268
|
+
initialSecondaryStock: 1,
|
|
1269
|
+
initialUserState: state(userId)
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
function mutation(current) {
|
|
1273
|
+
if (current.stock === 0 || current.secondaryStock === 0)
|
|
1274
|
+
return {
|
|
1275
|
+
nextStock: current.stock,
|
|
1276
|
+
nextSecondaryStock: current.secondaryStock,
|
|
1277
|
+
nextUserState: current.userState,
|
|
1278
|
+
auditLog: audit2("rejected", current.userState.userId ?? "unknown"),
|
|
1279
|
+
error: "OUT_OF_STOCK"
|
|
1280
|
+
};
|
|
1281
|
+
return {
|
|
1282
|
+
nextStock: current.stock === null ? null : current.stock - 1,
|
|
1283
|
+
nextSecondaryStock: current.secondaryStock === null ? null : current.secondaryStock - 1,
|
|
1284
|
+
nextUserState: {
|
|
1285
|
+
...current.userState,
|
|
1286
|
+
rewards: [{ rewardId: "reward", status: "CONSUMED" }],
|
|
1287
|
+
updatedAt: "2026-01-01T00:00:00.000Z"
|
|
1288
|
+
},
|
|
1289
|
+
auditLog: audit2("success", current.userState.userId ?? "unknown"),
|
|
1290
|
+
result: { ok: true }
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
function assert(condition, message) {
|
|
1294
|
+
if (!condition) throw new Error(`Persistence adapter compliance failed: ${message}`);
|
|
1295
|
+
}
|
|
1296
|
+
async function runPersistenceAdapterComplianceTests(createAdapter) {
|
|
1297
|
+
const adapter = await createAdapter();
|
|
1298
|
+
assert(
|
|
1299
|
+
adapter.supportsRewardStock !== false,
|
|
1300
|
+
"the adapter must explicitly support reward stock for this suite"
|
|
1301
|
+
);
|
|
1302
|
+
const [first, second] = await Promise.all([
|
|
1303
|
+
adapter.executeClaimRewardTransaction(params("alice", "race-a"), mutation),
|
|
1304
|
+
adapter.executeClaimRewardTransaction(params("bob", "race-b"), mutation)
|
|
1305
|
+
]);
|
|
1306
|
+
assert([first.success, second.success].filter(Boolean).length === 1, "race was not serialized");
|
|
1307
|
+
assert(
|
|
1308
|
+
await adapter.getRewardStock("compliance-rally", "__shared__") === 0,
|
|
1309
|
+
"shared stock was not decremented atomically"
|
|
1310
|
+
);
|
|
1311
|
+
assert(
|
|
1312
|
+
await adapter.getRewardStock("compliance-rally", "reward") === 0,
|
|
1313
|
+
"per-reward stock was not decremented atomically"
|
|
1314
|
+
);
|
|
1315
|
+
const idempotentAdapter = await createAdapter();
|
|
1316
|
+
const idempotentParams = params("alice", "same-key");
|
|
1317
|
+
const firstClaim = await idempotentAdapter.executeClaimRewardTransaction(
|
|
1318
|
+
idempotentParams,
|
|
1319
|
+
mutation
|
|
1320
|
+
);
|
|
1321
|
+
const secondClaim = await idempotentAdapter.executeClaimRewardTransaction(
|
|
1322
|
+
idempotentParams,
|
|
1323
|
+
mutation
|
|
1324
|
+
);
|
|
1325
|
+
assert(firstClaim.success && secondClaim.success, "idempotent claim did not remain successful");
|
|
1326
|
+
assert(
|
|
1327
|
+
await idempotentAdapter.getRewardStock("compliance-rally", "__shared__") === 0,
|
|
1328
|
+
"idempotent retry decremented shared stock twice"
|
|
1329
|
+
);
|
|
1330
|
+
const rollbackAdapter = await createAdapter();
|
|
1331
|
+
const rollbackParams = params("alice", "rollback");
|
|
1332
|
+
const rollback = await rollbackAdapter.executeClaimRewardTransaction(rollbackParams, () => {
|
|
1333
|
+
throw new Error("forced rollback");
|
|
1334
|
+
});
|
|
1335
|
+
assert(!rollback.success, "a failed mutation was committed");
|
|
1336
|
+
assert(
|
|
1337
|
+
await rollbackAdapter.getRewardStock("compliance-rally", "__shared__") === null,
|
|
1338
|
+
"rollback changed shared stock"
|
|
1339
|
+
);
|
|
1340
|
+
assert(
|
|
1341
|
+
await rollbackAdapter.getRewardStock("compliance-rally", "reward") === null,
|
|
1342
|
+
"rollback changed per-reward stock"
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1050
1346
|
exports.InMemoryServerPersistenceAdapter = InMemoryServerPersistenceAdapter;
|
|
1347
|
+
exports.RequestValidationException = RequestValidationException;
|
|
1051
1348
|
exports.StampRallyServer = StampRallyServer;
|
|
1349
|
+
exports.assertValidCheckInParams = assertValidCheckInParams;
|
|
1350
|
+
exports.assertValidClaimParams = assertValidClaimParams;
|
|
1351
|
+
exports.assertValidSyncParams = assertValidSyncParams;
|
|
1052
1352
|
exports.executeCheckInTransaction = executeCheckInTransaction;
|
|
1053
1353
|
exports.executeClaimRewardTransaction = executeClaimRewardTransaction;
|
|
1054
1354
|
exports.executeRedisTransaction = executeRedisTransaction;
|
|
1355
|
+
exports.runPersistenceAdapterComplianceTests = runPersistenceAdapterComplianceTests;
|
|
1055
1356
|
exports.validateCheckInRequest = validateCheckInRequest;
|
|
1056
1357
|
exports.validateClaimRewardRequest = validateClaimRewardRequest;
|
|
1057
1358
|
exports.validateSyncRequest = validateSyncRequest;
|