diff --git a/apps/dotcom/sync-worker/src/TLDrawDurableObject.ts b/apps/dotcom/sync-worker/src/TLDrawDurableObject.ts index 186d585ac9b4..7c332dcdfc06 100644 --- a/apps/dotcom/sync-worker/src/TLDrawDurableObject.ts +++ b/apps/dotcom/sync-worker/src/TLDrawDurableObject.ts @@ -59,7 +59,7 @@ import { getPublishedRoomSnapshot } from './routes/tla/getPublishedFile' import { generateSnapshotChunks } from './snapshotUtils' import { Analytics, DBLoadResult, Environment, TLServerEvent } from './types' import { EventData, writeDataPoint } from './utils/analytics' -import { createPierreClient } from './utils/createPierreClient' +import { createPierreClient, isSlugInPierreRollout } from './utils/createPierreClient' import { createSupabaseClient } from './utils/createSupabaseClient' import { getRoomDurableObject } from './utils/durableObjects' import { reconstructSnapshotFromPierre } from './utils/pierreSnapshot' @@ -1368,7 +1368,7 @@ export class TLFileDurableObject extends DurableObject { !this.pierreClient || !this.documentInfo.isApp || !this.env.TLDRAW_ENV || - this.env.TLDRAW_ENV === 'production' + !isSlugInPierreRollout(this.env, this.documentInfo.slug) ) { return null } @@ -1450,11 +1450,15 @@ export class TLFileDurableObject extends DurableObject { documentClock, schema: snapshot.schema, } - commitBuilder.addFileFromString('meta.json', JSON.stringify(meta)) + const metaJson = JSON.stringify(meta) + let incrementalCommitPayloadLength = metaJson.length + commitBuilder.addFileFromString('meta.json', metaJson) for (const [id, put] of Object.entries(diff.puts)) { const state = Array.isArray(put) ? put[1] : put - commitBuilder.addFileFromString(`records/${id}.json`, JSON.stringify(state)) + const recordJson = JSON.stringify(state) + incrementalCommitPayloadLength += recordJson.length + commitBuilder.addFileFromString(`records/${id}.json`, recordJson) } // Only apply diff.deletes when we have a parent commit and we're not in wipeAll. @@ -1493,6 +1497,12 @@ export class TLFileDurableObject extends DurableObject { headSha: result ? result.refUpdate.newSha : headSha, documentClock, } + // Incremental commits only (not the first commit to an empty repo); combined JSON string lengths (meta + record payloads). + if (headSha && result) { + this.writeEvent('pierre_incremental_write_chars', { + doubles: [incrementalCommitPayloadLength], + }) + } return } catch (error) { if (error instanceof RefUpdateError) { diff --git a/apps/dotcom/sync-worker/src/TLPostgresReplicator.ts b/apps/dotcom/sync-worker/src/TLPostgresReplicator.ts index f6707d532738..218a944adf19 100644 --- a/apps/dotcom/sync-worker/src/TLPostgresReplicator.ts +++ b/apps/dotcom/sync-worker/src/TLPostgresReplicator.ts @@ -94,7 +94,16 @@ export class TLPostgresReplicator extends DurableObject { private log private readonly replicationService - private readonly slotName + private slotName + + private getNewSlotName() { + const slotNameMaxLength = 63 // max postgres identifier length + const slotId = uniqueId().toLowerCase() + const slotNamePrefix = `tlpr_${slotId}_` + const durableObjectId = this.ctx.id.toString() + return slotNamePrefix + durableObjectId.slice(0, slotNameMaxLength - slotNamePrefix.length) + } + private readonly wal2jsonPlugin = new Wal2JsonPlugin({ addTables: 'public.user,public.file,public.file_state,public.user_mutation_number,public.replicator_boot_id,public.group,public.group_user,public.group_file', @@ -111,11 +120,16 @@ export class TLPostgresReplicator extends DurableObject { promise: promiseWithResolve(), } - const slotNameMaxLength = 63 // max postgres identifier length - const slotNamePrefix = 'tlpr_' // pick something short so we can get more of the durable object id - const durableObjectId = this.ctx.id.toString() - this.slotName = - slotNamePrefix + durableObjectId.slice(0, slotNameMaxLength - slotNamePrefix.length) + let slotName = null + try { + slotName = this.sqlite.exec('SELECT slotName FROM meta').one().slotName + } catch (_e) { + // noop + } + if (typeof slotName !== 'string') { + slotName = this.getNewSlotName() + } + this.slotName = slotName this.log = new Logger(env, 'TLPostgresReplicator', this.sentry) this.db = createPostgresConnectionPool(env, 'TLPostgresReplicator', 100) @@ -153,9 +167,7 @@ export class TLPostgresReplicator extends DurableObject { if (this.sqlite.exec('select slotName from meta').one().slotName !== this.slotName) { this.sqlite.exec('UPDATE meta SET slotName = ?, lsn = null', this.slotName) } - await sql`SELECT pg_create_logical_replication_slot(${this.slotName}, 'wal2json') WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = ${this.slotName})`.execute( - this.db - ) + await this.ensureValidSlot() this.pruneHistory() }) .then(() => { @@ -301,6 +313,33 @@ export class TLPostgresReplicator extends DurableObject { }) } + private async ensureValidSlot(): Promise { + const result = await sql<{ wal_status: string }>` + SELECT wal_status FROM pg_replication_slots + WHERE slot_name = ${this.slotName} + `.execute(this.db) + + const slotInfo = result.rows[0] + + if (!slotInfo) { + this.log.debug('creating replication slot', this.slotName) + await sql`SELECT pg_create_logical_replication_slot(${this.slotName}, 'wal2json')`.execute( + this.db + ) + } else if (slotInfo.wal_status === 'lost') { + this.captureException(new Error(`replication slot invalidated: ${this.slotName}`)) + await sql`SELECT pg_drop_replication_slot(${this.slotName})`.execute(this.db) + // increment generation to get a new slot name, which changes all sequenceIds + // and guarantees downstream user DOs hard reset + this.slotName = this.getNewSlotName() + this.sqlite.exec('UPDATE meta SET slotName = ?, lsn = null', this.slotName) + this.log.debug('creating replication slot', this.slotName) + await sql`SELECT pg_create_logical_replication_slot(${this.slotName}, 'wal2json')`.execute( + this.db + ) + } + } + private async boot() { this.log.debug('booting') this.lastPostgresMessageTime = Date.now() @@ -316,6 +355,8 @@ export class TLPostgresReplicator extends DurableObject { ) this.log.debug('done') + await this.ensureValidSlot() + const promise = 'promise' in this.state ? this.state.promise : promiseWithResolve() this.state = { type: 'connecting', diff --git a/apps/dotcom/sync-worker/src/utils/createPierreClient.ts b/apps/dotcom/sync-worker/src/utils/createPierreClient.ts index 4a22f99fddee..525ae7402802 100644 --- a/apps/dotcom/sync-worker/src/utils/createPierreClient.ts +++ b/apps/dotcom/sync-worker/src/utils/createPierreClient.ts @@ -1,12 +1,24 @@ import { GitStorage } from '@pierre/storage' import { Environment } from '../types' -export function createPierreClient(env: Environment): GitStorage | undefined { - // Only enable Pierre in non-production environments - if (env.TLDRAW_ENV === 'production') { - return undefined +/** Stable 0–99 bucket for staggered Pierre rollout (same slug always maps to the same bucket). */ +export function hashSlugToBucket(slug: string): number { + let h = 0 + for (let i = 0; i < slug.length; i++) { + h = (Math.imul(h, 31) + slug.charCodeAt(i)) | 0 } + return (h >>> 0) % 100 +} +/** In production, Pierre snapshots are enabled for 10% of app file slugs (bucket < 10). Elsewhere, all slugs. */ +export function isSlugInPierreRollout(env: Environment, slug: string): boolean { + if (env.TLDRAW_ENV !== 'production') { + return true + } + return hashSlugToBucket(slug) < 10 +} + +export function createPierreClient(env: Environment): GitStorage | undefined { if (!env.PIERRE_KEY) { return undefined } diff --git a/packages/editor/src/lib/components/MenuClickCapture.tsx b/packages/editor/src/lib/components/MenuClickCapture.tsx index e08d10dabe00..d433c5e61e8f 100644 --- a/packages/editor/src/lib/components/MenuClickCapture.tsx +++ b/packages/editor/src/lib/components/MenuClickCapture.tsx @@ -42,6 +42,22 @@ export function MenuClickCapture() { } rDidAPointerDownAndDragWhileMenuWasOpen.current = false } + if (e.button === 2) { + // Swallow the contextmenu event that follows this right-click pointerdown. + // clearOpenMenus() below triggers a synchronous render that unmounts this + // component, so our React onContextMenu handler won't be around to catch it. + // Without this, the contextmenu event reaches the Radix Trigger and briefly + // opens a new context menu (which then immediately dismisses — causing a flash). + const ownerDocument = editor.getContainerDocument() + ownerDocument.addEventListener( + 'contextmenu', + (event) => { + event.preventDefault() + event.stopImmediatePropagation() + }, + { capture: true, once: true } + ) + } editor.menus.clearOpenMenus() }, [editor] @@ -117,6 +133,10 @@ export function MenuClickCapture() { onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + }} /> ) ) diff --git a/packages/tldraw/src/lib/ui/components/primitives/menus/TldrawUiMenuItem.tsx b/packages/tldraw/src/lib/ui/components/primitives/menus/TldrawUiMenuItem.tsx index eb5823f7a4aa..6861b2430653 100644 --- a/packages/tldraw/src/lib/ui/components/primitives/menus/TldrawUiMenuItem.tsx +++ b/packages/tldraw/src/lib/ui/components/primitives/menus/TldrawUiMenuItem.tsx @@ -149,6 +149,15 @@ export function TldrawUiMenuItem< draggable={false} className="tlui-button tlui-button__menu" data-testid={`${sourceId}.${id}`} + onPointerUp={(e) => { + // Prevent right-click pointerup from triggering item selection. + // Radix calls click() on pointerup when the pointer wasn't pressed + // on the item, but doesn't check the button — so a right-click + // release while moving across the menu selects the item under the cursor. + if (e.button !== 0) { + preventDefault(e) + } + }} onSelect={(e) => { if (noClose) preventDefault(e) if (disableClicks) {