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