@stamprally/server 0.18.0 → 0.20.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 +26 -1
- package/dist/index.cjs +121 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -8
- package/dist/index.d.ts +18 -8
- package/dist/index.js +121 -8
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @stamprally/server v0.
|
|
1
|
+
# @stamprally/server v0.20.0
|
|
2
2
|
|
|
3
3
|
Web Standard `Request` / `Response` handlers for server-authoritative check-ins and reward claims.
|
|
4
4
|
|
|
@@ -26,6 +26,31 @@ Configure `anonymousPolicy: "session_scoped"` to use the UUID v4 from the
|
|
|
26
26
|
HTTP 401 when no authenticated identity is present. Request validation failures use
|
|
27
27
|
HTTP 400 with `{ error: "VALIDATION_FAILED", details: [...] }`.
|
|
28
28
|
|
|
29
|
+
## Batch sync and trusted authentication
|
|
30
|
+
|
|
31
|
+
`syncProgress(request, authContext)` evaluates `operations` by their request
|
|
32
|
+
timestamp, preserving FIFO order for equal timestamps, and returns
|
|
33
|
+
`{ results, currentState, syncTimestamp }`. A permanently rejected
|
|
34
|
+
operation does not stop unrelated operations; a later operation depending on that
|
|
35
|
+
resource receives `REJECTED_PREREQUISITE_FAILED`. Retryable failures are returned
|
|
36
|
+
as `FAILED_RETRYABLE` so the client can retain them for the next request.
|
|
37
|
+
|
|
38
|
+
Direct server calls require a verified `TrustedAuthContext`:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const progress = await server.syncProgress(
|
|
42
|
+
{ rallyId: "city-tour", operations },
|
|
43
|
+
{ authenticatedUserId: session.userId, claims: session.claims },
|
|
44
|
+
);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The context identity is authoritative and overrides any caller-supplied `userId`.
|
|
48
|
+
For inventory, `stockKey` selects the primary bucket and `secondaryStockKey`
|
|
49
|
+
selects an additional bucket that must be decremented in the same transaction.
|
|
50
|
+
An adapter using a secondary key must expose `supportsSecondaryStock: true` and
|
|
51
|
+
persist both buckets atomically; otherwise the claim fails closed with
|
|
52
|
+
`SECONDARY_STOCK_UNSUPPORTED`.
|
|
53
|
+
|
|
29
54
|
Hono can mount the handler directly because it accepts the same Web Standard request and response types.
|
|
30
55
|
|
|
31
56
|
## License
|
package/dist/index.cjs
CHANGED
|
@@ -4,9 +4,13 @@ var core = require('@stamprally/core');
|
|
|
4
4
|
|
|
5
5
|
// src/examples/transaction.ts
|
|
6
6
|
async function executeClaimRewardTransaction(database, store, params2, mutation2) {
|
|
7
|
+
if (params2.secondaryStockKey !== void 0 && store.writeSecondaryStock === void 0)
|
|
8
|
+
return { success: false, error: "SECONDARY_STOCK_UNSUPPORTED" };
|
|
7
9
|
try {
|
|
8
10
|
return await database.transaction(async (transaction) => {
|
|
9
11
|
const current = await store.readContext(transaction, params2);
|
|
12
|
+
if (params2.secondaryStockKey !== void 0 && current.secondaryStock === void 0)
|
|
13
|
+
return { success: false, error: "SECONDARY_STOCK_UNSUPPORTED" };
|
|
10
14
|
const secondaryStock = current.secondaryStock ?? null;
|
|
11
15
|
const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : current.stock;
|
|
12
16
|
const next = mutation2({
|
|
@@ -598,6 +602,40 @@ function validateSyncRequest(value) {
|
|
|
598
602
|
return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
|
|
599
603
|
if (value.userId !== void 0 && !nonEmpty(value.userId))
|
|
600
604
|
return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
|
|
605
|
+
if (value.operations !== void 0) {
|
|
606
|
+
if (!Array.isArray(value.operations))
|
|
607
|
+
return errors({
|
|
608
|
+
path: "operations",
|
|
609
|
+
message: "operations must be an array.",
|
|
610
|
+
code: "INVALID_TYPE"
|
|
611
|
+
});
|
|
612
|
+
const operationErrors = [];
|
|
613
|
+
value.operations.forEach((operation, index) => {
|
|
614
|
+
const path = `operations[${index}]`;
|
|
615
|
+
if (!record(operation)) {
|
|
616
|
+
operationErrors.push({
|
|
617
|
+
path,
|
|
618
|
+
message: "Expected an operation object.",
|
|
619
|
+
code: "INVALID_TYPE"
|
|
620
|
+
});
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (operation.kind !== "checkIn" && operation.kind !== "claimReward") {
|
|
624
|
+
operationErrors.push({
|
|
625
|
+
path: `${path}.kind`,
|
|
626
|
+
message: "Unknown sync operation.",
|
|
627
|
+
code: "INVALID_ENUM"
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const result = operation.kind === "checkIn" ? validateCheckInRequest(operation.request) : validateClaimRewardRequest(operation.request);
|
|
632
|
+
if (!result.success)
|
|
633
|
+
operationErrors.push(
|
|
634
|
+
...result.errors.map((error) => ({ ...error, path: `${path}.request.${error.path}` }))
|
|
635
|
+
);
|
|
636
|
+
});
|
|
637
|
+
if (operationErrors.length > 0) return errors(...operationErrors);
|
|
638
|
+
}
|
|
601
639
|
return {
|
|
602
640
|
success: true,
|
|
603
641
|
data: value
|
|
@@ -625,6 +663,15 @@ function timestampMillis(timestamp) {
|
|
|
625
663
|
const value = Date.parse(timestamp);
|
|
626
664
|
return Number.isFinite(value) ? value : Date.now();
|
|
627
665
|
}
|
|
666
|
+
function operationTimestamp(operation) {
|
|
667
|
+
const value = operation.request.now;
|
|
668
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
669
|
+
if (typeof value === "string") {
|
|
670
|
+
const parsed = Date.parse(value);
|
|
671
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
672
|
+
}
|
|
673
|
+
return Number.MAX_SAFE_INTEGER;
|
|
674
|
+
}
|
|
628
675
|
function validationResponse(errors2) {
|
|
629
676
|
return json(
|
|
630
677
|
{
|
|
@@ -712,6 +759,38 @@ function operationStatus(result) {
|
|
|
712
759
|
if (result.ok) return "ACCEPTED";
|
|
713
760
|
return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
|
|
714
761
|
}
|
|
762
|
+
function syncOperationId(operation, userId) {
|
|
763
|
+
return `${operation.kind}:${operation.request.rallyId}:${userId}:${operation.request.idempotencyKey}`;
|
|
764
|
+
}
|
|
765
|
+
function syncOperationResult(operation, userId, result) {
|
|
766
|
+
const operationId = syncOperationId(operation, userId);
|
|
767
|
+
const resourceId = operation.kind === "checkIn" ? operation.request.spotId : operation.request.rewardId;
|
|
768
|
+
const action = operation.kind === "checkIn" ? "CHECK_IN" : "CLAIM_REWARD";
|
|
769
|
+
const status = operationStatus(result);
|
|
770
|
+
if (status === "ACCEPTED") {
|
|
771
|
+
return {
|
|
772
|
+
operationId,
|
|
773
|
+
status,
|
|
774
|
+
resourceId,
|
|
775
|
+
action,
|
|
776
|
+
appliedAt: timestampMillis(result.ok ? result.state.updatedAt : "")
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
if (status === "RETRYABLE_ERROR")
|
|
780
|
+
return {
|
|
781
|
+
operationId,
|
|
782
|
+
status: "FAILED_RETRYABLE",
|
|
783
|
+
resourceId,
|
|
784
|
+
error: result.ok ? "The operation can be retried." : result.message
|
|
785
|
+
};
|
|
786
|
+
return {
|
|
787
|
+
operationId,
|
|
788
|
+
status,
|
|
789
|
+
resourceId,
|
|
790
|
+
errorCode: result.ok ? "REJECTED_PERMANENT" : result.code,
|
|
791
|
+
reason: result.ok ? "The operation was rejected." : result.message
|
|
792
|
+
};
|
|
793
|
+
}
|
|
715
794
|
function withDirectIdentity(request, authContext) {
|
|
716
795
|
if (authContext !== void 0) {
|
|
717
796
|
if (authContext.authenticatedUserId.trim() === "")
|
|
@@ -844,15 +923,17 @@ var StampRallyServer = class {
|
|
|
844
923
|
...sessionId === null ? {} : { isAnonymous: true, sessionId }
|
|
845
924
|
};
|
|
846
925
|
try {
|
|
926
|
+
const progress = await this.syncProgress(
|
|
927
|
+
{
|
|
928
|
+
rallyId: body.data.rallyId,
|
|
929
|
+
...body.data.operations === void 0 ? {} : { operations: body.data.operations },
|
|
930
|
+
...sessionId === null ? {} : { anonymousSessionId: sessionId }
|
|
931
|
+
},
|
|
932
|
+
authContext
|
|
933
|
+
);
|
|
847
934
|
return json({
|
|
848
935
|
ok: true,
|
|
849
|
-
|
|
850
|
-
{
|
|
851
|
-
rallyId: body.data.rallyId,
|
|
852
|
-
...sessionId === null ? {} : { anonymousSessionId: sessionId }
|
|
853
|
-
},
|
|
854
|
-
authContext
|
|
855
|
-
)
|
|
936
|
+
...progress
|
|
856
937
|
});
|
|
857
938
|
} catch (error) {
|
|
858
939
|
if (error instanceof RequestValidationException) return validationResponse(error.errors);
|
|
@@ -1189,7 +1270,39 @@ var StampRallyServer = class {
|
|
|
1189
1270
|
async syncProgress(request, authContext) {
|
|
1190
1271
|
const directRequest = withDirectIdentity(request, authContext);
|
|
1191
1272
|
assertValidSyncParams(directRequest, this.#config);
|
|
1192
|
-
|
|
1273
|
+
const results = [];
|
|
1274
|
+
const rejectedCheckIns = /* @__PURE__ */ new Set();
|
|
1275
|
+
const operations = [...request.operations ?? []].map((operation, index) => ({ operation, index })).sort(
|
|
1276
|
+
(left, right) => operationTimestamp(left.operation) - operationTimestamp(right.operation) || left.index - right.index
|
|
1277
|
+
).map(({ operation }) => operation);
|
|
1278
|
+
for (const operation of operations) {
|
|
1279
|
+
const resourceId = operation.kind === "checkIn" ? operation.request.spotId : operation.request.rewardId;
|
|
1280
|
+
if (operation.kind === "checkIn") {
|
|
1281
|
+
const spot = this.#config.spots.find((candidate) => candidate.id === resourceId);
|
|
1282
|
+
if (spot?.prerequisites?.some((prerequisite) => rejectedCheckIns.has(prerequisite))) {
|
|
1283
|
+
results.push({
|
|
1284
|
+
operationId: syncOperationId(operation, directRequest.userId),
|
|
1285
|
+
status: "REJECTED_PERMANENT",
|
|
1286
|
+
resourceId,
|
|
1287
|
+
errorCode: "REJECTED_PREREQUISITE_FAILED",
|
|
1288
|
+
reason: "A prerequisite operation was rejected by the server."
|
|
1289
|
+
});
|
|
1290
|
+
rejectedCheckIns.add(resourceId);
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
const result = operation.kind === "checkIn" ? await this.checkIn(operation.request, authContext) : await this.claimReward(operation.request, authContext);
|
|
1295
|
+
const operationResult = syncOperationResult(operation, directRequest.userId, result);
|
|
1296
|
+
results.push(operationResult);
|
|
1297
|
+
if (operation.kind === "checkIn" && operationResult.status === "REJECTED_PERMANENT")
|
|
1298
|
+
rejectedCheckIns.add(resourceId);
|
|
1299
|
+
}
|
|
1300
|
+
const currentState = await this.sync(directRequest.rallyId, authContext);
|
|
1301
|
+
return {
|
|
1302
|
+
results,
|
|
1303
|
+
currentState,
|
|
1304
|
+
syncTimestamp: timestampMillis(now(this.#options))
|
|
1305
|
+
};
|
|
1193
1306
|
}
|
|
1194
1307
|
async #attachInventory(state2) {
|
|
1195
1308
|
const rewardRemaining = {};
|