Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions apps/dotcom/sync-worker/src/TLDrawDurableObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
59 changes: 50 additions & 9 deletions apps/dotcom/sync-worker/src/TLPostgresReplicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,16 @@ export class TLPostgresReplicator extends DurableObject<Environment> {
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',
Expand All @@ -111,11 +120,16 @@ export class TLPostgresReplicator extends DurableObject<Environment> {
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)
Expand Down Expand Up @@ -153,9 +167,7 @@ export class TLPostgresReplicator extends DurableObject<Environment> {
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(() => {
Expand Down Expand Up @@ -301,6 +313,33 @@ export class TLPostgresReplicator extends DurableObject<Environment> {
})
}

private async ensureValidSlot(): Promise<void> {
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()
Expand All @@ -316,6 +355,8 @@ export class TLPostgresReplicator extends DurableObject<Environment> {
)
this.log.debug('done')

await this.ensureValidSlot()

const promise = 'promise' in this.state ? this.state.promise : promiseWithResolve()
this.state = {
type: 'connecting',
Expand Down
20 changes: 16 additions & 4 deletions apps/dotcom/sync-worker/src/utils/createPierreClient.ts
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down
20 changes: 20 additions & 0 deletions packages/editor/src/lib/components/MenuClickCapture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -117,6 +133,10 @@ export function MenuClickCapture() {
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading