mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-17 08:21:31 +00:00
Operator-run migration scripts for KV→Mongo and D1→trading_trades, plus a parity verifier and a rollback wiper. Pure local Node — no Worker code, no /__admin/* routes, no new Worker secrets. Complies with docs/architecture.md §10. Scripts - backfill-kv-to-mongo.js: paginates CF KV REST API per module, fetches values, $setOnInsert upsert into per-module Mongo collection. Resumes from .backfill-cursor-<module>.json on restart. Throttles 50 ops/sec. expiresAt derived from KV metadata.expiration (debugger #10). --dry-run and --module flags for incremental work. - backfill-d1-to-mongo.js: wrangler d1 execute --remote --json → parse → insertMany batches into trading_trades, preserving original integer id as legacy_id (code-reviewer #13). Pre-flight aborts if collection non-empty unless --force. - verify-mongo-parity.js: count parity ±1%, SHA256 value compare, expiresAt ±5min bucket. Full-scan when <10K docs, sqrt-sample capped at 500 otherwise (code-reviewer #21). Trading: full-scan on legacy_id/ts/user_id/symbol/qty. - wipe-mongo.js: rollback helper. deleteMany across all collections with readline confirm. --yes for CI. - lib/migration-helpers.js: shared sleep, sha256, checkpoint I/O, cfKvList/cfKvGet, MongoClient singleton, sample strategy. Surface updates - .env.deploy.example: CF account/token/namespace placeholders. - package.json: backfill:kv[:dry], backfill:d1[:dry], verify:mongo, wipe:mongo scripts. - check-secret-leaks.js: SECRETS array gains CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID for defense-in-depth. - .gitignore: .backfill-cursor-*.json excluded. Tests: 638 → 667 (+29 pure-logic tests for sha256, checkpoint round-trip, count-diff, sample-size, fetch-mocked CF REST). Lint clean. Operator-run sequence (after Phase 06 deploy): npm run backfill:kv:dry # preview npm run backfill:kv npm run backfill:d1:dry npm run backfill:d1 npm run verify:mongo # exit 0 = parity ok
195 lines
6.2 KiB
JavaScript
195 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @file migration-helpers — shared utilities for Phase 05 backfill scripts.
|
|
*
|
|
* Exports:
|
|
* - sleep, sha256
|
|
* - loadCheckpoint, saveCheckpoint, clearCheckpoint
|
|
* - cfKvList, cfKvGet
|
|
* - getMongoClient, closeMongoClient
|
|
* - countDiffRatio, sampleStrategy
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { MongoClient } from "mongodb";
|
|
|
|
const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
|
|
// ─── Primitives ───────────────────────────────────────────────────────────────
|
|
|
|
/** @param {number} ms @returns {Promise<void>} */
|
|
export const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
|
|
/** @param {string} text @returns {string} hex SHA-256 */
|
|
export const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
|
|
|
|
// ─── Checkpoint ───────────────────────────────────────────────────────────────
|
|
|
|
const cpPath = (mod) => resolve(process.cwd(), `.backfill-cursor-${mod}.json`);
|
|
|
|
/**
|
|
* Load saved cursor state for a module. Returns null if none.
|
|
*
|
|
* @param {string} moduleName
|
|
* @returns {{ cursor: string|null, lastKey: string|null }|null}
|
|
*/
|
|
export function loadCheckpoint(moduleName) {
|
|
const p = cpPath(moduleName);
|
|
if (!existsSync(p)) return null;
|
|
try {
|
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist cursor state so a crash can resume from this page.
|
|
*
|
|
* @param {string} moduleName
|
|
* @param {{ cursor: string|null, lastKey: string|null }} state
|
|
*/
|
|
export function saveCheckpoint(moduleName, state) {
|
|
writeFileSync(cpPath(moduleName), JSON.stringify(state), "utf8");
|
|
}
|
|
|
|
/**
|
|
* Remove checkpoint file on successful module completion.
|
|
*
|
|
* @param {string} moduleName
|
|
*/
|
|
export function clearCheckpoint(moduleName) {
|
|
const p = cpPath(moduleName);
|
|
if (existsSync(p)) {
|
|
try {
|
|
unlinkSync(p);
|
|
} catch {
|
|
// Non-fatal — may already be gone.
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── CF KV REST ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* List one page of KV keys for a given prefix.
|
|
*
|
|
* @param {string} accountId
|
|
* @param {string} nsId
|
|
* @param {string} token
|
|
* @param {string} prefix
|
|
* @param {string|null} cursor
|
|
* @returns {Promise<{
|
|
* keys: Array<{name: string, metadata?: {expiration?: number}}>,
|
|
* cursor: string|null,
|
|
* list_complete: boolean
|
|
* }>}
|
|
*/
|
|
export async function cfKvList(accountId, nsId, token, prefix, cursor) {
|
|
const url = new URL(`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${nsId}/keys`);
|
|
url.searchParams.set("prefix", prefix);
|
|
url.searchParams.set("limit", "1000");
|
|
if (cursor) url.searchParams.set("cursor", cursor);
|
|
|
|
const res = await fetch(url.toString(), {
|
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`CF KV list ${res.status}: ${body}`);
|
|
}
|
|
const json = await res.json();
|
|
if (!json.success) throw new Error(`CF KV list error: ${JSON.stringify(json.errors ?? json)}`);
|
|
return {
|
|
keys: json.result ?? [],
|
|
cursor: json.result_info?.cursor ?? null,
|
|
list_complete: !json.result_info?.cursor,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch the string value for a single KV key.
|
|
*
|
|
* @param {string} accountId
|
|
* @param {string} nsId
|
|
* @param {string} token
|
|
* @param {string} key
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function cfKvGet(accountId, nsId, token, key) {
|
|
const url = `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${nsId}/values/${encodeURIComponent(key)}`;
|
|
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`CF KV get "${key}" ${res.status}: ${body}`);
|
|
}
|
|
return res.text();
|
|
}
|
|
|
|
// ─── MongoDB singleton ────────────────────────────────────────────────────────
|
|
|
|
/** @type {MongoClient|null} */
|
|
let _client = null;
|
|
|
|
/**
|
|
* Return a shared MongoClient, connecting on first call.
|
|
* Registers process exit / signal handlers to close cleanly.
|
|
*
|
|
* @param {string} uri
|
|
* @returns {Promise<MongoClient>}
|
|
*/
|
|
export async function getMongoClient(uri) {
|
|
if (!uri) throw new Error("MongoDB URI is required — set it in .env.deploy and re-run");
|
|
if (_client) return _client;
|
|
const client = new MongoClient(uri);
|
|
await client.connect();
|
|
_client = client;
|
|
const close = () => {
|
|
_client?.close().catch(() => {});
|
|
_client = null;
|
|
};
|
|
process.once("exit", close);
|
|
process.once("SIGINT", () => {
|
|
close();
|
|
process.exit(0);
|
|
});
|
|
process.once("SIGTERM", () => {
|
|
close();
|
|
process.exit(0);
|
|
});
|
|
return _client;
|
|
}
|
|
|
|
/** Close the shared client (test teardown / forced close). */
|
|
export async function closeMongoClient() {
|
|
if (_client) {
|
|
await _client.close();
|
|
_client = null;
|
|
}
|
|
}
|
|
|
|
// ─── Parity math ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Relative difference between two counts — 0 means identical.
|
|
*
|
|
* @param {number} a
|
|
* @param {number} b
|
|
* @returns {number} value in [0, 1]
|
|
*/
|
|
export const countDiffRatio = (a, b) => Math.abs(a - b) / Math.max(a, b, 1);
|
|
|
|
/**
|
|
* Determine verify strategy: full-scan when total < 10 000,
|
|
* otherwise random-sample N = min(500, ceil(sqrt(total))).
|
|
*
|
|
* @param {number} total
|
|
* @returns {{ fullScan: boolean, sampleSize: number }}
|
|
*/
|
|
export function sampleStrategy(total) {
|
|
if (total < 10000) return { fullScan: true, sampleSize: total };
|
|
return { fullScan: false, sampleSize: Math.min(500, Math.ceil(Math.sqrt(total))) };
|
|
}
|