Skip to content

Commit 9f792a1

Browse files
committed
fix ci: include state sources
1 parent 04c8f37 commit 9f792a1

12 files changed

Lines changed: 1574 additions & 2 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ onchain_dumps/
189189
# Cache / KV (local)
190190
kv.local.json
191191
onchain-cache/
192-
state/
192+
/state/
193193

194194
# Optional: generated snapshots (keep canonical snapshots in repo)
195195
snapshots-generated/

src/canonical/auditTail.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export async function getRecentAuditEntries(
4949
try {
5050
const store = getStateStore();
5151
if (hasListOps(store)) {
52-
const items = await store.lrange(TAIL_KEY, 0, limit - 1);
52+
const items: string[] = await store.lrange(TAIL_KEY, 0, limit - 1);
5353
return items.map((s) => JSON.parse(s) as AuditRecord);
5454
}
5555
const raw = await store.get(TAIL_KEY);

src/state/cursorStateStore.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* Cursor Persistence Manager (StateStore-backed)
3+
*
4+
* Ensures no mention gaps and no double-processing.
5+
*/
6+
7+
import { logInfo, logError, logWarn } from "../ops/logger.js";
8+
import { getStateStore } from "./storeFactory.js";
9+
import type { CursorState } from "./stateStore.js";
10+
11+
const CURRENT_VERSION = 1;
12+
13+
// In-memory cursor cache
14+
let cachedCursor: string | null = null;
15+
let lastPersistedAt = 0;
16+
const PERSISTENCE_INTERVAL_MS = 60_000; // Persist at most every minute
17+
18+
/**
19+
* Load cursor state from store
20+
*/
21+
export async function loadCursorState(): Promise<CursorState> {
22+
try {
23+
const store = getStateStore();
24+
const state = await store.getCursor();
25+
26+
if (!state) {
27+
logInfo("[CURSOR] No persisted cursor found, starting fresh");
28+
return {
29+
since_id: null,
30+
last_fetch_at: new Date(0).toISOString(),
31+
fetched_count: 0,
32+
version: CURRENT_VERSION,
33+
};
34+
}
35+
36+
// Validate version
37+
if (state.version !== CURRENT_VERSION) {
38+
logWarn("[CURSOR] Cursor version mismatch, migrating", {
39+
oldVersion: state.version,
40+
newVersion: CURRENT_VERSION,
41+
});
42+
state.version = CURRENT_VERSION;
43+
}
44+
45+
// Update cache
46+
cachedCursor = state.since_id;
47+
48+
logInfo("[CURSOR] Loaded cursor state", {
49+
since_id: state.since_id,
50+
last_fetch_at: state.last_fetch_at,
51+
});
52+
53+
return state;
54+
} catch (error) {
55+
logError("[CURSOR] Failed to load cursor state", {
56+
error: error instanceof Error ? error.message : String(error),
57+
});
58+
59+
return {
60+
since_id: null,
61+
last_fetch_at: new Date(0).toISOString(),
62+
fetched_count: 0,
63+
version: CURRENT_VERSION,
64+
};
65+
}
66+
}
67+
68+
/**
69+
* Save cursor state to store
70+
*/
71+
export async function saveCursorState(state: CursorState): Promise<void> {
72+
try {
73+
const store = getStateStore();
74+
await store.setCursor(state);
75+
76+
// Update cache
77+
cachedCursor = state.since_id;
78+
lastPersistedAt = Date.now();
79+
80+
logInfo("[CURSOR] Persisted cursor state", {
81+
since_id: state.since_id,
82+
fetched_count: state.fetched_count,
83+
});
84+
} catch (error) {
85+
logError("[CURSOR] Failed to save cursor state", {
86+
error: error instanceof Error ? error.message : String(error),
87+
});
88+
}
89+
}
90+
91+
/**
92+
* Update cursor after successful fetch
93+
*/
94+
export async function updateCursor(
95+
sinceId: string | null,
96+
fetchedCount: number
97+
): Promise<void> {
98+
if (!sinceId) return;
99+
100+
// Check if cursor actually changed
101+
if (cachedCursor === sinceId) return;
102+
103+
const state: CursorState = {
104+
since_id: sinceId,
105+
last_fetch_at: new Date().toISOString(),
106+
fetched_count: fetchedCount,
107+
version: CURRENT_VERSION,
108+
};
109+
110+
// Throttle persistence
111+
const now = Date.now();
112+
if (now - lastPersistedAt < PERSISTENCE_INTERVAL_MS) {
113+
// Just update cache, don't persist yet
114+
cachedCursor = sinceId;
115+
return;
116+
}
117+
118+
await saveCursorState(state);
119+
}
120+
121+
/**
122+
* Get cached cursor (fast, no store read)
123+
*/
124+
export function getCachedCursor(): string | null {
125+
return cachedCursor;
126+
}
127+
128+
/**
129+
* Force immediate cursor persistence
130+
*/
131+
export async function forcePersistCursor(): Promise<void> {
132+
const state = await loadCursorState();
133+
await saveCursorState(state);
134+
}
135+
136+
/**
137+
* Validate cursor to prevent out-of-order ingestion
138+
*/
139+
export function isValidCursor(newCursor: string, oldCursor: string | null): boolean {
140+
if (!oldCursor) return true;
141+
142+
try {
143+
const newId = BigInt(newCursor);
144+
const oldId = BigInt(oldCursor);
145+
return newId > oldId;
146+
} catch {
147+
return newCursor > oldCursor;
148+
}
149+
}
150+
151+
/**
152+
* Cursor manager for poll loop integration
153+
*/
154+
export class CursorManager {
155+
private state: CursorState;
156+
private pendingSinceId: string | null = null;
157+
158+
constructor(state: CursorState) {
159+
this.state = state;
160+
cachedCursor = state.since_id;
161+
}
162+
163+
/**
164+
* Get current since_id for fetching
165+
*/
166+
getSinceId(): string | null {
167+
return this.state.since_id;
168+
}
169+
170+
/**
171+
* Queue cursor update (throttled persistence)
172+
*/
173+
async onFetchSuccess(sinceId: string | null, fetchedCount: number): Promise<void> {
174+
if (!sinceId) return;
175+
176+
// Validate cursor order
177+
if (!isValidCursor(sinceId, this.state.since_id)) {
178+
logWarn("[CURSOR] Ignoring out-of-order cursor", {
179+
newCursor: sinceId,
180+
oldCursor: this.state.since_id,
181+
});
182+
return;
183+
}
184+
185+
this.pendingSinceId = sinceId;
186+
this.state.since_id = sinceId;
187+
this.state.fetched_count = fetchedCount;
188+
189+
await updateCursor(sinceId, fetchedCount);
190+
}
191+
192+
/**
193+
* Force immediate persistence
194+
*/
195+
async flush(): Promise<void> {
196+
if (this.pendingSinceId) {
197+
await saveCursorState(this.state);
198+
this.pendingSinceId = null;
199+
}
200+
}
201+
202+
/**
203+
* Get stats for monitoring
204+
*/
205+
getStats(): { since_id: string | null; last_fetch_at: string; fetched_count: number } {
206+
return {
207+
since_id: this.state.since_id,
208+
last_fetch_at: this.state.last_fetch_at,
209+
fetched_count: this.state.fetched_count,
210+
};
211+
}
212+
}

0 commit comments

Comments
 (0)