diff --git a/apps/dotcom-worker/src/TLDrawDurableObject.ts b/apps/dotcom-worker/src/TLDrawDurableObject.ts index 6e9390ef0..245ae9850 100644 --- a/apps/dotcom-worker/src/TLDrawDurableObject.ts +++ b/apps/dotcom-worker/src/TLDrawDurableObject.ts @@ -1,29 +1,28 @@ /// /// -import { SupabaseClient } from '@supabase/supabase-js' +import { Client as PostgresClient } from 'pg' import { - READ_ONLY_LEGACY_PREFIX, - READ_ONLY_PREFIX, - ROOM_OPEN_MODE, - ROOM_PREFIX, - type RoomOpenMode, + READ_ONLY_LEGACY_PREFIX, + READ_ONLY_PREFIX, + ROOM_OPEN_MODE, + ROOM_PREFIX, + type RoomOpenMode, } from '@tldraw/dotcom-shared' import { - RoomSnapshot, - TLCloseEventCode, - TLSocketRoom, - type PersistedRoomSnapshotForSupabase, + RoomSnapshot, + TLSocketRoom, + type PersistedRoomSnapshotForSupabase, } from '@tldraw/sync-core' import { TLRecord } from '@tldraw/tlschema' -import { assert, assertExists, exhaustiveSwitchError } from '@tldraw/utils' +import { assertExists, exhaustiveSwitchError } from '@tldraw/utils' import { createPersistQueue, createSentry } from '@tldraw/worker-shared' import { IRequest, Router } from 'itty-router' import { AlarmScheduler } from './AlarmScheduler' import { PERSIST_INTERVAL_MS } from './config' import { getR2KeyForRoom } from './r2' import { Analytics, DBLoadResult, Environment, TLServerEvent } from './types' -import { createSupabaseClient } from './utils/createSupabaseClient' +import { createPostgresClient } from './utils/createPostgresClient' import { getSlug } from './utils/roomOpenMode' import { throttle } from './utils/throttle' @@ -32,396 +31,388 @@ const MAX_CONNECTIONS = 50 // increment this any time you make a change to this type const CURRENT_DOCUMENT_INFO_VERSION = 0 interface DocumentInfo { - version: number - slug: string + version: number + slug: string } const ROOM_NOT_FOUND = Symbol('room_not_found') export class TLDrawDurableObject { - // A unique identifier for this instance of the Durable Object - id: DurableObjectId + // A unique identifier for this instance of the Durable Object + id: DurableObjectId - // For TLSyncRoom - _room: Promise> | null = null + // For TLSyncRoom + _room: Promise> | null = null - getRoom() { - if (!this._documentInfo) { - throw new Error('documentInfo must be present when accessing room') - } - const slug = this._documentInfo.slug - if (!this._room) { - this._room = this.loadFromDatabase(slug).then((result) => { - switch (result.type) { - case 'room_found': { - const room = new TLSocketRoom({ - initialSnapshot: result.snapshot, - onSessionRemoved: async (room, args) => { - this.logEvent({ - type: 'client', - roomId: slug, - name: 'leave', - instanceId: args.sessionKey, - localClientId: args.meta.storeId, - }) + getRoom() { + if (!this._documentInfo) { + throw new Error('documentInfo must be present when accessing room') + } + const slug = this._documentInfo.slug + if (!this._room) { + this._room = this.loadFromDatabase(slug).then((result) => { + switch (result.type) { + case 'room_found': { + const room = new TLSocketRoom({ + initialSnapshot: result.snapshot, + onSessionRemoved: async (room, args) => { + this.logEvent({ + type: 'client', + roomId: slug, + name: 'leave', + instanceId: args.sessionKey, + localClientId: args.meta.storeId, + }) - if (args.numSessionsRemaining > 0) return - if (!this._room) return - this.logEvent({ - type: 'client', - roomId: slug, - name: 'last_out', - instanceId: args.sessionKey, - localClientId: args.meta.storeId, - }) - try { - await this.persistToDatabase() - } catch (err) { - // already logged - } - // make sure nobody joined the room while we were persisting - if (room.getNumActiveSessions() > 0) return - this._room = null - this.logEvent({ type: 'room', roomId: slug, name: 'room_empty' }) - room.close() - }, - onDataChange: () => { - this.triggerPersistSchedule() - }, - onBeforeSendMessage: ({ message, stringified }) => { - this.logEvent({ - type: 'send_message', - roomId: slug, - messageType: message.type, - messageLength: stringified.length, - }) - }, - }) - this.logEvent({ type: 'room', roomId: slug, name: 'room_start' }) - return room - } - case 'room_not_found': { - throw ROOM_NOT_FOUND - } - case 'error': { - throw result.error - } - default: { - exhaustiveSwitchError(result) - } - } - }) - } - return this._room - } + if (args.numSessionsRemaining > 0) return + if (!this._room) return + this.logEvent({ + type: 'client', + roomId: slug, + name: 'last_out', + instanceId: args.sessionKey, + localClientId: args.meta.storeId, + }) + try { + await this.persistToDatabase() + } catch (err) { + // already logged + } + // make sure nobody joined the room while we were persisting + if (room.getNumActiveSessions() > 0) return + this._room = null + this.logEvent({ type: 'room', roomId: slug, name: 'room_empty' }) + room.close() + }, + onDataChange: () => { + this.triggerPersistSchedule() + }, + onBeforeSendMessage: ({ message, stringified }) => { + this.logEvent({ + type: 'send_message', + roomId: slug, + messageType: message.type, + messageLength: stringified.length, + }) + }, + }) + this.logEvent({ type: 'room', roomId: slug, name: 'room_start' }) + return room + } + case 'room_not_found': { + throw ROOM_NOT_FOUND + } + case 'error': { + throw result.error + } + default: { + exhaustiveSwitchError(result) + } + } + }) + } + return this._room + } - // For storage - storage: DurableObjectStorage + // For storage + storage: DurableObjectStorage - // For persistence - supabaseClient: SupabaseClient | void + // For persistence + postgresClient: PostgresClient | null - // For analytics - measure: Analytics | undefined + // For analytics + measure: Analytics | undefined - // For error tracking - sentryDSN: string | undefined + // For error tracking + sentryDSN: string | undefined - readonly supabaseTable: string - readonly r2: { - readonly rooms: R2Bucket - readonly versionCache: R2Bucket - } + readonly postgresTable: string + readonly r2: { + readonly rooms: R2Bucket + readonly versionCache: R2Bucket + } - _documentInfo: DocumentInfo | null = null + _documentInfo: DocumentInfo | null = null - constructor( - private state: DurableObjectState, - private env: Environment - ) { - this.id = state.id - this.storage = state.storage - this.sentryDSN = env.SENTRY_DSN - this.measure = env.MEASURE - this.supabaseClient = createSupabaseClient(env) + constructor( + private state: DurableObjectState, + private env: Environment + ) { + this.id = state.id + this.storage = state.storage + this.sentryDSN = env.SENTRY_DSN + this.measure = env.MEASURE + this.postgresClient = createPostgresClient(env) - this.supabaseTable = env.TLDRAW_ENV === 'production' ? 'drawings' : 'drawings_staging' - this.r2 = { - rooms: env.ROOMS, - versionCache: env.ROOMS_HISTORY_EPHEMERAL, - } + this.postgresTable = env.TLDRAW_ENV === 'production' ? 'drawings' : 'drawings_staging' + this.r2 = { + rooms: env.ROOMS, + versionCache: env.ROOMS_HISTORY_EPHEMERAL, + } - state.blockConcurrencyWhile(async () => { - const existingDocumentInfo = (await this.storage.get('documentInfo')) as DocumentInfo | null - if (existingDocumentInfo?.version !== CURRENT_DOCUMENT_INFO_VERSION) { - this._documentInfo = null - } else { - this._documentInfo = existingDocumentInfo - } - }) - } + state.blockConcurrencyWhile(async () => { + const existingDocumentInfo = (await this.storage.get('documentInfo')) as DocumentInfo | null + if (existingDocumentInfo?.version !== CURRENT_DOCUMENT_INFO_VERSION) { + this._documentInfo = null + } else { + this._documentInfo = existingDocumentInfo + } + }) + } - readonly router = Router() - .get( - `/${ROOM_PREFIX}/:roomId`, - (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE), - (req) => this.onRequest(req) - ) - .get( - `/${READ_ONLY_LEGACY_PREFIX}/:roomId`, - (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY_LEGACY), - (req) => this.onRequest(req) - ) - .get( - `/${READ_ONLY_PREFIX}/:roomId`, - (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY), - (req) => this.onRequest(req) - ) - .post( - `/${ROOM_PREFIX}/:roomId/restore`, - (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE), - (req) => this.onRestore(req) - ) - .all('*', () => new Response('Not found', { status: 404 })) + readonly router = Router() + .get( + `/${ROOM_PREFIX}/:roomId`, + (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE), + (req) => this.onRequest(req) + ) + .get( + `/${READ_ONLY_LEGACY_PREFIX}/:roomId`, + (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY_LEGACY), + (req) => this.onRequest(req) + ) + .get( + `/${READ_ONLY_PREFIX}/:roomId`, + (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY), + (req) => this.onRequest(req) + ) + .post( + `/${ROOM_PREFIX}/:roomId/restore`, + (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE), + (req) => this.onRestore(req) + ) + .all('*', () => new Response('Not found', { status: 404 })) - readonly scheduler = new AlarmScheduler({ - storage: () => this.storage, - alarms: { - persist: async () => { - this.persistToDatabase() - }, - }, - }) + readonly scheduler = new AlarmScheduler({ + storage: () => this.storage, + alarms: { + persist: async () => { + this.persistToDatabase() + }, + }, + }) - // eslint-disable-next-line no-restricted-syntax - get documentInfo() { - return assertExists(this._documentInfo, 'documentInfo must be present') - } - extractDocumentInfoFromRequest = async (req: IRequest, roomOpenMode: RoomOpenMode) => { - const slug = assertExists( - await getSlug(this.env, req.params.roomId, roomOpenMode), - 'roomId must be present' - ) - if (this._documentInfo) { - assert(this._documentInfo.slug === slug, 'slug must match') - } else { - this._documentInfo = { - version: CURRENT_DOCUMENT_INFO_VERSION, - slug, - } - } - } + // eslint-disable-next-line no-restricted-syntax + get documentInfo() { + return assertExists(this._documentInfo, 'documentInfo must be present') + } + extractDocumentInfoFromRequest = async (req: IRequest, roomOpenMode: RoomOpenMode) => { + const slug = assertExists( + await getSlug(this.env, req.params.roomId, roomOpenMode), + 'roomId must be present' + ) + if (this._documentInfo) { + assert(this._documentInfo.slug === slug, 'slug must match') + } else { + this._documentInfo = { + version: CURRENT_DOCUMENT_INFO_VERSION, + slug, + } + } + } - // Handle a request to the Durable Object. - async fetch(req: IRequest) { - const sentry = createSentry(this.state, this.env, req) + // Handle a request to the Durable Object. + async fetch(req: IRequest) { + const sentry = createSentry(this.state, this.env, req) - try { - return await this.router.handle(req) - } catch (err) { - console.error(err) - // eslint-disable-next-line deprecation/deprecation - sentry?.captureException(err) - return new Response('Something went wrong', { - status: 500, - statusText: 'Internal Server Error', - }) - } - } + try { + return await this.router.handle(req) + } catch (err) { + console.error(err) + // eslint-disable-next-line deprecation/deprecation + sentry?.captureException(err) + return new Response('Something went wrong', { + status: 500, + statusText: 'Internal Server Error', + }) + } + } - _isRestoring = false - async onRestore(req: IRequest) { - this._isRestoring = true - try { - const roomId = this.documentInfo.slug - const roomKey = getR2KeyForRoom(roomId) - const timestamp = ((await req.json()) as any).timestamp - if (!timestamp) { - return new Response('Missing timestamp', { status: 400 }) - } - const data = await this.r2.versionCache.get(`${roomKey}/${timestamp}`) - if (!data) { - return new Response('Version not found', { status: 400 }) - } - const dataText = await data.text() - await this.r2.rooms.put(roomKey, dataText) - const room = await this.getRoom() + _isRestoring = false + async onRestore(req: IRequest) { + this._isRestoring = true + try { + const roomId = this.documentInfo.slug + const roomKey = getR2KeyForRoom(roomId) + const timestamp = ((await req.json()) as any).timestamp + if (!timestamp) { + return new Response('Missing timestamp', { status: 400 }) + } + const data = await this.r2.versionCache.get(`${roomKey}/${timestamp}`) + if (!data) { + return new Response('Version not found', { status: 400 }) + } + const dataText = await data.text() + await this.r2.rooms.put(roomKey, dataText) + const room = await this.getRoom() - const snapshot: RoomSnapshot = JSON.parse(dataText) - room.loadSnapshot(snapshot) + const snapshot: RoomSnapshot = JSON.parse(dataText) + room.loadSnapshot(snapshot) - return new Response() - } finally { - this._isRestoring = false - } - } + return new Response() + } finally { + this._isRestoring = false + } + } - async onRequest(req: IRequest) { - // extract query params from request, should include instanceId - const url = new URL(req.url) - const params = Object.fromEntries(url.searchParams.entries()) - let { sessionKey, storeId } = params + async onRequest(req: IRequest) { + // extract query params from request, should include instanceId + const url = new URL(req.url) + const params = Object.fromEntries(url.searchParams.entries()) + let { sessionKey, storeId } = params - // handle legacy param names - sessionKey ??= params.instanceId - storeId ??= params.localClientId - const isNewSession = !this._room + // handle legacy param names + sessionKey ??= params.instanceId + storeId ??= params.localClientId + const isNewSession = !this._room - // Create the websocket pair for the client - const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair() - serverWebSocket.accept() + // Create the websocket pair for the client + const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair() + serverWebSocket.accept() - try { - const room = await this.getRoom() - // Don't connect if we're already at max connections - if (room.getNumActiveSessions() >= MAX_CONNECTIONS) { - return new Response('Room is full', { status: 403 }) - } + try { + const room = await this.getRoom() + // Don't connect if we're already at max connections + if (room.getNumActiveSessions() >= MAX_CONNECTIONS) { + return new Response('Room is full', { status: 403 }) + } - // all good - room.handleSocketConnect(sessionKey, serverWebSocket, { storeId }) - if (isNewSession) { - this.logEvent({ - type: 'client', - roomId: this.documentInfo.slug, - name: 'room_reopen', - instanceId: sessionKey, - localClientId: storeId, - }) - } - this.logEvent({ - type: 'client', - roomId: this.documentInfo.slug, - name: 'enter', - instanceId: sessionKey, - localClientId: storeId, - }) - return new Response(null, { status: 101, webSocket: clientWebSocket }) - } catch (e) { - if (e === ROOM_NOT_FOUND) { - serverWebSocket.close(TLCloseEventCode.NOT_FOUND, 'Room not found') - return new Response(null, { status: 101, webSocket: clientWebSocket }) - } - throw e - } - } + // all good + room.handleSocketConnect(sessionKey, serverWebSocket, { storeId }) + if (isNewSession) { + this.logEvent({ + type: 'client', + roomId: this.documentInfo.slug, + name: 'room_reopen', + instanceId: sessionKey, + localClientId: storeId, + }) + } + this.logEvent({ + type: 'client', + roomId: this.documentInfo.slug, + name: 'enter', + instanceId: sessionKey, + localClientId: storeId, + }) + return new Response(null, { status: 101, webSocket: clientWebSocket }) + } catch (e) { + if (e === ROOM_NOT_FOUND) { + serverWebSocket.close(TLCloseEventCode.NOT_FOUND, 'Room not found') + return new Response(null, { status: 101, webSocket: clientWebSocket }) + } + throw e + } + } - triggerPersistSchedule = throttle(() => { - this.schedulePersist() - }, 2000) + triggerPersistSchedule = throttle(() => { + this.schedulePersist() + }, 2000) - private writeEvent( - name: string, - { blobs, indexes, doubles }: { blobs?: string[]; indexes?: [string]; doubles?: number[] } - ) { - this.measure?.writeDataPoint({ - blobs: [name, this.env.WORKER_NAME ?? 'development-tldraw-multiplayer', ...(blobs ?? [])], - doubles, - indexes, - }) - } + private writeEvent( + name: string, + { blobs, indexes, doubles }: { blobs?: string[]; indexes?: [string]; doubles?: number[] } + ) { + this.measure?.writeDataPoint({ + blobs: [name, this.env.WORKER_NAME ?? 'development-tldraw-multiplayer', ...(blobs ?? [])], + doubles, + indexes, + }) + } - logEvent(event: TLServerEvent) { - switch (event.type) { - case 'room': { - // we would add user/connection ids here if we could - this.writeEvent(event.name, { blobs: [event.roomId] }) - break - } - case 'client': { - // we would add user/connection ids here if we could - this.writeEvent(event.name, { - blobs: [event.roomId, 'unused', event.instanceId], - indexes: [event.localClientId], - }) - break - } - case 'send_message': { - this.writeEvent(event.type, { - blobs: [event.roomId, event.messageType], - doubles: [event.messageLength], - }) - break - } - default: { - exhaustiveSwitchError(event) - } - } - } + logEvent(event: TLServerEvent) { + switch (event.type) { + case 'room': { + // we would add user/connection ids here if we could + this.writeEvent(event.name, { blobs: [event.roomId] }) + break + } + case 'client': { + // we would add user/connection ids here if we could + this.writeEvent(event.name, { + blobs: [event.roomId, 'unused', event.instanceId], + indexes: [event.localClientId], + }) + break + } + case 'send_message': { + this.writeEvent(event.type, { + blobs: [event.roomId, event.messageType], + doubles: [event.messageLength], + }) + break + } + default: { + exhaustiveSwitchError(event) + } + } + } - // Load the room's drawing data. First we check the R2 bucket, then we fallback to supabase (legacy). - async loadFromDatabase(persistenceKey: string): Promise { - try { - const key = getR2KeyForRoom(persistenceKey) - // when loading, prefer to fetch documents from the bucket - const roomFromBucket = await this.r2.rooms.get(key) - if (roomFromBucket) { - return { type: 'room_found', snapshot: await roomFromBucket.json() } - } + // Load the room's drawing data. First we check the R2 bucket, then we fallback to Postgres (legacy). + async loadFromDatabase(persistenceKey: string): Promise { + try { + const key = getR2KeyForRoom(persistenceKey) + // when loading, prefer to fetch documents from the bucket + const roomFromBucket = await this.r2.rooms.get(key) + if (roomFromBucket) { + return { type: 'room_found', snapshot: await roomFromBucket.json() } + } - // if we don't have a room in the bucket, try to load from supabase - if (!this.supabaseClient) return { type: 'room_not_found' } - const { data, error } = await this.supabaseClient - .from(this.supabaseTable) - .select('*') - .eq('slug', persistenceKey) + // if we don't have a room in the bucket, try to load from Postgres + if (!this.postgresClient) return { type: 'room_not_found' } + await this.postgresClient.connect() + const result = await this.postgresClient.query('SELECT * FROM ' + this.postgresTable + ' WHERE slug = $1', [persistenceKey]) + await this.postgresClient.end() - if (error) { - this.logEvent({ type: 'room', roomId: persistenceKey, name: 'failed_load_from_db' }) + if (result.rows.length === 0) { + return { type: 'room_not_found' } + } - console.error('failed to retrieve document', persistenceKey, error) - return { type: 'error', error: new Error(error.message) } - } - // if it didn't find a document, data will be an empty array - if (data.length === 0) { - return { type: 'room_not_found' } - } + const roomFromPostgres = result.rows[0] as PersistedRoomSnapshotForSupabase + return { type: 'room_found', snapshot: roomFromPostgres.drawing } + } catch (error) { + this.logEvent({ type: 'room', roomId: persistenceKey, name: 'failed_load_from_db' }) - const roomFromSupabase = data[0] as PersistedRoomSnapshotForSupabase - return { type: 'room_found', snapshot: roomFromSupabase.drawing } - } catch (error) { - this.logEvent({ type: 'room', roomId: persistenceKey, name: 'failed_load_from_db' }) + console.error('failed to fetch doc', persistenceKey, error) + return { type: 'error', error: error as Error } + } + } - console.error('failed to fetch doc', persistenceKey, error) - return { type: 'error', error: error as Error } - } - } + _lastPersistedClock: number | null = null + _persistQueue = createPersistQueue(async () => { + // check whether the worker was woken up to persist after having gone to sleep + if (!this._room) return + const slug = this.documentInfo.slug + const room = await this.getRoom() + const clock = room.getCurrentDocumentClock() + if (this._lastPersistedClock === clock) return + if (this._isRestoring) return - _lastPersistedClock: number | null = null - _persistQueue = createPersistQueue(async () => { - // check whether the worker was woken up to persist after having gone to sleep - if (!this._room) return - const slug = this.documentInfo.slug - const room = await this.getRoom() - const clock = room.getCurrentDocumentClock() - if (this._lastPersistedClock === clock) return - if (this._isRestoring) return + const snapshot = JSON.stringify(room.getCurrentSnapshot()) - const snapshot = JSON.stringify(room.getCurrentSnapshot()) + const key = getR2KeyForRoom(slug) + await Promise.all([ + this.r2.rooms.put(key, snapshot), + this.r2.versionCache.put(key + `/` + new Date().toISOString(), snapshot), + ]) + this._lastPersistedClock = clock + // use a shorter timeout for this 'inner' loop than the 'outer' alarm-scheduled loop + // just in case there's any possibility of setting up a neverending queue + }, PERSIST_INTERVAL_MS / 2) - const key = getR2KeyForRoom(slug) - await Promise.all([ - this.r2.rooms.put(key, snapshot), - this.r2.versionCache.put(key + `/` + new Date().toISOString(), snapshot), - ]) - this._lastPersistedClock = clock - // use a shorter timeout for this 'inner' loop than the 'outer' alarm-scheduled loop - // just in case there's any possibility of setting up a neverending queue - }, PERSIST_INTERVAL_MS / 2) + // Save the room to Postgres + async persistToDatabase() { + await this._persistQueue() + } - // Save the room to supabase - async persistToDatabase() { - await this._persistQueue() - } - - async schedulePersist() { - await this.scheduler.scheduleAlarmAfter('persist', PERSIST_INTERVAL_MS, { - overwrite: 'if-sooner', - }) + async schedulePersist() { + await this.scheduler.scheduleAlarmAfter('persist', PERSIST_INTERVAL_MS, { + overwrite: 'if-sooner', + }) } // Will be called automatically when the alarm ticks. async alarm() { await this.scheduler.onAlarm() } -} +} \ No newline at end of file diff --git a/apps/dotcom-worker/src/routes/getRoomSnapshot.ts b/apps/dotcom-worker/src/routes/getRoomSnapshot.ts index f5c6a682a..f97d13966 100644 --- a/apps/dotcom-worker/src/routes/getRoomSnapshot.ts +++ b/apps/dotcom-worker/src/routes/getRoomSnapshot.ts @@ -3,58 +3,57 @@ import { notFound } from '@tldraw/worker-shared' import { IRequest } from 'itty-router' import { getR2KeyForSnapshot } from '../r2' import { Environment } from '../types' -import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient' -import { getSnapshotsTable } from '../utils/getSnapshotsTable' -import { R2Snapshot } from './createRoomSnapshot' +import { createPostgresClient, noPostgresSorry } from '../utils/createPostgresClient' function generateReponse(roomId: string, data: RoomSnapshot) { - return new Response( - JSON.stringify({ - roomId, - records: data.documents.map((d) => d.state), - schema: data.schema, - error: false, - }), - { - headers: { 'content-type': 'application/json' }, - } - ) + return new Response( + JSON.stringify({ + roomId, + records: data.documents.map((d) => d.state), + schema: data.schema, + error: false, + }), + { + headers: { 'content-type': 'application/json' }, + } + ) } // Returns a snapshot of the room at a given point in time export async function getRoomSnapshot(request: IRequest, env: Environment): Promise { - const roomId = request.params.roomId - if (!roomId) return notFound() + const roomId = request.params.roomId + if (!roomId) return notFound() - // Get the parent slug if it exists - const parentSlug = await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.get(roomId) + // Get the parent slug if it exists + const parentSlug = await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.get(roomId) - // Get the room snapshot from R2 - const snapshot = await env.ROOM_SNAPSHOTS.get(getR2KeyForSnapshot(parentSlug, roomId)) + // Get the room snapshot from R2 + const snapshot = await env.ROOM_SNAPSHOTS.get(getR2KeyForSnapshot(parentSlug, roomId)) - if (snapshot) { - const data = ((await snapshot.json()) as R2Snapshot)?.drawing as RoomSnapshot - if (data) { - return generateReponse(roomId, data) - } - } + if (snapshot) { + const data = ((await snapshot.json()) as R2Snapshot)?.drawing as RoomSnapshot + if (data) { + return generateReponse(roomId, data) + } + } - // If we can't find the snapshot in R2 then fallback to Supabase - // Create a supabase client - const supabase = createSupabaseClient(env) - if (!supabase) return noSupabaseSorry() + // If we can't find the snapshot in R2 then fallback to Postgres + // Create a Postgres client + const postgresClient = createPostgresClient(env) + if (!postgresClient) return noPostgresSorry() - // Get the snapshot from the table - const supabaseTable = getSnapshotsTable(env) - const result = await supabase - .from(supabaseTable) - .select('drawing') - .eq('slug', roomId) - .maybeSingle() - const data = result.data?.drawing as RoomSnapshot + try { + await postgresClient.connect() + const result = await postgresClient.query('SELECT drawing FROM snapshots WHERE slug = $1 LIMIT 1', [roomId]) + await postgresClient.end() - if (!data) return notFound() + if (result.rows.length === 0) return notFound() + const data = result.rows[0].drawing as RoomSnapshot - // Send back the snapshot! - return generateReponse(roomId, data) -} + // Send back the snapshot! + return generateReponse(roomId, data) + } catch (err) { + console.error('Error querying Postgres', err) + return new Response(JSON.stringify({ error: true, message: 'Error querying Postgres' }), { status: 500 }) + } +} \ No newline at end of file diff --git a/apps/dotcom-worker/src/utils/createPostgresClient.ts b/apps/dotcom-worker/src/utils/createPostgresClient.ts new file mode 100644 index 000000000..dc94eb3f9 --- /dev/null +++ b/apps/dotcom-worker/src/utils/createPostgresClient.ts @@ -0,0 +1,35 @@ +import { Client } from 'pg' +import { Environment } from '../types' + +export function createPostgresClient(env: Environment) { + if (env.POSTGRES_HOST && env.POSTGRES_USER && env.POSTGRES_PASSWORD && env.POSTGRES_DB) { + var client = new Client({ + host: env.POSTGRES_HOST, + port: env.POSTGRES_PORT ? parseInt(env.POSTGRES_PORT) : 5432, + user: env.POSTGRES_USER, + password: env.POSTGRES_PASSWORD, + database: env.POSTGRES_DB, + }) + + client.connect() + + client.query(` + CREATE TABLE IF NOT EXISTS snapshots ( + id SERIAL PRIMARY KEY, + slug VARCHAR(255) UNIQUE NOT NULL, + drawing JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `) + + return client + + } else { + console.warn('No Postgres credentials, loading from Postgres disabled') + return null + } +} + +export function noPostgresSorry() { + return new Response(JSON.stringify({ error: true, message: 'Could not create Postgres client' })) +} \ No newline at end of file