From ddebf3fc5c98e3a553cebe3233eab89b193c43df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Wed, 8 May 2024 11:06:02 +0200 Subject: [PATCH] Move storing of snapshots to R2 (#3693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of storing them in supabse we will store them in r2. I have already created `room-snapshots` and `room-snapshots-preview` buckets on cloudflare. We could also migrate all the data from supabase, but it seems we haven't done so for the rooms, so I also didn't look into doing it for snapshots. One slight drawback of moving to R2 is that it's harder to query data by parent slug. So answering questions like which room is the parent to the most snapshots is a bit harder to answer. Instead of just a simple query we'd need to do some custom logic to go through the bucket. Not sure if have ever needed this info though. ### Change Type - [ ] `sdk` — Changes the tldraw SDK - [x] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan Existing snapshots: 1. Load an existing snapshot. It should still load correctly. The best way to do that is probably to generate a few of them in advance. New snapshots: 1. Create a new room. 2. Create a few snapshot links. 3. They should work. - [ ] Unit Tests - [ ] End to end tests ### Release Notes - Move storing of snapshots to cloudflare R2. --- .../src/lib/TLDrawDurableObject.ts | 2 +- apps/dotcom-worker/src/lib/r2.ts | 6 +++ .../src/lib/routes/createRoomSnapshot.ts | 25 +++++++----- .../src/lib/routes/getRoomSnapshot.ts | 39 +++++++++++++++---- apps/dotcom-worker/src/lib/types.ts | 3 ++ apps/dotcom-worker/wrangler.toml | 38 ++++++++++++++++++ apps/dotcom/src/pages/public-snapshot.tsx | 17 ++++---- apps/dotcom/src/utils/sharing.ts | 9 +++-- packages/dotcom-shared/src/types.ts | 2 +- 9 files changed, 110 insertions(+), 31 deletions(-) diff --git a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts index af2dfd5e0..99867457c 100644 --- a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts +++ b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts @@ -338,7 +338,7 @@ export class TLDrawDurableObject extends TLServer { this._roomState = undefined } - // Load the room's drawing data from supabase + // Load the room's drawing data. First we check the R2 bucket, then we fallback to supabase (legacy). override async loadFromDatabase(persistenceKey: string): Promise { try { const key = getR2KeyForRoom(persistenceKey) diff --git a/apps/dotcom-worker/src/lib/r2.ts b/apps/dotcom-worker/src/lib/r2.ts index 706680de6..e11da5b29 100644 --- a/apps/dotcom-worker/src/lib/r2.ts +++ b/apps/dotcom-worker/src/lib/r2.ts @@ -1,3 +1,9 @@ export function getR2KeyForRoom(persistenceKey: string) { return `public_rooms/${persistenceKey}` } + +export function getR2KeyForSnapshot(parentSlug: string | undefined | null, snapshotSlug: string) { + // We might not have a parent slug. This happens when creating a snapshot from a local room. + const persistenceKey = parentSlug ? `${parentSlug}/${snapshotSlug}` : snapshotSlug + return getR2KeyForRoom(persistenceKey) +} diff --git a/apps/dotcom-worker/src/lib/routes/createRoomSnapshot.ts b/apps/dotcom-worker/src/lib/routes/createRoomSnapshot.ts index f5c02653a..b6ea37ed2 100644 --- a/apps/dotcom-worker/src/lib/routes/createRoomSnapshot.ts +++ b/apps/dotcom-worker/src/lib/routes/createRoomSnapshot.ts @@ -1,11 +1,16 @@ import { CreateSnapshotRequestBody } from '@tldraw/dotcom-shared' +import { RoomSnapshot } from '@tldraw/tlsync' import { IRequest } from 'itty-router' import { nanoid } from 'nanoid' +import { getR2KeyForSnapshot } from '../r2' import { Environment } from '../types' -import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient' -import { getSnapshotsTable } from '../utils/getSnapshotsTable' import { validateSnapshot } from '../utils/validateSnapshot' +export type R2Snapshot = { + parent_slug: CreateSnapshotRequestBody['parent_slug'] + drawing: RoomSnapshot +} + export async function createRoomSnapshot(request: IRequest, env: Environment): Promise { const data = (await request.json()) as CreateSnapshotRequestBody @@ -18,7 +23,6 @@ export async function createRoomSnapshot(request: IRequest, env: Environment): P const persistedRoomSnapshot = { parent_slug: data.parent_slug, - slug: roomId, drawing: { schema: data.schema, clock: 0, @@ -28,13 +32,16 @@ export async function createRoomSnapshot(request: IRequest, env: Environment): P })), tombstones: {}, }, + } satisfies R2Snapshot + + const parentSlug = data.parent_slug + if (parentSlug) { + await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.put(roomId, parentSlug) } - - const supabase = createSupabaseClient(env) - if (!supabase) return noSupabaseSorry() - - const supabaseTable = getSnapshotsTable(env) - await supabase.from(supabaseTable).insert(persistedRoomSnapshot) + await env.ROOM_SNAPSHOTS.put( + getR2KeyForSnapshot(parentSlug, roomId), + JSON.stringify(persistedRoomSnapshot) + ) return new Response(JSON.stringify({ error: false, roomId })) } diff --git a/apps/dotcom-worker/src/lib/routes/getRoomSnapshot.ts b/apps/dotcom-worker/src/lib/routes/getRoomSnapshot.ts index fc109e47b..5c8b6aa18 100644 --- a/apps/dotcom-worker/src/lib/routes/getRoomSnapshot.ts +++ b/apps/dotcom-worker/src/lib/routes/getRoomSnapshot.ts @@ -1,15 +1,45 @@ import { RoomSnapshot } from '@tldraw/tlsync' import { IRequest } from 'itty-router' +import { getR2KeyForSnapshot } from '../r2' import { Environment } from '../types' import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient' import { fourOhFour } from '../utils/fourOhFour' import { getSnapshotsTable } from '../utils/getSnapshotsTable' +import { R2Snapshot } from './createRoomSnapshot' + +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' }, + } + ) +} // 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 fourOhFour() + // 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)) + + 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() @@ -26,12 +56,5 @@ export async function getRoomSnapshot(request: IRequest, env: Environment): Prom if (!data) return fourOhFour() // Send back the snapshot! - return new Response( - JSON.stringify({ - roomId, - records: data.documents.map((d) => d.state), - schema: data.schema, - error: false, - }) - ) + return generateReponse(roomId, data) } diff --git a/apps/dotcom-worker/src/lib/types.ts b/apps/dotcom-worker/src/lib/types.ts index 9fdbd8ef0..913078ece 100644 --- a/apps/dotcom-worker/src/lib/types.ts +++ b/apps/dotcom-worker/src/lib/types.ts @@ -17,6 +17,9 @@ export interface Environment { ROOMS: R2Bucket ROOMS_HISTORY_EPHEMERAL: R2Bucket + ROOM_SNAPSHOTS: R2Bucket + SNAPSHOT_SLUG_TO_PARENT_SLUG: KVNamespace + SLUG_TO_READONLY_SLUG: KVNamespace READONLY_SLUG_TO_SLUG: KVNamespace diff --git a/apps/dotcom-worker/wrangler.toml b/apps/dotcom-worker/wrangler.toml index 6d6163a63..6b72e9d1b 100644 --- a/apps/dotcom-worker/wrangler.toml +++ b/apps/dotcom-worker/wrangler.toml @@ -115,6 +115,44 @@ bucket_name = "rooms-history-ephemeral-preview" binding = "ROOMS_HISTORY_EPHEMERAL" bucket_name = "rooms-history-ephemeral" +#################### Room snapshots bucket #################### +# in dev, we write to the preview bucket and need a `preview_bucket_name` +[[env.dev.r2_buckets]] +binding = "ROOM_SNAPSHOTS" +bucket_name = "room-snapshots-preview" +preview_bucket_name = "room-snapshots-preview" + +# in preview and staging we write to the preview bucket +[[env.preview.r2_buckets]] +binding = "ROOM_SNAPSHOTS" +bucket_name = "room-snapshots-preview" + +[[env.staging.r2_buckets]] +binding = "ROOM_SNAPSHOTS" +bucket_name = "room-snapshots-preview" + +# in production, we write to the main bucket +[[env.production.r2_buckets]] +binding = "ROOM_SNAPSHOTS" +bucket_name = "room-snapshots" + +#################### Room snapshots parent slug KV store #################### +[[env.dev.kv_namespaces]] +binding = "SNAPSHOT_SLUG_TO_PARENT_SLUG" +id = "5eaa50a2b87145e582661ea3344804b8" + +[[env.preview.kv_namespaces]] +binding = "SNAPSHOT_SLUG_TO_PARENT_SLUG" +id = "5eaa50a2b87145e582661ea3344804b8" + +[[env.staging.kv_namespaces]] +binding = "SNAPSHOT_SLUG_TO_PARENT_SLUG" +id = "5eaa50a2b87145e582661ea3344804b8" + +[[env.production.kv_namespaces]] +binding = "SNAPSHOT_SLUG_TO_PARENT_SLUG" +id = "c6ce1f45447e4a44a00edb2a2077bc5c" + #################### Key value storage #################### [[env.dev.kv_namespaces]] binding = "SLUG_TO_READONLY_SLUG" diff --git a/apps/dotcom/src/pages/public-snapshot.tsx b/apps/dotcom/src/pages/public-snapshot.tsx index a0d469df6..ef43089ce 100644 --- a/apps/dotcom/src/pages/public-snapshot.tsx +++ b/apps/dotcom/src/pages/public-snapshot.tsx @@ -7,20 +7,21 @@ import { defineLoader } from '../utils/defineLoader' const { loader, useData } = defineLoader(async (args) => { const roomId = args.params.roomId const result = await fetch(`/api/snapshot/${roomId}`) - return result.ok - ? ((await result.json()) as { - roomId: string - schema: SerializedSchema - records: TLRecord[] - }) - : null + if (!result.ok) throw new Error('Room not found') + + const data = await result.json() + if (!data || data.error) throw new Error('Room not found') + return data as { + roomId: string + schema: SerializedSchema + records: TLRecord[] + } }) export { loader } export function Component() { const result = useData() - if (!result) throw Error('Room not found') const { roomId, records, schema } = result return ( diff --git a/apps/dotcom/src/utils/sharing.ts b/apps/dotcom/src/utils/sharing.ts index 1d2d7c91d..86c9bc3ae 100644 --- a/apps/dotcom/src/utils/sharing.ts +++ b/apps/dotcom/src/utils/sharing.ts @@ -7,7 +7,7 @@ import { Snapshot, } from '@tldraw/dotcom-shared' import { useMemo } from 'react' -import { useNavigate, useSearchParams } from 'react-router-dom' +import { useNavigate, useParams } from 'react-router-dom' import { AssetRecordType, Editor, @@ -96,7 +96,8 @@ export async function getNewRoomResponse(snapshot: Snapshot) { export function useSharing(): TLUiOverrides { const navigate = useNavigate() - const id = useSearchParams()[0].get('id') ?? undefined + const params = useParams() + const roomId = params.roomId const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL) const handleUiEvent = useHandleUiEvents() const runningInIFrame = isInIframe() @@ -173,7 +174,7 @@ export function useSharing(): TLUiOverrides { addToast, msg, uploadFileToAsset, - id + roomId ) if (navigator?.clipboard?.write) { await navigator.clipboard.write([ @@ -196,7 +197,7 @@ export function useSharing(): TLUiOverrides { return actions }, }), - [handleUiEvent, navigate, uploadFileToAsset, id, runningInIFrame] + [handleUiEvent, navigate, uploadFileToAsset, roomId, runningInIFrame] ) } diff --git a/packages/dotcom-shared/src/types.ts b/packages/dotcom-shared/src/types.ts index 489629959..a44651c63 100644 --- a/packages/dotcom-shared/src/types.ts +++ b/packages/dotcom-shared/src/types.ts @@ -13,7 +13,7 @@ export type CreateRoomRequestBody = { export type CreateSnapshotRequestBody = { schema: SerializedSchema snapshot: SerializedStore - parent_slug?: string | string[] | undefined + parent_slug?: string | undefined } export type CreateSnapshotResponseBody =