Skip to content

Commit 2e74b92

Browse files
authored
Merge pull request #9 from Asphodel-OS/velas.push-notif-projector
feat(store-indexer): notification-event projector for push notifications (PR 3/4)
2 parents dbf056b + 452fba5 commit 2e74b92

3 files changed

Lines changed: 472 additions & 1 deletion

File tree

packages/store-indexer/src/bin/postgres-decoded-indexer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { storeBlockHash } from "../postgres/blockCache";
2323
import { ReorgError } from "../postgres/ReorgError";
2424
import { createSupabasePushAdapter } from "../postgres/supabasePush";
2525
import { createTourneyAnnouncementProjector } from "../postgres/tourneyAnnouncementProjector";
26+
import { createNotificationEventProjector } from "../postgres/notificationEventProjector";
2627
import { logger } from "../logger";
2728
import packageJson from "../../package.json";
2829

@@ -77,7 +78,7 @@ const supabasePush = createSupabasePushAdapter({
7778
supabaseUrl: env.SUPABASE_URL,
7879
serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY,
7980
isCaughtUp: () => isCaughtUp,
80-
projectors: [createTourneyAnnouncementProjector()],
81+
projectors: [createTourneyAnnouncementProjector(), createNotificationEventProjector()],
8182
});
8283

