Schedule posts to Bluesky with Cloudflare workers. skyscheduler.work
cf tool bsky-tool cloudflare bluesky schedule bsky service social-media cloudflare-workers
at main 108 lines 4.6 kB view raw
1import { sql } from "drizzle-orm"; 2import { index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; 3import { RepostInfo } from "../classes/repost"; 4import { EmbedData, PostLabel } from '../types'; 5import { users } from "./auth.schema"; 6 7export const posts = sqliteTable('posts', { 8 uuid: text('uuid', {mode: 'text'}).primaryKey(), 9 content: text('content').notNull(), 10 scheduledDate: integer('scheduled_date', { mode: 'timestamp_ms' }).notNull(), 11 posted: integer('posted', { mode: 'boolean' }).default(false), 12 // This is a flag to help beat any race conditions with our cron jobs 13 postNow: integer('postNow', { mode: 'boolean' }).default(false), 14 embedContent: text('embedContent', {mode: 'json'}).notNull().$type<EmbedData[]>().default(sql`(json_array())`), 15 // Contains the reposting cadence of this post object, actionable rules are in the reposts table 16 repostInfo: text('repostInfo', {mode: 'json'}).$type<RepostInfo[]>(), 17 // bsky/atproto record information once a post is posted 18 uri: text('uri'), 19 cid: text('cid'), 20 // if this post is a pseudo post (i.e. a repost of anything) 21 isRepost: integer('isRepost', { mode: 'boolean' }).default(false), 22 rootPost: text('rootPost'), 23 parentPost: text('parentPost'), 24 threadOrder: integer('threadOrder').default(-1), 25 // bsky content labels 26 contentLabel: text('contentLabel', {mode: 'text'}).$type<PostLabel>().default(PostLabel.None).notNull(), 27 // metadata timestamps 28 createdAt: integer('created_at', { mode: 'timestamp_ms' }) 29 .default(sql`CURRENT_TIMESTAMP`) 30 .notNull(), 31 updatedAt: integer("updated_at", { mode: "timestamp_ms" }) 32 .$onUpdate(() => sql`CURRENT_TIMESTAMP`), 33 // who created this post 34 userId: text("user") 35 .notNull() 36 .references(() => users.id, { onDelete: "cascade" }), 37}, (table) => [ 38 // finding posts by user 39 index("user_idx").on(table.userId), 40 // for purging posted posts after a set time 41 index("postedUpdate_idx") 42 .on(table.updatedAt, table.posted) 43 .where(sql`posted = 1`), 44 // for reposts of the user 45 index("repostOnlyUser_idx") 46 .on(table.userId, table.isRepost) 47 .where(sql`isRepost = 1`), 48 // for db pruning and parity with the PDS 49 index("postedUUID_idx").on(table.uuid, table.posted), 50 // Querying children 51 index("generalThread_idx") 52 .on(table.parentPost, table.rootPost) 53 .where(sql`parentPost is not NULL`), 54 // Updating thread orders 55 index("threadOrder_idx") 56 .on(table.rootPost, table.threadOrder) 57 .where(sql`threadOrder <> -1`), 58 // cron job 59 index("postNowScheduledDatePosted_idx") 60 .on(table.posted, table.scheduledDate, table.postNow) 61 .where(sql`posted = 0 and postNow <> 1`), 62 // used to lower down the amount of posts that fill up the post table 63 index("repostAddOn_idx").on(table.userId, table.cid) 64]); 65 66export const reposts = sqliteTable('reposts', { 67 // garbage key 68 id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }), 69 // reflected post uuid 70 uuid: text('post_uuid') 71 .notNull() 72 .references(() => posts.uuid, {onDelete: "cascade"}), 73 scheduledDate: integer('scheduled_date', { mode: 'timestamp_ms' }).notNull(), 74 // bunching schedule cadence actions 75 scheduleGuid: text('schedule_guid') 76}, (table) => [ 77 // cron queries 78 index("repost_scheduledDate_idx").on(table.scheduledDate), 79 // used for left joining and matching with posts field 80 index("repost_postid_idx").on(table.uuid), 81 // used for checking if a schedule still has actions left 82 index("repost_scheduleGuid_idx").on(table.scheduleGuid, table.uuid), 83 // preventing similar actions from pushing to the table 84 unique("repost_noduplicates_idx").on(table.uuid, table.scheduledDate), 85]); 86 87// cache table for handling repost counts, without having to scan the entire 88// repost table 89export const repostCounts = sqliteTable('repostCounts', { 90 uuid: text('post_uuid') 91 .notNull() 92 .references(() => posts.uuid, {onDelete: "cascade"}).primaryKey(), 93 count: integer('count').default(0).notNull() 94}); 95 96// helper bookkeeping to make sure we don't have a ton of abandoned files in R2 97export const mediaFiles = sqliteTable('media', { 98 fileName: text('file', {mode: 'text'}).primaryKey(), 99 hasPost: integer('hasPost', { mode: 'boolean' }).default(false), 100 userId: text("user") 101 .references(() => users.id, { onDelete: "cascade" }), 102 createdAt: integer('created_at', { mode: 'timestamp_ms' }) 103 .$defaultFn(() => /* @__PURE__ */ new Date()) 104 .notNull(), 105}, (table) => [ 106 index("media_oldWithNoPost_idx").on(table.hasPost, table.createdAt).where(sql`hasPost = 0`), 107 index("media_userid_idx").on(table.userId) 108]);