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}
-
-{:else if event?.record}
-
-{/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