8384
async function getStartBlock(configTable: (typeof mudTables)["configTable"]): Promise<bigint> {
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { describe, it, expect } from "vitest";
2+
import { concatHex, numberToHex, type Hex } from "viem";
3+
import { resourceToHex } from "@latticexyz/common";
4+
import type { StorageAdapterLog } from "@latticexyz/store-sync";
5+
import { emptyCaches, extractNotifEvents, unpackIndices } from "./notificationEventProjector";
6+
7+
const CORE = resourceToHex({ type: "table", namespace: "app", name: "TaruchiCore" });
8+
const STATUS = resourceToHex({ type: "table", namespace: "app", name: "TaruchiStatus" });
9+
const DUEL = resourceToHex({ type: "table", namespace: "app", name: "Duel" });
10+
const TOURNEY = resourceToHex({ type: "table", namespace: "app", name: "Tourney" });
11+
const TOURNEY_RESULT = resourceToHex({ type: "offchainTable", namespace: "app", name: "TourneyResult" });
12+
13+
const OWNER_A = ("0x" + "a".repeat(40)) as Hex;
14+
const OWNER_B = ("0x" + "b".repeat(40)) as Hex;
15+
16+
const IDLE = 1;
17+
18+
function mkSetRecord(tableId: Hex, id: bigint, staticData: Hex): StorageAdapterLog {
19+
return {
20+
eventName: "Store_SetRecord",
21+
args: { tableId, keyTuple: [numberToHex(id, { size: 32 })], staticData },
22+
} as unknown as StorageAdapterLog;
23+
}
24+
25+
// TaruchiCore: owner address @0 (20), index u32 @20 (4)
26+
function coreLog(id: bigint, owner: Hex, index: number): StorageAdapterLog {
27+
return mkSetRecord(CORE, id, concatHex([owner, numberToHex(index, { size: 4 })]));
28+
}
29+
// TaruchiStatus: affinity u8 @0, state u8 @1, then 34 trailing bytes
30+
function statusLog(id: bigint, state: number): StorageAdapterLog {
31+
return mkSetRecord(
32+
STATUS,
33+
id,
34+
concatHex([numberToHex(0, { size: 1 }), numberToHex(state, { size: 1 }), numberToHex(0n, { size: 34 })]),
35+
);
36+
}
37+
// Duel ENROLL SetRecord: aIdx u32 @0, bIdx u32 @4, bracket u8 @8, status u8 @9, specs u256 @10
38+
// (resolve is a status splice we don't watch — we trigger off TourneyResult).
39+
function duelEnrollLog(id: bigint, a: number, b: number): StorageAdapterLog {
40+
return mkSetRecord(
41+
DUEL,
42+
id,
43+
concatHex([
44+
numberToHex(a, { size: 4 }),
45+
numberToHex(b, { size: 4 }),
46+
numberToHex(1, { size: 1 }), // bracket
47+
numberToHex(1, { size: 1 }), // status = ACTIVE
48+
numberToHex(0n, { size: 32 }), // specs
49+
]),
50+
);
51+
}
52+
// Tourney enroll: players u256 @0, specs u256 @32, bracket u8 @64, status u8 @65
53+
function tourneyEnrollLog(id: bigint, bracket: number, packedPlayers: bigint): StorageAdapterLog {
54+
return mkSetRecord(
55+
TOURNEY,
56+
id,
57+
concatHex([
58+
numberToHex(packedPlayers, { size: 32 }),
59+
numberToHex(0n, { size: 32 }),
60+
numberToHex(bracket, { size: 1 }),
61+
numberToHex(1, { size: 1 }),
62+
]),
63+
);
64+
}
65+
// TourneyResult: the resolve signal for BOTH duels and festivals (full SetRecord).
66+
function tourneyResultLog(id: bigint): StorageAdapterLog {
67+
return mkSetRecord(TOURNEY_RESULT, id, concatHex([numberToHex(0n, { size: 32 }), numberToHex(0, { size: 4 })]));
68+
}
69+
70+
function packPlayers(...idx: number[]): bigint {
71+
let p = 0n;
72+
idx.forEach((v, i) => {
73+
p |= BigInt(v) << (32n * BigInt(i));
74+
});
75+
return p;
76+
}
77+
78+
describe("unpackIndices", () => {
79+
it("returns all non-zero indices, drops empty slots", () => {
80+
expect(unpackIndices(packPlayers(10, 20, 0, 30))).toEqual(expect.arrayContaining([10, 20, 30]));
81+
expect(unpackIndices(packPlayers(10, 20, 0, 30))).toHaveLength(3);
82+
expect(unpackIndices(0n)).toEqual([]);
83+
});
84+
});
85+
86+
describe("extractNotifEvents — MINT", () => {
87+
it("fires on the FIRST TaruchiStatus write landing IDLE (the reveal)", () => {
88+
const c = emptyCaches();
89+
// mint writes Core; reveal is the first Status write, set directly to IDLE.
90+
const ev = extractNotifEvents([coreLog(1n, OWNER_A, 10), statusLog(1n, IDLE)], c, 1);
91+
expect(ev).toEqual([{ type: "mint", recipient_wallet: OWNER_A, taruchi_id: "1" }]);
92+
});
93+
94+
it("does NOT re-fire on a later IDLE write (training/duel return to IDLE)", () => {
95+
const c = emptyCaches();
96+
extractNotifEvents([coreLog(1n, OWNER_A, 10), statusLog(1n, IDLE)], c, 1); // reveal (fires)
97+
expect(extractNotifEvents([statusLog(1n, IDLE)], c, 9)).toEqual([]); // already seen
98+
});
99+
100+
it("skips when the owner isn't cached", () => {
101+
const c = emptyCaches();
102+
expect(extractNotifEvents([statusLog(5n, IDLE)], c, 1)).toEqual([]);
103+
});
104+
105+
it("re-fires a reveal after a reorg replay (block regresses → mint-seen gate cleared)", () => {
106+
const c = emptyCaches();
107+
extractNotifEvents([coreLog(1n, OWNER_A, 10), statusLog(1n, IDLE)], c, 100); // reveal at block 100
108+
// reorg: indexer re-processes from an earlier block; the reveal re-lands.
109+
const ev = extractNotifEvents([statusLog(1n, IDLE)], c, 98);
110+
expect(ev).toEqual([{ type: "mint", recipient_wallet: OWNER_A, taruchi_id: "1" }]);
111+
});
112+
113+
it("preserves prior-state ACROSS replayed blocks — the gate clears once at the boundary, not every block", () => {
114+
const c = emptyCaches();
115+
extractNotifEvents([coreLog(1n, OWNER_A, 10)], c, 100); // learn owner, lastBlock=100
116+
// reorg boundary: block regresses to 98. Reveal re-lands here (first write
117+
// since the gate cleared) → mint re-fires. This is the boundary block.
118+
expect(extractNotifEvents([statusLog(1n, IDLE)], c, 98)).toEqual([
119+
{ type: "mint", recipient_wallet: OWNER_A, taruchi_id: "1" },
120+
]);
121+
// Next replayed block (99 > 98) climbs forward — must NOT re-clear the gate.
122+
// A training-return-to-IDLE on the already-revealed taru must stay silent.
123+
// (With the old Math.max bug, 99 < highWater(100) re-cleared → false mint.)
124+
expect(extractNotifEvents([statusLog(1n, IDLE)], c, 99)).toEqual([]);
125+
});
126+
});
127+
128+
describe("extractNotifEvents — DUEL (resolves via TourneyResult, not a status splice)", () => {
129+
it("fires for both players when the duel's TourneyResult is written", () => {
130+
const c = emptyCaches();
131+
// enroll: learn owners + duel player indices. No event yet.
132+
expect(
133+
extractNotifEvents([coreLog(100n, OWNER_A, 10), coreLog(200n, OWNER_B, 20), duelEnrollLog(999n, 10, 20)], c, 1),
134+
).toEqual([]);
135+
// resolve: TourneyResult write for the duel id.
136+
expect(extractNotifEvents([tourneyResultLog(999n)], c, 2)).toEqual([
137+
{ type: "duel", recipient_wallet: OWNER_A, taruchi_id: "999" },
138+
{ type: "duel", recipient_wallet: OWNER_B, taruchi_id: "999" },
139+
]);
140+
});
141+
142+
it("skips a duel player whose owner isn't cached", () => {
143+
const c = emptyCaches();
144+
c.ownerByIndex.set(10, OWNER_A); // only player 10 known
145+
extractNotifEvents([duelEnrollLog(999n, 10, 20)], c, 1);
146+
expect(extractNotifEvents([tourneyResultLog(999n)], c, 2)).toEqual([
147+
{ type: "duel", recipient_wallet: OWNER_A, taruchi_id: "999" },
148+
]);
149+
});
150+
});
151+
152+
describe("extractNotifEvents — FESTIVAL", () => {
153+
it("notifies every entrant on the result of a festival bracket", () => {
154+
const c = emptyCaches();
155+
extractNotifEvents([coreLog(100n, OWNER_A, 10), coreLog(200n, OWNER_B, 20)], c, 1);
156+
extractNotifEvents([tourneyEnrollLog(5000n, 5, packPlayers(10, 20))], c, 2); // bracket 5 = festival
157+
const ev = extractNotifEvents([tourneyResultLog(5000n)], c, 3);
158+
expect(ev).toEqual(
159+
expect.arrayContaining([
160+
{ type: "festival", recipient_wallet: OWNER_A, taruchi_id: "5000" },
161+
{ type: "festival", recipient_wallet: OWNER_B, taruchi_id: "5000" },
162+
]),
163+
);
164+
expect(ev).toHaveLength(2);
165+
});
166+
167+
it("ignores a non-festival (duel-tier) tourney bracket", () => {
168+
const c = emptyCaches();
169+
c.ownerByIndex.set(10, OWNER_A);
170+
extractNotifEvents([tourneyEnrollLog(6000n, 2, packPlayers(10))], c, 1); // bracket 2 = Veteran
171+
expect(extractNotifEvents([tourneyResultLog(6000n)], c, 2)).toEqual([]);
172+
});
173+
});

0 commit comments

Comments
 (0)