mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(storage): migrate config, dedup and delivery state to D1
- Move oversized queue payloads from KV to R2 (PAYLOAD binding, webhooks/YYYY/MM/DD/*.json, KV queue:payload:* fallback)
- Persist routes/groups to D1 (d1_routes/d1_groups) with memory -> KV -> D1 three-tier cache, seeded from legacy KV config keys
- Move webhook dedup (dedup_keys), delivery state (delivery_state) and message tracking (message_tracking) to D1 via canUseD1 probe with automatic KV fallback
- Batch send_logs inserts (recordSendBatch) and add group_id/ts index
- Add storage-prune scheduled task for expired dedup/state/tracking rows
- Add TTL to invite:group:{id} index and audit all ephemeral KV keys
- Add D1 indexes for the new tables
- Sync AGENTS.md, README.md/zh and docs/ (en/zh) with the new storage layout
This commit is contained in:
parent
2e1b0f022e
commit
25ebae4ae5
46 changed files with 1450 additions and 117 deletions
174
tests/config-store.test.ts
Normal file
174
tests/config-store.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { d1ConfigStore, type ConfigStore } from "../server/lib/storage/config-store";
|
||||
import type { Route, Group } from "../server/lib/types";
|
||||
|
||||
interface Stmt {
|
||||
bind: (...args: unknown[]) => Stmt;
|
||||
all: () => Promise<{ results: Array<Record<string, unknown>> }>;
|
||||
}
|
||||
|
||||
interface FakeDB {
|
||||
prepare: (sql: string) => Stmt;
|
||||
batch: (stmts: Stmt[]) => Promise<unknown[]>;
|
||||
}
|
||||
|
||||
function route(id: string, groupId: string, overrides: Partial<Route> = {}): Route {
|
||||
return {
|
||||
id,
|
||||
name: `route-${id}`,
|
||||
enabled: true,
|
||||
filters: [],
|
||||
targets: [],
|
||||
groupId,
|
||||
stop: false,
|
||||
fallback: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function group(id: string): Group {
|
||||
return { id, name: `group-${id}`, adminIds: [] };
|
||||
}
|
||||
|
||||
function createDB(): { db: FakeDB & D1Database; routesTable: Route[]; groupsTable: Group[] } {
|
||||
const routesTable: Route[] = [];
|
||||
const groupsTable: Group[] = [];
|
||||
const db: FakeDB = {
|
||||
prepare(sql: string): Stmt {
|
||||
let _bound: unknown[] = [];
|
||||
const stmt: Stmt = {
|
||||
bind(...args: unknown[]): Stmt {
|
||||
_bound = args;
|
||||
return stmt;
|
||||
},
|
||||
async all(): Promise<{ results: Array<Record<string, unknown>> }> {
|
||||
if (sql.includes("FROM d1_routes")) {
|
||||
return {
|
||||
results: routesTable.map((r) => ({
|
||||
id: r.id,
|
||||
group_id: r.groupId ?? "",
|
||||
name: r.name,
|
||||
enabled: r.enabled ? 1 : 0,
|
||||
filters: JSON.stringify(r.filters),
|
||||
targets: JSON.stringify(r.targets),
|
||||
stop: r.stop ? 1 : 0,
|
||||
fallback: r.fallback ? 1 : 0,
|
||||
discord_role_ids: r.discordRoleIds ? JSON.stringify(r.discordRoleIds) : null,
|
||||
ast: r.ast ? JSON.stringify(r.ast) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (sql.includes("FROM d1_groups")) {
|
||||
return {
|
||||
results: groupsTable.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
data: JSON.stringify(g),
|
||||
version: 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
};
|
||||
return stmt;
|
||||
},
|
||||
async batch(stmts: Stmt[]): Promise<unknown[]> {
|
||||
void stmts;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
return { db: db as FakeDB & D1Database, routesTable, groupsTable };
|
||||
}
|
||||
|
||||
function createKV(): { kv: KVNamespace; store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
const kv = {
|
||||
store,
|
||||
get: async <T>(key: string, type?: string): Promise<T | null> => {
|
||||
const v = store.get(key);
|
||||
if (v == null) return null;
|
||||
if (type === "json") return JSON.parse(v) as T;
|
||||
return v as unknown as T;
|
||||
},
|
||||
put: async (key: string, value: string): Promise<void> => {
|
||||
store.set(key, value);
|
||||
},
|
||||
delete: async (key: string): Promise<void> => {
|
||||
store.delete(key);
|
||||
},
|
||||
list: async (): Promise<{ keys: unknown[] }> => ({ keys: [] }),
|
||||
};
|
||||
return { kv: kv as unknown as KVNamespace, store };
|
||||
}
|
||||
|
||||
describe("d1ConfigStore", () => {
|
||||
it("loads empty when D1 and KV are empty", async () => {
|
||||
const { db } = createDB();
|
||||
const { kv } = createKV();
|
||||
const store: ConfigStore = d1ConfigStore(db, kv);
|
||||
expect(await store.loadRoutes()).toEqual([]);
|
||||
expect(await store.loadGroups()).toEqual([]);
|
||||
});
|
||||
|
||||
it("seeds routes from KV into memory when D1 is empty and caches in KV", async () => {
|
||||
const { db } = createDB();
|
||||
const { kv, store } = createKV();
|
||||
const existing = [route("r1", "g1")];
|
||||
store.set("config:routes", JSON.stringify(existing));
|
||||
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
||||
const loaded = await cfg.loadRoutes();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].id).toBe("r1");
|
||||
});
|
||||
|
||||
it("reads routes directly from D1 when populated", async () => {
|
||||
const { db, routesTable } = createDB();
|
||||
const { kv } = createKV();
|
||||
routesTable.push(route("r1", "g1"));
|
||||
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
||||
const loaded = await cfg.loadRoutes();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].id).toBe("r1");
|
||||
expect(loaded[0].groupId).toBe("g1");
|
||||
});
|
||||
|
||||
it("round-trips routes through D1 + KV cache on save", async () => {
|
||||
const { db, routesTable } = createDB();
|
||||
const { kv, store } = createKV();
|
||||
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
||||
const routes = [route("r1", "g1"), route("r2", "g2")];
|
||||
await cfg.saveRoutes(routes);
|
||||
expect(routesTable).toHaveLength(0);
|
||||
const cached = store.get("config:routes");
|
||||
expect(cached).toBe(JSON.stringify(routes));
|
||||
const reloaded = await cfg.loadRoutes();
|
||||
expect(reloaded).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("round-trips groups through D1 + KV cache on save", async () => {
|
||||
const { db } = createDB();
|
||||
const { kv, store } = createKV();
|
||||
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
||||
const groups = [group("g1"), group("g2")];
|
||||
await cfg.saveGroups(groups);
|
||||
expect(store.get("config:groups")).toBe(JSON.stringify(groups));
|
||||
const reloaded = await cfg.loadGroups();
|
||||
expect(reloaded).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("invalidateCache forces a reload", async () => {
|
||||
const { db, routesTable } = createDB();
|
||||
const { kv } = createKV();
|
||||
const cfg: ConfigStore = d1ConfigStore(db, kv);
|
||||
routesTable.push(route("r1", "g1"));
|
||||
const first = await cfg.loadRoutes();
|
||||
expect(first).toHaveLength(1);
|
||||
routesTable.push(route("r2", "g2"));
|
||||
const cached = await cfg.loadRoutes();
|
||||
expect(cached).toHaveLength(1);
|
||||
cfg.invalidateCache();
|
||||
const second = await cfg.loadRoutes();
|
||||
expect(second).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
262
tests/d1-stores.test.ts
Normal file
262
tests/d1-stores.test.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
d1IdempotencyStore,
|
||||
idempotencyStore,
|
||||
kvIdempotencyStore,
|
||||
} from "../server/lib/lib/idempotency";
|
||||
import {
|
||||
d1MessageTracker,
|
||||
kvMessageTracker,
|
||||
messageTracker,
|
||||
} from "../server/lib/lib/message-tracker";
|
||||
import {
|
||||
getDeliveryState,
|
||||
setDeliveryState,
|
||||
} from "../server/lib/queue/delivery";
|
||||
import { canUseD1 } from "../server/lib/storage/d1";
|
||||
import type { Env } from "../server/lib/types";
|
||||
|
||||
interface Row {
|
||||
message_id?: string;
|
||||
status?: string;
|
||||
hit?: number;
|
||||
}
|
||||
|
||||
function createMockKV(): KVNamespace {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get: async (key: string) => store.get(key) ?? null,
|
||||
put: async (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
} as unknown as KVNamespace;
|
||||
}
|
||||
|
||||
function createMockD1(): {
|
||||
db: D1Database;
|
||||
dedupKeys: Map<string, { claimedAt: number; expiresAt: number }>;
|
||||
messageRows: Map<string, { messageId: string; updatedAt: number }>;
|
||||
deliveryRows: Map<string, { status: string; updatedAt: number }>;
|
||||
} {
|
||||
const dedupKeys = new Map<string, { claimedAt: number; expiresAt: number }>();
|
||||
const messageRows = new Map<string, { messageId: string; updatedAt: number }>();
|
||||
const deliveryRows = new Map<string, { status: string; updatedAt: number }>();
|
||||
|
||||
const run = (
|
||||
sql: string,
|
||||
args: unknown[],
|
||||
): { success: boolean; meta: { changes: number } } => {
|
||||
if (sql.includes("INSERT INTO dedup_keys")) {
|
||||
const [key, claimedAt, expiresAt] = args as [string, number, number];
|
||||
const existing = dedupKeys.get(key);
|
||||
if (!existing) {
|
||||
dedupKeys.set(key, { claimedAt, expiresAt });
|
||||
return { success: true, meta: { changes: 1 } };
|
||||
}
|
||||
if (existing.expiresAt < expiresAt) {
|
||||
dedupKeys.set(key, { claimedAt, expiresAt });
|
||||
return { success: true, meta: { changes: 1 } };
|
||||
}
|
||||
return { success: true, meta: { changes: 0 } };
|
||||
}
|
||||
if (sql.includes("INSERT INTO message_tracking")) {
|
||||
const [eventId, targetId, messageId, updatedAt] = args as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
];
|
||||
messageRows.set(`${eventId}\u0000${targetId}`, { messageId, updatedAt });
|
||||
return { success: true, meta: { changes: 1 } };
|
||||
}
|
||||
if (sql.includes("INSERT INTO delivery_state")) {
|
||||
const [key, status, updatedAt] = args as [string, string, number];
|
||||
deliveryRows.set(key, { status, updatedAt });
|
||||
return { success: true, meta: { changes: 1 } };
|
||||
}
|
||||
if (sql.includes("DELETE FROM message_tracking")) {
|
||||
const [eventId, targetId] = args as [string, string];
|
||||
const existed = messageRows.delete(`${eventId}\u0000${targetId}`);
|
||||
return { success: true, meta: { changes: existed ? 1 : 0 } };
|
||||
}
|
||||
return { success: true, meta: { changes: 0 } };
|
||||
};
|
||||
|
||||
const first = (sql: string, args: unknown[]): Row | null => {
|
||||
if (sql.includes("SELECT 1 AS hit FROM dedup_keys")) {
|
||||
const [key, now] = args as [string, number];
|
||||
const row = dedupKeys.get(key);
|
||||
if (row && row.expiresAt > now) return { hit: 1 };
|
||||
return null;
|
||||
}
|
||||
if (sql.includes("SELECT message_id FROM message_tracking")) {
|
||||
const [eventId, targetId] = args as [string, string];
|
||||
const row = messageRows.get(`${eventId}\u0000${targetId}`);
|
||||
return row ? { message_id: row.messageId } : null;
|
||||
}
|
||||
if (sql.includes("SELECT status FROM delivery_state")) {
|
||||
const [key] = args as [string];
|
||||
const row = deliveryRows.get(key);
|
||||
return row ? { status: row.status } : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const db = {
|
||||
prepare: (sql: string): {
|
||||
bind: (...args: unknown[]) => {
|
||||
run: () => Promise<{ success: boolean; meta: { changes: number } }>;
|
||||
all: () => Promise<{ results: unknown[] }>;
|
||||
first: () => Promise<Row | null>;
|
||||
};
|
||||
} => ({
|
||||
bind: (...args: unknown[]): {
|
||||
run: () => Promise<{ success: boolean; meta: { changes: number } }>;
|
||||
all: () => Promise<{ results: unknown[] }>;
|
||||
first: () => Promise<Row | null>;
|
||||
} => ({
|
||||
run: async () => run(sql, args),
|
||||
all: async () => ({ results: [] }),
|
||||
first: async () => first(sql, args),
|
||||
}),
|
||||
}),
|
||||
batch: async (): Promise<unknown[]> => [],
|
||||
} as unknown as D1Database;
|
||||
|
||||
return { db, dedupKeys, messageRows, deliveryRows };
|
||||
}
|
||||
|
||||
function envWith(db: D1Database, kv: KVNamespace): Env {
|
||||
return { KV: kv, DB: db } as Env;
|
||||
}
|
||||
|
||||
describe("d1IdempotencyStore", () => {
|
||||
it("claims a key exactly once", async () => {
|
||||
const { db } = createMockD1();
|
||||
const store = d1IdempotencyStore(db);
|
||||
const key = "delivery:github:g1:e1";
|
||||
await expect(store.claim(key, 120)).resolves.toBe(true);
|
||||
await expect(store.claim(key, 120)).resolves.toBe(false);
|
||||
await expect(store.has(key)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats an expired dedup key as absent", async () => {
|
||||
const { db, dedupKeys } = createMockD1();
|
||||
const store = d1IdempotencyStore(db);
|
||||
const key = "delivery:gitea:global:e2";
|
||||
await store.claim(key, 120);
|
||||
dedupKeys.set(key, { claimedAt: Date.now(), expiresAt: Date.now() - 1000 });
|
||||
await expect(store.has(key)).resolves.toBe(false);
|
||||
await expect(store.claim(key, 120)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("does not collide across different keys", async () => {
|
||||
const { db } = createMockD1();
|
||||
const store = d1IdempotencyStore(db);
|
||||
await store.claim("delivery:github:g1:e1", 120);
|
||||
await expect(store.claim("delivery:github:g1:e2", 120)).resolves.toBe(true);
|
||||
await expect(store.has("delivery:github:g1:e2")).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("d1MessageTracker", () => {
|
||||
it("round-trips a message id", async () => {
|
||||
const { db } = createMockD1();
|
||||
const tracker = d1MessageTracker(db);
|
||||
await tracker.set("evt-1", "discord:123", "message-42");
|
||||
await expect(tracker.get("evt-1", "discord:123")).resolves.toBe("message-42");
|
||||
await expect(tracker.get("evt-1", "discord:999")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("updates on re-set and deletes on delete", async () => {
|
||||
const { db } = createMockD1();
|
||||
const tracker = d1MessageTracker(db);
|
||||
await tracker.set("evt-1", "tg:9", "old");
|
||||
await tracker.set("evt-1", "tg:9", "new");
|
||||
await expect(tracker.get("evt-1", "tg:9")).resolves.toBe("new");
|
||||
await tracker.delete("evt-1", "tg:9");
|
||||
await expect(tracker.get("evt-1", "tg:9")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delivery state backed by D1", () => {
|
||||
it("round-trips status through the delivery_state table", async () => {
|
||||
const { db } = createMockD1();
|
||||
const env = envWith(db, createMockKV());
|
||||
const key = "delivery-state:github:global:d1";
|
||||
await setDeliveryState(env, key, "processing");
|
||||
await expect(getDeliveryState(env, key)).resolves.toBe("processing");
|
||||
await setDeliveryState(env, key, "delivered");
|
||||
await expect(getDeliveryState(env, key)).resolves.toBe("delivered");
|
||||
});
|
||||
|
||||
it("returns null when no state exists", async () => {
|
||||
const { db } = createMockD1();
|
||||
const env = envWith(db, createMockKV());
|
||||
await expect(
|
||||
getDeliveryState(env, "delivery-state:github:g9:nope"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("factory fallback decision", () => {
|
||||
it("canUseD1 is false for a db without batch", () => {
|
||||
const empty = {} as D1Database;
|
||||
expect(canUseD1(empty)).toBe(false);
|
||||
expect(canUseD1(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("idempotencyStore falls back to KV semantics without batch", async () => {
|
||||
const db = {
|
||||
prepare: (): { bind: () => { run: () => Promise<{ success: boolean }>; all: () => Promise<{ results: unknown[] }> } } => ({
|
||||
bind: (): { run: () => Promise<{ success: boolean }>; all: () => Promise<{ results: unknown[] }> } => ({
|
||||
run: async () => ({ success: true }),
|
||||
all: async () => ({ results: [] }),
|
||||
}),
|
||||
}),
|
||||
} as unknown as D1Database;
|
||||
const kv = createMockKV();
|
||||
const store = idempotencyStore(db, kv);
|
||||
await store.claim("delivery:github:g1:e1", 120);
|
||||
await expect(store.claim("delivery:github:g1:e1", 120)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("messageTracker falls back to KV semantics without batch", async () => {
|
||||
const db = {} as D1Database;
|
||||
const kv = createMockKV();
|
||||
const tracker = messageTracker(db, kv);
|
||||
await tracker.set("evt-1", "discord:1", "m1");
|
||||
await expect(tracker.get("evt-1", "discord:1")).resolves.toBe("m1");
|
||||
await tracker.delete("evt-1", "discord:1");
|
||||
await expect(tracker.get("evt-1", "discord:1")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("kv stores still work on their own", async () => {
|
||||
const kv = createMockKV();
|
||||
const idem = kvIdempotencyStore(kv);
|
||||
await idem.claim("delivery:github:g1:e1", 120);
|
||||
await expect(idem.has("delivery:github:g1:e1")).resolves.toBe(true);
|
||||
|
||||
const msg = kvMessageTracker(kv);
|
||||
await msg.set("evt-1", "discord:1", "m1");
|
||||
await expect(msg.get("evt-1", "discord:1")).resolves.toBe("m1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delivery state falls back to KV without batch", () => {
|
||||
it("stores JSON status in KV", async () => {
|
||||
const db = {} as D1Database;
|
||||
const kv = createMockKV();
|
||||
const env = envWith(db, kv);
|
||||
const key = "delivery-state:github:global:d2";
|
||||
await setDeliveryState(env, key, "delivered");
|
||||
const raw = await kv.get(key);
|
||||
expect(raw).not.toBeNull();
|
||||
expect(JSON.parse(raw as string)).toMatchObject({ status: "delivered" });
|
||||
await expect(getDeliveryState(env, key)).resolves.toBe("delivered");
|
||||
await expect(getDeliveryState(env, "delivery-state:github:global:nope")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
68
tests/payload.test.ts
Normal file
68
tests/payload.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { r2PayloadStore, type PayloadStore } from "../server/lib/storage/payload";
|
||||
import type { Env } from "../server/lib/types";
|
||||
|
||||
interface FakeBucket {
|
||||
put: (key: string, value: string) => Promise<void>;
|
||||
get: (key: string) => Promise<{ text: () => Promise<string> } | null>;
|
||||
delete: (key: string) => Promise<void>;
|
||||
objects: Map<string, string>;
|
||||
}
|
||||
|
||||
function createBucket(): FakeBucket {
|
||||
const objects = new Map<string, string>();
|
||||
const bucket: FakeBucket = {
|
||||
objects,
|
||||
put: async (key, value) => {
|
||||
objects.set(key, value);
|
||||
},
|
||||
get: async (key) => {
|
||||
const v = objects.get(key);
|
||||
return v ? { text: async () => v } : null;
|
||||
},
|
||||
delete: async (key) => {
|
||||
objects.delete(key);
|
||||
},
|
||||
};
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function envWith(bucket?: FakeBucket): Env {
|
||||
return {
|
||||
GITHUB_WEBHOOK_SECRET: "secret",
|
||||
KV: {} as KVNamespace,
|
||||
DB: {} as D1Database,
|
||||
PAYLOAD: bucket as unknown as R2Bucket,
|
||||
};
|
||||
}
|
||||
|
||||
describe("r2PayloadStore", () => {
|
||||
it("round-trips a payload through put/get/delete", async () => {
|
||||
const bucket = createBucket();
|
||||
const store: PayloadStore = r2PayloadStore(envWith(bucket));
|
||||
const key = await store.put('{"hello":"world"}');
|
||||
expect(key.startsWith("webhooks/")).toBe(true);
|
||||
expect(await store.get(key)).toBe('{"hello":"world"}');
|
||||
await store.delete(key);
|
||||
expect(await store.get(key)).toBeNull();
|
||||
});
|
||||
|
||||
it("generates unique keys per put", async () => {
|
||||
const store = r2PayloadStore(envWith(createBucket()));
|
||||
const a = await store.put("a");
|
||||
const b = await store.put("b");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("returns null for a missing key", async () => {
|
||||
const store = r2PayloadStore(envWith(createBucket()));
|
||||
expect(await store.get("webhooks/nope.json")).toBeNull();
|
||||
});
|
||||
|
||||
it("throws when the R2 binding is not configured", async () => {
|
||||
const store = r2PayloadStore(envWith(undefined));
|
||||
await expect(store.put("x")).rejects.toThrow("R2 binding is not configured");
|
||||
await expect(store.get("x")).rejects.toThrow("R2 binding is not configured");
|
||||
await expect(store.delete("x")).rejects.toThrow("R2 binding is not configured");
|
||||
});
|
||||
});
|
||||
112
tests/send-log-batch.test.ts
Normal file
112
tests/send-log-batch.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { recordSendBatch } from "../server/lib/lib/send-log-batch";
|
||||
import type { SendRecord } from "../server/lib/lib/send-log";
|
||||
|
||||
interface BoundStmt {
|
||||
sql: string;
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
function createMockDB(): { db: D1Database; rows: Array<Record<string, unknown>> } {
|
||||
const rows: Array<Record<string, unknown>> = [];
|
||||
const insertCols = [
|
||||
"ts",
|
||||
"route_id",
|
||||
"group_id",
|
||||
"event",
|
||||
"repo",
|
||||
"target",
|
||||
"ok",
|
||||
"error",
|
||||
"status",
|
||||
"message_id",
|
||||
"delivery_id",
|
||||
"platform",
|
||||
"actor",
|
||||
"action",
|
||||
"duration_ms",
|
||||
"error_code",
|
||||
"attempts",
|
||||
"detail",
|
||||
];
|
||||
const db = {
|
||||
prepare: (sql: string) => ({
|
||||
bind: (...args: unknown[]): BoundStmt => ({ sql, args }),
|
||||
run: async (): Promise<{ success: boolean }> => ({ success: true }),
|
||||
all: async (): Promise<{ results: Array<Record<string, unknown>> }> => ({
|
||||
results: rows,
|
||||
}),
|
||||
}),
|
||||
batch: async (stmts: BoundStmt[]): Promise<unknown[]> => {
|
||||
for (const s of stmts) {
|
||||
if (s.sql.startsWith("INSERT")) {
|
||||
const row: Record<string, unknown> = {};
|
||||
insertCols.forEach((col, i) => {
|
||||
row[col] = s.args[i];
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
return { db, rows };
|
||||
}
|
||||
|
||||
function record(overrides: Partial<SendRecord> = {}): SendRecord {
|
||||
return {
|
||||
ts: 1234,
|
||||
routeId: "r1",
|
||||
event: "push",
|
||||
target: "111",
|
||||
ok: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("recordSendBatch", () => {
|
||||
it("is a no-op for an empty list", async () => {
|
||||
const { db } = createMockDB();
|
||||
await expect(recordSendBatch(db, [])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("batches multiple inserts in a single db.batch call", async () => {
|
||||
const { db, rows } = createMockDB();
|
||||
await recordSendBatch(db, [
|
||||
record({ routeId: "a", ok: true }),
|
||||
record({ routeId: "b", ok: false, error: "boom", groupId: "g1" }),
|
||||
]);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].route_id).toBe("a");
|
||||
expect(rows[1].route_id).toBe("b");
|
||||
expect(rows[1].group_id).toBe("g1");
|
||||
expect(rows[1].ok).toBe(0);
|
||||
expect(rows[1].error).toBe("boom");
|
||||
});
|
||||
|
||||
it("serializes detail and encodes ok/error", async () => {
|
||||
const { db, rows } = createMockDB();
|
||||
await recordSendBatch(db, [
|
||||
record({ detail: { a: 1 }, ok: false, error: "x", status: 500 }),
|
||||
]);
|
||||
expect(rows[0].detail).toBe('{"a":1}');
|
||||
expect(rows[0].ok).toBe(0);
|
||||
expect(rows[0].status).toBe(500);
|
||||
});
|
||||
|
||||
it("swallows database errors", async () => {
|
||||
const db = {
|
||||
prepare: (): { bind: () => BoundStmt } => ({
|
||||
bind: (): BoundStmt => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
}),
|
||||
batch: async (): Promise<unknown[]> => {
|
||||
throw new Error("batch failed");
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
await expect(
|
||||
recordSendBatch(db, [record()]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue