@nano133/nanoterra 0.14.0 → 0.14.1
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.1 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
- **The shop's Firestore adapter: a lost race is the next amount, never an error.** When many buyers of one price order at the same instant, they all race
|
|
6
|
+
for one amount's document, and Firestore retries the losers. A loser that lost on every attempt got ABORTED (gRPC code 10), and `insert` let it through:
|
|
7
|
+
`reserveOrder` threw a raw Firestore error, not the next amount and not `ShopBusy`. `insert` now answers an ABORTED (code `10`, or the web SDK's
|
|
8
|
+
`"aborted"`) as "taken": an aborted transaction wrote nothing, so the order goes on to the next amount, and `ShopBusy` comes after the last one. Any other
|
|
9
|
+
error still goes to the caller. Found by the conformance case "25 at the same instant" on the Firestore emulator (about 1 run in 6 failed). New tests: the
|
|
10
|
+
adapter on the fake with an injected ABORTED, and on the emulator a burst of 25 at once that counts the ABORTED answers.
|
|
11
|
+
|
|
3
12
|
## 0.14.0 (2026-11-02)
|
|
4
13
|
|
|
5
14
|
- **"Free play" in /admin (Wes, 2026-11-02):** the Game card's fifth setting, a switch, On by default. Off says that the game has no free play: a player must pay in
|
|
@@ -20,6 +20,11 @@ const text = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
|
20
20
|
const num = (v, fallback = 0) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
|
|
21
21
|
const orNull = (v) => (typeof v === "string" ? v : null);
|
|
22
22
|
const STATUSES = ["open", "paid", "expired", "paid_late"];
|
|
23
|
+
/** A transaction that lost to other ones on every attempt: the Admin SDK's gRPC code 10, or the web SDK's "aborted". */
|
|
24
|
+
const aborted = (e) => {
|
|
25
|
+
const code = e?.code;
|
|
26
|
+
return code === 10 || code === "aborted";
|
|
27
|
+
};
|
|
23
28
|
function orderOf(id, d) {
|
|
24
29
|
return {
|
|
25
30
|
id,
|
|
@@ -110,14 +115,23 @@ export function createFirestoreShop(db, collections = {}) {
|
|
|
110
115
|
async insert(o) {
|
|
111
116
|
const lockRef = col("amounts").doc(o.amount);
|
|
112
117
|
const orderRef = col("orders").doc(o.id);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
try {
|
|
119
|
+
return await db.runTransaction(async (tx) => {
|
|
120
|
+
const [lock, order] = [await tx.get(lockRef), await tx.get(orderRef)];
|
|
121
|
+
if (lock.exists || order.exists)
|
|
122
|
+
return false;
|
|
123
|
+
tx.set(lockRef, { orderId: o.id, holdUntil: o.holdUntil });
|
|
124
|
+
tx.set(orderRef, dataOf({ ...o, amountLock: o.amount, status: "open", hash: null, payer: null, paidAt: null }));
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
// Many buyers of one price at the same instant all race for one amount's document. Firestore retries the losers, and one that loses on every
|
|
130
|
+
// attempt gets ABORTED. An aborted transaction wrote nothing, so it is "taken": reserveOrder goes on to the next amount (ShopBusy after the last).
|
|
131
|
+
if (aborted(e))
|
|
116
132
|
return false;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return true;
|
|
120
|
-
});
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
121
135
|
},
|
|
122
136
|
async order(id) {
|
|
123
137
|
const snap = await col("orders").doc(id).get();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano133/nanoterra",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin and its wallet sign-in, the friends list, an options menu, emotes, the player's sign-in (a passkey or a wallet app) and a shop that sells looks for Nano with no key on the server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -55,6 +55,11 @@ const text = (v: unknown, fallback = ""): string => (typeof v === "string" ? v :
|
|
|
55
55
|
const num = (v: unknown, fallback = 0): number => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
|
|
56
56
|
const orNull = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
57
57
|
const STATUSES: readonly OrderStatus[] = ["open", "paid", "expired", "paid_late"];
|
|
58
|
+
/** A transaction that lost to other ones on every attempt: the Admin SDK's gRPC code 10, or the web SDK's "aborted". */
|
|
59
|
+
const aborted = (e: unknown): boolean => {
|
|
60
|
+
const code = (e as { code?: unknown } | null)?.code;
|
|
61
|
+
return code === 10 || code === "aborted";
|
|
62
|
+
};
|
|
58
63
|
|
|
59
64
|
function orderOf(id: string, d: Record<string, unknown>): ShopOrder {
|
|
60
65
|
return {
|
|
@@ -149,13 +154,20 @@ export function createFirestoreShop(db: FirestoreShopLike, collections: ShopColl
|
|
|
149
154
|
async insert(o) {
|
|
150
155
|
const lockRef = col("amounts").doc(o.amount);
|
|
151
156
|
const orderRef = col("orders").doc(o.id);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
try {
|
|
158
|
+
return await db.runTransaction(async (tx) => {
|
|
159
|
+
const [lock, order] = [await tx.get(lockRef), await tx.get(orderRef)];
|
|
160
|
+
if (lock.exists || order.exists) return false;
|
|
161
|
+
tx.set(lockRef, { orderId: o.id, holdUntil: o.holdUntil });
|
|
162
|
+
tx.set(orderRef, dataOf({ ...o, amountLock: o.amount, status: "open", hash: null, payer: null, paidAt: null }));
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// Many buyers of one price at the same instant all race for one amount's document. Firestore retries the losers, and one that loses on every
|
|
167
|
+
// attempt gets ABORTED. An aborted transaction wrote nothing, so it is "taken": reserveOrder goes on to the next amount (ShopBusy after the last).
|
|
168
|
+
if (aborted(e)) return false;
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
159
171
|
},
|
|
160
172
|
|
|
161
173
|
async order(id) {
|