@stamprally/server 0.17.0 → 0.19.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/dist/index.d.cts CHANGED
@@ -93,6 +93,8 @@ interface ServerPersistenceAdapter {
93
93
  }>;
94
94
  /** Set false when the adapter cannot persist per-reward inventory atomically. */
95
95
  readonly supportsRewardStock?: boolean;
96
+ /** Must be true when a claim plan uses `secondaryStockKey`. */
97
+ readonly supportsSecondaryStock?: boolean;
96
98
  acquireLock(rallyId: string, lockKey: string, ttlMs: number): Promise<boolean>;
97
99
  releaseLock(rallyId: string, lockKey: string): Promise<void>;
98
100
  getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
@@ -117,6 +119,7 @@ interface InMemoryServerPersistenceOptions {
117
119
  declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
118
120
  #private;
119
121
  readonly supportsRewardStock: boolean;
122
+ readonly supportsSecondaryStock: boolean;
120
123
  constructor(options?: InMemoryServerPersistenceOptions);
121
124
  acquireLock(rallyId: string, key: string, ttlMs: number): Promise<boolean>;
122
125
  releaseLock(rallyId: string, key: string): Promise<void>;
@@ -245,6 +248,7 @@ declare function validateSyncRequest(value: unknown): RequestValidationResult<{
245
248
  readonly rallyId: string;
246
249
  readonly userId?: string;
247
250
  readonly anonymousSessionId?: string;
251
+ readonly operations?: SyncProgressRequest["operations"];
248
252
  }>;
249
253
 
250
254
  type ErrorResponse = {
@@ -271,12 +275,8 @@ declare class StampRallyServer {
271
275
  handleSync(request: Request): Promise<Response>;
272
276
  checkIn(request: CheckInRequest, authContext?: TrustedAuthContext): Promise<CheckInResponse>;
273
277
  claimReward(request: ClaimRewardRequest, authContext?: TrustedAuthContext): Promise<ClaimResponse>;
274
- sync(rallyId: string, userId: string): Promise<UserRallyState>;
275
- syncProgress(request: {
276
- readonly rallyId: string;
277
- readonly userId?: string;
278
- readonly anonymousSessionId?: string;
279
- }): Promise<UserRallyState>;
278
+ sync(rallyId: string, identity: string | TrustedAuthContext): Promise<UserRallyState>;
279
+ syncProgress(request: SyncProgressRequest, authContext: TrustedAuthContext): Promise<UserRallyState>;
280
280
  }
281
281
 
282
282
  /**
@@ -316,6 +316,19 @@ interface ClaimRewardRequest {
316
316
  readonly staffId?: string;
317
317
  readonly now?: string | number;
318
318
  }
319
+ type SyncProgressOperation = {
320
+ readonly kind: "checkIn";
321
+ readonly request: CheckInRequest;
322
+ } | {
323
+ readonly kind: "claimReward";
324
+ readonly request: ClaimRewardRequest;
325
+ };
326
+ interface SyncProgressRequest {
327
+ readonly rallyId: string;
328
+ readonly userId?: string;
329
+ readonly anonymousSessionId?: string;
330
+ readonly operations?: ReadonlyArray<SyncProgressOperation>;
331
+ }
319
332
  type CheckInResponse = {
320
333
  readonly ok: true;
321
334
  readonly state: UserRallyState;
@@ -335,4 +348,4 @@ interface ServerOptions {
335
348
  readonly anonymousPolicy?: "session_scoped" | "reject" | "shared_global_opt_in_insecure";
336
349
  }
337
350
 
338
- export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardMutationContext, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type TrustedAuthContext, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
351
+ export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardMutationContext, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type SyncProgressOperation, type SyncProgressRequest, type TrustedAuthContext, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
package/dist/index.d.ts CHANGED
@@ -93,6 +93,8 @@ interface ServerPersistenceAdapter {
93
93
  }>;
94
94
  /** Set false when the adapter cannot persist per-reward inventory atomically. */
95
95
  readonly supportsRewardStock?: boolean;
96
+ /** Must be true when a claim plan uses `secondaryStockKey`. */
97
+ readonly supportsSecondaryStock?: boolean;
96
98
  acquireLock(rallyId: string, lockKey: string, ttlMs: number): Promise<boolean>;
97
99
  releaseLock(rallyId: string, lockKey: string): Promise<void>;
98
100
  getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
@@ -117,6 +119,7 @@ interface InMemoryServerPersistenceOptions {
117
119
  declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
118
120
  #private;
119
121
  readonly supportsRewardStock: boolean;
122
+ readonly supportsSecondaryStock: boolean;
120
123
  constructor(options?: InMemoryServerPersistenceOptions);
121
124
  acquireLock(rallyId: string, key: string, ttlMs: number): Promise<boolean>;
122
125
  releaseLock(rallyId: string, key: string): Promise<void>;
@@ -245,6 +248,7 @@ declare function validateSyncRequest(value: unknown): RequestValidationResult<{
245
248
  readonly rallyId: string;
246
249
  readonly userId?: string;
247
250
  readonly anonymousSessionId?: string;
251
+ readonly operations?: SyncProgressRequest["operations"];
248
252
  }>;
249
253
 
250
254
  type ErrorResponse = {
@@ -271,12 +275,8 @@ declare class StampRallyServer {
271
275
  handleSync(request: Request): Promise<Response>;
272
276
  checkIn(request: CheckInRequest, authContext?: TrustedAuthContext): Promise<CheckInResponse>;
273
277
  claimReward(request: ClaimRewardRequest, authContext?: TrustedAuthContext): Promise<ClaimResponse>;
274
- sync(rallyId: string, userId: string): Promise<UserRallyState>;
275
- syncProgress(request: {
276
- readonly rallyId: string;
277
- readonly userId?: string;
278
- readonly anonymousSessionId?: string;
279
- }): Promise<UserRallyState>;
278
+ sync(rallyId: string, identity: string | TrustedAuthContext): Promise<UserRallyState>;
279
+ syncProgress(request: SyncProgressRequest, authContext: TrustedAuthContext): Promise<UserRallyState>;
280
280
  }
281
281
 
282
282
  /**
@@ -316,6 +316,19 @@ interface ClaimRewardRequest {
316
316
  readonly staffId?: string;
317
317
  readonly now?: string | number;
318
318
  }
319
+ type SyncProgressOperation = {
320
+ readonly kind: "checkIn";
321
+ readonly request: CheckInRequest;
322
+ } | {
323
+ readonly kind: "claimReward";
324
+ readonly request: ClaimRewardRequest;
325
+ };
326
+ interface SyncProgressRequest {
327
+ readonly rallyId: string;
328
+ readonly userId?: string;
329
+ readonly anonymousSessionId?: string;
330
+ readonly operations?: ReadonlyArray<SyncProgressOperation>;
331
+ }
319
332
  type CheckInResponse = {
320
333
  readonly ok: true;
321
334
  readonly state: UserRallyState;
@@ -335,4 +348,4 @@ interface ServerOptions {
335
348
  readonly anonymousPolicy?: "session_scoped" | "reject" | "shared_global_opt_in_insecure";
336
349
  }
337
350
 
338
- export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardMutationContext, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type TrustedAuthContext, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
351
+ export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardMutationContext, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type SyncProgressOperation, type SyncProgressRequest, type TrustedAuthContext, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
package/dist/index.js CHANGED
@@ -2,9 +2,13 @@ import { reconcileRewardStates, consumeReward, evaluateConditionDetailed } from
2
2
 
3
3
  // src/examples/transaction.ts
4
4
  async function executeClaimRewardTransaction(database, store, params2, mutation2) {
5
+ if (params2.secondaryStockKey !== void 0 && store.writeSecondaryStock === void 0)
6
+ return { success: false, error: "SECONDARY_STOCK_UNSUPPORTED" };
5
7
  try {
6
8
  return await database.transaction(async (transaction) => {
7
9
  const current = await store.readContext(transaction, params2);
10
+ if (params2.secondaryStockKey !== void 0 && current.secondaryStock === void 0)
11
+ return { success: false, error: "SECONDARY_STOCK_UNSUPPORTED" };
8
12
  const secondaryStock = current.secondaryStock ?? null;
9
13
  const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : current.stock;
10
14
  const next = mutation2({
@@ -76,6 +80,7 @@ async function executeRedisTransaction(redis, queue) {
76
80
  // src/persistence.ts
77
81
  var InMemoryServerPersistenceAdapter = class {
78
82
  supportsRewardStock = true;
83
+ supportsSecondaryStock = true;
79
84
  #locks = /* @__PURE__ */ new Map();
80
85
  #idempotent = /* @__PURE__ */ new Map();
81
86
  #states = /* @__PURE__ */ new Map();
@@ -595,6 +600,40 @@ function validateSyncRequest(value) {
595
600
  return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
596
601
  if (value.userId !== void 0 && !nonEmpty(value.userId))
597
602
  return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
603
+ if (value.operations !== void 0) {
604
+ if (!Array.isArray(value.operations))
605
+ return errors({
606
+ path: "operations",
607
+ message: "operations must be an array.",
608
+ code: "INVALID_TYPE"
609
+ });
610
+ const operationErrors = [];
611
+ value.operations.forEach((operation, index) => {
612
+ const path = `operations[${index}]`;
613
+ if (!record(operation)) {
614
+ operationErrors.push({
615
+ path,
616
+ message: "Expected an operation object.",
617
+ code: "INVALID_TYPE"
618
+ });
619
+ return;
620
+ }
621
+ if (operation.kind !== "checkIn" && operation.kind !== "claimReward") {
622
+ operationErrors.push({
623
+ path: `${path}.kind`,
624
+ message: "Unknown sync operation.",
625
+ code: "INVALID_ENUM"
626
+ });
627
+ return;
628
+ }
629
+ const result = operation.kind === "checkIn" ? validateCheckInRequest(operation.request) : validateClaimRewardRequest(operation.request);
630
+ if (!result.success)
631
+ operationErrors.push(
632
+ ...result.errors.map((error) => ({ ...error, path: `${path}.request.${error.path}` }))
633
+ );
634
+ });
635
+ if (operationErrors.length > 0) return errors(...operationErrors);
636
+ }
598
637
  return {
599
638
  success: true,
600
639
  data: value
@@ -640,26 +679,41 @@ function initialState(config, userId, timestamp) {
640
679
  updatedAt: timestamp
641
680
  };
642
681
  }
643
- function rewardStock(config, rewardId, stockLimit) {
644
- const configured = config.inventory?.[rewardId];
682
+ function rewardStock(config, reward) {
683
+ const stockLimit = reward.stockLimit;
684
+ const key = reward.stockKey ?? reward.id;
685
+ const configured = key === "__shared__" ? config.inventory?.sharedStock : config.inventory?.[key];
645
686
  if (stockLimit === void 0) return configured ?? null;
646
687
  if (configured === void 0) return stockLimit;
647
688
  return Math.min(stockLimit, configured);
648
689
  }
649
690
  function sharedStock(config) {
650
- return config.inventory?.sharedStock ?? config.inventory?.global ?? null;
691
+ return config.inventory?.sharedStock ?? null;
651
692
  }
652
693
  function inventoryPlan(config, rewardId, stockLimit) {
653
- const individual = rewardStock(config, rewardId, stockLimit);
694
+ const reward = config.rewards.find((item) => item.id === rewardId);
695
+ const individual = reward === void 0 ? stockLimit ?? null : rewardStock(config, reward);
654
696
  const shared = sharedStock(config);
697
+ const explicitPrimaryKey = reward?.stockKey;
698
+ const explicitSecondaryKey = reward?.secondaryStockKey;
655
699
  if (config.inventoryMode === "shared" && shared !== null) {
656
700
  return {
657
- primaryKey: "__shared__",
658
- primaryInitial: shared,
659
- ...individual === null ? {} : { secondaryKey: rewardId, secondaryInitial: individual }
701
+ primaryKey: explicitPrimaryKey ?? "__shared__",
702
+ primaryInitial: explicitPrimaryKey === void 0 ? shared : individual,
703
+ ...explicitSecondaryKey !== void 0 ? {
704
+ secondaryKey: explicitSecondaryKey,
705
+ secondaryInitial: config.inventory?.[explicitSecondaryKey] ?? individual
706
+ } : individual === null ? {} : { secondaryKey: rewardId, secondaryInitial: individual }
660
707
  };
661
708
  }
662
- return { primaryKey: rewardId, primaryInitial: individual };
709
+ return {
710
+ primaryKey: explicitPrimaryKey ?? rewardId,
711
+ primaryInitial: individual,
712
+ ...explicitSecondaryKey === void 0 ? {} : {
713
+ secondaryKey: explicitSecondaryKey,
714
+ secondaryInitial: config.inventory?.[explicitSecondaryKey] ?? null
715
+ }
716
+ };
663
717
  }
664
718
  function getProof(context) {
665
719
  return context.type === "qr" ? context.token : context.type === "passcode" ? context.code : context.type === "gps" ? { latitude: context.latitude, longitude: context.longitude } : context.type === "nfc" ? context.tagId : context.value;
@@ -821,14 +875,21 @@ var StampRallyServer = class {
821
875
  401
822
876
  );
823
877
  const sessionId = request.headers.get("x-anonymous-session-id");
878
+ const authContext = {
879
+ authenticatedUserId: userId,
880
+ ...sessionId === null ? {} : { isAnonymous: true, sessionId }
881
+ };
824
882
  try {
825
883
  return json({
826
884
  ok: true,
827
- state: await this.syncProgress({
828
- rallyId: body.data.rallyId,
829
- userId,
830
- ...sessionId === null ? {} : { anonymousSessionId: sessionId }
831
- })
885
+ state: await this.syncProgress(
886
+ {
887
+ rallyId: body.data.rallyId,
888
+ ...body.data.operations === void 0 ? {} : { operations: body.data.operations },
889
+ ...sessionId === null ? {} : { anonymousSessionId: sessionId }
890
+ },
891
+ authContext
892
+ )
832
893
  });
833
894
  } catch (error) {
834
895
  if (error instanceof RequestValidationException) return validationResponse(error.errors);
@@ -988,6 +1049,8 @@ var StampRallyServer = class {
988
1049
  now(this.#options)
989
1050
  );
990
1051
  const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
1052
+ if (plan.secondaryKey !== void 0 && this.#persistence.supportsSecondaryStock !== true)
1053
+ throw new Error("SECONDARY_STOCK_UNSUPPORTED");
991
1054
  const inventoryEnabled = plan.primaryInitial !== null || plan.secondaryInitial !== void 0 && plan.secondaryInitial !== null;
992
1055
  if (inventoryEnabled && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function" || typeof this.#persistence.executeClaimRewardTransaction !== "function"))
993
1056
  return this.#rememberClaim(
@@ -1022,7 +1085,7 @@ var StampRallyServer = class {
1022
1085
  rewardId: reward.id,
1023
1086
  stockKey: plan.primaryKey,
1024
1087
  ...plan.secondaryKey === void 0 ? {} : { secondaryStockKey: plan.secondaryKey },
1025
- rewardStockLimit: rewardStock(this.#config, reward.id, reward.stockLimit),
1088
+ rewardStockLimit: rewardStock(this.#config, reward),
1026
1089
  sharedStockLimit: this.#config.inventoryMode === "shared" ? sharedStock(this.#config) : null,
1027
1090
  initialStock: plan.primaryInitial,
1028
1091
  ...plan.secondaryInitial === void 0 ? {} : { initialSecondaryStock: plan.secondaryInitial },
@@ -1154,21 +1217,28 @@ var StampRallyServer = class {
1154
1217
  await this.#persistence.releaseLock(request.rallyId, lockKey);
1155
1218
  }
1156
1219
  }
1157
- async sync(rallyId, userId) {
1220
+ async sync(rallyId, identity) {
1221
+ const userId = typeof identity === "string" ? identity : identity.authenticatedUserId;
1158
1222
  assertValidSyncParams({ rallyId, userId }, this.#config);
1159
1223
  const state2 = await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
1160
1224
  return this.#attachInventory(state2);
1161
1225
  }
1162
- async syncProgress(request) {
1163
- const directRequest = withDirectIdentity(request);
1226
+ async syncProgress(request, authContext) {
1227
+ const directRequest = withDirectIdentity(request, authContext);
1164
1228
  assertValidSyncParams(directRequest, this.#config);
1165
- return this.sync(directRequest.rallyId, directRequest.userId);
1229
+ for (const operation of request.operations ?? []) {
1230
+ if (operation.kind === "checkIn") await this.checkIn(operation.request, authContext);
1231
+ else await this.claimReward(operation.request, authContext);
1232
+ }
1233
+ return this.sync(directRequest.rallyId, authContext);
1166
1234
  }
1167
1235
  async #attachInventory(state2) {
1168
1236
  const rewardRemaining = {};
1169
1237
  for (const reward of this.#config.rewards) {
1170
1238
  const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
1171
1239
  if (plan.secondaryKey !== void 0) {
1240
+ if (this.#persistence.supportsSecondaryStock !== true)
1241
+ throw new Error("SECONDARY_STOCK_UNSUPPORTED");
1172
1242
  const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.secondaryKey);
1173
1243
  const remaining = stock ?? plan.secondaryInitial ?? null;
1174
1244
  if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);