From eabb8057541be546fec8b984bb6566466a311bd3 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:58:35 +0200 Subject: [PATCH] cleanup, delete deleted posts --- migrations/0006_missing_since.sql | 18 ++ src/lib/atproto/server/feed.remote.ts | 54 ----- src/lib/components/embed/EventEmbed.svelte | 139 ------------ .../embed/special/AtmoRsvpEmbed.svelte | 11 - src/lib/reddit/RedditPostCard.svelte | 126 ++++++++++- src/lib/reddit/bot.ts | 200 ++++++++++++++++-- src/lib/reddit/db.ts | 76 ++++++- src/routes/_cron/check-dms/+server.ts | 1 - 8 files changed, 386 insertions(+), 239 deletions(-) create mode 100644 migrations/0006_missing_since.sql delete mode 100644 src/lib/components/embed/EventEmbed.svelte delete mode 100644 src/lib/components/embed/special/AtmoRsvpEmbed.svelte diff --git a/migrations/0006_missing_since.sql b/migrations/0006_missing_since.sql new file mode 100644 index 0000000..14471c4 --- /dev/null +++ b/migrations/0006_missing_since.sql @@ -0,0 +1,18 @@ +-- Track when we first detected a cached post's underlying bsky post as +-- missing from the appview. The refresh path stamps this column on every +-- tick that `app.bsky.feed.getPosts` omits the `quoted_post_uri`, and +-- clears it if the post reappears (weathering transient appview hiccups). +-- +-- A grace-period sweep runs later in the cron tick: once `missing_since` +-- is older than ~2h, the community's wrapper record (quote post or +-- repost, plus any threadgate) is deleted from the community account's +-- PDS via DPoP and the cached D1 row is removed. Without this, a +-- bsky-deleted post leaves a ghost wrapper record and a stale D1 row +-- that the UI keeps surfacing with a "quoted post unavailable" placeholder. +ALTER TABLE posts ADD COLUMN missing_since TEXT; + +-- Partial index: only rows actively marked missing need to be scanned +-- by the sweep query. A full-column index would be mostly wasted since +-- the common case is `missing_since IS NULL`. +CREATE INDEX idx_posts_missing_since ON posts(missing_since) + WHERE missing_since IS NOT NULL; diff --git a/src/lib/atproto/server/feed.remote.ts b/src/lib/atproto/server/feed.remote.ts index 0f882d4..05c4c1d 100644 --- a/src/lib/atproto/server/feed.remote.ts +++ b/src/lib/atproto/server/feed.remote.ts @@ -193,60 +193,6 @@ export const unfollowUser = command( } ); -export const createRsvp = command( - v.object({ - eventUri: v.string(), - eventCid: v.string(), - status: v.optional(v.string()) - }), - async (input) => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const rkey = TID.now(); - const res = await locals.client.post('com.atproto.repo.createRecord', { - input: { - repo: locals.did, - collection: 'community.lexicon.calendar.rsvp', - rkey, - record: { - $type: 'community.lexicon.calendar.rsvp', - status: input.status ?? 'community.lexicon.calendar.rsvp#going', - subject: { uri: input.eventUri, cid: input.eventCid }, - createdAt: new Date().toISOString() - } - } - }); - - if (!res.ok) error(res.status, 'Failed to RSVP'); - return { uri: res.data.uri }; - } -); - -export const deleteRsvp = command( - v.object({ - rsvpUri: v.string() - }), - async (input) => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const parts = input.rsvpUri.split('/'); - const rkey = parts[parts.length - 1]; - - const res = await locals.client.post('com.atproto.repo.deleteRecord', { - input: { - repo: locals.did, - collection: 'community.lexicon.calendar.rsvp', - rkey - } - }); - - if (!res.ok) error(res.status, 'Failed to cancel RSVP'); - return { ok: true }; - } -); - export const getProfile = command( v.object({ actor: v.string() diff --git a/src/lib/components/embed/EventEmbed.svelte b/src/lib/components/embed/EventEmbed.svelte deleted file mode 100644 index 80c9fb4..0000000 --- a/src/lib/components/embed/EventEmbed.svelte +++ /dev/null @@ -1,139 +0,0 @@ - - -{#if loading} -
-
Loading event...
-
-{:else if event?.record} -
- - {#if getThumbnail()} - - {/if} -
-

- {event.record.name} -

-
- {#if event.record.startsAt} - {formatDate(event.record.startsAt)}, {formatTime(event.record.startsAt)} - {/if} - {#if (event.rsvpsGoingCount + goingDelta) > 0 || event.rsvpsInterestedCount > 0} - {event.rsvpsGoingCount + goingDelta} going, {event.rsvpsInterestedCount} interested - {/if} -
-
-
- {#if user.did} -
- -
- {/if} -
-{/if} diff --git a/src/lib/components/embed/special/AtmoRsvpEmbed.svelte b/src/lib/components/embed/special/AtmoRsvpEmbed.svelte deleted file mode 100644 index 7d7ae4b..0000000 --- a/src/lib/components/embed/special/AtmoRsvpEmbed.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/src/lib/reddit/RedditPostCard.svelte b/src/lib/reddit/RedditPostCard.svelte index ff5e316..3fed44c 100644 --- a/src/lib/reddit/RedditPostCard.svelte +++ b/src/lib/reddit/RedditPostCard.svelte @@ -29,6 +29,110 @@ [key: string]: unknown; }; + /** + * Return a shallow copy of `post` with any mention facets targeting + * `communityDid` cut out of the record text, and surviving facets' + * byte indices re-shifted to match the shortened text. + * + * Bluesky facet indices are UTF-8 byte offsets, so we do all surgery + * on `TextEncoder()`-encoded bytes rather than JS string char indices. + * Adjacent whitespace is swallowed alongside each mention (preferring + * one leading space, falling back to one trailing space) so we don't + * leave dangling " " between words after stripping. + */ + function stripCommunityMention(post: PostView, communityDid: string): PostView { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const record = (post as any).record as + | { + text?: string; + facets?: Array<{ + index: { byteStart: number; byteEnd: number }; + features: Array<{ $type: string; did?: string }>; + }>; + } + | undefined + | null; + if (!record?.text || !record.facets || record.facets.length === 0) return post; + + const isTargetMention = (f: NonNullable[number]) => + f.features.some( + (feat) => + feat.$type === 'app.bsky.richtext.facet#mention' && feat.did === communityDid + ); + + const toRemove = record.facets.filter(isTargetMention); + if (toRemove.length === 0) return post; + + const textBytes = new TextEncoder().encode(record.text); + + // Expand each removal to swallow one adjacent whitespace byte — + // leading if possible, else trailing. We only care about 0x20 + // (space) and 0x0a (newline) — anything else is rare enough to + // leave as-is. + const isSpace = (b: number) => b === 0x20 || b === 0x0a; + const expanded = toRemove + .map((f) => { + let start = f.index.byteStart; + let end = f.index.byteEnd; + if (start > 0 && isSpace(textBytes[start - 1])) start -= 1; + else if (end < textBytes.length && isSpace(textBytes[end])) end += 1; + return { start, end }; + }) + // Non-overlapping (facets don't overlap) so start order = byte order. + .sort((a, b) => a.start - b.start); + + // Splice out the holes. + const pieces: Uint8Array[] = []; + let cursor = 0; + for (const { start, end } of expanded) { + if (start > cursor) pieces.push(textBytes.slice(cursor, start)); + cursor = end; + } + if (cursor < textBytes.length) pieces.push(textBytes.slice(cursor)); + + const totalLen = pieces.reduce((n, p) => n + p.length, 0); + const merged = new Uint8Array(totalLen); + let offset = 0; + for (const p of pieces) { + merged.set(p, offset); + offset += p.length; + } + const newText = new TextDecoder().decode(merged); + + // For each surviving facet, subtract the total bytes removed + // strictly BEFORE its position. `expanded` is sorted and + // non-overlapping, so one linear pass per lookup is fine. + const shiftByte = (byte: number): number => { + let removed = 0; + for (const r of expanded) { + if (r.end <= byte) removed += r.end - r.start; + else break; + } + return byte - removed; + }; + + const removeSet = new Set(toRemove); + const newFacets = record.facets + .filter((f) => !removeSet.has(f)) + .map((f) => ({ + ...f, + index: { + byteStart: shiftByte(f.index.byteStart), + byteEnd: shiftByte(f.index.byteEnd) + } + })); + + return { + ...post, + record: { + ...record, + text: newText, + facets: newFacets + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + type CardRow = PostRow | PostWithCommunity; let { @@ -78,9 +182,23 @@ : null ); + // The mention-based submission flow (see `processCommunityMentions` in + // src/lib/reddit/bot.ts) reposts any top-level bsky post that tags the + // community's handle. By the time we're rendering that post inside the + // community feed, the `@community.atmo.garden` mention is just noise — + // the UI already tells the viewer which community they're looking at. + // Strip any mention facet pointing at `row.community_did` from the + // quoted PostView before handing it to `blueskyPostToPostData`, so the + // rendered text and the rebuilt byte-indexed facets stay in sync. + const communityDid = $derived(row.community_did); + const strippedQuoted = $derived.by(() => { + if (!quoted || !communityDid) return quoted ?? null; + return stripCommunityMention(quoted, communityDid); + }); + const quotedEmbeds = $derived.by(() => { - if (!quoted) return []; - const { embeds } = blueskyPostToPostData(quoted as never, 'https://bsky.app'); + if (!strippedQuoted) return []; + const { embeds } = blueskyPostToPostData(strippedQuoted as never, 'https://bsky.app'); return wireEmbedClicks( embeds, (handle, rkey) => goto(`/profile/${handle}/post/${rkey}`), @@ -89,8 +207,8 @@ }); const quotedPostData = $derived.by(() => { - if (!quoted) return null; - const { postData } = blueskyPostToPostData(quoted as never, 'https://bsky.app'); + if (!strippedQuoted) return null; + const { postData } = blueskyPostToPostData(strippedQuoted as never, 'https://bsky.app'); return postData; }); diff --git a/src/lib/reddit/bot.ts b/src/lib/reddit/bot.ts index 99940f5..552195b 100644 --- a/src/lib/reddit/bot.ts +++ b/src/lib/reddit/bot.ts @@ -11,10 +11,12 @@ import { Client, simpleFetchHandler } from '@atcute/client'; import type { Did, ResourceUri } from '@atcute/lexicons'; import { decryptPassword, encryptPassword } from './crypto'; import { + deletePostsByUris, getCombinedFeed, getCommunityByDid, getJetstreamCursor, getPostsDueForRefresh, + getPostsPastDeletionGrace, hasSubmission, insertCommunity, insertPost, @@ -1444,14 +1446,20 @@ export async function drainSubmissionJetstream( * 3. Build a single D1 batch of UPDATE statements and flush in one * round-trip — far cheaper than the previous per-row await loop. * - * Dead-post backoff: if a quoted post is missing from the appview - * response (deleted, taken down, hidden), we still stamp - * `last_refreshed_at` on the row. Without that, the row would fail the - * `get → undefined` branch every tick and keep re-hitting the appview - * forever. Stamping lets it back off into its normal age bucket. + * Missing-post tracking: if a quoted post is absent from the appview + * response (deleted, taken down, hidden) we stamp `missing_since` on + * the row the first tick we see it, and leave it set on subsequent + * ticks. If the post reappears (brief appview inconsistency) we clear + * the stamp on that tick's update. `last_refreshed_at` is always + * advanced so the normal age-bucket backoff kicks in instead of every + * tick re-fetching the same dead URI. `sweepDeletedPosts` later in the + * cron tick picks up rows whose `missing_since` has aged past the + * grace period and nukes both the bsky wrapper record and the D1 row. * * Transient failures (network error, non-ok response for the whole - * batch) leave the rows alone so the next tick retries them. + * batch) leave the rows alone so the next tick retries them — in + * particular we do NOT stamp `missing_since` from a failed batch, so + * an appview outage can't trip the grace-period deletion path. */ export async function refreshPostMetrics(db: D1Database): Promise { const due = await getPostsDueForRefresh(db, 100); @@ -1510,19 +1518,20 @@ export async function refreshPostMetrics(db: D1Database): Promise { ); // Build one big D1 batch of UPDATEs — both the "we got metrics" - // path and the "quoted post is gone, back off" path. D1 processes - // all statements in a single round-trip, so 100 updates cost roughly - // the same as 1. + // (clears missing_since if set) and "quoted post is gone" (stamps + // missing_since via COALESCE so we keep the earliest detection + // time). D1 processes all statements in a single round-trip, so + // 100 updates cost roughly the same as 1. const updateWithMetrics = db.prepare( - 'UPDATE posts SET like_count = ?, reply_count = ?, repost_count = ?, last_refreshed_at = datetime("now") WHERE uri = ?' + 'UPDATE posts SET like_count = ?, reply_count = ?, repost_count = ?, last_refreshed_at = datetime("now"), missing_since = NULL WHERE uri = ?' ); - const touchOnly = db.prepare( - 'UPDATE posts SET last_refreshed_at = datetime("now") WHERE uri = ?' + const markMissing = db.prepare( + 'UPDATE posts SET last_refreshed_at = datetime("now"), missing_since = COALESCE(missing_since, datetime("now")) WHERE uri = ?' ); const statements: D1PreparedStatement[] = []; let refreshed = 0; - let deadBackoff = 0; + let missing = 0; for (const { batch, metricsByQuoted } of chunkResults) { if (!metricsByQuoted) continue; // transient failure — retry next tick @@ -1534,8 +1543,8 @@ export async function refreshPostMetrics(db: D1Database): Promise { ); refreshed++; } else { - statements.push(touchOnly.bind(row.uri)); - deadBackoff++; + statements.push(markMissing.bind(row.uri)); + missing++; } } } @@ -1549,15 +1558,155 @@ export async function refreshPostMetrics(db: D1Database): Promise { } } - if (deadBackoff > 0) { + if (missing > 0) { console.log( - `[refreshPostMetrics] ${refreshed} refreshed, ${deadBackoff} dead posts stamped for backoff` + `[refreshPostMetrics] ${refreshed} refreshed, ${missing} marked missing` ); } return refreshed; } +// ------------------------------------------------------------------------- +// Deleted-post sweep +// ------------------------------------------------------------------------- + +/** Parse `at://did:…/collection/rkey` into its collection + rkey parts. */ +function parseCommunityRecordUri( + uri: string +): { collection: string; rkey: string } | null { + const m = uri.match(/^at:\/\/did:[^/]+\/([^/]+)\/([^/]+)$/); + if (!m) return null; + return { collection: m[1], rkey: m[2] }; +} + +/** + * Grace period between "first tick we saw the post missing from the + * appview" and actually deleting it. Short enough that users don't + * stare at `(quoted post unavailable)` placeholders for long, long + * enough to weather brief appview inconsistencies without nuking valid + * submissions. + */ +const DELETE_GRACE_HOURS = 2; + +/** + * Per-tick cap so the sweep can't starve the rest of the cron if a + * massive takedown wave hits. Unprocessed rows stay queued and get + * picked up on the next tick. + */ +const SWEEP_LIMIT = 50; + +/** + * Nuke wrapper records for posts whose underlying bsky post has been + * deleted / taken down. Refresh stamps `missing_since` the first tick + * `getPosts` omits the quoted URI; we wait `DELETE_GRACE_HOURS` and + * then: + * + * 1. Delete the community account's wrapper record from its PDS via + * DPoP (`app.bsky.feed.post` for quotes or `app.bsky.feed.repost` + * for straight boosts). + * 2. For quote posts, also delete the paired threadgate record (same + * rkey, different collection). + * 3. Delete the cached D1 row. + * + * Best-effort on the bsky side: if `deleteRecord` fails (network + * blip, record already gone, broken community credentials, etc.) we + * STILL delete the D1 row. Leaving the D1 row would mean retrying + * forever on a row the UI no longer trusts anyway; a stale wrapper + * record on bsky is the lesser evil and will usually get cleaned up + * by the record being a dangling reference. + */ +export async function sweepDeletedPosts( + env: App.Platform['env'], + db: D1Database +): Promise { + const dueRows = await getPostsPastDeletionGrace(db, DELETE_GRACE_HOURS, SWEEP_LIMIT); + if (dueRows.length === 0) return 0; + + // Group by community so we can `loadClient` once per community + // instead of once per row. + const byCommunity = new Map(); + for (const r of dueRows) { + const arr = byCommunity.get(r.community_did) ?? []; + arr.push(r.uri); + byCommunity.set(r.community_did, arr); + } + + let deleted = 0; + const drainedFromD1: string[] = []; + + for (const [communityDid, uris] of byCommunity) { + const communityRow = await getCommunityByDid(db, communityDid); + if (!communityRow) { + // Community is gone (manually cleaned up, or `deleteCommunity` + // race). Drop the stragglers from D1 with no bsky call. + drainedFromD1.push(...uris); + deleted += uris.length; + continue; + } + + let client: WelcomeMatClient; + try { + client = await loadClient(env, communityRow); + } catch (e) { + console.error( + `[sweepDeletedPosts] loadClient failed for ${communityRow.handle}`, + e + ); + // Leave the rows alone — retry next tick once the failure + // (bad creds / rookery down) hopefully clears. + continue; + } + + for (const uri of uris) { + const parsed = parseCommunityRecordUri(uri); + if (!parsed) { + console.error('[sweepDeletedPosts] unparseable uri, dropping', uri); + drainedFromD1.push(uri); + deleted++; + continue; + } + const { collection, rkey } = parsed; + + // Delete the wrapper record (quote post or repost). + try { + await client.deleteRecord(collection, rkey); + } catch (e) { + console.error( + `[sweepDeletedPosts] deleteRecord(${collection}/${rkey}) on ${communityRow.handle} failed`, + e + ); + // Fall through — we still drop the D1 row below. + } + + // Quote posts carry a paired threadgate at the same rkey + // (see `createSubmissionPost`). Reposts don't. Non-fatal. + if (collection === 'app.bsky.feed.post') { + try { + await client.deleteRecord('app.bsky.feed.threadgate', rkey); + } catch { + // Probably already gone or never existed (pre-threadgate + // rows). Not worth logging at error level. + } + } + + drainedFromD1.push(uri); + deleted++; + } + } + + if (drainedFromD1.length > 0) { + try { + await deletePostsByUris(db, drainedFromD1); + } catch (e) { + console.error('[sweepDeletedPosts] D1 delete batch failed', e); + } + } + + console.log(`[sweepDeletedPosts] deleted ${deleted} rows`); + return deleted; +} + // ------------------------------------------------------------------------- // Top-level cron entry point // ------------------------------------------------------------------------- @@ -1569,15 +1718,14 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ webSubmissionsCreated: number; jetstreamEvents: number; postsRefreshed: number; + postsDeleted: number; errors: string[]; }> { - console.log('[cron] runCronTick start'); const db = env.DB; if (!db) throw new Error('DB binding missing'); const errors: string[] = []; const communities = await listCommunities(db); - console.log(`[cron] listCommunities → ${communities.length} rows`); let postsCreated = 0; let mentionsCreated = 0; @@ -1596,7 +1744,6 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ let posts = 0; let mentions = 0; - console.log(`[cron] ${row.handle} — DMs`); try { posts = await processCommunityDms(env, db, row); } catch (e) { @@ -1604,9 +1751,6 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ errors.push(`${row.handle}: ${String(e)}`); } - console.log( - `[cron] ${row.handle} — mentions (cursor=${row.last_mention_seen_at ?? 'NULL'})` - ); try { mentions = await processCommunityMentions(env, db, row); } catch (e) { @@ -1645,6 +1789,15 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ return 0; }); + // Sweep runs AFTER refresh so the same tick that stamps a new row + // as missing never also deletes it — the grace-period check in + // `getPostsPastDeletionGrace` guarantees this, but running the two + // in order is clearer. + const postsDeleted = await sweepDeletedPosts(env, db).catch((e) => { + errors.push(`sweep: ${String(e)}`); + return 0; + }); + return { communitiesChecked: communities.length, postsCreated, @@ -1652,6 +1805,7 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ webSubmissionsCreated: jetstreamResult.created, jetstreamEvents: jetstreamResult.events, postsRefreshed, + postsDeleted, errors }; } diff --git a/src/lib/reddit/db.ts b/src/lib/reddit/db.ts index bfeb088..2c50b24 100644 --- a/src/lib/reddit/db.ts +++ b/src/lib/reddit/db.ts @@ -51,6 +51,14 @@ export type PostRow = { like_count_at_submission: number | null; indexed_at: string; last_refreshed_at: string; + /** + * ISO-8601 timestamp of the first tick that saw the underlying bsky + * post as missing from `app.bsky.feed.getPosts`. NULL while the post + * is healthy; cleared if the post reappears. After a grace period + * (see `sweepDeletedPosts` in bot.ts) the wrapper record + this row + * get cleaned up. + */ + missing_since: string | null; }; export type PostWithCommunity = PostRow & { @@ -200,7 +208,10 @@ export async function hasSubmission( return !!res; } -export async function insertPost(db: D1Database, row: Omit): Promise { +export async function insertPost( + db: D1Database, + row: Omit +): Promise { try { await db .prepare( @@ -283,12 +294,17 @@ export async function getRecentPostsForCommunity( offset = 0 ): Promise { const { where, order } = sortClauses(sort); + // Hide rows currently flagged missing — between the first refresh + // tick that detects the underlying bsky post is gone and the sweep + // cleaning them up (grace period), we don't want users to see + // `(quoted post unavailable)` placeholders in the feed. const res = await db .prepare( `SELECT p.*, c.handle AS community_handle, c.display_name AS community_display_name, c.avatar AS community_avatar, c.accent_color AS community_accent_color FROM posts p JOIN communities c ON c.did = p.community_did WHERE p.community_did = ? + AND p.missing_since IS NULL ${where} ORDER BY ${order} LIMIT ? OFFSET ?` @@ -327,17 +343,19 @@ export async function getCombinedFeed( offset = 0 ): Promise { const { where, order } = sortClauses(sort); - // sortClauses returns a fragment starting with `AND` since it was - // written for the community-scoped query that always has a - // `WHERE p.community_did = ?` clause. For the home feed there's no - // base WHERE, so we rewrite the leading `AND` to `WHERE`. - const whereClause = where ? where.replace(/^\s*AND\s+/, 'WHERE ') : ''; + // Same rationale as `getRecentPostsForCommunity`: drop rows whose + // underlying post is flagged as gone. `sortClauses` returns a + // fragment starting with `AND` (written for the community-scoped + // query's `WHERE p.community_did = ?` base). For the home feed we + // anchor on the missing_since filter so every sort's fragment + // stays consistent as an `AND`-prefixed addition. const res = await db .prepare( `SELECT p.*, c.handle AS community_handle, c.display_name AS community_display_name, c.avatar AS community_avatar, c.accent_color AS community_accent_color FROM posts p JOIN communities c ON c.did = p.community_did - ${whereClause} + WHERE p.missing_since IS NULL + ${where} ORDER BY ${order} LIMIT ? OFFSET ?` ) @@ -432,3 +450,47 @@ export async function getPostsDueForRefresh( .all<{ uri: string; quoted_post_uri: string }>(); return res.results ?? []; } + +/** + * Rows past the deletion grace period — used by the cron's + * `sweepDeletedPosts` step. A row qualifies when `missing_since` is + * non-NULL (set by the refresh path on the first tick that the quoted + * bsky post dropped out of `getPosts`) AND the grace period has fully + * elapsed since that stamp. Ordered oldest-missing-first so chronically + * dead rows get cleaned up before newly-detected ones when the per-tick + * cap is hit. + */ +export async function getPostsPastDeletionGrace( + db: D1Database, + graceHours: number, + limit: number +): Promise<{ uri: string; community_did: Did }[]> { + const res = await db + .prepare( + `SELECT uri, community_did + FROM posts + WHERE missing_since IS NOT NULL + AND (julianday('now') - julianday(missing_since)) * 24 >= ? + ORDER BY missing_since ASC + LIMIT ?` + ) + .bind(graceHours, limit) + .all<{ uri: string; community_did: Did }>(); + return res.results ?? []; +} + +/** + * Delete a set of cached submission rows by URI. Used by the sweep + * after the community-account wrapper records have been deleted from + * bsky (best-effort — we still drop the D1 row even if the bsky delete + * fails, to avoid indefinite retries on broken rows). + */ +export async function deletePostsByUris( + db: D1Database, + uris: string[] +): Promise { + if (uris.length === 0) return; + await db.batch( + uris.map((uri) => db.prepare('DELETE FROM posts WHERE uri = ?').bind(uri)) + ); +} diff --git a/src/routes/_cron/check-dms/+server.ts b/src/routes/_cron/check-dms/+server.ts index 2f060b7..b87d2fb 100644 --- a/src/routes/_cron/check-dms/+server.ts +++ b/src/routes/_cron/check-dms/+server.ts @@ -8,7 +8,6 @@ import { json, error } from '@sveltejs/kit'; import { runCronTick } from '$lib/reddit/bot'; async function handle(request: Request, platform: App.Platform | undefined) { - console.log('[cron] handler entry', request.method, request.url); const env = platform?.env; if (!env) error(500, 'Platform env unavailable'); -- 2.51.2