mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +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
|
|
@ -1,10 +1,11 @@
|
|||
import { canUseD1 } from "../storage/d1";
|
||||
|
||||
/**
|
||||
* Delivery idempotency: a small, reusable abstraction over "has this key been
|
||||
* seen / can I claim it once" so webhook dedup, nonce replay protection and
|
||||
* future delivery retry all share one semantics. `claim` is best-effort atomic
|
||||
* on KV (get-then-put): under a concurrent double-send both callers may see
|
||||
* "unclaimed", but that matches the existing dedup behavior and is acceptable
|
||||
* because dispatch itself is idempotent per (provider, group, delivery).
|
||||
* future delivery retry all share one semantics. Backed by D1 when the binding
|
||||
* is present (atomic `INSERT OR IGNORE`), falling back to a best-effort
|
||||
* get-then-put KV claim otherwise.
|
||||
*/
|
||||
export interface IdempotencyStore {
|
||||
has(key: string): Promise<boolean>;
|
||||
|
|
@ -26,6 +27,35 @@ export function kvIdempotencyStore(kv: KVNamespace): IdempotencyStore {
|
|||
};
|
||||
}
|
||||
|
||||
export function d1IdempotencyStore(db: D1Database): IdempotencyStore {
|
||||
return {
|
||||
async has(key): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare("SELECT 1 AS hit FROM dedup_keys WHERE key = ? AND expires_at > ?")
|
||||
.bind(key, Date.now())
|
||||
.first<{ hit: number }>();
|
||||
return row != null;
|
||||
},
|
||||
async claim(key, ttlSeconds): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const result = await db
|
||||
.prepare(
|
||||
`INSERT INTO dedup_keys (key, claimed_at, expires_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
claimed_at = excluded.claimed_at, expires_at = excluded.expires_at
|
||||
WHERE dedup_keys.expires_at < excluded.expires_at`,
|
||||
)
|
||||
.bind(key, now, now + ttlSeconds * 1000)
|
||||
.run();
|
||||
return (result.meta?.changes ?? 0) > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function idempotencyStore(db: D1Database, kv: KVNamespace): IdempotencyStore {
|
||||
return canUseD1(db) ? d1IdempotencyStore(db) : kvIdempotencyStore(kv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical dedup key for a webhook delivery: provider + tenant + delivery id.
|
||||
* The legacy global endpoint has no tenant, so `groupId` is "global".
|
||||
|
|
@ -36,4 +66,4 @@ export function deliveryKey(
|
|||
deliveryId: string,
|
||||
): string {
|
||||
return `delivery:${provider}:${groupId ?? "global"}:${deliveryId}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { canUseD1 } from "../storage/d1";
|
||||
|
||||
export interface MessageTracker {
|
||||
get(eventId: string, targetId: string): Promise<string | null>;
|
||||
set(eventId: string, targetId: string, messageId: string): Promise<void>;
|
||||
|
|
@ -22,3 +24,36 @@ export function kvMessageTracker(kv: KVNamespace): MessageTracker {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function d1MessageTracker(db: D1Database): MessageTracker {
|
||||
return {
|
||||
async get(eventId: string, targetId: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare("SELECT message_id FROM message_tracking WHERE event_id = ? AND target_id = ?")
|
||||
.bind(eventId, targetId)
|
||||
.first<{ message_id: string }>();
|
||||
return row?.message_id ?? null;
|
||||
},
|
||||
async set(eventId: string, targetId: string, messageId: string): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO message_tracking (event_id, target_id, message_id, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(event_id, target_id) DO UPDATE SET
|
||||
message_id = excluded.message_id, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.bind(eventId, targetId, messageId, Date.now())
|
||||
.run();
|
||||
},
|
||||
async delete(eventId: string, targetId: string): Promise<void> {
|
||||
await db
|
||||
.prepare("DELETE FROM message_tracking WHERE event_id = ? AND target_id = ?")
|
||||
.bind(eventId, targetId)
|
||||
.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function messageTracker(db: D1Database, kv: KVNamespace): MessageTracker {
|
||||
return canUseD1(db) ? d1MessageTracker(db) : kvMessageTracker(kv);
|
||||
}
|
||||
41
server/lib/lib/send-log-batch.ts
Normal file
41
server/lib/lib/send-log-batch.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { SendRecord } from "./send-log";
|
||||
import { log } from "./log";
|
||||
|
||||
export async function recordSendBatch(
|
||||
db: D1Database,
|
||||
records: SendRecord[],
|
||||
): Promise<void> {
|
||||
if (records.length === 0) return;
|
||||
try {
|
||||
const stmts = records.map((r) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO send_logs (ts, route_id, group_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
r.ts,
|
||||
r.routeId,
|
||||
r.groupId ?? null,
|
||||
r.event,
|
||||
r.repo ?? null,
|
||||
r.target,
|
||||
r.ok ? 1 : 0,
|
||||
r.error ?? null,
|
||||
r.status ?? null,
|
||||
r.messageId ?? null,
|
||||
r.deliveryId ?? null,
|
||||
r.platform ?? null,
|
||||
r.actor ?? null,
|
||||
r.action ?? null,
|
||||
r.durationMs ?? null,
|
||||
r.errorCode ?? null,
|
||||
r.attempts ?? null,
|
||||
r.detail ? JSON.stringify(r.detail) : null,
|
||||
),
|
||||
);
|
||||
await db.batch(stmts);
|
||||
} catch (err) {
|
||||
log.warn({ err, count: records.length }, "Failed to record send log batch");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue