// IndexStore: SQLite Durable Object holding the merged x402 resource index as JSON chunks (one row per 1,000
// resources, ~37 rows per refresh; no KV). Searches run inside the object against an in-memory copy.
// Refresh is lazy (no cron): a query that finds the index older than REFRESH_AFTER_MS sets an alarm, and the alarm
// pulls the public discovery APIs. A failed refresh keeps the last good index.
import { DurableObject } from 'cloudflare:workers';
import { buildIndex } from './engine.js';
import { queryIndex } from './query.js';
export const REFRESH_AFTER_MS = 6 * 3600_000;
const CHUNK = 1000;
export class IndexStore extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.sql = ctx.storage.sql;
this.sql.exec('CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)');
this.sql.exec('CREATE TABLE IF NOT EXISTS chunks (gen INTEGER NOT NULL, i INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY (gen, i))');
this.mem = null;
}
meta(k) { const r = this.sql.exec('SELECT v FROM meta WHERE k = ?', k).toArray()[0]; return r ? JSON.parse(r.v) : null; }
setMeta(k, v) { this.sql.exec('INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v', k, JSON.stringify(v)); }
load() {
const info = this.meta('index');
if (!info) return null;
if (this.mem && this.mem.gen === info.gen) return this.mem;
const rows = [];
for (const r of this.sql.exec('SELECT data FROM chunks WHERE gen = ? ORDER BY i', info.gen)) rows.push(...JSON.parse(r.data));
this.mem = { gen: info.gen, info, rows };
return this.mem;
}
async maybeRefresh(now) {
const info = this.meta('index');
const last = this.meta('last_attempt') || 0;
if (info && now - info.at < REFRESH_AFTER_MS) return false;
if (now - last < 10 * 60_000) return false; // at most one attempt per 10 minutes
const alarm = await this.ctx.storage.getAlarm();
if (alarm) return false;
await this.ctx.storage.setAlarm(now + 100);
return true;
}
async alarm() {
const now = Date.now();
this.setMeta('last_attempt', now);
this.mem = null; // free the in-memory copy while building (memory headroom); reloaded from SQLite on the next query
try {
const idx = await buildIndex({ fetchImpl: (...a) => fetch(...a), now });
const prev = this.meta('index');
const gen = (prev?.gen || 0) + 1;
for (let i = 0; i * CHUNK < idx.rows.length; i++) this.sql.exec('INSERT OR REPLACE INTO chunks (gen, i, data) VALUES (?, ?, ?)', gen, i, JSON.stringify(idx.rows.slice(i * CHUNK, (i + 1) * CHUNK)));
this.setMeta('index', { gen, at: idx.at, counts: idx.counts, sources_failed: idx.sources_failed });
this.sql.exec('DELETE FROM chunks WHERE gen <> ?', gen);
this.setMeta('last_error', null);
this.mem = null;
} catch (e) {
this.setMeta('last_error', { at: now, error: String(e?.message || e).slice(0, 200) });
console.warn(`toolpick refresh failed: ${String(e?.message || e).slice(0, 200)}`);
}
}
/** RPC: run one query against the index. Returns { info, result } or { info: null }. */
async query(tier, v, nowMs) {
const now = nowMs || Date.now();
await this.maybeRefresh(now);
const m = this.load();
if (!m) return { info: null, last_error: this.meta('last_error') };
return { info: m.info, result: queryIndex(m.rows, tier, v, Math.floor(now / 1000)) };
}
async status() { const i = this.meta('index'); return { index: i, last_error: this.meta('last_error'), last_attempt: this.meta('last_attempt') }; }
}
agentgate-source: code mirror on Cloudflare Pages. All repos