snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
2
apps/dotcom/sync-worker/.gitignore
vendored
Normal file
2
apps/dotcom/sync-worker/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
build
|
||||
.wrangler
|
||||
38
apps/dotcom/sync-worker/README.md
Normal file
38
apps/dotcom/sync-worker/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# @tldraw/dotcom-worker
|
||||
|
||||
The dotcom-worker is used for room management on tldraw.com (e.g. creating & joining rooms, readonly rooms, snapshots).
|
||||
|
||||
## Enable database persistence for local dev
|
||||
|
||||
The values for `env.SUPABASE_KEY` and `env.SUPABASE_URL` are stored in the Cloudflare Workers dashboard for this worker. However we use `--local` mode for local development, which doesn't read these values from the dashboard.
|
||||
|
||||
To workaround this, create a file called `.dev.vars` under `merge-server` with the required values (which you can currently find at https://app.supabase.com/project/bfcjbbjqflgfzxhskwct/settings/api). This will be read by `wrangler dev --local` and used to populate the environment variables.
|
||||
|
||||
```
|
||||
SUPABASE_URL=<url>
|
||||
SUPABASE_KEY=<key>
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Distributions
|
||||
|
||||
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
5
apps/dotcom/sync-worker/dev.sh
Executable file
5
apps/dotcom/sync-worker/dev.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#! /bin/bash
|
||||
|
||||
set -eux
|
||||
|
||||
yarn run -T tsx ./dev.ts
|
||||
49
apps/dotcom/sync-worker/package.json
Normal file
49
apps/dotcom/sync-worker/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@tldraw/dotcom-worker",
|
||||
"description": "tldraw infinite canvas SDK (merge server).",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "tldraw GB Ltd.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"main": "./src/worker.ts",
|
||||
"type": "module",
|
||||
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
|
||||
"files": [],
|
||||
"scripts": {
|
||||
"dev": "yarn run -T tsx ../../../internal/scripts/workers/dev.ts --persist-to .wrangler/state-dev --var ASSET_UPLOAD_ORIGIN:http://localhost:8788 --var USER_CONTENT_URL:http://localhost:8789",
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"clean": "rm -rf .wrangler/state .wrangler/state-*",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"check-bundle-size": "yarn run -T tsx ../../../internal/scripts/check-worker-bundle.ts --entry src/worker.ts --size-limit-bytes 1550000",
|
||||
"reset-db": "./reset-db.sh",
|
||||
"lint": "yarn run -T tsx ../../../internal/scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/backend": "^3.17.1",
|
||||
"@rocicorp/zero": "1.9.0",
|
||||
"@supabase/supabase-js": "^2.48.1",
|
||||
"@tldraw/dotcom-shared": "workspace:*",
|
||||
"@tldraw/store": "workspace:*",
|
||||
"@tldraw/sync-collaboration": "workspace:*",
|
||||
"@tldraw/sync-core": "workspace:*",
|
||||
"@tldraw/tlschema": "workspace:*",
|
||||
"@tldraw/utils": "workspace:*",
|
||||
"@tldraw/worker-shared": "workspace:*",
|
||||
"itty-router": "^5.0.18",
|
||||
"kysely": "^0.28.12",
|
||||
"p-queue": "^9.3.0",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260302.0",
|
||||
"@types/pg": "^8.11.11",
|
||||
"esbuild": "^0.28.0",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.0",
|
||||
"wrangler": "^4.75.0"
|
||||
}
|
||||
}
|
||||
45
apps/dotcom/sync-worker/src/Logger.ts
Normal file
45
apps/dotcom/sync-worker/src/Logger.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createSentry } from '@tldraw/worker-shared'
|
||||
import { Environment, isDebugLogging } from './types'
|
||||
import { getLogger } from './utils/durableObjects'
|
||||
|
||||
export class Logger {
|
||||
readonly logger
|
||||
constructor(
|
||||
env: Environment,
|
||||
private prefix: string,
|
||||
private sentry?: ReturnType<typeof createSentry>
|
||||
) {
|
||||
if (isDebugLogging(env)) {
|
||||
this.logger = getLogger(env)
|
||||
}
|
||||
}
|
||||
private outgoing: string[] = []
|
||||
|
||||
private isRunning = false
|
||||
|
||||
debug(...args: any[]) {
|
||||
if (!this.logger && !this.sentry) return
|
||||
const msg = `[${this.prefix} ${new Date().toISOString()}]: ${args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' ')}`
|
||||
this.outgoing.push(msg)
|
||||
// A failed debug RPC must not become an unhandled rejection in the caller.
|
||||
this.processQueue().catch(() => {})
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
if (this.isRunning) return
|
||||
this.isRunning = true
|
||||
try {
|
||||
while (this.outgoing.length) {
|
||||
const batch = this.outgoing
|
||||
this.outgoing = []
|
||||
await this.logger?.debug(batch)
|
||||
for (const message of batch) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
this.sentry?.addBreadcrumb({ message })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
231
apps/dotcom/sync-worker/src/TLFileEffectProcessor.ts
Normal file
231
apps/dotcom/sync-worker/src/TLFileEffectProcessor.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { TlaEffectOutbox } from '@tldraw/dotcom-shared'
|
||||
import { createSentry } from '@tldraw/worker-shared'
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
import { sql } from 'kysely'
|
||||
import { FileEffectDeps, processFileEffect } from './fileEffects'
|
||||
import {
|
||||
EFFECT_TIMEOUT_MS,
|
||||
MAX_ATTEMPTS,
|
||||
computeNextAlarm,
|
||||
drainOutbox,
|
||||
formatOutboxError,
|
||||
shouldReportEffectFailure,
|
||||
} from './outboxDrain'
|
||||
import { createPostgresConnectionPool } from './postgres'
|
||||
import { Analytics, Environment } from './types'
|
||||
import { EventData, writeDataPoint } from './utils/analytics'
|
||||
import { getRoomDurableObject } from './utils/durableObjects'
|
||||
import { publishSnapshot, unpublishSnapshot } from './utils/publishSnapshots'
|
||||
|
||||
const SWEEP_INTERVAL_MS = 30_000
|
||||
|
||||
// Singleton DO that drains effect_outbox. Poked by the Zero push endpoint and
|
||||
// admin routes after file writes; the alarm sweep catches PG-trigger writes
|
||||
// (workspace-delete cascade) and lost pokes.
|
||||
export class TLFileEffectProcessor extends DurableObject<Environment> {
|
||||
private drainPromise: Promise<void> | null = null
|
||||
private sentry
|
||||
private measure: Analytics | undefined
|
||||
|
||||
constructor(ctx: DurableObjectState, env: Environment) {
|
||||
super(ctx, env)
|
||||
this.sentry = createSentry(ctx, env)
|
||||
this.measure = env.MEASURE
|
||||
// Keep the sweep chain alive across restarts/evictions: if the persisted alarm is
|
||||
// missing or past-due (a past-due alarm can't be trusted to fire), re-arm it. Note this
|
||||
// runs only once something instantiates the DO — the first poke() ever is what starts
|
||||
// the chain; until then, outbox rows wait.
|
||||
ctx.blockConcurrencyWhile(async () => {
|
||||
const scheduled = await ctx.storage.getAlarm()
|
||||
if (scheduled === null || scheduled <= Date.now()) {
|
||||
await ctx.storage.setAlarm(Date.now() + SWEEP_INTERVAL_MS)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line tldraw/prefer-class-methods
|
||||
private captureException = (exception: unknown, extras?: Record<string, unknown>) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
this.sentry?.withScope((scope) => {
|
||||
if (extras) scope.setExtras(extras)
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
this.sentry?.captureException(exception) as any
|
||||
})
|
||||
if (!this.sentry) {
|
||||
console.error(`[TLFileEffectProcessor]: `, exception)
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line tldraw/prefer-class-methods
|
||||
private writeEvent = (name: string, eventData: EventData) => {
|
||||
writeDataPoint(this.sentry, this.measure, this.env, name, eventData)
|
||||
}
|
||||
|
||||
async poke() {
|
||||
// Always (re)arm: a past-due persisted alarm can't be trusted to fire (the runtime can
|
||||
// drop one, e.g. across restarts), and re-setting an imminent alarm is a harmless
|
||||
// storage write.
|
||||
await this.ctx.storage.setAlarm(Date.now())
|
||||
}
|
||||
|
||||
override async alarm() {
|
||||
try {
|
||||
await this.drain()
|
||||
} catch (e) {
|
||||
// row-level failures are handled inside the drain; this catches drain-level
|
||||
// crashes like an unreachable database
|
||||
this.captureException(e)
|
||||
} finally {
|
||||
// Re-arm the sweep without swallowing a mid-drain poke: a poke that arrived during
|
||||
// the drain set alarm(now), which is past-due by now; computeNextAlarm re-arms it at
|
||||
// ~1s (a past-due alarm can't be trusted to fire on its own). null => leave as-is.
|
||||
const scheduled = await this.ctx.storage.getAlarm()
|
||||
const next = computeNextAlarm(scheduled, Date.now(), SWEEP_INTERVAL_MS)
|
||||
if (next !== null) await this.ctx.storage.setAlarm(next)
|
||||
}
|
||||
}
|
||||
|
||||
private drain() {
|
||||
// Coalesce: one drain in flight; a poke during a drain lands on the ~1s follow-up alarm
|
||||
// computeNextAlarm arms after this drain finishes.
|
||||
if (!this.drainPromise) {
|
||||
const promise = this._drain().finally(() => {
|
||||
// Identity guard: only clear the latch if it's still ours.
|
||||
if (this.drainPromise === promise) this.drainPromise = null
|
||||
})
|
||||
this.drainPromise = promise
|
||||
}
|
||||
return this.drainPromise
|
||||
}
|
||||
|
||||
private async _drain() {
|
||||
const drainStart = Date.now()
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
const db = createPostgresConnectionPool(this.env, 'TLFileEffectProcessor')
|
||||
const fileDeps: FileEffectDeps = {
|
||||
getCurrentFile: (fileId) =>
|
||||
db.selectFrom('file').selectAll().where('id', '=', fileId).executeTakeFirst(),
|
||||
notifyInsert: (file) => getRoomDurableObject(this.env, file.id).appFileRecordCreated(file),
|
||||
notifyUpdate: (file) => getRoomDurableObject(this.env, file.id).appFileRecordDidUpdate(file),
|
||||
notifyDelete: (fileRow) =>
|
||||
getRoomDurableObject(this.env, fileRow.id).appFileRecordDidDelete(fileRow),
|
||||
publish: (file) =>
|
||||
publishSnapshot(this.env, file, (error) =>
|
||||
this.captureException(error, { publishThumbnailEnqueue: true })
|
||||
),
|
||||
unpublish: (file) => unpublishSnapshot(this.env, file),
|
||||
}
|
||||
// One handler per source table. Future effect sources (e.g. notifications)
|
||||
// register here alongside their trigger.
|
||||
const handlers: Record<string, (row: TlaEffectOutbox) => Promise<void>> = {
|
||||
file: (row) => processFileEffect(fileDeps, row),
|
||||
}
|
||||
try {
|
||||
await drainOutbox({
|
||||
getBatch: () =>
|
||||
db
|
||||
.selectFrom('effect_outbox')
|
||||
.selectAll()
|
||||
.where('attempts', '<', MAX_ATTEMPTS)
|
||||
.where((eb) =>
|
||||
eb.or([eb('nextRetryAt', 'is', null), eb('nextRetryAt', '<=', sql<Date>`now()`)])
|
||||
)
|
||||
.orderBy('id')
|
||||
.limit(50)
|
||||
.execute(),
|
||||
deleteRow: async (id) => {
|
||||
await db.deleteFrom('effect_outbox').where('id', '=', id).execute()
|
||||
processed++
|
||||
},
|
||||
bumpAttempts: async (row, error) => {
|
||||
failed++
|
||||
// Exponential backoff from the row's current attempt count, capped at 5 minutes.
|
||||
// The base IS the effect timeout, so the first retry can't land before a
|
||||
// timed-out RPC's window closes (the RPC keeps running — it can't be cancelled),
|
||||
// avoiding an overlapping retry. This relies on the RPC actually finishing within
|
||||
// the window: a publish effect's awaitPersist used to retry for up to ~200s
|
||||
// (PERSIST_RETRIES_MAX * 2s), well past this 30s timeout, so a zombie publish
|
||||
// could still be running when a retried attempt started. awaitPersist now caps
|
||||
// its throwing retries at ~20s (PERSIST_RETRIES_MAX_THROWING), so it can't.
|
||||
const backoffSeconds = Math.min(2 ** row.attempts * (EFFECT_TIMEOUT_MS / 1000), 300)
|
||||
const backoff = sql<Date>`now() + (${backoffSeconds} || ' seconds')::interval`
|
||||
// (a) Back off the failed row itself and bump its attempt count.
|
||||
await db
|
||||
.updateTable('effect_outbox')
|
||||
.set((eb) => ({
|
||||
attempts: eb('attempts', '+', 1),
|
||||
nextRetryAt: backoff,
|
||||
lastError: formatOutboxError(error),
|
||||
}))
|
||||
.where('id', '=', row.id)
|
||||
.execute()
|
||||
// (b) Defer the failed row's later same-entity siblings so per-entity ordering
|
||||
// holds across drains: they must not run while this row is backing off. Do NOT
|
||||
// touch their attempts (they haven't been tried), and never shrink an existing
|
||||
// later nextRetryAt (GREATEST keeps the furthest-out schedule).
|
||||
await db
|
||||
.updateTable('effect_outbox')
|
||||
.set({
|
||||
nextRetryAt: sql<Date>`GREATEST("nextRetryAt", ${backoff})`,
|
||||
})
|
||||
.where('tableName', '=', row.tableName)
|
||||
.where('entityId', '=', row.entityId)
|
||||
.where('id', '>', row.id)
|
||||
.execute()
|
||||
},
|
||||
deleteParkedRowsOlderThan: async (days) => {
|
||||
await db
|
||||
.deleteFrom('effect_outbox')
|
||||
.where('attempts', '>=', MAX_ATTEMPTS)
|
||||
.where('createdAt', '<', sql<Date>`now() - (${days} || ' days')::interval`)
|
||||
.execute()
|
||||
},
|
||||
process: async (row) => {
|
||||
const handler = handlers[row.tableName]
|
||||
if (!handler) {
|
||||
// trigger + handler ship together; this only catches mistakes
|
||||
this.captureException(new Error('effect_outbox row for unhandled table, dropping'), {
|
||||
tableName: row.tableName,
|
||||
outboxId: row.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
await handler(row)
|
||||
// End-to-end effect latency: time from the row landing in the outbox (its
|
||||
// transaction commit) to the effect completing. This is the user-facing number —
|
||||
// how long after a file change the room notification / publish actually lands.
|
||||
this.writeEvent('outbox_effect', {
|
||||
blobs: [row.tableName, row.command],
|
||||
doubles: [Date.now() - new Date(row.createdAt).getTime()],
|
||||
})
|
||||
},
|
||||
onError: (error, row) => {
|
||||
// Report the first failure (immediate visibility) and the parking failure (data
|
||||
// loss), but not every retry in between - under an outage, one row can burn through
|
||||
// MAX_ATTEMPTS attempts, and reporting each would eat Sentry's rate limit and drop
|
||||
// the parking events that matter most. See shouldReportEffectFailure's doc comment.
|
||||
const isParking = row.attempts + 1 >= MAX_ATTEMPTS
|
||||
if (shouldReportEffectFailure(row.attempts)) {
|
||||
this.captureException(error, {
|
||||
tableName: row.tableName,
|
||||
entityId: row.entityId,
|
||||
command: row.command,
|
||||
outboxId: row.id,
|
||||
attempts: row.attempts + 1,
|
||||
})
|
||||
}
|
||||
if (isParking) {
|
||||
this.writeEvent('outbox_parked', { blobs: [row.tableName, row.command] })
|
||||
}
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await db.destroy()
|
||||
// Per-drain summary: throughput (processed/failed) and how long the drain took.
|
||||
// Drains coalesce, so this is low-volume; the sweep also emits an empty summary every
|
||||
// ~30s, which doubles as a liveness heartbeat for the processor.
|
||||
this.writeEvent('outbox_drain', { doubles: [processed, failed, Date.now() - drainStart] })
|
||||
}
|
||||
}
|
||||
}
|
||||
67
apps/dotcom/sync-worker/src/TLLoggerDurableObject.ts
Normal file
67
apps/dotcom/sync-worker/src/TLLoggerDurableObject.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment, isDebugLogging } from './types'
|
||||
|
||||
export class TLLoggerDurableObject extends DurableObject<Environment> {
|
||||
private readonly isDebugEnv
|
||||
private readonly db
|
||||
constructor(ctx: DurableObjectState, env: Environment) {
|
||||
super(ctx, env)
|
||||
this.isDebugEnv = isDebugLogging(env)
|
||||
this.db = this.ctx.storage.sql
|
||||
this.db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS logs (
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS limit_logs
|
||||
AFTER INSERT ON logs
|
||||
BEGIN
|
||||
DELETE FROM logs WHERE rowid NOT IN (
|
||||
SELECT rowid FROM logs ORDER BY rowid DESC LIMIT 20000
|
||||
);
|
||||
END;`
|
||||
)
|
||||
}
|
||||
|
||||
private sockets = new Set<WebSocket>()
|
||||
|
||||
async debug(messages: string[]) {
|
||||
if (!this.isDebugEnv) return
|
||||
for (const message of messages) {
|
||||
this.db.exec(`INSERT INTO logs (message) VALUES (?)`, message)
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
for (const socket of this.sockets) {
|
||||
socket.send(message + '\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFullHistory() {
|
||||
return this.db
|
||||
.exec('SELECT message FROM logs ORDER BY rowid ASC')
|
||||
.toArray()
|
||||
.map((row) => row.message)
|
||||
}
|
||||
|
||||
async clear() {
|
||||
this.db.exec('DELETE FROM logs')
|
||||
}
|
||||
|
||||
override async fetch(_req: IRequest) {
|
||||
if (!this.isDebugEnv) return new Response('Not Found', { status: 404 })
|
||||
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
|
||||
serverWebSocket.accept()
|
||||
|
||||
this.sockets.add(serverWebSocket)
|
||||
const cleanup = () => {
|
||||
this.sockets.delete(serverWebSocket)
|
||||
serverWebSocket.close()
|
||||
}
|
||||
serverWebSocket.addEventListener('close', cleanup)
|
||||
serverWebSocket.addEventListener('error', cleanup)
|
||||
serverWebSocket.send('Connected to logger\n' + this.getFullHistory().join('\n'))
|
||||
return new Response(null, { status: 101, webSocket: clientWebSocket })
|
||||
}
|
||||
}
|
||||
263
apps/dotcom/sync-worker/src/adminFeatureFlagRoutes.test.ts
Normal file
263
apps/dotcom/sync-worker/src/adminFeatureFlagRoutes.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { StatusError } from 'itty-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { adminRoutes } from './adminRoutes'
|
||||
import { Environment } from './types'
|
||||
|
||||
// The admin feature-flag routes, which are now the only way onto the `mcp_server_access` allowlist —
|
||||
// the bespoke KV list this PR deleted was the other one. adminRoutes.ts had no test file at all, so
|
||||
// nothing covered the path that decides who may drive the MCP server.
|
||||
//
|
||||
// Postgres and Clerk are mocked; the flag store is a real in-memory KV stand-in, so what a save
|
||||
// actually writes is what the assertions read back.
|
||||
vi.mock('./postgres', () => ({ createPostgresConnectionPool: vi.fn() }))
|
||||
vi.mock('./utils/tla/getAuth', () => ({
|
||||
requireAuth: vi.fn(async () => ({ userId: 'user_admin' })),
|
||||
requireAdminAccess: vi.fn(async () => {}),
|
||||
getClerkClient: vi.fn(),
|
||||
}))
|
||||
|
||||
const { createPostgresConnectionPool } = await import('./postgres')
|
||||
|
||||
let kv: Map<string, string>
|
||||
|
||||
/** Stands in for the `user` table lookups both the save and the admin GET make. */
|
||||
function mockUsers(rows: Array<{ id: string; email: string }>) {
|
||||
vi.mocked(createPostgresConnectionPool).mockReturnValue({
|
||||
selectFrom: () => ({
|
||||
select: () => ({ where: () => ({ execute: async () => rows }) }),
|
||||
}),
|
||||
destroy: async () => {},
|
||||
} as any)
|
||||
}
|
||||
|
||||
function makeEnv() {
|
||||
kv = new Map()
|
||||
return {
|
||||
FEATURE_FLAGS: {
|
||||
get: async (key: string) => kv.get(key) ?? null,
|
||||
put: async (key: string, value: string) => void kv.set(key, value),
|
||||
},
|
||||
} as unknown as Environment
|
||||
}
|
||||
|
||||
/**
|
||||
* The router throws `StatusError` and the worker's `handleApiRequest` turns it into a response, so a
|
||||
* test driving the router directly has to do the same or every refusal reads as an unhandled throw.
|
||||
*/
|
||||
async function fetchAdmin(request: Request, env: Environment) {
|
||||
try {
|
||||
return await adminRoutes.fetch(request, env)
|
||||
} catch (error: any) {
|
||||
if (error instanceof StatusError) {
|
||||
return Response.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function post(env: Environment, body: unknown) {
|
||||
return fetchAdmin(
|
||||
new Request('https://sync.tldraw.xyz/app/admin/feature-flags', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
env
|
||||
)
|
||||
}
|
||||
|
||||
function storedFlag(key: string) {
|
||||
return JSON.parse(kv.get(key)!)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUsers([])
|
||||
})
|
||||
|
||||
describe('POST /app/admin/feature-flags', () => {
|
||||
it('resolves emails to user ids and stores the pair', async () => {
|
||||
const env = makeEnv()
|
||||
mockUsers([{ id: 'user_1', email: 'One@tldraw.com' }])
|
||||
|
||||
const response = await post(env, {
|
||||
flag: 'mcp_server_access',
|
||||
emails: 'one@tldraw.com',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
// The address as the database has it, not as the admin typed it, so the panel shows the real one.
|
||||
expect(storedFlag('mcp_server_access').users).toEqual([
|
||||
{ userId: 'user_1', email: 'One@tldraw.com' },
|
||||
])
|
||||
})
|
||||
|
||||
// The server's parser, not the client's: a comma-separated paste is one line and several people.
|
||||
// The admin panel used to count these differently from the worker and report "1 user" for five.
|
||||
it('accepts a comma-separated paste as several addresses', async () => {
|
||||
const env = makeEnv()
|
||||
mockUsers([
|
||||
{ id: 'user_1', email: 'one@tldraw.com' },
|
||||
{ id: 'user_2', email: 'two@tldraw.com' },
|
||||
])
|
||||
|
||||
await post(env, { flag: 'mcp_server_access', emails: 'one@tldraw.com, two@tldraw.com' })
|
||||
|
||||
expect(storedFlag('mcp_server_access').users).toHaveLength(2)
|
||||
})
|
||||
|
||||
// An address with no account would be stored as an entry that looks like a grant and matches
|
||||
// nobody, so it fails the save instead — at the point someone can still fix the typo.
|
||||
it('refuses an email with no tldraw account', async () => {
|
||||
const env = makeEnv()
|
||||
mockUsers([])
|
||||
|
||||
const response = await post(env, { flag: 'mcp_server_access', emails: 'ghost@tldraw.com' })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.text()).toContain('ghost@tldraw.com')
|
||||
expect(kv.has('mcp_server_access')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses input that is not an email address at all', async () => {
|
||||
const env = makeEnv()
|
||||
|
||||
const response = await post(env, { flag: 'mcp_server_access', emails: 'tldraw.com' })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(kv.has('mcp_server_access')).toBe(false)
|
||||
})
|
||||
|
||||
// The field means nothing for a percentage flag. It used to be dropped in silence while the
|
||||
// response still answered `{success: true, users: […]}`, so an admin could be told a save had
|
||||
// landed that stored nothing anywhere.
|
||||
it('refuses emails sent for a flag that has no allowlist', async () => {
|
||||
const env = makeEnv()
|
||||
|
||||
const response = await post(env, { flag: 'rum_enabled', emails: 'one@tldraw.com' })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.text()).toContain('emails do not apply')
|
||||
expect(kv.has('rum_enabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a percentage sent for an allowlist flag', async () => {
|
||||
const env = makeEnv()
|
||||
|
||||
const response = await post(env, { flag: 'mcp_server_access', percentage: 50 })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(kv.has('mcp_server_access')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses an unknown flag', async () => {
|
||||
const env = makeEnv()
|
||||
|
||||
expect((await post(env, { flag: 'not_a_flag', enabled: true })).status).toBe(400)
|
||||
})
|
||||
|
||||
// A save replaces the list rather than merging into it, so removing someone is an ordinary save.
|
||||
it('replaces the list wholesale', async () => {
|
||||
const env = makeEnv()
|
||||
mockUsers([
|
||||
{ id: 'user_1', email: 'one@tldraw.com' },
|
||||
{ id: 'user_2', email: 'two@tldraw.com' },
|
||||
])
|
||||
await post(env, { flag: 'mcp_server_access', emails: 'one@tldraw.com\ntwo@tldraw.com' })
|
||||
|
||||
mockUsers([{ id: 'user_2', email: 'two@tldraw.com' }])
|
||||
await post(env, { flag: 'mcp_server_access', emails: 'two@tldraw.com' })
|
||||
|
||||
expect(storedFlag('mcp_server_access').users).toEqual([
|
||||
{ userId: 'user_2', email: 'two@tldraw.com' },
|
||||
])
|
||||
})
|
||||
|
||||
// The list lives in one KV value that every evaluation reads, so a pasted address book is worth
|
||||
// refusing rather than storing.
|
||||
it('refuses a list past the cap', async () => {
|
||||
const env = makeEnv()
|
||||
const emails = Array.from({ length: 201 }, (_, i) => `user${i}@tldraw.com`).join('\n')
|
||||
|
||||
const response = await post(env, { flag: 'mcp_server_access', emails })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(kv.has('mcp_server_access')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles the master switch without touching the list', async () => {
|
||||
const env = makeEnv()
|
||||
mockUsers([{ id: 'user_1', email: 'one@tldraw.com' }])
|
||||
await post(env, { flag: 'mcp_server_access', emails: 'one@tldraw.com' })
|
||||
|
||||
await post(env, { flag: 'mcp_server_access', enabled: true })
|
||||
|
||||
expect(storedFlag('mcp_server_access')).toMatchObject({
|
||||
enabled: true,
|
||||
users: [{ userId: 'user_1', email: 'one@tldraw.com' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /app/admin/feature-flags', () => {
|
||||
async function get(env: Environment) {
|
||||
const response = await fetchAdmin(
|
||||
new Request('https://sync.tldraw.xyz/app/admin/feature-flags'),
|
||||
env
|
||||
)
|
||||
return (await response.json()) as any
|
||||
}
|
||||
|
||||
// The stored email is written once at save time and never updated, so it rots. Re-resolving on read
|
||||
// costs the admin panel one query and keeps the list re-saveable.
|
||||
it('refreshes the email labels from the database', async () => {
|
||||
const env = makeEnv()
|
||||
kv.set(
|
||||
'mcp_server_access',
|
||||
JSON.stringify({
|
||||
type: 'allowlist',
|
||||
enabled: true,
|
||||
users: [{ userId: 'user_1', email: 'old-address@tldraw.com' }],
|
||||
})
|
||||
)
|
||||
mockUsers([{ id: 'user_1', email: 'new-address@tldraw.com' }])
|
||||
|
||||
const flags = await get(env)
|
||||
|
||||
expect(flags.mcp_server_access.users).toEqual([
|
||||
{ userId: 'user_1', email: 'new-address@tldraw.com' },
|
||||
])
|
||||
})
|
||||
|
||||
// A deleted account leaves an entry that still looks like a live grant and matches nobody, and
|
||||
// re-saving the list as displayed would 400 on a line the admin cannot pick out from the rest.
|
||||
it('marks an entry whose user id no longer resolves', async () => {
|
||||
const env = makeEnv()
|
||||
kv.set(
|
||||
'mcp_server_access',
|
||||
JSON.stringify({
|
||||
type: 'allowlist',
|
||||
enabled: true,
|
||||
users: [{ userId: 'user_gone', email: 'gone@tldraw.com' }],
|
||||
})
|
||||
)
|
||||
mockUsers([])
|
||||
|
||||
const flags = await get(env)
|
||||
|
||||
expect(flags.mcp_server_access.users).toEqual([
|
||||
{ userId: 'user_gone', email: 'gone@tldraw.com', missing: true },
|
||||
])
|
||||
})
|
||||
|
||||
// The defaults table is the schema; KV holds only state. A hand-edited value with a typo'd type
|
||||
// must not reach the panel — or the request path — as a shape neither of them recognises.
|
||||
it('reports the type from the defaults, not from KV', async () => {
|
||||
const env = makeEnv()
|
||||
kv.set('mcp_server_access', JSON.stringify({ type: 'allowList', enabled: true }))
|
||||
|
||||
const flags = await get(env)
|
||||
|
||||
expect(flags.mcp_server_access.type).toBe('allowlist')
|
||||
})
|
||||
})
|
||||
1199
apps/dotcom/sync-worker/src/adminRoutes.ts
Normal file
1199
apps/dotcom/sync-worker/src/adminRoutes.ts
Normal file
File diff suppressed because it is too large
Load Diff
115
apps/dotcom/sync-worker/src/assetAssociation.test.ts
Normal file
115
apps/dotcom/sync-worker/src/assetAssociation.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectAssetAssociationChanges } from './assetAssociation'
|
||||
|
||||
const SLUG = 'file-slug-123'
|
||||
const USER_CONTENT = 'https://tldrawusercontent.com'
|
||||
|
||||
function imageAsset(overrides: {
|
||||
id?: string
|
||||
src?: string | null
|
||||
fileId?: string | null
|
||||
type?: string
|
||||
}) {
|
||||
return {
|
||||
typeName: 'asset',
|
||||
type: overrides.type ?? 'image',
|
||||
id: overrides.id ?? 'asset:1',
|
||||
props: { src: overrides.src },
|
||||
meta: overrides.fileId === undefined ? {} : { fileId: overrides.fileId },
|
||||
}
|
||||
}
|
||||
|
||||
function collect(records: unknown[], skipObjectNames?: ReadonlySet<string>) {
|
||||
return collectAssetAssociationChanges(records, {
|
||||
slug: SLUG,
|
||||
userContentUrl: USER_CONTENT,
|
||||
skipObjectNames,
|
||||
})
|
||||
}
|
||||
|
||||
describe('collectAssetAssociationChanges', () => {
|
||||
it('queues a copy for an asset from another file, preserving the file type suffix', () => {
|
||||
const { assetsToReplace, assetsToMigrate } = collect([
|
||||
imageAsset({ src: `${USER_CONTENT}/abc123-png`, fileId: 'other-file' }),
|
||||
])
|
||||
expect(assetsToMigrate).toEqual([])
|
||||
expect(assetsToReplace).toEqual([
|
||||
{
|
||||
assetId: 'asset:1',
|
||||
objectName: 'abc123-png',
|
||||
newObjectName: expect.stringMatching(/-png$/),
|
||||
newSrc: expect.stringMatching(new RegExp(`^${USER_CONTENT}/.*-png$`)),
|
||||
},
|
||||
])
|
||||
expect(assetsToReplace[0].newObjectName).not.toBe('abc123-png')
|
||||
})
|
||||
|
||||
it('queues a copy for an unassociated asset without a file type suffix', () => {
|
||||
const { assetsToReplace } = collect([imageAsset({ src: `${USER_CONTENT}/abc123` })])
|
||||
expect(assetsToReplace).toHaveLength(1)
|
||||
expect(assetsToReplace[0].objectName).toBe('abc123')
|
||||
})
|
||||
|
||||
it('migrates old-format urls for assets already associated with this file', () => {
|
||||
const { assetsToReplace, assetsToMigrate } = collect([
|
||||
imageAsset({ src: 'https://old-host.tldraw.com/uploads/abc123-png', fileId: SLUG }),
|
||||
])
|
||||
expect(assetsToReplace).toEqual([])
|
||||
expect(assetsToMigrate).toEqual([{ assetId: 'asset:1', newSrc: `${USER_CONTENT}/abc123-png` }])
|
||||
})
|
||||
|
||||
it('leaves associated assets with current-format urls alone', () => {
|
||||
const result = collect([imageAsset({ src: `${USER_CONTENT}/abc123-png`, fileId: SLUG })])
|
||||
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
|
||||
})
|
||||
|
||||
it('skips bookmark assets — their src is the bookmarked page url, not an upload', () => {
|
||||
const result = collect([
|
||||
imageAsset({ type: 'bookmark', src: 'https://example.com/products/melvin' }),
|
||||
imageAsset({ type: 'bookmark', src: 'https://example.com/page', fileId: SLUG }),
|
||||
])
|
||||
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
|
||||
})
|
||||
|
||||
it('skips data: and missing srcs', () => {
|
||||
const result = collect([
|
||||
imageAsset({ src: 'data:image/png;base64,AAAA' }),
|
||||
imageAsset({ src: null }),
|
||||
imageAsset({ src: '' }),
|
||||
])
|
||||
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
|
||||
})
|
||||
|
||||
it('skips object names R2 would reject', () => {
|
||||
const longName = 'a'.repeat(2000)
|
||||
const result = collect([
|
||||
imageAsset({ src: `https://example.com/${longName}` }),
|
||||
imageAsset({ src: 'https://example.com/trailing/' }),
|
||||
])
|
||||
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
|
||||
})
|
||||
|
||||
it('does not migrate old-format urls whose object name R2 would reject', () => {
|
||||
const longName = 'a'.repeat(2000)
|
||||
const result = collect([
|
||||
imageAsset({ src: `https://old-host.tldraw.com/uploads/${longName}`, fileId: SLUG }),
|
||||
])
|
||||
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
|
||||
})
|
||||
|
||||
it('skips object names a previous pass found missing from the bucket', () => {
|
||||
const { assetsToReplace } = collect(
|
||||
[
|
||||
imageAsset({ id: 'asset:1', src: `${USER_CONTENT}/gone-png` }),
|
||||
imageAsset({ id: 'asset:2', src: `${USER_CONTENT}/present-png` }),
|
||||
],
|
||||
new Set(['gone-png'])
|
||||
)
|
||||
expect(assetsToReplace.map((a) => a.assetId)).toEqual(['asset:2'])
|
||||
})
|
||||
|
||||
it('ignores non-asset records', () => {
|
||||
const result = collect([{ typeName: 'shape', type: 'image', props: {} }])
|
||||
expect(result).toEqual({ assetsToReplace: [], assetsToMigrate: [] })
|
||||
})
|
||||
})
|
||||
86
apps/dotcom/sync-worker/src/assetAssociation.ts
Normal file
86
apps/dotcom/sync-worker/src/assetAssociation.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { uniqueId } from '@tldraw/utils'
|
||||
import { isValidR2ObjectName } from '@tldraw/worker-shared'
|
||||
|
||||
export interface AssetToReplace {
|
||||
objectName: string
|
||||
newObjectName: string
|
||||
newSrc: string
|
||||
assetId: string
|
||||
}
|
||||
|
||||
export interface AssetToMigrate {
|
||||
assetId: string
|
||||
newSrc: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The uploads-bucket object name an asset's src points at, or null for assets the
|
||||
* association pass can never act on: bookmark assets (their src is the bookmarked page URL,
|
||||
* not an uploads object), non-http srcs, and object names R2 would reject. Shared with the
|
||||
* admin asset diagnostics so its `external` count matches what the pass actually skips.
|
||||
*/
|
||||
export function getUploadObjectName(asset: {
|
||||
type?: string
|
||||
props?: { src?: string | null }
|
||||
}): string | null {
|
||||
if (asset.type === 'bookmark') return null
|
||||
const src = asset.props?.src
|
||||
if (!src || !src.startsWith('http')) return null
|
||||
const objectName = src.split('/').pop()
|
||||
if (!objectName || !isValidR2ObjectName(objectName)) return null
|
||||
return objectName
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides which asset records an association pass should act on. Assets `getUploadObjectName`
|
||||
* rejects are skipped entirely so the pass doesn't re-attempt them on every run; `skipObjectNames`
|
||||
* (objects a previous pass already found missing from the uploads bucket) only gates the copy
|
||||
* path — URL migration is a string rewrite and needs no R2 access.
|
||||
*/
|
||||
export function collectAssetAssociationChanges(
|
||||
records: Iterable<unknown>,
|
||||
{
|
||||
slug,
|
||||
userContentUrl,
|
||||
skipObjectNames,
|
||||
}: {
|
||||
slug: string
|
||||
userContentUrl: string
|
||||
skipObjectNames?: ReadonlySet<string>
|
||||
}
|
||||
): { assetsToReplace: AssetToReplace[]; assetsToMigrate: AssetToMigrate[] } {
|
||||
const assetsToReplace: AssetToReplace[] = []
|
||||
const assetsToMigrate: AssetToMigrate[] = []
|
||||
for (const record of records) {
|
||||
if ((record as any).typeName !== 'asset') continue
|
||||
const asset = record as any
|
||||
const objectName = getUploadObjectName(asset)
|
||||
if (!objectName) continue
|
||||
const meta = asset.meta
|
||||
const src = asset.props.src
|
||||
|
||||
// Migrate old-format HTTP URLs to tldrawusercontent.com (same R2 bucket, no copy needed)
|
||||
if (meta?.fileId === slug && !src.startsWith(userContentUrl)) {
|
||||
assetsToMigrate.push({
|
||||
assetId: asset.id,
|
||||
newSrc: `${userContentUrl}/${objectName}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (meta?.fileId === slug) continue
|
||||
if (skipObjectNames?.has(objectName)) continue
|
||||
|
||||
const split = objectName.split('-')
|
||||
const fileType = split.length > 1 ? split.pop() : null
|
||||
const id = uniqueId()
|
||||
const newObjectName = fileType ? `${id}-${fileType}` : id
|
||||
assetsToReplace.push({
|
||||
objectName,
|
||||
newObjectName,
|
||||
assetId: asset.id,
|
||||
newSrc: `${userContentUrl}/${newObjectName}`,
|
||||
})
|
||||
}
|
||||
return { assetsToReplace, assetsToMigrate }
|
||||
}
|
||||
211
apps/dotcom/sync-worker/src/authorizeFileRecord.test.ts
Normal file
211
apps/dotcom/sync-worker/src/authorizeFileRecord.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
TLComment,
|
||||
TLNoteShape,
|
||||
TLPageId,
|
||||
TLShape,
|
||||
createComment,
|
||||
createCommentThread,
|
||||
createShapeId,
|
||||
toRichText,
|
||||
} from '@tldraw/tlschema'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionMeta, authorizeFileRecord } from './authorizeFileRecord'
|
||||
|
||||
const pageId = 'page:test' as TLPageId
|
||||
|
||||
function session(
|
||||
userId: string | null,
|
||||
isReadonly = false
|
||||
): {
|
||||
sessionId: string
|
||||
isReadonly: boolean
|
||||
meta: SessionMeta
|
||||
} {
|
||||
return { sessionId: 's1', isReadonly, meta: { storeId: 'store', userId } }
|
||||
}
|
||||
|
||||
describe('authorizeFileRecord', () => {
|
||||
// The comment/thread policy itself is tested in @tldraw/sync-collaboration; these two only
|
||||
// verify the factory is wired in with getUserId mapped to session.meta.userId.
|
||||
describe('comment authorizer wiring', () => {
|
||||
const thread = createCommentThread({
|
||||
pageId,
|
||||
anchor: { type: 'page' },
|
||||
createdBy: 'real-bob',
|
||||
})
|
||||
|
||||
it('stamps authorId from the session user id', () => {
|
||||
const next = createComment({
|
||||
threadId: thread.id,
|
||||
pageId,
|
||||
authorId: 'client-claims-alice',
|
||||
body: toRichText('hi'),
|
||||
})
|
||||
const result = authorizeFileRecord.comment!({
|
||||
session: session('real-bob'),
|
||||
type: 'create',
|
||||
prev: null,
|
||||
next,
|
||||
}) as TLComment
|
||||
expect(result.authorId).toBe('real-bob')
|
||||
})
|
||||
|
||||
it('vetoes thread hard-deletes (deletion is soft)', () => {
|
||||
expect(
|
||||
authorizeFileRecord['comment-thread']!({
|
||||
session: session('real-bob'),
|
||||
type: 'delete',
|
||||
prev: thread,
|
||||
next: null,
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('vetoes a comment create from a canvas read-only session', () => {
|
||||
const next = createComment({
|
||||
threadId: thread.id,
|
||||
pageId,
|
||||
authorId: 'real-bob',
|
||||
body: toRichText('hi'),
|
||||
})
|
||||
expect(
|
||||
authorizeFileRecord.comment!({
|
||||
session: session('real-bob', true),
|
||||
type: 'create',
|
||||
prev: null,
|
||||
next,
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shape', () => {
|
||||
const authorize = authorizeFileRecord.shape!
|
||||
|
||||
function makeNote(textLastEditedBy: string | null): TLNoteShape {
|
||||
return {
|
||||
id: createShapeId('note1'),
|
||||
typeName: 'shape',
|
||||
type: 'note',
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
index: 'a1' as TLNoteShape['index'],
|
||||
parentId: 'page:test' as TLNoteShape['parentId'],
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
meta: {},
|
||||
props: {
|
||||
color: 'black',
|
||||
richText: toRichText('hello'),
|
||||
size: 'm',
|
||||
font: 'draw',
|
||||
align: 'middle',
|
||||
verticalAlign: 'middle',
|
||||
labelColor: 'black',
|
||||
growY: 0,
|
||||
fontSizeAdjustment: 1,
|
||||
url: '',
|
||||
scale: 1,
|
||||
textLastEditedBy,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeGeo(): TLShape {
|
||||
const note = makeNote(null)
|
||||
const { textLastEditedBy: _, ...props } = note.props
|
||||
return { ...note, id: createShapeId('geo1'), type: 'geo', props } as unknown as TLShape
|
||||
}
|
||||
|
||||
it('vetoes a create with foreign attribution (duplication now re-stamps the copy)', () => {
|
||||
const next = makeNote('real-alice')
|
||||
expect(
|
||||
authorize({ session: session('real-bob'), type: 'create', prev: null, next })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('allows a create attributed to the session user', () => {
|
||||
const next = makeNote('real-bob')
|
||||
expect(authorize({ session: session('real-bob'), type: 'create', prev: null, next })).toBe(
|
||||
next
|
||||
)
|
||||
})
|
||||
|
||||
it('allows a create with no attribution (empty or anonymous note)', () => {
|
||||
const next = makeNote(null)
|
||||
expect(authorize({ session: session('real-bob'), type: 'create', prev: null, next })).toBe(
|
||||
next
|
||||
)
|
||||
expect(authorize({ session: session(null), type: 'create', prev: null, next })).toBe(next)
|
||||
})
|
||||
|
||||
it('vetoes an anonymous session creating a note with any attribution', () => {
|
||||
const next = makeNote('real-alice')
|
||||
expect(authorize({ session: session(null), type: 'create', prev: null, next })).toBeNull()
|
||||
})
|
||||
|
||||
it('allows updates that do not touch attribution, from any user', () => {
|
||||
const prev = makeNote('real-alice')
|
||||
const next = { ...prev, x: 100 }
|
||||
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
|
||||
expect(authorize({ session: session(null), type: 'update', prev, next })).toBe(next)
|
||||
})
|
||||
|
||||
it('allows changing attribution to the session user', () => {
|
||||
const prev = makeNote('real-alice')
|
||||
const next = { ...prev, props: { ...prev.props, textLastEditedBy: 'real-bob' } }
|
||||
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
|
||||
})
|
||||
|
||||
it('allows clearing attribution to null (text emptied, or anonymous edit)', () => {
|
||||
const prev = makeNote('real-alice')
|
||||
const next = { ...prev, props: { ...prev.props, textLastEditedBy: null } }
|
||||
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
|
||||
expect(authorize({ session: session(null), type: 'update', prev, next })).toBe(next)
|
||||
})
|
||||
|
||||
it('vetoes changing attribution to someone else', () => {
|
||||
const prev = makeNote('real-bob')
|
||||
const next = { ...prev, props: { ...prev.props, textLastEditedBy: 'real-alice' } }
|
||||
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
|
||||
})
|
||||
|
||||
it('vetoes an anonymous session setting any non-null attribution', () => {
|
||||
const prev = makeNote(null)
|
||||
const next = { ...prev, props: { ...prev.props, textLastEditedBy: 'real-alice' } }
|
||||
expect(authorize({ session: session(null), type: 'update', prev, next })).toBeNull()
|
||||
})
|
||||
|
||||
it('vetoes smuggling attribution in via a shape-type change', () => {
|
||||
const prev = makeGeo()
|
||||
const next = { ...makeNote('real-alice'), id: prev.id }
|
||||
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores non-note shapes', () => {
|
||||
const prev = makeGeo()
|
||||
const next = { ...prev, x: 50 }
|
||||
expect(authorize({ session: session(null), type: 'update', prev, next })).toBe(next)
|
||||
// creates of non-note shapes carry no attribution to enforce, so they pass through
|
||||
expect(authorize({ session: session(null), type: 'create', prev: null, next: prev })).toBe(
|
||||
prev
|
||||
)
|
||||
})
|
||||
|
||||
it('allows deletes', () => {
|
||||
const prev = makeNote('real-alice')
|
||||
expect(authorize({ session: session('real-bob'), type: 'delete', prev, next: null })).toBe(
|
||||
prev
|
||||
)
|
||||
})
|
||||
|
||||
it('allows a legal note update from a canvas read-only session (gated at the protocol level, not here)', () => {
|
||||
const prev = makeNote('real-alice')
|
||||
const next = { ...prev, x: 100 }
|
||||
expect(authorize({ session: session('real-bob', true), type: 'update', prev, next })).toBe(
|
||||
next
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
62
apps/dotcom/sync-worker/src/authorizeFileRecord.ts
Normal file
62
apps/dotcom/sync-worker/src/authorizeFileRecord.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createCommentAuthorizers } from '@tldraw/sync-collaboration'
|
||||
import { TLRecordAuthorizer, TLRecordAuthorizers } from '@tldraw/sync-core'
|
||||
import {
|
||||
TLComment,
|
||||
TLCommentReaction,
|
||||
TLCommentThread,
|
||||
TLNoteShape,
|
||||
TLRecord,
|
||||
TLShape,
|
||||
} from '@tldraw/tlschema'
|
||||
|
||||
/** Per-session metadata attached to each socket: the local store id and the authenticated user (if any). */
|
||||
export interface SessionMeta {
|
||||
storeId: string
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
// The file room's record union, widened to include the object-lane comment records so each
|
||||
// authorizer is typed to its record — renaming an attribution field fails to compile here.
|
||||
type FileRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction
|
||||
|
||||
/**
|
||||
* A note's text attribution, or `undefined` when the shape isn't carrying one (non-note shapes).
|
||||
* Authorizers see records at the server's schema version, so only the current field name exists.
|
||||
*/
|
||||
function getNoteAttribution(shape: TLShape): string | null | undefined {
|
||||
if (shape.type !== 'note') return undefined
|
||||
return (shape as TLNoteShape).props.textLastEditedBy
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes stamp `textLastEditedBy` client-side (the editor on text edit, `null` when emptied or
|
||||
* anonymous). Enforce it here on both create and update: the attribution may only be `null` or
|
||||
* the session's own user. Duplication and paste re-stamp the copy to the current user (via
|
||||
* `NoteShapeUtil.onBeforeDuplicate`), so a legitimate create never carries another user's
|
||||
* attribution and we can reject one that does — closing the forged-create loophole the create
|
||||
* passthrough previously had to accept. Updates still allow an unchanged attribution from any
|
||||
* user, so anyone with access can edit a note's other props.
|
||||
*/
|
||||
const authorizeShape: TLRecordAuthorizer<TLShape, SessionMeta> = ({
|
||||
session,
|
||||
type,
|
||||
prev,
|
||||
next,
|
||||
}) => {
|
||||
if (type === 'delete') return prev
|
||||
// On create there's no prior attribution; on update an unchanged attribution is always allowed.
|
||||
const before = type === 'create' ? null : getNoteAttribution(prev)
|
||||
const after = getNoteAttribution(next)
|
||||
if (after !== before && after != null && after !== session.meta.userId) return null
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Force comment, thread, and reaction authorship from the session's identity (via the shared
|
||||
* commenting authorizers), and guard note text attribution, so nothing can be posted, pinned,
|
||||
* or reacted in someone else's name.
|
||||
*/
|
||||
export const authorizeFileRecord = {
|
||||
...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),
|
||||
shape: authorizeShape,
|
||||
} satisfies TLRecordAuthorizers<FileRecord, SessionMeta>
|
||||
1203
apps/dotcom/sync-worker/src/commentRows.test.ts
Normal file
1203
apps/dotcom/sync-worker/src/commentRows.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
595
apps/dotcom/sync-worker/src/commentRows.ts
Normal file
595
apps/dotcom/sync-worker/src/commentRows.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
import { DB, extractMentionIds } from '@tldraw/dotcom-shared'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import {
|
||||
TLComment,
|
||||
TLCommentAnchor,
|
||||
TLCommentReaction,
|
||||
TLCommentThread,
|
||||
commentReactionRecordConfig,
|
||||
commentRecordConfig,
|
||||
commentThreadRecordConfig,
|
||||
isCommentId,
|
||||
isCommentReactionId,
|
||||
isCommentThreadId,
|
||||
} from '@tldraw/tlschema'
|
||||
import { JsonObject } from '@tldraw/utils'
|
||||
import { Kysely } from 'kysely'
|
||||
|
||||
/**
|
||||
* Conversions between the room's comment records and their Postgres rows. Postgres is the sole
|
||||
* durable store, so the columns carry every record field and the DO can rebuild records losslessly
|
||||
* on cold start. Timestamps and clocks go through Number() in case the global int8-to-number parser
|
||||
* in `postgres.ts` ever goes away.
|
||||
*/
|
||||
|
||||
// A Postgres foreign-key violation (SQLSTATE 23503) on one of the named constraints.
|
||||
function isFkViolation(error: unknown, constraints: readonly string[]): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const { code, constraint } = error as { code?: unknown; constraint?: unknown }
|
||||
return code === '23503' && typeof constraint === 'string' && constraints.includes(constraint)
|
||||
}
|
||||
|
||||
/**
|
||||
* The author's user row is gone — deleting a user cascades their comment rows away, so a warm room
|
||||
* is still holding records for a deleted author. Retrying can't succeed; the caller prunes.
|
||||
*/
|
||||
export function isCommentAuthorFkViolation(error: unknown): boolean {
|
||||
return isFkViolation(error, ['comment_author_id_fkey'])
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread's file row doesn't exist: the file was deleted, or a client pushed comments into a
|
||||
* room whose slug was never a `file` row. Neither can succeed on retry, so the caller prunes.
|
||||
*/
|
||||
export function isCommentThreadFkViolation(error: unknown): boolean {
|
||||
return isFkViolation(error, ['comment_thread_file_id_fkey'])
|
||||
}
|
||||
|
||||
/** The comment's file row is gone, cascading every comment row with it. The caller prunes. */
|
||||
export function isCommentFileFkViolation(error: unknown): boolean {
|
||||
return isFkViolation(error, ['comment_file_id_fkey'])
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment's thread row doesn't exist. Unlike the other FK matchers this is NOT on its own
|
||||
* sufficient to prune: threads upsert before comments in the same drain, so a thread whose own
|
||||
* upsert just failed transiently also fails its comments' FK, and it's still queued to retry.
|
||||
* The caller only prunes when the threadId is also absent from the room's lane.
|
||||
*/
|
||||
export function isCommentThreadIdFkViolation(error: unknown): boolean {
|
||||
return isFkViolation(error, ['comment_thread_id_fkey'])
|
||||
}
|
||||
|
||||
/**
|
||||
* A `comment_mention` insert hit either of its foreign keys: the mentioned user is gone, or the
|
||||
* comment was deleted between its upsert and the mention write. The caller skips the row.
|
||||
*/
|
||||
export function isCommentMentionFkViolation(error: unknown): boolean {
|
||||
return isFkViolation(error, ['comment_mention_user_id_fkey', 'comment_mention_comment_id_fkey'])
|
||||
}
|
||||
|
||||
/**
|
||||
* A reaction's comment, thread, or user row is gone, cascading the reaction rows with it. The
|
||||
* caller prunes.
|
||||
*/
|
||||
export function isCommentReactionFkViolation(error: unknown): boolean {
|
||||
return isFkViolation(error, [
|
||||
'comment_reaction_comment_id_fkey',
|
||||
'comment_reaction_thread_id_fkey',
|
||||
'comment_reaction_user_id_fkey',
|
||||
])
|
||||
}
|
||||
|
||||
/** One comment's desired `comment_mention` rows: the full set its body currently mentions. */
|
||||
export interface CommentMentionReconcile {
|
||||
commentId: string
|
||||
userIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The desired end state of `comment_mention` for a drain's upserted comments. The caller
|
||||
* reconciles Postgres to this (delete rows not in the set, insert the rest with ON CONFLICT DO
|
||||
* NOTHING), so an at-least-once replay is a no-op rather than WAL churn for Zero.
|
||||
*/
|
||||
export function planMentionReconciles(commentRows: DB['comment'][]): CommentMentionReconcile[] {
|
||||
return commentRows.map((row) => ({
|
||||
commentId: row.id,
|
||||
userIds: extractMentionIds(row.body),
|
||||
}))
|
||||
}
|
||||
|
||||
export function threadRecordToRow(
|
||||
record: TLCommentThread,
|
||||
fileId: string,
|
||||
lastChangedClock: number
|
||||
): DB['comment_thread'] {
|
||||
return {
|
||||
id: record.id,
|
||||
fileId,
|
||||
pageId: record.pageId,
|
||||
anchor: record.anchor,
|
||||
shapeId: record.anchor.type === 'shape' ? record.anchor.shapeId : null,
|
||||
resolvedAt: record.resolved?.at ?? null,
|
||||
resolvedBy: record.resolved?.by ?? null,
|
||||
isDeleted: record.isDeleted,
|
||||
createdBy: record.createdBy,
|
||||
createdAt: record.createdAt,
|
||||
meta: record.meta,
|
||||
lastChangedClock,
|
||||
}
|
||||
}
|
||||
|
||||
export function reactionRecordToRow(
|
||||
record: TLCommentReaction,
|
||||
fileId: string,
|
||||
lastChangedClock: number
|
||||
): DB['comment_reaction'] {
|
||||
return {
|
||||
id: record.id,
|
||||
fileId,
|
||||
commentId: record.commentId,
|
||||
threadId: record.threadId,
|
||||
pageId: record.pageId,
|
||||
userId: record.userId,
|
||||
// stamped by Postgres (set_comment_reaction_user_name_trigger); value here is ignored
|
||||
userName: '',
|
||||
emoji: record.emoji,
|
||||
// client-dated; clamp so it can't outrun the server-clamped comment_read.readAt watermark,
|
||||
// which would make a "reacted to your comment" notification impossible to mark read
|
||||
createdAt: Math.min(record.createdAt, Date.now()),
|
||||
meta: record.meta,
|
||||
lastChangedClock,
|
||||
}
|
||||
}
|
||||
|
||||
export function commentRecordToRow(
|
||||
record: TLComment,
|
||||
fileId: string,
|
||||
lastChangedClock: number
|
||||
): DB['comment'] {
|
||||
return {
|
||||
id: record.id,
|
||||
fileId,
|
||||
threadId: record.threadId,
|
||||
pageId: record.pageId,
|
||||
authorId: record.authorId,
|
||||
// placeholders — a Postgres trigger stamps the real values on insert (migration 040)
|
||||
authorName: '',
|
||||
authorColor: '',
|
||||
authorAvatar: '',
|
||||
// TLRichText's content is unknown[], not structurally a zero ReadonlyJSONValue
|
||||
body: record.body as DB['comment']['body'],
|
||||
// client-dated placeholder — a Postgres trigger re-stamps it with server arrival time on
|
||||
// insert (migration 046), and the upsert's conflict branch never writes this column
|
||||
createdAt: record.createdAt,
|
||||
editedAt: record.editedAt,
|
||||
isDeleted: record.isDeleted,
|
||||
updatedAt: record.editedAt ?? record.createdAt,
|
||||
meta: record.meta,
|
||||
lastChangedClock,
|
||||
}
|
||||
}
|
||||
|
||||
export function rowToThreadRecord(row: DB['comment_thread']): TLCommentThread {
|
||||
const record: TLCommentThread = {
|
||||
id: row.id as TLCommentThread['id'],
|
||||
typeName: 'comment-thread',
|
||||
pageId: row.pageId as TLCommentThread['pageId'],
|
||||
anchor: row.anchor as TLCommentAnchor,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: Number(row.createdAt),
|
||||
resolved: row.resolvedAt != null ? { at: Number(row.resolvedAt), by: row.resolvedBy! } : null,
|
||||
isDeleted: row.isDeleted,
|
||||
meta: (row.meta ?? {}) as JsonObject,
|
||||
}
|
||||
// The fields above are raw casts, so validate: a corrupt row should fail the room open rather
|
||||
// than seed an invalid record.
|
||||
return commentThreadRecordConfig.validator.validate(record) as TLCommentThread
|
||||
}
|
||||
|
||||
export function rowToCommentRecord(row: DB['comment']): TLComment {
|
||||
const record: TLComment = {
|
||||
id: row.id as TLComment['id'],
|
||||
typeName: 'comment',
|
||||
threadId: row.threadId as TLComment['threadId'],
|
||||
pageId: row.pageId as TLComment['pageId'],
|
||||
authorId: row.authorId,
|
||||
createdAt: Number(row.createdAt),
|
||||
editedAt: row.editedAt != null ? Number(row.editedAt) : null,
|
||||
body: row.body as TLComment['body'],
|
||||
isDeleted: row.isDeleted,
|
||||
meta: (row.meta ?? {}) as JsonObject,
|
||||
}
|
||||
// The fields above are raw casts, so validate: a corrupt row should fail the room open rather
|
||||
// than seed an invalid record.
|
||||
return commentRecordConfig.validator.validate(record) as TLComment
|
||||
}
|
||||
|
||||
export function rowToReactionRecord(row: DB['comment_reaction']): TLCommentReaction {
|
||||
const record: TLCommentReaction = {
|
||||
id: row.id as TLCommentReaction['id'],
|
||||
typeName: 'comment-reaction',
|
||||
commentId: row.commentId as TLCommentReaction['commentId'],
|
||||
threadId: row.threadId as TLCommentReaction['threadId'],
|
||||
pageId: row.pageId as TLCommentReaction['pageId'],
|
||||
userId: row.userId,
|
||||
emoji: row.emoji,
|
||||
createdAt: Number(row.createdAt),
|
||||
meta: (row.meta ?? {}) as JsonObject,
|
||||
}
|
||||
// See rowToCommentRecord.
|
||||
return commentReactionRecordConfig.validator.validate(record) as TLCommentReaction
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild object-lane snapshot documents from Postgres rows, for merging into the room snapshot
|
||||
* on load.
|
||||
*/
|
||||
export function rowsToSnapshotDocuments(
|
||||
threadRows: DB['comment_thread'][],
|
||||
commentRows: DB['comment'][],
|
||||
reactionRows: DB['comment_reaction'][] = []
|
||||
): RoomSnapshot['documents'] {
|
||||
return [
|
||||
...threadRows.map((row) => ({
|
||||
state: rowToThreadRecord(row),
|
||||
lastChangedClock: Number(row.lastChangedClock),
|
||||
})),
|
||||
...commentRows.map((row) => ({
|
||||
state: rowToCommentRecord(row),
|
||||
lastChangedClock: Number(row.lastChangedClock),
|
||||
})),
|
||||
...reactionRows.map((row) => ({
|
||||
state: rowToReactionRecord(row),
|
||||
lastChangedClock: Number(row.lastChangedClock),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
/** The room-seedable subset of a file's comment rows; see {@link liveCommentDocuments}. */
|
||||
export interface CommentLoadResult {
|
||||
documents: RoomSnapshot['documents']
|
||||
/**
|
||||
* The highest `lastChangedClock` across ALL of the file's comment rows, including soft-deleted
|
||||
* ones dropped from `documents` — a dropped row can hold the file's highest clock, and seeding
|
||||
* the room below it would make future edits emit clocks the drain's guard rejects.
|
||||
*/
|
||||
clockFloor: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the room-seedable comment documents from a file's Postgres rows. Soft-deleted records stay
|
||||
* in Postgres for recovery and Zero-side filtering but never re-enter a room.
|
||||
*
|
||||
* A live thread whose comments are all soft-deleted is dropped too — the durable backstop for the
|
||||
* drain's emptied-thread prune, whose isDeleted stamp rides an outbox entry in DO SQLite. A thread
|
||||
* with no comment rows at all is kept: that's a new thread whose first comment hasn't drained.
|
||||
*/
|
||||
export function liveCommentDocuments(
|
||||
threadRows: DB['comment_thread'][],
|
||||
commentRows: DB['comment'][],
|
||||
reactionRows: DB['comment_reaction'][] = []
|
||||
): CommentLoadResult {
|
||||
const threadIdsWithComments = new Set(commentRows.map((row) => row.threadId))
|
||||
const threadIdsWithLiveComments = new Set(
|
||||
commentRows.filter((row) => !row.isDeleted).map((row) => row.threadId)
|
||||
)
|
||||
const liveThreadRows = threadRows.filter(
|
||||
(row) =>
|
||||
!row.isDeleted &&
|
||||
(!threadIdsWithComments.has(row.id) || threadIdsWithLiveComments.has(row.id))
|
||||
)
|
||||
const liveThreadIds = new Set(liveThreadRows.map((row) => row.id))
|
||||
const liveCommentRows = commentRows.filter(
|
||||
(row) => !row.isDeleted && liveThreadIds.has(row.threadId)
|
||||
)
|
||||
const liveCommentIds = new Set(liveCommentRows.map((row) => row.id))
|
||||
const liveReactionRows = reactionRows.filter((row) => liveCommentIds.has(row.commentId))
|
||||
let clockFloor = 0
|
||||
for (const row of threadRows) clockFloor = Math.max(clockFloor, Number(row.lastChangedClock))
|
||||
for (const row of commentRows) clockFloor = Math.max(clockFloor, Number(row.lastChangedClock))
|
||||
for (const row of reactionRows) clockFloor = Math.max(clockFloor, Number(row.lastChangedClock))
|
||||
return {
|
||||
documents: rowsToSnapshotDocuments(liveThreadRows, liveCommentRows, liveReactionRows),
|
||||
clockFloor,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a file's comment rows over a single checked-out connection. Each `execute()` on the bare
|
||||
* Kysely instance checks out its own connection, and `TLPostgresPool` dials a fresh socket per
|
||||
* checkout, so three parallel-looking queries would otherwise cost three sequential dials.
|
||||
*/
|
||||
export async function loadCommentDocuments(
|
||||
db: Kysely<DB>,
|
||||
fileId: string
|
||||
): Promise<CommentLoadResult> {
|
||||
const [threadRows, commentRows, reactionRows] = await db
|
||||
.connection()
|
||||
.execute((conn) =>
|
||||
Promise.all([
|
||||
conn.selectFrom('comment_thread').where('fileId', '=', fileId).selectAll().execute(),
|
||||
conn.selectFrom('comment').where('fileId', '=', fileId).selectAll().execute(),
|
||||
conn.selectFrom('comment_reaction').where('fileId', '=', fileId).selectAll().execute(),
|
||||
])
|
||||
)
|
||||
// Soft-deleted threads and their comments never re-enter a room, and neither do reactions
|
||||
// whose comment doesn't; their rows stay in Postgres only (see liveCommentDocuments).
|
||||
return liveCommentDocuments(threadRows, commentRows, reactionRows)
|
||||
}
|
||||
|
||||
/** A row of the DO's `comment_outbox` table: a monotonic sequence number and the touched record id. */
|
||||
export interface CommentOutboxEntry {
|
||||
seq: number
|
||||
recordId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Postgres writes grouped by table and operation. The delete buckets carry lane-absent ids, and
|
||||
* "delete" means stamping `isDeleted` — the drain never hard-deletes comment-lane rows.
|
||||
*/
|
||||
export interface CommentDrainPlan {
|
||||
threadUpserts: DB['comment_thread'][]
|
||||
commentUpserts: DB['comment'][]
|
||||
reactionUpserts: DB['comment_reaction'][]
|
||||
threadDeletes: string[]
|
||||
commentDeletes: string[]
|
||||
reactionDeletes: string[]
|
||||
/** Ids of no known comment record type — a bug or a corrupted outbox row. */
|
||||
unknownIds: string[]
|
||||
/**
|
||||
* Reactions naming a parent comment this room doesn't have. A reaction is the one comment record
|
||||
* keyed to a parent it doesn't own — its id is derived from (comment, user, emoji), so a forged
|
||||
* `commentId` still mints a unique id and lands as a plain insert, where the upserts' `fileId`
|
||||
* conflict guard never fires. These prune rather than retry: the parent is either gone (a
|
||||
* reaction racing its comment's deletion) or was never here, and neither resolves on a retry.
|
||||
*/
|
||||
orphanedReactionIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure planning half of the comment outbox drain (see drainCommentOutbox). The outbox stores
|
||||
* only ids; upsert-vs-delete is decided by presence in the object `lane` at plan time, so multiple
|
||||
* entries for one record coalesce into a single write and a create-then-prune nets out to a delete.
|
||||
*
|
||||
* `lane` must already carry the parent comment of every outboxed reaction — a reaction's parent is
|
||||
* usually not outboxed alongside it, and an absent parent is read here as a foreign or orphaned
|
||||
* `commentId` (see `orphanedReactionIds`). The caller seeds them.
|
||||
*/
|
||||
export function planCommentDrain(
|
||||
entries: CommentOutboxEntry[],
|
||||
lane: ReadonlyMap<string, { state: unknown; lastChangedClock: number }>,
|
||||
fileId: string
|
||||
): CommentDrainPlan {
|
||||
const plan: CommentDrainPlan = {
|
||||
threadUpserts: [],
|
||||
commentUpserts: [],
|
||||
reactionUpserts: [],
|
||||
threadDeletes: [],
|
||||
commentDeletes: [],
|
||||
reactionDeletes: [],
|
||||
unknownIds: [],
|
||||
orphanedReactionIds: [],
|
||||
}
|
||||
const pendingIds = new Set(entries.map((e) => e.recordId))
|
||||
for (const id of pendingIds) {
|
||||
const doc = lane.get(id)
|
||||
if (isCommentThreadId(id)) {
|
||||
if (doc) {
|
||||
plan.threadUpserts.push(
|
||||
threadRecordToRow(doc.state as TLCommentThread, fileId, doc.lastChangedClock)
|
||||
)
|
||||
} else {
|
||||
plan.threadDeletes.push(id)
|
||||
}
|
||||
} else if (isCommentReactionId(id)) {
|
||||
if (doc) {
|
||||
const reaction = doc.state as TLCommentReaction
|
||||
if (lane.has(reaction.commentId)) {
|
||||
plan.reactionUpserts.push(reactionRecordToRow(reaction, fileId, doc.lastChangedClock))
|
||||
} else {
|
||||
plan.orphanedReactionIds.push(id)
|
||||
}
|
||||
} else {
|
||||
plan.reactionDeletes.push(id)
|
||||
}
|
||||
} else if (isCommentId(id)) {
|
||||
if (doc) {
|
||||
plan.commentUpserts.push(
|
||||
commentRecordToRow(doc.state as TLComment, fileId, doc.lastChangedClock)
|
||||
)
|
||||
} else {
|
||||
plan.commentDeletes.push(id)
|
||||
}
|
||||
} else {
|
||||
plan.unknownIds.push(id)
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
/**
|
||||
* The drain's three comment upserts, as query builders.
|
||||
*
|
||||
* Every one conflicts on the primary key and is guarded on `fileId`. A comment record id is only
|
||||
* addressable from the room that owns it, so a Postgres row carrying that id under a different
|
||||
* `fileId` is a row this room has no business writing — without the guard, a client that learned
|
||||
* another file's record ids could push records under them and have that file's drain overwrite the
|
||||
* original rows. The conflict target has to be the primary key (it's the unique index the upsert
|
||||
* needs), so the ownership check rides along in the update predicate instead: a mismatch updates
|
||||
* nothing rather than rewriting the row. The `lastChangedClock` guard alone doesn't cover this — a
|
||||
* forged record just carries a higher clock.
|
||||
*
|
||||
* They live here rather than inline in the drain so the guard can be asserted against the compiled
|
||||
* SQL without a database (see commentRows.test.ts).
|
||||
*/
|
||||
export function upsertCommentThreadRows(db: Kysely<DB>, rows: DB['comment_thread'][]) {
|
||||
return db
|
||||
.insertInto('comment_thread')
|
||||
.values(rows)
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.column('id')
|
||||
.doUpdateSet((eb) => ({
|
||||
pageId: eb.ref('excluded.pageId'),
|
||||
anchor: eb.ref('excluded.anchor'),
|
||||
shapeId: eb.ref('excluded.shapeId'),
|
||||
resolvedAt: eb.ref('excluded.resolvedAt'),
|
||||
resolvedBy: eb.ref('excluded.resolvedBy'),
|
||||
isDeleted: eb.ref('excluded.isDeleted'),
|
||||
meta: eb.ref('excluded.meta'),
|
||||
lastChangedClock: eb.ref('excluded.lastChangedClock'),
|
||||
}))
|
||||
.whereRef('comment_thread.fileId', '=', 'excluded.fileId')
|
||||
.whereRef('comment_thread.lastChangedClock', '<', 'excluded.lastChangedClock')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "createdAt" is deliberately absent from the update set: Postgres stamps it on first insert
|
||||
* (migration 046) and the stamp must survive at-least-once replays and edits. The trigger side of
|
||||
* that is covered by zero-cache/stamp_comment_created_at.test.ts, against a fixture table with no
|
||||
* fileId column — so it exercises the createdAt behavior, not this builder's ownership guard.
|
||||
*/
|
||||
export function upsertCommentRows(db: Kysely<DB>, rows: DB['comment'][]) {
|
||||
return db
|
||||
.insertInto('comment')
|
||||
.values(rows)
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.column('id')
|
||||
.doUpdateSet((eb) => ({
|
||||
threadId: eb.ref('excluded.threadId'),
|
||||
pageId: eb.ref('excluded.pageId'),
|
||||
body: eb.ref('excluded.body'),
|
||||
editedAt: eb.ref('excluded.editedAt'),
|
||||
isDeleted: eb.ref('excluded.isDeleted'),
|
||||
// excluded.* has been through the BEFORE INSERT stamp trigger, which
|
||||
// lifts updatedAt to the (server) attempt stamp — so this can never
|
||||
// regress updatedAt below the row's createdAt
|
||||
updatedAt: eb.ref('excluded.updatedAt'),
|
||||
meta: eb.ref('excluded.meta'),
|
||||
lastChangedClock: eb.ref('excluded.lastChangedClock'),
|
||||
}))
|
||||
.whereRef('comment.fileId', '=', 'excluded.fileId')
|
||||
.whereRef('comment.lastChangedClock', '<', 'excluded.lastChangedClock')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A reaction's id is derived from its whole (comment, user, emoji) triple, so reacting with a
|
||||
* different emoji mints a different id and arrives as a plain insert — a conflict here is a replay
|
||||
* of the same triple, not an edit. The update set still lists every mutable column so a replay
|
||||
* carrying a corrected denormalized field (pageId follows its thread between pages) lands rather
|
||||
* than being silently dropped.
|
||||
*/
|
||||
export function upsertCommentReactionRows(db: Kysely<DB>, rows: DB['comment_reaction'][]) {
|
||||
return db
|
||||
.insertInto('comment_reaction')
|
||||
.values(rows)
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.column('id')
|
||||
.doUpdateSet((eb) => ({
|
||||
commentId: eb.ref('excluded.commentId'),
|
||||
threadId: eb.ref('excluded.threadId'),
|
||||
pageId: eb.ref('excluded.pageId'),
|
||||
emoji: eb.ref('excluded.emoji'),
|
||||
createdAt: eb.ref('excluded.createdAt'),
|
||||
meta: eb.ref('excluded.meta'),
|
||||
lastChangedClock: eb.ref('excluded.lastChangedClock'),
|
||||
}))
|
||||
.whereRef('comment_reaction.fileId', '=', 'excluded.fileId')
|
||||
.whereRef('comment_reaction.lastChangedClock', '<', 'excluded.lastChangedClock')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which outbox entries a finished drain may delete. `failedIds` stay queued for the next drain;
|
||||
* everything else clears. `clearAll` is the fast path when nothing failed.
|
||||
*/
|
||||
export function outboxEntriesToClear(
|
||||
entries: CommentOutboxEntry[],
|
||||
failedIds: ReadonlySet<string>
|
||||
): { clearAll: boolean; seqs: number[] } {
|
||||
if (failedIds.size === 0) {
|
||||
return { clearAll: true, seqs: [] }
|
||||
}
|
||||
return {
|
||||
clearAll: false,
|
||||
seqs: entries.filter((e) => !failedIds.has(e.recordId)).map((e) => e.seq),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Of the just-pruned comments' threads, which no longer have any comments. Callers delete those
|
||||
* threads in the same transaction: an emptied thread never renders, and no client may delete a
|
||||
* thread it didn't create, so the ordinary "delete the last comment" path needs this prune.
|
||||
*
|
||||
* `remaining` must be transaction-time, not an earlier lane snapshot — a reply committed in
|
||||
* between has to keep its thread alive.
|
||||
*/
|
||||
export function findEmptiedCommentThreads(
|
||||
candidateThreadIds: ReadonlySet<string>,
|
||||
remaining: { keys(): Iterable<string>; get(id: string): unknown }
|
||||
): string[] {
|
||||
if (candidateThreadIds.size === 0) return []
|
||||
const emptied = new Set(candidateThreadIds)
|
||||
for (const id of remaining.keys()) {
|
||||
if (!isCommentId(id)) continue
|
||||
const threadId = (remaining.get(id) as TLComment | undefined)?.threadId
|
||||
if (threadId !== undefined && emptied.delete(threadId) && emptied.size === 0) break
|
||||
}
|
||||
return [...emptied]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactions pointing at just-pruned comments. Postgres already cascaded these rows away, so this
|
||||
* only catches the warm room's SQLite up.
|
||||
*/
|
||||
export function findOrphanedReactions(
|
||||
prunedCommentIds: ReadonlySet<string>,
|
||||
remaining: { keys(): Iterable<string>; get(id: string): unknown }
|
||||
): string[] {
|
||||
if (prunedCommentIds.size === 0) return []
|
||||
const orphaned: string[] = []
|
||||
for (const id of remaining.keys()) {
|
||||
if (!isCommentReactionId(id)) continue
|
||||
const commentId = (remaining.get(id) as TLCommentReaction | undefined)?.commentId
|
||||
if (commentId !== undefined && prunedCommentIds.has(commentId)) orphaned.push(id)
|
||||
}
|
||||
return orphaned
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge rehydrated comment documents into a room snapshot, clamping the snapshot's clocks up to
|
||||
* the load's `clockFloor`. Comments push to Postgres per-commit while the snapshot persists on a
|
||||
* throttle, so after a storage loss the comment clocks can be ahead — seeding the room below them
|
||||
* would make future edits emit clocks the drain's guard silently rejects.
|
||||
*
|
||||
* The clamp works off `documentClock ?? clock ?? 0`, matching how `SQLiteSyncStorage` seeds.
|
||||
* `tombstoneHistoryStartsAtClock` rises with it: the tombstone history for that range lived only
|
||||
* in the lost SQLite, so reconnecting clients must full-resync rather than take a partial diff.
|
||||
*/
|
||||
export function mergeCommentDocumentsIntoSnapshot(
|
||||
snapshot: RoomSnapshot,
|
||||
load: CommentLoadResult
|
||||
): void {
|
||||
const { documents: commentDocs, clockFloor } = load
|
||||
if (commentDocs.length === 0 && clockFloor === 0) return
|
||||
if (commentDocs.length > 0) {
|
||||
snapshot.documents = [...snapshot.documents, ...commentDocs]
|
||||
}
|
||||
// a loop rather than `Math.max(clockFloor, ...clocks)`, which overflows the stack past ~100k docs
|
||||
let maxClock = clockFloor
|
||||
for (const doc of commentDocs) {
|
||||
if (doc.lastChangedClock > maxClock) maxClock = doc.lastChangedClock
|
||||
}
|
||||
const effectiveClock = snapshot.documentClock ?? snapshot.clock ?? 0
|
||||
if (effectiveClock >= maxClock) return
|
||||
snapshot.documentClock = maxClock
|
||||
if (snapshot.clock !== undefined && snapshot.clock < maxClock) {
|
||||
snapshot.clock = maxClock
|
||||
}
|
||||
snapshot.tombstoneHistoryStartsAtClock = maxClock
|
||||
}
|
||||
266
apps/dotcom/sync-worker/src/config.ts
Normal file
266
apps/dotcom/sync-worker/src/config.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
// Tuning constants for the sync worker, gathered here so the dials live in one place.
|
||||
//
|
||||
// Several of these come out to 60 seconds. That is coincidence, not coupling: each one is derived
|
||||
// from its own constraint (production persist timings, capture p90, Cloudflare's fixed rate limit
|
||||
// period), and each can move without the others. The one genuine interdependency in this file is
|
||||
// OG_PENDING_MARKER_TTL_MS against the retry chain (OG_MAX_RENDER_ATTEMPTS, OG_RETRY_DELAY_SECONDS
|
||||
// and the capture timeout), and a test in ogImageQueue.test.ts pins that inequality.
|
||||
|
||||
/**
|
||||
* How often we the document to R2?
|
||||
* 8 seconds.
|
||||
*/
|
||||
export const PERSIST_INTERVAL_MS = 8_000
|
||||
|
||||
/**
|
||||
* How long a board must go without persisting before its thumbnail is rendered. 60 seconds.
|
||||
*
|
||||
* A debounce, not a throttle or an interval: each persist pushes the render further out, so a board
|
||||
* renders once its editing *settles*, which is the state a thumbnail is meant to show. Note that a
|
||||
* fixed window would be the wrong shape here whatever its length — production's mean gap between one
|
||||
* board's persists is ~39s, so a window shorter than that suppresses almost nothing and a longer one
|
||||
* costs thumbnail freshness. See "Why a debounce and not a throttle" in
|
||||
* apps/dotcom/browser-run-thumbnails.md for the measurements and the cost table.
|
||||
*
|
||||
* 60s rather than 30s because replaying both values over six hours of production `persist_success`
|
||||
* timings costs ~35.7 renders/min at 30s against ~32.3 at 60s. Note how small that is: raising this
|
||||
* alone cannot save much, because a merged session then runs long enough to hit
|
||||
* `OG_RENDER_MAX_WAIT_MS` and trades a settle render for a max-wait one. The two constants have to
|
||||
* move together to matter — see "What it costs" in the doc for the grid.
|
||||
*/
|
||||
export const OG_RENDER_DEBOUNCE_MS = 60_000
|
||||
|
||||
/**
|
||||
* Ceiling on how long a continuously edited board can go without its thumbnail being refreshed.
|
||||
* 5 minutes.
|
||||
*
|
||||
* Bounds the one failure mode of a pure debounce: a board that never stops being edited would
|
||||
* otherwise never render, since every persist pushes the deadline out again. Measured from the first
|
||||
* persist after a render.
|
||||
*
|
||||
* Raising it does not affect the far more common short-burst board, which costs one render either way.
|
||||
*
|
||||
* Measured against six hours of production persists, this is now the constant that governs total
|
||||
* spend, and it governs it jointly with `OG_RENDER_DEBOUNCE_MS`: at a 60s debounce, taking this from
|
||||
* 5 to 10 minutes goes from ~32.3 to ~26.7 renders/min, while leaving it at 5 minutes makes the
|
||||
* debounce itself nearly inert. Left at 5 minutes deliberately — a board edited without pause holds a
|
||||
* five minute old thumbnail rather than a ten minute old one — but this is the first dial to turn if
|
||||
* spend needs to come down.
|
||||
*
|
||||
* Its floor is OG_PENDING_MARKER_TTL_MS. Shared files have no follow-up render: a max-wait fire is
|
||||
* the one ask the debounce doesn't bound, and it is safe only because it lands at or past the TTL of
|
||||
* any marker that could turn it away. Below that floor, an ask can be dropped with nothing left to
|
||||
* re-ask (the inequality is pinned in ogImageQueue.test.ts).
|
||||
*/
|
||||
export const OG_RENDER_MAX_WAIT_MS = 5 * 60_000
|
||||
|
||||
/**
|
||||
* Suppresses a duplicate OG render enqueue while one is queued or in flight. A single-flight, not a
|
||||
* rate limit: the consumer clears it as soon as a render lands, so on a healthy board it never
|
||||
* reaches this TTL, which exists only so a consumer dying cannot wedge a board permanently. To
|
||||
* change how often a board renders, change OG_RENDER_DEBOUNCE_MS.
|
||||
*
|
||||
* Must outlive a job's worst-case retry chain — every capture running to its timeout plus every
|
||||
* backoff delay — or the marker lapses while the job is still alive, a fresh ask enqueues a second
|
||||
* job for the same board, and the two clobber each other's per-board render token record
|
||||
* (renderTokens.ts is keyed on the single-flight this marker provides). A test in
|
||||
* ogImageQueue.test.ts pins the inequality against OG_MAX_RENDER_ATTEMPTS, OG_RETRY_DELAY_SECONDS
|
||||
* and THUMBNAIL_RENDER_TIMEOUT_MS.
|
||||
*/
|
||||
export const OG_PENDING_MARKER_TTL_MS = 5 * 60_000
|
||||
|
||||
/**
|
||||
* Retries are bounded by max_retries in wrangler.toml too; this lower cap keeps thumbnail jobs from
|
||||
* burning Browser Run capacity on a persistently failing board.
|
||||
*/
|
||||
export const OG_MAX_RENDER_ATTEMPTS = 3
|
||||
export const OG_RETRY_DELAY_SECONDS = 30
|
||||
|
||||
/**
|
||||
* How long the OG route's published-board repair (`repairMissingPublishedImage` in getOgImage.ts)
|
||||
* stays quiet after a crawler-triggered render job for that board has burnt its whole retry budget.
|
||||
* 1 hour.
|
||||
*
|
||||
* The repair is the one render ask an unauthenticated request can cause, and without this it re-arms
|
||||
* as soon as the failed job clears its pending marker: a published board that deterministically fails
|
||||
* to render — a huge board that times out, say — would burn a full retry chain of Browser Run every
|
||||
* ~4 minutes for as long as anything crawls its URL. With it, such a board costs one chain per hour.
|
||||
*
|
||||
* Only the *crawler-reason* give-up arms it, so a publish-triggered render that fails transiently
|
||||
* still gets one immediate repair on the next crawl — the case the repair exists for — and the
|
||||
* cooldown only meters the attempts after that one has also failed. The publish trigger clears it,
|
||||
* because an in-place republish reuses the slug: the cooldown is evidence about the snapshot that
|
||||
* failed, and it must not outlive that snapshot and block the new one's repair.
|
||||
*/
|
||||
export const OG_REPAIR_COOLDOWN_MS = 60 * 60_000
|
||||
|
||||
/**
|
||||
* Cache lifetimes the OG image route hands to callers, in seconds because they land in
|
||||
* `cache-control` headers.
|
||||
*
|
||||
* Short, and deliberately not chosen for cost or freshness. Neither is the binding constraint:
|
||||
* R2 reads are a rounding error next to rendering, and unfurl platforms cache a card their own side
|
||||
* for days whatever we say. What the lifetime actually decides is how long an image can be served
|
||||
* without the share gate being consulted — nothing deletes a board's image when it stops being
|
||||
* public, so the route re-checking the gate per request is the only thing keeping an unshared
|
||||
* board's thumbnail off the internet. Every second of cache lifetime is a second that check does not
|
||||
* happen, so these are minutes, and revalidation is made cheap with an etag instead.
|
||||
*/
|
||||
export const OG_FRESH_IMAGE_MAX_AGE_SECONDS = 5 * 60
|
||||
export const OG_STALE_IMAGE_MAX_AGE_SECONDS = 5 * 60
|
||||
export const OG_FALLBACK_MAX_AGE_SECONDS = 60
|
||||
|
||||
/**
|
||||
* How long a minted render token stays valid.
|
||||
*
|
||||
* Sized against what a capture needs rather than generously, because this token is what stands between
|
||||
* an HMAC signature and a *private* board's full document: thumbnails are rendered for every board, not
|
||||
* only public ones. Measured renders run 4s at p50 and 12-17s at p90, so 60s is several times p90.
|
||||
*
|
||||
* Queue linger and retry backoff do not eat into it — a token is minted immediately before the
|
||||
* `quickAction` call, not at enqueue. A render slower than this fails and retries with a fresh token.
|
||||
*/
|
||||
export const THUMBNAIL_RENDER_TOKEN_TTL_MS = 60_000
|
||||
|
||||
/**
|
||||
* MCP rate limits: the only rate limiting anywhere in the thumbnail pipeline, applied in
|
||||
* mcpServer.ts. The MCP endpoint is the one Browser Run-spending surface an outside
|
||||
* caller can drive directly, so a rogue or looping agent is the threat being bounded, and only the
|
||||
* calls that actually spend Browser Run are limited — `get_board_info` does the same work the
|
||||
* ordinary board routes do for anyone. `search_boards` has its own budget, bounding a different
|
||||
* resource: it spends no Browser Run, but it drives the Postgres that serves all of dotcom, and
|
||||
* its matching is the one part no index reaches — `name ILIKE '%term%'` over the caller's whole
|
||||
* in-scope set, so a query matching nothing costs the most. See "MCP tools" in
|
||||
* browser-run-thumbnails.md for the measured query plans.
|
||||
* The global limit is applied per Cloudflare location, so it bounds a caller rather than the
|
||||
* account. See "Request limits" in browser-run-thumbnails.md.
|
||||
*
|
||||
* These constants are only the isolate-local fallback for local dev and tests. Deployed environments
|
||||
* are governed by the Cloudflare rate limit bindings in wrangler.toml, so changing a number here
|
||||
* alone changes nothing in production. Each budget has its own binding, and the pairs must move
|
||||
* together:
|
||||
*
|
||||
* MCP_PER_USER_RATE_LIMIT -> MCP_SCREENSHOT_RATE_LIMITER (limit = 10)
|
||||
* MCP_PER_BOARD_RATE_LIMIT -> MCP_SERVER_BOARD_RATE_LIMITER (limit = 2)
|
||||
* MCP_GLOBAL_BROWSER_RUN_RATE_LIMIT -> MCP_SERVER_BROWSER_RATE_LIMITER (limit = 20)
|
||||
* MCP_SEARCH_PER_USER_RATE_LIMIT -> MCP_SERVER_SEARCH_RATE_LIMITER (limit = 60)
|
||||
*
|
||||
* The first of these keyed on client IP until the endpoint required authentication. An account is
|
||||
* the better key in both directions: a proxy pool no longer buys a caller more budget, and everyone
|
||||
* behind one NAT no longer shares a single one. IP limits still matter on endpoints with no caller
|
||||
* identity — the render page's snapshot route, and anything the OAuth flow exposes.
|
||||
*
|
||||
* Per-user counts *tool calls that can spend Browser Run*, not captures, and it is one budget across
|
||||
* every such tool — see `perUserRateLimitKey`. That distinction matters because the clustering tools
|
||||
* each run a measure render, which costs a full browser session: the ordinary one-screenshot flow is
|
||||
* get_page_info, get_cluster_info, get_cluster_screenshot, so 10 calls a minute is nearer 3 finished
|
||||
* screenshots a minute than 10. Worth revisiting on the binding before the flag is widened; the
|
||||
* number here alone changes nothing deployed.
|
||||
*
|
||||
* Per-board is far below per-user on purpose: no single board may absorb more than 2 captures a
|
||||
* minute. Cache misses only, so this does not bound the usual "screenshot several pages of one board"
|
||||
* flow — a repeated capture of the same page is a cache hit. Measures are deliberately *not* counted
|
||||
* against it: three of them land on one board in the flow above, so a limit of 2 would refuse the
|
||||
* documented path rather than an abusive one. The global limiter is what bounds measure spend.
|
||||
*
|
||||
* The window matches the period configured on the Cloudflare bindings, which only support 60s (or
|
||||
* 10s) periods — this is the one number here that is Cloudflare's rather than ours.
|
||||
*/
|
||||
/**
|
||||
* The `search_boards` budget, separate from the Browser Run one above because it bounds a different
|
||||
* cost and deserves its own number rather than a Browser Run number inherited.
|
||||
*
|
||||
* Set where a legitimate caller never meets it and a runaway loop is still capped. Paging is what
|
||||
* spends this, not abuse: the page size is fixed at 20, so a caller in an 8000-board workspace needs
|
||||
* 400 calls to walk it, and an agent that reformulates a failed query spends a few more. A refusal
|
||||
* mid-page has no good recovery either — a model cannot wait, so it retries or reports a partial
|
||||
* answer as a complete one, which is worse than the load it would have prevented. Against that, the
|
||||
* worst measured call is ~9ms of database time (30,000 rows over three 10k-board workspaces), so 60
|
||||
* a minute is well under a second of database time per caller per minute.
|
||||
*
|
||||
* Per account, and only per account. It does not bound many accounts looping at once; that would
|
||||
* need a global search limiter alongside it, the way MCP_SERVER_BROWSER_RATE_LIMITER sits under the
|
||||
* per-user screenshot budget. Deliberately left until there is real traffic to size it against.
|
||||
*/
|
||||
export const MCP_SEARCH_PER_USER_RATE_LIMIT = 60
|
||||
|
||||
export const MCP_PER_USER_RATE_LIMIT = 10
|
||||
export const MCP_PER_BOARD_RATE_LIMIT = 2
|
||||
export const MCP_GLOBAL_BROWSER_RUN_RATE_LIMIT = 20
|
||||
export const MCP_RATE_LIMIT_WINDOW_MS = 60_000
|
||||
|
||||
/**
|
||||
* Version-chain tuning. The version cache stores a full keyframe, then deltas until one of these
|
||||
* limits cuts the next keyframe. All three trade storage against restore latency, and the numbers
|
||||
* come from the 2026-08-25 measurement over 16 production rooms (1,661 versions).
|
||||
*
|
||||
* Keyframe every 64 deltas: the measured knee. 16 gives up ~3.5x of storage for little latency
|
||||
* gain; 256 buys ~15% more compression while quadrupling worst-case replay. Count matters because
|
||||
* time alone fails hot boards — at the ~39s mean persist gap an active day is ~2,000 versions.
|
||||
*/
|
||||
export const MAX_DELTAS_PER_CHAIN = 64
|
||||
|
||||
/**
|
||||
* Keyframe at least daily. Time matters because count alone leaves a cold board's chain open
|
||||
* indefinitely, so a single lost keyframe would strand an unbounded run of deltas.
|
||||
*/
|
||||
export const MAX_CHAIN_AGE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* A delta larger than this fraction of its keyframe cuts a keyframe instead: it is a mass rewrite
|
||||
* (a wipe, a paste of a whole board) and not worth chaining from. Compared compressed against
|
||||
* compressed — a raw delta against a gzipped keyframe would trip on boards that simply compress well.
|
||||
*/
|
||||
export const MAX_DELTA_SIZE_RATIO = 0.5
|
||||
|
||||
/**
|
||||
* An open segment that would grow past this fraction of its keyframe cuts a keyframe instead. The
|
||||
* delta rule above bounds one write; this bounds what accumulates: a run of edits each just under
|
||||
* half the board packs up to `SEGMENT_CAP / 2` keyframes' worth into one segment, all of it decoded
|
||||
* in the durable object's memory and re-serialized on every persist — past the isolate's 128MB on
|
||||
* a board near the room size limit. A segment worth more than its keyframe is worth a keyframe.
|
||||
*/
|
||||
export const MAX_SEGMENT_SIZE_RATIO = 1
|
||||
|
||||
/**
|
||||
* The size rules only fire above this. Gzip's fixed overhead makes a tiny delta comparable to a
|
||||
* tiny keyframe, so without a floor a near-empty board cuts a keyframe on every persist — and a
|
||||
* sub-4KB delta or segment is never the mass rewrite or the memory hazard the rules exist to catch.
|
||||
*/
|
||||
export const MIN_SIZE_RULE_DELTA_BYTES = 4096
|
||||
|
||||
/**
|
||||
* A retired chain is only verified when its keyframe is at most this many (gzipped) bytes.
|
||||
*
|
||||
* Verification reconstructs the whole chain inside the live durable object: the keyframe decoded,
|
||||
* every segment decoded, and the replayed snapshot, all held alongside the SQLite board, the live
|
||||
* room, and the two persisted snapshots the write already keeps. That is four or five board-sized
|
||||
* copies at once, and the segment size rule bounds only the open segment, not this. Gzip takes
|
||||
* tldraw JSON down roughly 5-10x and the decoded objects are a few times the JSON again, so a 1MB
|
||||
* keyframe is tens of MB per copy — comfortably inside the isolate's 128MB with room for the rest,
|
||||
* where a keyframe near the 25MB room limit would not be. The chain itself is still cut and served
|
||||
* as usual; only the check is skipped, and it says so (`version_chain_verify` with outcome
|
||||
* `skipped`) so the threshold can be tuned against how many boards it excludes.
|
||||
*/
|
||||
export const MAX_VERIFY_KEYFRAME_BYTES = 1024 * 1024
|
||||
|
||||
/**
|
||||
* Deltas per segment object. Bounds three things at once: how much the durable object rewrites on
|
||||
* each persist (~140KB worst case at this cap), how many GETs a restore costs (1 keyframe + 4
|
||||
* segments at kf64), and how many ISO timestamps have to fit in the segment's R2 custom metadata
|
||||
* (~400 bytes at 16). Much above 32 and the metadata budget starts to bind.
|
||||
*
|
||||
* Restore fetches the keyframe and every segment in parallel, and a Worker may have six
|
||||
* connections waiting for headers at once; a seventh queues. `1 + MAX_DELTAS_PER_CHAIN /
|
||||
* SEGMENT_CAP` must stay at or under six or restores start serializing — a test in
|
||||
* versionChain.test.ts pins it.
|
||||
*/
|
||||
export const SEGMENT_CAP = 16
|
||||
|
||||
/** Cloudflare's per-invocation ceiling on connections simultaneously waiting for headers. */
|
||||
export const WORKER_MAX_SIMULTANEOUS_CONNECTIONS = 6
|
||||
|
||||
/**
|
||||
* The URL of the PostHog instance to use.
|
||||
*/
|
||||
export const POSTHOG_URL = 'https://eu.i.posthog.com'
|
||||
237
apps/dotcom/sync-worker/src/fileEffects.test.ts
Normal file
237
apps/dotcom/sync-worker/src/fileEffects.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { TlaEffectOutbox, TlaFile } from '@tldraw/dotcom-shared'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FileEffectDeps, getPublishTransition, processFileEffect } from './fileEffects'
|
||||
|
||||
function file(partial: Partial<TlaFile>): TlaFile {
|
||||
return {
|
||||
id: 'f1',
|
||||
name: 'file',
|
||||
ownerName: '',
|
||||
thumbnail: '',
|
||||
shared: true,
|
||||
sharedLinkType: 'edit',
|
||||
published: false,
|
||||
lastPublished: 0,
|
||||
publishedSlug: 'slug-1',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
isEmpty: false,
|
||||
isDeleted: false,
|
||||
createSource: null,
|
||||
owningGroupId: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('getPublishTransition', () => {
|
||||
it('returns null for inserts and deletes', () => {
|
||||
expect(
|
||||
getPublishTransition({
|
||||
command: 'insert',
|
||||
payload: file({ published: true }),
|
||||
prevPayload: null,
|
||||
})
|
||||
).toBe(null)
|
||||
expect(
|
||||
getPublishTransition({
|
||||
command: 'delete',
|
||||
payload: file({ published: true }),
|
||||
prevPayload: null,
|
||||
})
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
it('returns publish when file becomes published', () => {
|
||||
expect(
|
||||
getPublishTransition({
|
||||
command: 'update',
|
||||
payload: file({ published: true, lastPublished: 100 }),
|
||||
prevPayload: file({ published: false, lastPublished: 0 }),
|
||||
})
|
||||
).toBe('publish')
|
||||
})
|
||||
|
||||
it('returns publish when file is republished with newer timestamp', () => {
|
||||
expect(
|
||||
getPublishTransition({
|
||||
command: 'update',
|
||||
payload: file({ published: true, lastPublished: 200 }),
|
||||
prevPayload: file({ published: true, lastPublished: 100 }),
|
||||
})
|
||||
).toBe('publish')
|
||||
})
|
||||
|
||||
it('returns null when file remains published with same timestamp', () => {
|
||||
expect(
|
||||
getPublishTransition({
|
||||
command: 'update',
|
||||
payload: file({ published: true, lastPublished: 100 }),
|
||||
prevPayload: file({ published: true, lastPublished: 100 }),
|
||||
})
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
it('returns unpublish when file becomes unpublished', () => {
|
||||
expect(
|
||||
getPublishTransition({
|
||||
command: 'update',
|
||||
payload: file({ published: false, lastPublished: 100 }),
|
||||
prevPayload: file({ published: true, lastPublished: 100 }),
|
||||
})
|
||||
).toBe('unpublish')
|
||||
})
|
||||
|
||||
it('returns null for a plain update with no publication change', () => {
|
||||
expect(
|
||||
getPublishTransition({
|
||||
command: 'update',
|
||||
payload: file({ name: 'renamed' }),
|
||||
prevPayload: file({}),
|
||||
})
|
||||
).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
function effectRow(partial: Partial<TlaEffectOutbox>): TlaEffectOutbox {
|
||||
return {
|
||||
id: 1,
|
||||
tableName: 'file',
|
||||
entityId: 'f1',
|
||||
command: 'update',
|
||||
payload: file({}),
|
||||
prevPayload: null,
|
||||
attempts: 0,
|
||||
createdAt: new Date(0),
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function makeFileDeps(current: Record<string, TlaFile | undefined>): FileEffectDeps & {
|
||||
calls: string[]
|
||||
} {
|
||||
const calls: string[] = []
|
||||
return {
|
||||
calls,
|
||||
getCurrentFile: async (fileId) => current[fileId],
|
||||
notifyInsert: async (f) => {
|
||||
calls.push(`insert:${f.id}`)
|
||||
},
|
||||
notifyUpdate: async (f) => {
|
||||
calls.push(`update:${f.id}`)
|
||||
},
|
||||
notifyDelete: async (f) => {
|
||||
calls.push(`delete:${f.id}`)
|
||||
},
|
||||
publish: async (f) => {
|
||||
calls.push(`publish:${f.id}`)
|
||||
},
|
||||
unpublish: async (f) => {
|
||||
calls.push(`unpublish:${f.id}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('processFileEffect', () => {
|
||||
it('skips insert/update effects when the file no longer exists (staleness guard)', async () => {
|
||||
const deps = makeFileDeps({})
|
||||
await processFileEffect(deps, effectRow({ command: 'insert' }))
|
||||
await processFileEffect(deps, effectRow({ command: 'update' }))
|
||||
expect(deps.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('delete command uses the outbox payload, not current state', async () => {
|
||||
const deps = makeFileDeps({})
|
||||
await processFileEffect(deps, effectRow({ command: 'delete', payload: file({ id: 'f1' }) }))
|
||||
expect(deps.calls).toEqual(['delete:f1'])
|
||||
})
|
||||
|
||||
it('notifies with the freshly-read row, not the outbox payload', async () => {
|
||||
const current = file({ id: 'f1', name: 'newest' })
|
||||
const deps = makeFileDeps({ f1: current })
|
||||
await processFileEffect(
|
||||
deps,
|
||||
effectRow({
|
||||
command: 'update',
|
||||
payload: file({ id: 'f1', name: 'stale' }),
|
||||
prevPayload: file({ id: 'f1' }),
|
||||
})
|
||||
)
|
||||
expect(deps.calls).toEqual(['update:f1'])
|
||||
})
|
||||
|
||||
it('publishes only when current state still matches the transition', async () => {
|
||||
const published = file({ id: 'f1', published: true, lastPublished: 100 })
|
||||
const deps = makeFileDeps({ f1: published })
|
||||
await processFileEffect(
|
||||
deps,
|
||||
effectRow({
|
||||
command: 'update',
|
||||
payload: published,
|
||||
prevPayload: file({ id: 'f1', published: false, lastPublished: 0 }),
|
||||
})
|
||||
)
|
||||
expect(deps.calls).toEqual(['update:f1', 'publish:f1'])
|
||||
})
|
||||
|
||||
it('skips a stale publish superseded by a newer publish (flapping collapses)', async () => {
|
||||
const current = file({ id: 'f1', published: true, lastPublished: 500 })
|
||||
const deps = makeFileDeps({ f1: current })
|
||||
await processFileEffect(
|
||||
deps,
|
||||
effectRow({
|
||||
command: 'update',
|
||||
payload: file({ id: 'f1', published: true, lastPublished: 100 }),
|
||||
prevPayload: file({ id: 'f1', published: false, lastPublished: 0 }),
|
||||
})
|
||||
)
|
||||
expect(deps.calls).toEqual(['update:f1'])
|
||||
})
|
||||
|
||||
it('skips a stale unpublish when the file is currently published', async () => {
|
||||
const current = file({ id: 'f1', published: true, lastPublished: 500 })
|
||||
const deps = makeFileDeps({ f1: current })
|
||||
await processFileEffect(
|
||||
deps,
|
||||
effectRow({
|
||||
command: 'update',
|
||||
payload: file({ id: 'f1', published: false, lastPublished: 100 }),
|
||||
prevPayload: file({ id: 'f1', published: true, lastPublished: 100 }),
|
||||
})
|
||||
)
|
||||
expect(deps.calls).toEqual(['update:f1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('processFileEffect on trashed files', () => {
|
||||
it('still notifies update but skips publish when current file is soft-deleted', async () => {
|
||||
const current = file({ id: 'f1', published: true, lastPublished: 100, isDeleted: true })
|
||||
const deps = makeFileDeps({ f1: current })
|
||||
await processFileEffect(
|
||||
deps,
|
||||
effectRow({
|
||||
id: 1,
|
||||
command: 'update',
|
||||
payload: file({ id: 'f1', published: true, lastPublished: 100, isDeleted: true }),
|
||||
prevPayload: file({ id: 'f1', published: false, lastPublished: 0 }),
|
||||
})
|
||||
)
|
||||
expect(deps.calls).toEqual(['update:f1'])
|
||||
})
|
||||
|
||||
it('still unpublishes when current file is soft-deleted', async () => {
|
||||
const current = file({ id: 'f1', published: false, isDeleted: true })
|
||||
const deps = makeFileDeps({ f1: current })
|
||||
await processFileEffect(
|
||||
deps,
|
||||
effectRow({
|
||||
id: 2,
|
||||
command: 'update',
|
||||
payload: file({ id: 'f1', published: false, isDeleted: true }),
|
||||
prevPayload: file({ id: 'f1', published: true, lastPublished: 100 }),
|
||||
})
|
||||
)
|
||||
expect(deps.calls).toEqual(['update:f1', 'unpublish:f1'])
|
||||
})
|
||||
})
|
||||
64
apps/dotcom/sync-worker/src/fileEffects.ts
Normal file
64
apps/dotcom/sync-worker/src/fileEffects.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { TlaEffectOutbox, TlaFile } from '@tldraw/dotcom-shared'
|
||||
|
||||
// An effect_outbox row whose tableName is 'file'.
|
||||
export interface FileEffectRow extends TlaEffectOutbox {
|
||||
payload: TlaFile
|
||||
prevPayload: TlaFile | null
|
||||
}
|
||||
|
||||
// A file's `update` row is a publish when it becomes published (or republishes with a new
|
||||
// lastPublished timestamp) and an unpublish when it stops being published.
|
||||
export function getPublishTransition(
|
||||
row: Pick<FileEffectRow, 'command' | 'payload' | 'prevPayload'>
|
||||
): 'publish' | 'unpublish' | null {
|
||||
if (row.command !== 'update' || !row.prevPayload) return null
|
||||
const file = row.payload
|
||||
const previous = row.prevPayload
|
||||
if (file.published && (!previous.published || file.lastPublished !== previous.lastPublished)) {
|
||||
return 'publish'
|
||||
}
|
||||
if (!file.published && previous.published) {
|
||||
return 'unpublish'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface FileEffectDeps {
|
||||
getCurrentFile(fileId: string): Promise<TlaFile | undefined>
|
||||
notifyInsert(file: TlaFile): Promise<void> // room DO appFileRecordCreated
|
||||
notifyUpdate(file: TlaFile): Promise<void> // room DO appFileRecordDidUpdate (fresh row)
|
||||
notifyDelete(fileRow: TlaFile): Promise<void> // room DO appFileRecordDidDelete (outbox payload)
|
||||
publish(file: TlaFile): Promise<void>
|
||||
unpublish(file: TlaFile): Promise<void>
|
||||
}
|
||||
|
||||
export async function processFileEffect(deps: FileEffectDeps, genericRow: TlaEffectOutbox) {
|
||||
// Invariant: caller must route only tableName === 'file' rows here.
|
||||
const row = genericRow as FileEffectRow
|
||||
if (row.command === 'delete') {
|
||||
// Terminal: appFileRecordDidDelete is self-guarded, no staleness check needed.
|
||||
await deps.notifyDelete(row.payload)
|
||||
return
|
||||
}
|
||||
// Staleness guard: act on present truth, treat the row as a wake-up signal.
|
||||
const current = await deps.getCurrentFile(row.entityId)
|
||||
if (!current) return // hard-deleted since; a delete row follows or already ran
|
||||
if (row.command === 'insert') {
|
||||
await deps.notifyInsert(current)
|
||||
return
|
||||
}
|
||||
await deps.notifyUpdate(current)
|
||||
const transition = getPublishTransition(row)
|
||||
if (
|
||||
transition === 'publish' &&
|
||||
// Publishing a trashed file is a race artifact; never publish it. Skipping is safe:
|
||||
// undeleteFile bumps lastPublished on restore, so a fresh publish runs then.
|
||||
!current.isDeleted &&
|
||||
current.published &&
|
||||
current.lastPublished === row.payload.lastPublished
|
||||
) {
|
||||
await deps.publish(current)
|
||||
} else if (transition === 'unpublish' && !current.published) {
|
||||
await deps.unpublish(current)
|
||||
}
|
||||
}
|
||||
342
apps/dotcom/sync-worker/src/fileStats.test.ts
Normal file
342
apps/dotcom/sync-worker/src/fileStats.test.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countRichTextCharacters, summarizeSnapshotDocuments } from './fileStats'
|
||||
|
||||
function documents(records: unknown[]): RoomSnapshot['documents'] {
|
||||
return records.map((state, i) => ({ state, lastChangedClock: i }) as any)
|
||||
}
|
||||
|
||||
function summarize(records: unknown[]) {
|
||||
return summarizeSnapshotDocuments(documents(records))
|
||||
}
|
||||
|
||||
function page(id: string) {
|
||||
return { typeName: 'page', id, name: 'Page', index: 'a1' }
|
||||
}
|
||||
|
||||
function shape(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
typeName: 'shape',
|
||||
id: 'shape:1',
|
||||
type: 'geo',
|
||||
parentId: 'page:1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
isLocked: false,
|
||||
props: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function richText(text: string) {
|
||||
return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }
|
||||
}
|
||||
|
||||
describe('summarizeSnapshotDocuments', () => {
|
||||
it('reports an empty snapshot as all zeroes rather than failing', () => {
|
||||
expect(summarize([])).toEqual({
|
||||
recordsByTypeName: {},
|
||||
pages: { total: 0, maxShapesOnAPage: 0, empty: 0 },
|
||||
shapes: {
|
||||
total: 0,
|
||||
byType: {},
|
||||
maxDepth: 0,
|
||||
locked: 0,
|
||||
rotated: 0,
|
||||
orphaned: 0,
|
||||
extent: null,
|
||||
},
|
||||
text: { shapesWithText: 0, totalCharacters: 0, longestCharacters: 0 },
|
||||
bindings: {
|
||||
total: 0,
|
||||
byType: {},
|
||||
arrows: { boundBothEnds: 0, boundOneEnd: 0, unbound: 0, dangling: 0 },
|
||||
},
|
||||
styles: {},
|
||||
assets: { total: 0, byType: {}, totalSizeBytes: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it('counts every record by type name, including ones it has no other opinion about', () => {
|
||||
const { recordsByTypeName } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:1' }),
|
||||
shape({ id: 'shape:2' }),
|
||||
{ typeName: 'document', id: 'document:document' },
|
||||
{ typeName: 'camera', id: 'camera:1' },
|
||||
])
|
||||
expect(recordsByTypeName).toEqual({ page: 1, shape: 2, document: 1, camera: 1 })
|
||||
})
|
||||
|
||||
it('tallies shapes by type, locked, and rotated', () => {
|
||||
const { shapes } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:1', type: 'geo', isLocked: true }),
|
||||
shape({ id: 'shape:2', type: 'geo', rotation: 0.5 }),
|
||||
shape({ id: 'shape:3', type: 'arrow' }),
|
||||
])
|
||||
expect(shapes.byType).toEqual({ geo: 2, arrow: 1 })
|
||||
expect(shapes.total).toBe(3)
|
||||
expect(shapes.locked).toBe(1)
|
||||
expect(shapes.rotated).toBe(1)
|
||||
})
|
||||
|
||||
describe('page and nesting resolution', () => {
|
||||
it('follows parent chains through frames and groups to find each shape’s page', () => {
|
||||
const { pages, shapes } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:frame', type: 'frame', parentId: 'page:1' }),
|
||||
shape({ id: 'shape:group', type: 'group', parentId: 'shape:frame' }),
|
||||
shape({ id: 'shape:leaf', parentId: 'shape:group' }),
|
||||
])
|
||||
expect(pages).toEqual({ total: 1, maxShapesOnAPage: 3, empty: 0 })
|
||||
expect(shapes.maxDepth).toBe(3)
|
||||
expect(shapes.orphaned).toBe(0)
|
||||
})
|
||||
|
||||
it('reports the busiest page and counts pages with nothing on them', () => {
|
||||
const { pages } = summarize([
|
||||
page('page:1'),
|
||||
page('page:2'),
|
||||
page('page:3'),
|
||||
shape({ id: 'shape:1', parentId: 'page:1' }),
|
||||
shape({ id: 'shape:2', parentId: 'page:2' }),
|
||||
shape({ id: 'shape:3', parentId: 'page:2' }),
|
||||
])
|
||||
expect(pages).toEqual({ total: 3, maxShapesOnAPage: 2, empty: 1 })
|
||||
})
|
||||
|
||||
it('counts a shape whose parent is missing from the snapshot as orphaned', () => {
|
||||
const { shapes, pages } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:1', parentId: 'shape:gone' }),
|
||||
])
|
||||
expect(shapes.orphaned).toBe(1)
|
||||
expect(pages).toEqual({ total: 1, maxShapesOnAPage: 0, empty: 1 })
|
||||
})
|
||||
|
||||
it('terminates on a parent cycle instead of recursing forever', () => {
|
||||
const { shapes } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:a', parentId: 'shape:b' }),
|
||||
shape({ id: 'shape:b', parentId: 'shape:a' }),
|
||||
])
|
||||
expect(shapes.orphaned).toBe(2)
|
||||
expect(shapes.total).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extent', () => {
|
||||
it('covers the top-level shapes, using width and height where the shape has them', () => {
|
||||
const { shapes } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:1', x: -100, y: -50, props: { w: 100, h: 50 } }),
|
||||
shape({ id: 'shape:2', x: 400, y: 200, props: { w: 100, h: 100 } }),
|
||||
])
|
||||
expect(shapes.extent).toEqual({ width: 600, height: 350 })
|
||||
})
|
||||
|
||||
it('ignores nested shapes, whose coordinates are relative to their parent', () => {
|
||||
const { shapes } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:frame', type: 'frame', x: 0, y: 0, props: { w: 100, h: 100 } }),
|
||||
// would blow the extent up to 100,000 wide if it were treated as page-space
|
||||
shape({ id: 'shape:leaf', parentId: 'shape:frame', x: 100000, y: 0, props: {} }),
|
||||
])
|
||||
expect(shapes.extent).toEqual({ width: 100, height: 100 })
|
||||
})
|
||||
|
||||
it('is null when a snapshot has pages but no shapes on them', () => {
|
||||
expect(summarize([page('page:1')]).shapes.extent).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('text', () => {
|
||||
it('measures rich text length across nested nodes without keeping the text', () => {
|
||||
const { text } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:1', props: { richText: richText('hello') } }),
|
||||
shape({
|
||||
id: 'shape:2',
|
||||
props: {
|
||||
richText: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'twotwo' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
expect(text).toEqual({ shapesWithText: 2, totalCharacters: 14, longestCharacters: 9 })
|
||||
})
|
||||
|
||||
it('falls back to the pre-rich-text `text` prop that old snapshots still carry', () => {
|
||||
const { text } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:1', type: 'text', props: { text: 'legacy' } }),
|
||||
])
|
||||
expect(text).toEqual({ shapesWithText: 1, totalCharacters: 6, longestCharacters: 6 })
|
||||
})
|
||||
|
||||
it('does not count a shape whose rich text is empty', () => {
|
||||
const { text } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:1', props: { richText: richText('') } }),
|
||||
])
|
||||
expect(text).toEqual({ shapesWithText: 0, totalCharacters: 0, longestCharacters: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('styles', () => {
|
||||
it('tallies each allowlisted style prop by value', () => {
|
||||
const { styles } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:1', props: { color: 'black', fill: 'solid', size: 'm' } }),
|
||||
shape({ id: 'shape:2', props: { color: 'black', fill: 'none', size: 'l' } }),
|
||||
shape({ id: 'shape:3', props: { color: 'red' } }),
|
||||
])
|
||||
expect(styles).toEqual({
|
||||
color: { black: 2, red: 1 },
|
||||
fill: { solid: 1, none: 1 },
|
||||
size: { m: 1, l: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves out props that could hold something a user typed', () => {
|
||||
const { styles } = summarize([
|
||||
page('page:1'),
|
||||
shape({
|
||||
id: 'shape:1',
|
||||
props: { color: 'black', text: 'a secret', url: 'https://example.com', name: 'Q3 plan' },
|
||||
}),
|
||||
])
|
||||
expect(styles).toEqual({ color: { black: 1 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindings', () => {
|
||||
function arrowBinding(id: string, fromId: string, toId: string, terminal: string) {
|
||||
return { typeName: 'binding', type: 'arrow', id, fromId, toId, props: { terminal } }
|
||||
}
|
||||
|
||||
it('splits arrows by how many ends are bound', () => {
|
||||
const { bindings } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:box', type: 'geo' }),
|
||||
shape({ id: 'shape:both', type: 'arrow' }),
|
||||
shape({ id: 'shape:one', type: 'arrow' }),
|
||||
shape({ id: 'shape:free', type: 'arrow' }),
|
||||
arrowBinding('binding:1', 'shape:both', 'shape:box', 'start'),
|
||||
arrowBinding('binding:2', 'shape:both', 'shape:box', 'end'),
|
||||
arrowBinding('binding:3', 'shape:one', 'shape:box', 'end'),
|
||||
])
|
||||
expect(bindings).toEqual({
|
||||
total: 3,
|
||||
byType: { arrow: 3 },
|
||||
arrows: { boundBothEnds: 1, boundOneEnd: 1, unbound: 1, dangling: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
// The three bound/unbound counts partition arrows by how many terminals have a binding
|
||||
// record; whether the target still exists is the separate `dangling` signal.
|
||||
it('counts a binding pointing at a shape that is not in the snapshot as dangling', () => {
|
||||
const { bindings } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:arrow', type: 'arrow' }),
|
||||
arrowBinding('binding:1', 'shape:arrow', 'shape:deleted', 'end'),
|
||||
])
|
||||
expect(bindings.arrows).toEqual({
|
||||
boundBothEnds: 0,
|
||||
boundOneEnd: 1,
|
||||
unbound: 0,
|
||||
dangling: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('tallies non-arrow bindings by type too', () => {
|
||||
const { bindings } = summarize([
|
||||
page('page:1'),
|
||||
{ typeName: 'binding', type: 'layout', id: 'binding:1', fromId: 'a', toId: 'b', props: {} },
|
||||
])
|
||||
expect(bindings.total).toBe(1)
|
||||
expect(bindings.byType).toEqual({ layout: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('assets', () => {
|
||||
it('tallies assets by type and sums their declared sizes', () => {
|
||||
const { assets } = summarize([
|
||||
{ typeName: 'asset', type: 'image', id: 'asset:1', props: { fileSize: 1000 } },
|
||||
{ typeName: 'asset', type: 'image', id: 'asset:2', props: { fileSize: 2000 } },
|
||||
{ typeName: 'asset', type: 'bookmark', id: 'asset:3', props: {} },
|
||||
])
|
||||
expect(assets).toEqual({
|
||||
total: 3,
|
||||
byType: { image: 2, bookmark: 1 },
|
||||
totalSizeBytes: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores the -1 file size that assets use for unknown', () => {
|
||||
const { assets } = summarize([
|
||||
{ typeName: 'asset', type: 'image', id: 'asset:1', props: { fileSize: -1 } },
|
||||
])
|
||||
expect(assets.totalSizeBytes).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a malformed record defensively rather than throwing', () => {
|
||||
const { shapes, recordsByTypeName } = summarize([
|
||||
page('page:1'),
|
||||
// no props, non-numeric coordinates, missing parent
|
||||
{ typeName: 'shape', id: 'shape:1', type: 'geo', x: null, y: undefined },
|
||||
{ id: 'nope' },
|
||||
null,
|
||||
])
|
||||
expect(shapes.total).toBe(1)
|
||||
expect(recordsByTypeName).toEqual({ page: 1, shape: 1 })
|
||||
})
|
||||
|
||||
it('buckets a record with no type under `unknown` rather than tallying it as "undefined"', () => {
|
||||
const { shapes, bindings, assets } = summarize([
|
||||
page('page:1'),
|
||||
shape({ id: 'shape:1', type: undefined }),
|
||||
{ typeName: 'binding', id: 'binding:1', fromId: 'shape:1', toId: 'shape:1' },
|
||||
{ typeName: 'asset', id: 'asset:1', props: { fileSize: 10 } },
|
||||
])
|
||||
expect(shapes.byType).toEqual({ unknown: 1 })
|
||||
expect(bindings.byType).toEqual({ unknown: 1 })
|
||||
expect(assets.byType).toEqual({ unknown: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('countRichTextCharacters', () => {
|
||||
it('sums text nodes at any depth', () => {
|
||||
expect(
|
||||
countRichTextCharacters({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'abc' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'de' }] },
|
||||
],
|
||||
})
|
||||
).toBe(5)
|
||||
})
|
||||
|
||||
it('returns 0 for anything that is not a node', () => {
|
||||
expect(countRichTextCharacters(null)).toBe(0)
|
||||
expect(countRichTextCharacters(undefined)).toBe(0)
|
||||
expect(countRichTextCharacters('not a node')).toBe(0)
|
||||
})
|
||||
})
|
||||
274
apps/dotcom/sync-worker/src/fileStats.ts
Normal file
274
apps/dotcom/sync-worker/src/fileStats.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { AdminFileStatsResponseBody } from '@tldraw/dotcom-shared'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
|
||||
/**
|
||||
* Shape props tallied by the board-stats report. These are the enum style props (`StyleProp.defineEnum`
|
||||
* in tlschema) plus `align`, their pre-`horizontalAlign` name, which old snapshots still carry. The
|
||||
* list is an allowlist rather than "every short string prop" on purpose: it's what keeps a stats
|
||||
* report free of anything a user typed or pasted.
|
||||
*/
|
||||
const STYLE_PROP_NAMES = [
|
||||
'align',
|
||||
'arrowheadEnd',
|
||||
'arrowheadStart',
|
||||
'color',
|
||||
'dash',
|
||||
'fill',
|
||||
'font',
|
||||
'geo',
|
||||
'horizontalAlign',
|
||||
'kind',
|
||||
'labelColor',
|
||||
'size',
|
||||
'spline',
|
||||
'textAlign',
|
||||
'verticalAlign',
|
||||
] as const
|
||||
|
||||
/** Shapes nest, so a parent chain this long is a corrupted snapshot, not a deeply grouped board. */
|
||||
const MAX_PARENT_DEPTH = 100
|
||||
|
||||
/**
|
||||
* A record's `type` as a tally key. Snapshots are read unvalidated, so a broken record can carry no
|
||||
* type at all; those go in one bucket rather than becoming a literal `"undefined"` row in the report.
|
||||
*/
|
||||
function typeKey(type: unknown): string {
|
||||
return typeof type === 'string' ? type : 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Total length of the text in a rich text document, without ever holding the text itself. Rich text
|
||||
* is a ProseMirror document (see TLRichText), so the text lives in `text` nodes at arbitrary depth.
|
||||
*/
|
||||
export function countRichTextCharacters(node: unknown): number {
|
||||
if (!node || typeof node !== 'object') return 0
|
||||
const { text, content } = node as { text?: unknown; content?: unknown }
|
||||
let total = typeof text === 'string' ? text.length : 0
|
||||
if (Array.isArray(content)) {
|
||||
for (const child of content) total += countRichTextCharacters(child)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** The parts of a board-stats report that come from the snapshot itself. */
|
||||
export type FileSnapshotStats = Pick<
|
||||
AdminFileStatsResponseBody,
|
||||
'pages' | 'shapes' | 'text' | 'bindings' | 'styles' | 'assets'
|
||||
> & { recordsByTypeName: Record<string, number> }
|
||||
|
||||
/**
|
||||
* Counts, tallies, and sizes for the records in a board's snapshot — never their contents. Records
|
||||
* are read defensively rather than through the schema's validators: this runs on whatever is
|
||||
* actually in R2, including snapshots too old or too broken to parse, which are exactly the ones
|
||||
* worth asking about.
|
||||
*/
|
||||
export function summarizeSnapshotDocuments(
|
||||
documents: RoomSnapshot['documents']
|
||||
): FileSnapshotStats {
|
||||
const pageIds = new Set<string>()
|
||||
const shapeIds = new Set<string>()
|
||||
const parentIdByShapeId = new Map<string, string>()
|
||||
const recordsByTypeName: Record<string, number> = {}
|
||||
const shapesByType: Record<string, number> = {}
|
||||
const bindingsByType: Record<string, number> = {}
|
||||
const assetsByType: Record<string, number> = {}
|
||||
const styles: Record<string, Record<string, number>> = {}
|
||||
const arrowBindings: Array<{ fromId: unknown; toId: unknown; terminal: unknown }> = []
|
||||
// x/y/w/h per shape, for the extent below. Kept until parents are resolved, since a shape's
|
||||
// coordinates only mean something once we know whether it sits on a page.
|
||||
const boxes: Array<{ id: string; x: number; y: number; w: number; h: number }> = []
|
||||
|
||||
let totalBindings = 0
|
||||
let locked = 0
|
||||
let rotated = 0
|
||||
let shapesWithText = 0
|
||||
let totalCharacters = 0
|
||||
let longestCharacters = 0
|
||||
let assetsTotal = 0
|
||||
let assetSizeBytes = 0
|
||||
|
||||
for (const { state } of documents) {
|
||||
const record = state as any
|
||||
if (!record || typeof record !== 'object') continue
|
||||
if (typeof record.typeName === 'string') {
|
||||
recordsByTypeName[record.typeName] = (recordsByTypeName[record.typeName] ?? 0) + 1
|
||||
}
|
||||
switch (record.typeName) {
|
||||
case 'page':
|
||||
pageIds.add(record.id)
|
||||
break
|
||||
case 'shape': {
|
||||
shapeIds.add(record.id)
|
||||
if (typeof record.parentId === 'string') {
|
||||
parentIdByShapeId.set(record.id, record.parentId)
|
||||
}
|
||||
const shapeType = typeKey(record.type)
|
||||
shapesByType[shapeType] = (shapesByType[shapeType] ?? 0) + 1
|
||||
if (record.isLocked) locked++
|
||||
if (record.rotation) rotated++
|
||||
|
||||
const props = record.props ?? {}
|
||||
for (const name of STYLE_PROP_NAMES) {
|
||||
const value = props[name]
|
||||
if (typeof value !== 'string') continue
|
||||
const tally = (styles[name] ??= {})
|
||||
tally[value] = (tally[value] ?? 0) + 1
|
||||
}
|
||||
|
||||
// Shapes carried plain `text` before rich text; old snapshots still have it
|
||||
const characters = props.richText
|
||||
? countRichTextCharacters(props.richText)
|
||||
: typeof props.text === 'string'
|
||||
? props.text.length
|
||||
: 0
|
||||
if (characters > 0) {
|
||||
shapesWithText++
|
||||
totalCharacters += characters
|
||||
longestCharacters = Math.max(longestCharacters, characters)
|
||||
}
|
||||
|
||||
boxes.push({
|
||||
id: record.id,
|
||||
x: typeof record.x === 'number' ? record.x : 0,
|
||||
y: typeof record.y === 'number' ? record.y : 0,
|
||||
// Only some shapes have a width and height; the rest count as points
|
||||
w: typeof props.w === 'number' ? props.w : 0,
|
||||
h: typeof props.h === 'number' ? props.h : 0,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'binding': {
|
||||
totalBindings++
|
||||
const bindingType = typeKey(record.type)
|
||||
bindingsByType[bindingType] = (bindingsByType[bindingType] ?? 0) + 1
|
||||
if (bindingType === 'arrow') {
|
||||
arrowBindings.push({
|
||||
fromId: record.fromId,
|
||||
toId: record.toId,
|
||||
terminal: record.props?.terminal,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'asset': {
|
||||
assetsTotal++
|
||||
const assetType = typeKey(record.type)
|
||||
assetsByType[assetType] = (assetsByType[assetType] ?? 0) + 1
|
||||
const size = record.props?.fileSize
|
||||
if (typeof size === 'number' && size > 0) assetSizeBytes += size
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shapes nest inside frames and groups, so a shape's page sits at the top of its parent chain.
|
||||
// Memoized, and capped so a snapshot with a parent cycle can't spin the worker.
|
||||
const resolvedByShapeId = new Map<string, { depth: number; pageId: string | null }>()
|
||||
function resolveShape(shapeId: string, hops: number): { depth: number; pageId: string | null } {
|
||||
const cached = resolvedByShapeId.get(shapeId)
|
||||
if (cached) return cached
|
||||
const parentId = parentIdByShapeId.get(shapeId)
|
||||
let resolved: { depth: number; pageId: string | null }
|
||||
if (parentId !== undefined && pageIds.has(parentId)) {
|
||||
resolved = { depth: 1, pageId: parentId }
|
||||
} else if (
|
||||
parentId === undefined ||
|
||||
!parentIdByShapeId.has(parentId) ||
|
||||
hops >= MAX_PARENT_DEPTH
|
||||
) {
|
||||
resolved = { depth: 1, pageId: null }
|
||||
} else {
|
||||
const parent = resolveShape(parentId, hops + 1)
|
||||
resolved = { depth: parent.depth + 1, pageId: parent.pageId }
|
||||
}
|
||||
resolvedByShapeId.set(shapeId, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
const shapeCountByPageId = new Map<string, number>()
|
||||
let maxDepth = 0
|
||||
let orphaned = 0
|
||||
for (const shapeId of shapeIds) {
|
||||
const { depth, pageId } = resolveShape(shapeId, 0)
|
||||
maxDepth = Math.max(maxDepth, depth)
|
||||
if (pageId === null) {
|
||||
orphaned++
|
||||
} else {
|
||||
shapeCountByPageId.set(pageId, (shapeCountByPageId.get(pageId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
let emptyPages = 0
|
||||
let maxShapesOnAPage = 0
|
||||
for (const pageId of pageIds) {
|
||||
const count = shapeCountByPageId.get(pageId) ?? 0
|
||||
if (count === 0) emptyPages++
|
||||
maxShapesOnAPage = Math.max(maxShapesOnAPage, count)
|
||||
}
|
||||
|
||||
// Only shapes parented to a page: a nested shape's x/y is relative to its frame or group, so
|
||||
// mixing the two would give a meaningless box. Rotation is ignored — this is the extent of the
|
||||
// unrotated boxes, which is all it needs to be for "is this board absurdly spread out".
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const box of boxes) {
|
||||
const resolved = resolvedByShapeId.get(box.id)
|
||||
if (!resolved || resolved.depth !== 1 || resolved.pageId === null) continue
|
||||
minX = Math.min(minX, box.x)
|
||||
minY = Math.min(minY, box.y)
|
||||
maxX = Math.max(maxX, box.x + box.w)
|
||||
maxY = Math.max(maxY, box.y + box.h)
|
||||
}
|
||||
const extent =
|
||||
minX === Infinity ? null : { width: Math.round(maxX - minX), height: Math.round(maxY - minY) }
|
||||
|
||||
// An arrow has one binding per bound terminal, so the terminals seen per arrow say whether it's
|
||||
// bound at both ends, one, or neither
|
||||
const terminalsByArrowId = new Map<string, Set<string>>()
|
||||
let danglingBindings = 0
|
||||
for (const binding of arrowBindings) {
|
||||
if (typeof binding.toId !== 'string' || !shapeIds.has(binding.toId)) danglingBindings++
|
||||
if (typeof binding.fromId !== 'string' || !shapeIds.has(binding.fromId)) continue
|
||||
let terminals = terminalsByArrowId.get(binding.fromId)
|
||||
if (!terminals) {
|
||||
terminals = new Set<string>()
|
||||
terminalsByArrowId.set(binding.fromId, terminals)
|
||||
}
|
||||
if (typeof binding.terminal === 'string') terminals.add(binding.terminal)
|
||||
}
|
||||
let boundBothEnds = 0
|
||||
let boundOneEnd = 0
|
||||
for (const terminals of terminalsByArrowId.values()) {
|
||||
if (terminals.size >= 2) boundBothEnds++
|
||||
else if (terminals.size === 1) boundOneEnd++
|
||||
}
|
||||
|
||||
return {
|
||||
recordsByTypeName,
|
||||
pages: { total: pageIds.size, maxShapesOnAPage, empty: emptyPages },
|
||||
shapes: {
|
||||
total: shapeIds.size,
|
||||
byType: shapesByType,
|
||||
maxDepth,
|
||||
locked,
|
||||
rotated,
|
||||
orphaned,
|
||||
extent,
|
||||
},
|
||||
text: { shapesWithText, totalCharacters, longestCharacters },
|
||||
bindings: {
|
||||
total: totalBindings,
|
||||
byType: bindingsByType,
|
||||
arrows: {
|
||||
boundBothEnds,
|
||||
boundOneEnd,
|
||||
unbound: Math.max(0, (shapesByType.arrow ?? 0) - boundBothEnds - boundOneEnd),
|
||||
dangling: danglingBindings,
|
||||
},
|
||||
},
|
||||
styles,
|
||||
assets: { total: assetsTotal, byType: assetsByType, totalSizeBytes: assetSizeBytes },
|
||||
}
|
||||
}
|
||||
241
apps/dotcom/sync-worker/src/healthCheckRoutes.ts
Normal file
241
apps/dotcom/sync-worker/src/healthCheckRoutes.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { createRouter, notFound } from '@tldraw/worker-shared'
|
||||
import { sql } from 'kysely'
|
||||
import { MAX_ATTEMPTS } from './outboxDrain'
|
||||
import { createPostgresConnectionPool } from './postgres'
|
||||
import { isDebugLogging, type Environment } from './types'
|
||||
import { getClerkClient } from './utils/tla/getAuth'
|
||||
|
||||
function isAuthorized(req: Request, env: Environment) {
|
||||
const auth = req.headers.get('Authorization')
|
||||
const bearer = auth?.split('Bearer ')[1]
|
||||
return bearer && bearer === env.HEALTH_CHECK_BEARER_TOKEN
|
||||
}
|
||||
|
||||
export const healthCheckRoutes = createRouter<Environment>()
|
||||
.all('/health-check/*', (req, env) => {
|
||||
if (isDebugLogging(env) || isAuthorized(req, env)) return undefined
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
})
|
||||
.get('/health-check/clerk', async (_, env) => {
|
||||
const clerk = getClerkClient(env)
|
||||
try {
|
||||
const result = await clerk.users.getCount()
|
||||
if (!result || typeof result !== 'number') {
|
||||
return new Response('Could not reach clerk', { status: 500 })
|
||||
}
|
||||
return new Response('ok', { status: 200 })
|
||||
} catch (_e) {
|
||||
return new Response('Could not reach clerk', { status: 500 })
|
||||
}
|
||||
})
|
||||
.get('/health-check/db', async (_, env) => {
|
||||
const db = createPostgresConnectionPool(env, '/health-check/db')
|
||||
try {
|
||||
await db
|
||||
.selectFrom('user')
|
||||
.select('name')
|
||||
.where('email', '=', 'mitja@tldraw.com')
|
||||
.executeTakeFirstOrThrow()
|
||||
|
||||
return new Response('ok', { status: 200 })
|
||||
} catch (_e) {
|
||||
return new Response('Could not reach the database', { status: 500 })
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
})
|
||||
.get('/health-check/zero-replicator', async (_, env) => {
|
||||
const db = createPostgresConnectionPool(env, '/health-check/zero-replicator')
|
||||
try {
|
||||
const result = await sql<{ status: string }>`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN write_lsn IS NULL THEN 'STALLED'
|
||||
WHEN write_lag > interval '1 minute' THEN 'LAGGING'
|
||||
ELSE 'HEALTHY'
|
||||
END AS status
|
||||
FROM pg_stat_replication
|
||||
WHERE application_name = 'zero-replicator'
|
||||
`.execute(db)
|
||||
if (result.rows.length === 0) {
|
||||
return new Response('zero-replicator not connected', { status: 500 })
|
||||
}
|
||||
const status = result.rows[0].status
|
||||
if (status !== 'HEALTHY') {
|
||||
return new Response(`zero-replicator: ${status}`, { status: 500 })
|
||||
}
|
||||
return new Response('ok', { status: 200 })
|
||||
} catch (_e) {
|
||||
return new Response('Could not check zero-replicator status', { status: 500 })
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
})
|
||||
// Combined postgres health check: db size, changelog size, WAL retention, and replication slots.
|
||||
// Grouped into a single endpoint because updown.io charges per check invocation.
|
||||
// Failures include the sub-check name so alerts remain distinguishable.
|
||||
.get('/health-check/postgres', async (_, env) => {
|
||||
const db = createPostgresConnectionPool(env, '/health-check/postgres')
|
||||
const failures: string[] = []
|
||||
const okDetails: string[] = []
|
||||
try {
|
||||
// db-size
|
||||
try {
|
||||
const thresholdGb = parseFloat(env.HEALTH_CHECK_DB_SIZE_THRESHOLD_GB ?? '4')
|
||||
const result = await sql<{ size_bytes: string }>`
|
||||
SELECT pg_database_size(current_database()) AS size_bytes
|
||||
`.execute(db)
|
||||
const sizeGb = parseInt(result.rows[0].size_bytes, 10) / (1024 * 1024 * 1024)
|
||||
if (sizeGb > thresholdGb) {
|
||||
failures.push(`db-size: ${sizeGb.toFixed(2)} GB > ${thresholdGb} GB threshold`)
|
||||
} else {
|
||||
okDetails.push(`db: ${sizeGb.toFixed(2)} GB`)
|
||||
}
|
||||
} catch (_e) {
|
||||
failures.push('db-size: query failed')
|
||||
}
|
||||
|
||||
// changelog-size
|
||||
try {
|
||||
const thresholdMb = parseFloat(env.HEALTH_CHECK_CHANGELOG_SIZE_THRESHOLD_MB ?? '1024')
|
||||
const result = await sql<{ size_bytes: string }>`
|
||||
SELECT pg_total_relation_size('"zero_0/cdc"."changeLog"') AS size_bytes
|
||||
`.execute(db)
|
||||
const sizeMb = parseInt(result.rows[0].size_bytes, 10) / (1024 * 1024)
|
||||
if (sizeMb > thresholdMb) {
|
||||
failures.push(`changelog-size: ${sizeMb.toFixed(0)} MB > ${thresholdMb} MB threshold`)
|
||||
} else {
|
||||
okDetails.push(`changelog: ${sizeMb.toFixed(0)} MB`)
|
||||
}
|
||||
} catch (_e) {
|
||||
failures.push('changelog-size: query failed')
|
||||
}
|
||||
|
||||
// wal-size
|
||||
try {
|
||||
const thresholdMb = parseFloat(env.HEALTH_CHECK_WAL_SIZE_THRESHOLD_MB ?? '1024')
|
||||
const result = await sql<{
|
||||
slot_name: string
|
||||
retained_bytes: string
|
||||
}>`
|
||||
SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes
|
||||
FROM pg_replication_slots
|
||||
`.execute(db)
|
||||
const overThreshold = result.rows.filter(
|
||||
(row) => parseInt(row.retained_bytes, 10) / (1024 * 1024) > thresholdMb
|
||||
)
|
||||
if (overThreshold.length > 0) {
|
||||
const details = overThreshold
|
||||
.map((r) => {
|
||||
const mb = (parseInt(r.retained_bytes, 10) / (1024 * 1024)).toFixed(0)
|
||||
return `${r.slot_name}: ${mb} MB`
|
||||
})
|
||||
.join(', ')
|
||||
failures.push(`wal-size: ${details} > ${thresholdMb} MB threshold`)
|
||||
} else {
|
||||
const maxMb = result.rows.reduce(
|
||||
(max, r) => Math.max(max, parseInt(r.retained_bytes, 10) / (1024 * 1024)),
|
||||
0
|
||||
)
|
||||
okDetails.push(`wal: ${maxMb.toFixed(0)} MB`)
|
||||
}
|
||||
} catch (_e) {
|
||||
failures.push('wal-size: query failed')
|
||||
}
|
||||
|
||||
// replication-slots
|
||||
try {
|
||||
const result = await sql<{
|
||||
slot_name: string
|
||||
active: boolean
|
||||
wal_status: string | null
|
||||
}>`
|
||||
SELECT slot_name, active, wal_status
|
||||
FROM pg_replication_slots
|
||||
WHERE slot_name LIKE 'zero_%'
|
||||
`.execute(db)
|
||||
const unhealthy = result.rows.filter(
|
||||
(row) => row.wal_status === 'lost' || row.wal_status === 'unreserved'
|
||||
)
|
||||
if (unhealthy.length > 0) {
|
||||
const details = unhealthy
|
||||
.map((r) => `${r.slot_name}: wal_status=${r.wal_status}`)
|
||||
.join(', ')
|
||||
failures.push(`replication-slots: ${details}`)
|
||||
} else {
|
||||
okDetails.push(`slots: ${result.rows.length} ok`)
|
||||
}
|
||||
} catch (_e) {
|
||||
failures.push('replication-slots: query failed')
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
return new Response(`FAIL ${failures.join('; ')}`, { status: 500 })
|
||||
}
|
||||
return new Response(`ok (${okDetails.join(', ')})`, { status: 200 })
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
})
|
||||
// Split from /health-check/postgres so the monitor name identifies the outbox subsystem
|
||||
// directly instead of burying it in a combined postgres failure string. There's no lag
|
||||
// alert here: backoff rows are expected to sit with a future nextRetryAt, so alerting on
|
||||
// lag alone would trip on healthy backoff; Sentry already covers individual sub-parking
|
||||
// failures. Parked and stalled below cover the two failure modes that actually matter.
|
||||
.get('/health-check/outbox', async (_, env) => {
|
||||
const db = createPostgresConnectionPool(env, '/health-check/outbox')
|
||||
const failures: string[] = []
|
||||
const okDetails: string[] = []
|
||||
try {
|
||||
// outbox-parked
|
||||
try {
|
||||
const result = await sql<{ parked: string }>`
|
||||
SELECT count(*) FILTER (WHERE attempts >= ${sql.raw(String(MAX_ATTEMPTS))}) AS parked
|
||||
FROM effect_outbox
|
||||
`.execute(db)
|
||||
const row = result.rows[0]
|
||||
const parked = row?.parked ? parseInt(row.parked, 10) : 0
|
||||
if (parked > 0) {
|
||||
failures.push(`outbox-parked: ${parked} rows parked`)
|
||||
} else {
|
||||
okDetails.push('outbox: 0 parked')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('health-check outbox:', e)
|
||||
failures.push('outbox: query failed')
|
||||
}
|
||||
|
||||
// outbox-stalled: parked-only detection needs the drain to run at all. If the alarm
|
||||
// chain died, unparked rows sit untouched forever and never reach the parked
|
||||
// threshold, so the check above stays green. A row is stalled when it has been
|
||||
// ELIGIBLE to process (created, or past its backoff) for 15+ minutes: a live drain
|
||||
// sweeps every 30s and backoff caps at 5 minutes, so it would have deleted, bumped,
|
||||
// or re-deferred any eligible row long before that.
|
||||
try {
|
||||
const result = await sql<{ stalled: string }>`
|
||||
SELECT count(*) AS stalled
|
||||
FROM effect_outbox
|
||||
WHERE attempts < ${sql.raw(String(MAX_ATTEMPTS))}
|
||||
AND GREATEST("createdAt", coalesce("nextRetryAt", "createdAt")) < now() - interval '15 minutes'
|
||||
`.execute(db)
|
||||
const row = result.rows[0]
|
||||
const stalled = row?.stalled ? parseInt(row.stalled, 10) : 0
|
||||
if (stalled > 0) {
|
||||
failures.push(`outbox-stalled: ${stalled} rows untouched > 15m`)
|
||||
} else {
|
||||
okDetails.push('outbox: 0 stalled')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('health-check outbox:', e)
|
||||
failures.push('outbox-stalled: query failed')
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
return new Response(`FAIL ${failures.join('; ')}`, { status: 500 })
|
||||
}
|
||||
return new Response(`ok (${okDetails.join(', ')})`, { status: 200 })
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
})
|
||||
.all('*', notFound)
|
||||
104
apps/dotcom/sync-worker/src/mcpClusterIndexStorage.test.ts
Normal file
104
apps/dotcom/sync-worker/src/mcpClusterIndexStorage.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
McpClusterIndexSql,
|
||||
ensureMcpClusterIndexTable,
|
||||
pruneMcpClusterIndexRows,
|
||||
readMcpClusterIndexRow,
|
||||
writeMcpClusterIndexRow,
|
||||
} from './mcpClusterIndexStorage'
|
||||
import { McpClusterIndexKey } from './types'
|
||||
|
||||
// Run against a real SQLite database, because the primary key is the whole design here and the fake
|
||||
// durable object the rest of the tests use would accept a key that SQLite would not. It is also what
|
||||
// keeps that fake honest: if these two disagree, the tool-level tests are proving nothing.
|
||||
|
||||
// A stand-in for Cloudflare's `SqlStorage`: same shape, a positional-binding exec returning a cursor.
|
||||
function makeSql(): McpClusterIndexSql {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
return {
|
||||
exec(query: string, ...bindings: unknown[]) {
|
||||
const statement = db.prepare(query)
|
||||
if (/^\s*select/i.test(query)) {
|
||||
return { toArray: () => statement.all(...(bindings as any[])) }
|
||||
}
|
||||
statement.run(...(bindings as any[]))
|
||||
return { toArray: () => [] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const KEY: McpClusterIndexKey = { kind: 'shared_file', pageId: 'page:a', version: 'etag-1' }
|
||||
|
||||
function countRows(sql: McpClusterIndexSql) {
|
||||
const rows = sql.exec('SELECT COUNT(*) AS count FROM mcp_cluster_index').toArray() as {
|
||||
count: number
|
||||
}[]
|
||||
return rows[0].count
|
||||
}
|
||||
|
||||
describe('the cluster index table', () => {
|
||||
it('reads back what it wrote, for that content version only', () => {
|
||||
const sql = makeSql()
|
||||
ensureMcpClusterIndexTable(sql)
|
||||
writeMcpClusterIndexRow(sql, KEY, '{"v":1}')
|
||||
|
||||
expect(readMcpClusterIndexRow(sql, KEY)).toBe('{"v":1}')
|
||||
expect(readMcpClusterIndexRow(sql, { ...KEY, version: 'etag-2' })).toBeNull()
|
||||
expect(readMcpClusterIndexRow(sql, { ...KEY, pageId: 'page:b' })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps one row per page, whatever the version', () => {
|
||||
const sql = makeSql()
|
||||
ensureMcpClusterIndexTable(sql)
|
||||
writeMcpClusterIndexRow(sql, KEY, '{"v":1,"n":1}')
|
||||
writeMcpClusterIndexRow(sql, { ...KEY, version: 'etag-2' }, '{"v":1,"n":2}')
|
||||
writeMcpClusterIndexRow(sql, { ...KEY, pageId: 'page:b' }, '{"v":1,"n":3}')
|
||||
|
||||
// An edited board replaces its page's row rather than adding one, so a file that is edited
|
||||
// hourly for a year still holds one row per page.
|
||||
expect(countRows(sql)).toBe(2)
|
||||
expect(readMcpClusterIndexRow(sql, { ...KEY, version: 'etag-2' })).toBe('{"v":1,"n":2}')
|
||||
expect(readMcpClusterIndexRow(sql, KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a file and the board published from it apart', () => {
|
||||
const sql = makeSql()
|
||||
ensureMcpClusterIndexTable(sql)
|
||||
// One file is two boards, and they cluster differently as soon as the live document moves past
|
||||
// the frozen snapshot — so `kind` has to be enough to separate them on its own.
|
||||
writeMcpClusterIndexRow(sql, KEY, '{"v":1,"n":"live"}')
|
||||
writeMcpClusterIndexRow(sql, { ...KEY, kind: 'published' }, '{"v":1,"n":"frozen"}')
|
||||
|
||||
expect(countRows(sql)).toBe(2)
|
||||
expect(readMcpClusterIndexRow(sql, KEY)).toBe('{"v":1,"n":"live"}')
|
||||
})
|
||||
|
||||
it('drops the rows for pages the board no longer has', () => {
|
||||
const sql = makeSql()
|
||||
ensureMcpClusterIndexTable(sql)
|
||||
writeMcpClusterIndexRow(sql, KEY, '{"v":1,"n":"a"}')
|
||||
writeMcpClusterIndexRow(sql, { ...KEY, pageId: 'page:gone' }, '{"v":1,"n":"gone"}')
|
||||
writeMcpClusterIndexRow(sql, { ...KEY, kind: 'published', pageId: 'page:gone' }, '{"v":1}')
|
||||
|
||||
pruneMcpClusterIndexRows(sql, 'shared_file', ['page:a'])
|
||||
|
||||
// Without this the table is bounded by every page id the board has ever had: a page deleted
|
||||
// after it was indexed leaves a row nothing reads, replaces, or expires.
|
||||
expect(readMcpClusterIndexRow(sql, { ...KEY, pageId: 'page:gone' })).toBeNull()
|
||||
expect(readMcpClusterIndexRow(sql, KEY)).toBe('{"v":1,"n":"a"}')
|
||||
// Scoped to the kind it was told about: the published board's pages are a different list.
|
||||
expect(readMcpClusterIndexRow(sql, { ...KEY, kind: 'published', pageId: 'page:gone' })).toBe(
|
||||
'{"v":1}'
|
||||
)
|
||||
})
|
||||
|
||||
it('is safe to ensure repeatedly, which every call does', () => {
|
||||
const sql = makeSql()
|
||||
ensureMcpClusterIndexTable(sql)
|
||||
writeMcpClusterIndexRow(sql, KEY, '{"v":1}')
|
||||
ensureMcpClusterIndexTable(sql)
|
||||
|
||||
expect(readMcpClusterIndexRow(sql, KEY)).toBe('{"v":1}')
|
||||
})
|
||||
})
|
||||
87
apps/dotcom/sync-worker/src/mcpClusterIndexStorage.ts
Normal file
87
apps/dotcom/sync-worker/src/mcpClusterIndexStorage.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { McpClusterIndexKey, ThumbnailBoardKind } from './types'
|
||||
|
||||
// The SQL behind the MCP cluster index cache (see mcpClusterIndex.ts for what it is for), kept apart
|
||||
// from TLFileDurableObject so it can be run against a real SQLite database in a test — otherwise
|
||||
// these statements would only ever execute in production.
|
||||
|
||||
/** The subset of Cloudflare's `SqlStorage` these statements need. */
|
||||
export interface McpClusterIndexSql {
|
||||
exec(query: string, ...bindings: unknown[]): { toArray(): unknown[] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary key deliberately excludes `version`: a page holds one row, and new content replaces the
|
||||
* row for the old content. That is what bounds a file's cache by its page count rather than by its
|
||||
* edit history, with no expiry policy to run. There is no slug either — the object is already
|
||||
* per-file, and a published slug can be rotated, which would strand a row nothing ever replaces.
|
||||
* `kind` stays, because one file is two boards: the live shared file and the frozen published
|
||||
* snapshot, which cluster differently once they have drifted apart. Unpublishing leaves the
|
||||
* `published` rows behind — bounded by the page count, and replaced by version on a republish.
|
||||
*/
|
||||
export function ensureMcpClusterIndexTable(sql: McpClusterIndexSql) {
|
||||
sql.exec(
|
||||
`CREATE TABLE IF NOT EXISTS mcp_cluster_index (
|
||||
kind TEXT NOT NULL,
|
||||
pageId TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (kind, pageId)
|
||||
)`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One page's stored index, or null when the row is missing *or* is for content that has since moved
|
||||
* on. The version is matched in the statement rather than handed back for the caller to check, so a
|
||||
* stale payload cannot leave the object at all.
|
||||
*/
|
||||
export function readMcpClusterIndexRow(
|
||||
sql: McpClusterIndexSql,
|
||||
key: McpClusterIndexKey
|
||||
): string | null {
|
||||
const rows = sql
|
||||
.exec(
|
||||
'SELECT payload FROM mcp_cluster_index WHERE kind = ? AND pageId = ? AND version = ?',
|
||||
key.kind,
|
||||
key.pageId,
|
||||
key.version
|
||||
)
|
||||
.toArray() as { payload: string }[]
|
||||
return rows[0]?.payload ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the rows for pages this board no longer has.
|
||||
*
|
||||
* Without this the table is bounded by the page ids a board has *ever* had rather than by the pages
|
||||
* it has now: a page deleted after it was indexed leaves a row nothing reads, replaces, or expires.
|
||||
* Run on every write, which is the only moment the current page list is in hand anyway.
|
||||
*/
|
||||
export function pruneMcpClusterIndexRows(
|
||||
sql: McpClusterIndexSql,
|
||||
kind: ThumbnailBoardKind,
|
||||
pageIds: string[]
|
||||
) {
|
||||
// A board always has at least the page being written, so the list is never empty and the generated
|
||||
// `NOT IN ()` is never the syntax error an empty one would be.
|
||||
const placeholders = pageIds.map(() => '?').join(', ')
|
||||
sql.exec(
|
||||
`DELETE FROM mcp_cluster_index WHERE kind = ? AND pageId NOT IN (${placeholders})`,
|
||||
kind,
|
||||
...pageIds
|
||||
)
|
||||
}
|
||||
|
||||
export function writeMcpClusterIndexRow(
|
||||
sql: McpClusterIndexSql,
|
||||
key: McpClusterIndexKey,
|
||||
payload: string
|
||||
) {
|
||||
sql.exec(
|
||||
'INSERT OR REPLACE INTO mcp_cluster_index (kind, pageId, version, payload) VALUES (?, ?, ?, ?)',
|
||||
key.kind,
|
||||
key.pageId,
|
||||
key.version,
|
||||
payload
|
||||
)
|
||||
}
|
||||
326
apps/dotcom/sync-worker/src/outboxDrain.test.ts
Normal file
326
apps/dotcom/sync-worker/src/outboxDrain.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { TlaEffectOutbox } from '@tldraw/dotcom-shared'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MAX_ATTEMPTS,
|
||||
OutboxDeps,
|
||||
computeNextAlarm,
|
||||
drainOutbox,
|
||||
formatOutboxError,
|
||||
shouldReportEffectFailure,
|
||||
} from './outboxDrain'
|
||||
|
||||
function row(partial: Partial<TlaEffectOutbox>): TlaEffectOutbox {
|
||||
return {
|
||||
id: 1,
|
||||
tableName: 'file',
|
||||
entityId: 'f1',
|
||||
command: 'update',
|
||||
payload: {},
|
||||
prevPayload: null,
|
||||
attempts: 0,
|
||||
createdAt: new Date(0),
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
interface TestDeps extends OutboxDeps {
|
||||
calls: string[]
|
||||
bumped: Array<{ id: number; attempts: number; error: unknown }>
|
||||
}
|
||||
|
||||
function makeDeps(rows: TlaEffectOutbox[], timeoutMs = 30_000): TestDeps {
|
||||
const calls: string[] = []
|
||||
const bumped: Array<{ id: number; attempts: number; error: unknown }> = []
|
||||
let batch = rows
|
||||
return {
|
||||
calls,
|
||||
bumped,
|
||||
timeoutMs,
|
||||
getBatch: async () => {
|
||||
const b = batch
|
||||
batch = []
|
||||
return b
|
||||
},
|
||||
deleteRow: async (id) => {
|
||||
calls.push(`deleteRow:${id}`)
|
||||
},
|
||||
bumpAttempts: async (r, error) => {
|
||||
calls.push(`bump:${r.id}`)
|
||||
bumped.push({ id: r.id, attempts: r.attempts, error })
|
||||
},
|
||||
deleteParkedRowsOlderThan: async () => {},
|
||||
process: async (r) => {
|
||||
calls.push(`process:${r.id}`)
|
||||
},
|
||||
onError: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('drainOutbox', () => {
|
||||
it('processes rows in id order and deletes them', async () => {
|
||||
const deps = makeDeps([row({ id: 1 }), row({ id: 2 })])
|
||||
await drainOutbox(deps)
|
||||
expect(deps.calls).toEqual(['process:1', 'deleteRow:1', 'process:2', 'deleteRow:2'])
|
||||
})
|
||||
|
||||
it('on failure bumps attempts and skips later rows for the same entity only', async () => {
|
||||
const deps = makeDeps([
|
||||
row({ id: 1, entityId: 'f1' }),
|
||||
row({ id: 2, entityId: 'f1' }),
|
||||
row({ id: 3, entityId: 'f2' }),
|
||||
])
|
||||
deps.process = async (r) => {
|
||||
if (r.entityId === 'f1') throw new Error('room DO unavailable')
|
||||
deps.calls.push(`process:${r.id}`)
|
||||
}
|
||||
await drainOutbox(deps)
|
||||
// f1 and f2 are separate entities processed concurrently, so ordering across them
|
||||
// is not guaranteed; assert on membership instead.
|
||||
expect(deps.calls).toContain('bump:1')
|
||||
expect(deps.calls).toContain('process:3')
|
||||
expect(deps.calls).toContain('deleteRow:3')
|
||||
expect(deps.calls).not.toContain('process:2')
|
||||
expect(deps.calls).not.toContain('bump:2')
|
||||
expect(deps.onError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('same entityId in different tables does not cross-skip', async () => {
|
||||
const deps = makeDeps([
|
||||
row({ id: 1, tableName: 'file', entityId: 'x' }),
|
||||
row({ id: 2, tableName: 'comment', entityId: 'x' }),
|
||||
])
|
||||
deps.process = async (r) => {
|
||||
if (r.tableName === 'file') throw new Error('boom')
|
||||
deps.calls.push(`process:${r.id}`)
|
||||
}
|
||||
await drainOutbox(deps)
|
||||
expect(deps.calls).toContain('bump:1')
|
||||
expect(deps.calls).toContain('process:2')
|
||||
expect(deps.calls).toContain('deleteRow:2')
|
||||
})
|
||||
|
||||
it('stops instead of spinning when the same failing batch keeps coming back', async () => {
|
||||
const rows = [row({ id: 1, entityId: 'f1' }), row({ id: 2, entityId: 'f2' })]
|
||||
const deps = makeDeps([])
|
||||
let getBatchCalls = 0
|
||||
deps.getBatch = async () => {
|
||||
getBatchCalls++
|
||||
return rows
|
||||
}
|
||||
deps.process = async () => {
|
||||
throw new Error('always fails')
|
||||
}
|
||||
await drainOutbox(deps)
|
||||
// Each entity bumped exactly once, then the loop breaks instead of refetching forever.
|
||||
expect(deps.calls.filter((c) => c === 'bump:1')).toHaveLength(1)
|
||||
expect(deps.calls.filter((c) => c === 'bump:2')).toHaveLength(1)
|
||||
expect(getBatchCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('bumps a failing entity at most once per drain even if later batches resurface it', async () => {
|
||||
const batch1 = [row({ id: 1, entityId: 'bad' }), row({ id: 2, entityId: 'good' })]
|
||||
// Simulates the failing row still being in range (attempts < MAX_ATTEMPTS) and reappearing
|
||||
// in the next getBatch call alongside a new healthy row.
|
||||
const batch2 = [row({ id: 1, entityId: 'bad' }), row({ id: 3, entityId: 'good2' })]
|
||||
const batches = [batch1, batch2, []]
|
||||
const deps = makeDeps([])
|
||||
deps.getBatch = async () => batches.shift() ?? []
|
||||
deps.process = async (r) => {
|
||||
if (r.entityId === 'bad') throw new Error('always fails')
|
||||
deps.calls.push(`process:${r.id}`)
|
||||
}
|
||||
await drainOutbox(deps)
|
||||
expect(deps.calls).toContain('process:2')
|
||||
expect(deps.calls).toContain('deleteRow:2')
|
||||
expect(deps.calls).toContain('process:3')
|
||||
expect(deps.calls).toContain('deleteRow:3')
|
||||
expect(deps.calls.filter((c) => c === 'bump:1')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('processes independent entities concurrently: a hung entity does not block another', async () => {
|
||||
// entity "slow" row never resolves; entity "fast" must still complete because groups
|
||||
// run concurrently and the per-effect timeout unsticks the slow group.
|
||||
const deps = makeDeps(
|
||||
[row({ id: 1, entityId: 'slow' }), row({ id: 2, entityId: 'fast' })],
|
||||
5 // short injected timeout so the hung row fails fast
|
||||
)
|
||||
deps.process = (r) => {
|
||||
deps.calls.push(`process:${r.id}`)
|
||||
if (r.entityId === 'slow') return new Promise<void>(() => {}) // never resolves
|
||||
return Promise.resolve()
|
||||
}
|
||||
await drainOutbox(deps)
|
||||
// fast entity completed
|
||||
expect(deps.calls).toContain('process:2')
|
||||
expect(deps.calls).toContain('deleteRow:2')
|
||||
// slow entity timed out => failure path
|
||||
expect(deps.calls).toContain('bump:1')
|
||||
expect(deps.onError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('processes rows within a single entity strictly sequentially even under concurrency', async () => {
|
||||
const order: string[] = []
|
||||
const deps = makeDeps([
|
||||
row({ id: 1, entityId: 'e' }),
|
||||
row({ id: 2, entityId: 'e' }),
|
||||
row({ id: 3, entityId: 'e' }),
|
||||
])
|
||||
deps.process = async (r) => {
|
||||
order.push(`start:${r.id}`)
|
||||
await new Promise((res) => setTimeout(res, 1))
|
||||
order.push(`end:${r.id}`)
|
||||
}
|
||||
await drainOutbox(deps)
|
||||
// each row fully completes (start then end) before the next starts
|
||||
expect(order).toEqual(['start:1', 'end:1', 'start:2', 'end:2', 'start:3', 'end:3'])
|
||||
})
|
||||
|
||||
it('a per-effect timeout counts as a failure: bumps and reports onError with a timeout error', async () => {
|
||||
const deps = makeDeps([row({ id: 1 })], 5)
|
||||
deps.process = () => new Promise<void>(() => {}) // never resolves
|
||||
await drainOutbox(deps)
|
||||
expect(deps.calls).toContain('bump:1')
|
||||
expect(deps.calls).not.toContain('deleteRow:1')
|
||||
expect(deps.onError).toHaveBeenCalledTimes(1)
|
||||
const err = (deps.onError as any).mock.calls[0][0]
|
||||
expect(String(err)).toMatch(/timed out/i)
|
||||
expect(formatOutboxError(deps.bumped[0].error)).toBe(
|
||||
'EffectTimeoutError: effect for file:f1 (outbox 1) timed out after 5ms'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the effect error before bumping so a failing bump cannot hide it', async () => {
|
||||
const deps = makeDeps([row({ id: 1 })])
|
||||
const effectError = new Error('effect failed')
|
||||
deps.process = async () => {
|
||||
throw effectError
|
||||
}
|
||||
deps.bumpAttempts = async () => {
|
||||
throw new Error('db down')
|
||||
}
|
||||
await expect(drainOutbox(deps)).rejects.toThrow('db down')
|
||||
expect(deps.onError).toHaveBeenCalledWith(effectError, expect.objectContaining({ id: 1 }))
|
||||
})
|
||||
|
||||
it('late resolution after a timeout does not double-delete or double-bump', async () => {
|
||||
let resolveLate!: () => void
|
||||
const deps = makeDeps([row({ id: 1 })], 5)
|
||||
deps.process = () =>
|
||||
new Promise<void>((res) => {
|
||||
resolveLate = res
|
||||
})
|
||||
await drainOutbox(deps)
|
||||
// timeout already recorded a failure
|
||||
expect(deps.calls.filter((c) => c === 'bump:1')).toHaveLength(1)
|
||||
expect(deps.calls).not.toContain('deleteRow:1')
|
||||
// now the underlying RPC finally resolves; must not trigger a delete
|
||||
resolveLate()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
expect(deps.calls).not.toContain('deleteRow:1')
|
||||
expect(deps.calls.filter((c) => c === 'bump:1')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('passes the row current attempts and the error to bumpAttempts', async () => {
|
||||
const deps = makeDeps([row({ id: 7, attempts: 3 })])
|
||||
const error = new Error('fail')
|
||||
deps.process = async () => {
|
||||
throw error
|
||||
}
|
||||
await drainOutbox(deps)
|
||||
expect(deps.bumped).toEqual([{ id: 7, attempts: 3, error }])
|
||||
})
|
||||
|
||||
it('passes the FULL failed row to bumpAttempts so the impl can defer later siblings', async () => {
|
||||
// bumpAttempts is the only hook the DO has to defer the failed row's later same-entity
|
||||
// siblings (set their nextRetryAt) and preserve per-entity ordering across drains, so it
|
||||
// must receive the whole row (tableName + entityId + id), not just id/attempts.
|
||||
const failed = row({ id: 5, tableName: 'file', entityId: 'f1', attempts: 2 })
|
||||
let received: TlaEffectOutbox | undefined
|
||||
const deps = makeDeps([failed, row({ id: 6, tableName: 'file', entityId: 'f1' })])
|
||||
deps.bumpAttempts = async (r) => {
|
||||
received = r
|
||||
}
|
||||
deps.process = async () => {
|
||||
throw new Error('fail')
|
||||
}
|
||||
await drainOutbox(deps)
|
||||
expect(received).toEqual(failed)
|
||||
// the later sibling (id 6) is NOT processed this drain — the DO's sibling deferral keeps it
|
||||
// out of the next drain too, but here we only assert the in-drain skip.
|
||||
expect(deps.calls).not.toContain('process:6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatOutboxError', () => {
|
||||
it('formats an Error as name: message', () => {
|
||||
expect(formatOutboxError(new TypeError('cannot connect'))).toBe('TypeError: cannot connect')
|
||||
})
|
||||
|
||||
it('stringifies non-Error values', () => {
|
||||
expect(formatOutboxError('proxy request failed')).toBe('proxy request failed')
|
||||
expect(formatOutboxError({ code: 42 })).toBe('{"code":42}')
|
||||
expect(formatOutboxError(undefined)).toBe('undefined')
|
||||
})
|
||||
|
||||
it('appends one level of cause', () => {
|
||||
const inner = new Error('connect ECONNREFUSED', { cause: new Error('deeper') })
|
||||
expect(formatOutboxError(new TypeError('fetch failed', { cause: inner }))).toBe(
|
||||
'TypeError: fetch failed (cause: Error: connect ECONNREFUSED)'
|
||||
)
|
||||
expect(formatOutboxError(new Error('x', { cause: { code: 'ETIMEDOUT' } }))).toBe(
|
||||
'Error: x (cause: {"code":"ETIMEDOUT"})'
|
||||
)
|
||||
})
|
||||
|
||||
it('never throws and strips NUL bytes so the attempts bump cannot fail on it', () => {
|
||||
const circular: any = {}
|
||||
circular.self = circular
|
||||
expect(formatOutboxError(circular)).toBe('[unformattable object]')
|
||||
expect(formatOutboxError({ toJSON: () => undefined })).toBe('[object Object]')
|
||||
expect(formatOutboxError(new Error('x', { cause: circular }))).toBe('[unformattable object]')
|
||||
expect(formatOutboxError(new Error('a\0b'))).toBe('Error: ab')
|
||||
})
|
||||
|
||||
it('truncates to 500 chars', () => {
|
||||
const out = formatOutboxError(new Error('x'.repeat(1000)))
|
||||
expect(out).toHaveLength(500)
|
||||
expect(out.startsWith('Error: xxx')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeNextAlarm', () => {
|
||||
const SWEEP = 30_000
|
||||
const now = 1_000_000
|
||||
|
||||
it('arms the next sweep when no alarm is persisted', () => {
|
||||
expect(computeNextAlarm(null, now, SWEEP)).toBe(now + SWEEP)
|
||||
})
|
||||
|
||||
it('arms the next sweep when the persisted alarm is further out than the next sweep', () => {
|
||||
expect(computeNextAlarm(now + SWEEP + 5_000, now, SWEEP)).toBe(now + SWEEP)
|
||||
})
|
||||
|
||||
it('honors a mid-drain poke (past-due alarm) by arming now+1s, not the full sweep', () => {
|
||||
// a poke during the drain set alarm(now); it is past-due in the finally and can't be
|
||||
// trusted to fire on its own, so re-arm with a small delay instead of swallowing it.
|
||||
expect(computeNextAlarm(now, now, SWEEP)).toBe(now + 1_000)
|
||||
expect(computeNextAlarm(now - 5_000, now, SWEEP)).toBe(now + 1_000)
|
||||
})
|
||||
|
||||
it('leaves a sooner future alarm (imminent poke) untouched', () => {
|
||||
expect(computeNextAlarm(now + 1_000, now, SWEEP)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldReportEffectFailure', () => {
|
||||
it.each([
|
||||
[0, true],
|
||||
[1, false],
|
||||
[MAX_ATTEMPTS - 2, false],
|
||||
[MAX_ATTEMPTS - 1, true],
|
||||
])('attempts=%i -> %s', (attempts, expected) => {
|
||||
expect(shouldReportEffectFailure(attempts)).toBe(expected)
|
||||
})
|
||||
})
|
||||
188
apps/dotcom/sync-worker/src/outboxDrain.ts
Normal file
188
apps/dotcom/sync-worker/src/outboxDrain.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { TlaEffectOutbox } from '@tldraw/dotcom-shared'
|
||||
|
||||
export const MAX_ATTEMPTS = 10
|
||||
export const PARKED_ROW_TTL_DAYS = 7
|
||||
// Groups (one per entity) processed at once. A slow publish/cold-boot only stalls its own
|
||||
// group; other entities keep draining. Small so a burst can't fan out to unbounded RPCs.
|
||||
export const MAX_CONCURRENT_ENTITIES = 5
|
||||
// Per-effect ceiling. The underlying room-DO RPC can't be cancelled, but the drain stops
|
||||
// waiting on it and moves on; a timed-out effect is treated as a failure and retried later.
|
||||
export const EFFECT_TIMEOUT_MS = 30_000
|
||||
|
||||
// Gates which failed attempts get reported (e.g. to Sentry): the first (immediate visibility)
|
||||
// and the one that parks the row (data loss). Every attempt in between is a retry-in-progress,
|
||||
// not new information, and under a sustained outage reporting all of them would burn the
|
||||
// Sentry rate limit and could crowd out the parking events that matter most.
|
||||
export function shouldReportEffectFailure(attempts: number): boolean {
|
||||
return attempts === 0 || attempts + 1 >= MAX_ATTEMPTS
|
||||
}
|
||||
|
||||
// Row-stored form of a failed attempt's error. Capped so a runaway message (a stringified
|
||||
// response body, say) can't bloat the table.
|
||||
export const MAX_LAST_ERROR_LENGTH = 500
|
||||
export function formatOutboxError(error: unknown): string {
|
||||
let text: string
|
||||
try {
|
||||
text = describeError(error)
|
||||
// One level of cause: pg and fetch errors put the useful part (ECONNREFUSED, the dial
|
||||
// target) there, and for attempts 2..n this column is the only record of it.
|
||||
if (error instanceof Error && error.cause !== undefined) {
|
||||
text += ` (cause: ${describeError(error.cause)})`
|
||||
}
|
||||
} catch {
|
||||
text = `[unformattable ${typeof error}]`
|
||||
}
|
||||
// Written in the same UPDATE as the attempts bump, so it must never be what fails it:
|
||||
// PG TEXT rejects NUL bytes.
|
||||
return text.replaceAll('\u0000', '').slice(0, MAX_LAST_ERROR_LENGTH)
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
if (error instanceof Error) return `${error.name}: ${error.message}`
|
||||
// JSON.stringify returns undefined (not a string) when toJSON() returns undefined.
|
||||
if (typeof error === 'object' && error !== null) return JSON.stringify(error) ?? String(error)
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export interface OutboxDeps {
|
||||
getBatch(): Promise<TlaEffectOutbox[]> // WHERE attempts < MAX_ATTEMPTS AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= now()) ORDER BY id LIMIT 50
|
||||
deleteRow(id: number): Promise<void>
|
||||
// Called with the FAILED row (its `attempts` is the current count, so backoff can be derived).
|
||||
// The implementation must both back off the failed row AND defer its later same-entity
|
||||
// siblings (see the drainOutbox comment) so per-entity ordering holds across drains, not just
|
||||
// within one.
|
||||
// `error` is stored on the row as lastError (see migration 051 for why).
|
||||
bumpAttempts(row: TlaEffectOutbox, error: unknown): Promise<void>
|
||||
deleteParkedRowsOlderThan(days: number): Promise<void>
|
||||
process(row: TlaEffectOutbox): Promise<void> // dispatches by tableName (wired in the DO)
|
||||
onError(error: unknown, row: TlaEffectOutbox): void
|
||||
// Overridable so tests don't wait the full timeout in real time.
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
// Decides what to (re)arm the sweep alarm to after a drain finishes, given the currently
|
||||
// persisted alarm. Returns null to mean "leave the persisted alarm unchanged".
|
||||
// - scheduled === null || scheduled > nextSweep: nothing sooner is pending → arm the next sweep.
|
||||
// - scheduled <= now: a poke that arrived MID-drain set alarm(now) and is now past-due; a
|
||||
// past-due alarm can't be trusted to fire on its own, so honor the poke by arming now+1s
|
||||
// (a small delay, still never trusting the stale past-due alarm).
|
||||
// - otherwise (future, sooner than nextSweep): a legit imminent poke → leave it.
|
||||
export function computeNextAlarm(
|
||||
scheduled: number | null,
|
||||
now: number,
|
||||
sweepIntervalMs: number
|
||||
): number | null {
|
||||
const nextSweep = now + sweepIntervalMs
|
||||
if (scheduled === null || scheduled > nextSweep) return nextSweep
|
||||
if (scheduled <= now) return now + 1_000
|
||||
return null
|
||||
}
|
||||
|
||||
class EffectTimeoutError extends Error {
|
||||
constructor(row: TlaEffectOutbox, ms: number) {
|
||||
super(`effect for ${row.tableName}:${row.entityId} (outbox ${row.id}) timed out after ${ms}ms`)
|
||||
this.name = 'EffectTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
// Races process(row) against a timeout. Resolves true on success, false on failure/timeout.
|
||||
// A late resolution of the underlying promise after a timeout is swallowed so it can't
|
||||
// double-act; only the first settlement of the race drives delete/bump.
|
||||
async function processWithTimeout(deps: OutboxDeps, row: TlaEffectOutbox): Promise<boolean> {
|
||||
const ms = deps.timeoutMs ?? EFFECT_TIMEOUT_MS
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new EffectTimeoutError(row, ms)), ms)
|
||||
})
|
||||
// Swallow late rejections from BOTH branches: only the race's first settlement drives
|
||||
// delete/bump. Without this, a timeout firing while deleteRow is still awaited (or a late
|
||||
// work rejection) becomes an unhandled rejection.
|
||||
timeout.catch(() => {})
|
||||
const work = deps.process(row)
|
||||
work.catch(() => {})
|
||||
try {
|
||||
await Promise.race([work, timeout])
|
||||
// Stop the clock before the follow-up await so a slow deleteRow can't trip the timeout.
|
||||
if (timer) clearTimeout(timer)
|
||||
await deps.deleteRow(row.id)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Report first: if the bump UPDATE itself fails (DB down), the effect's own error would
|
||||
// otherwise never be reported with row context.
|
||||
deps.onError(error, row)
|
||||
await deps.bumpAttempts(row, error)
|
||||
return false
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// Runs tasks with a bounded concurrency, preserving nothing about order (callers that need
|
||||
// ordering enforce it inside a single task).
|
||||
async function runWithConcurrency(tasks: Array<() => Promise<void>>, limit: number) {
|
||||
let next = 0
|
||||
async function worker() {
|
||||
while (next < tasks.length) {
|
||||
const task = tasks[next++]
|
||||
await task()
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker())
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
const entityKey = (row: TlaEffectOutbox) => `${row.tableName}:${row.entityId}`
|
||||
|
||||
export async function drainOutbox(deps: OutboxDeps) {
|
||||
// Per-entity ordering is strict WITHIN and ACROSS drains:
|
||||
// - within a drain: each entity is one sequential group; a failure stops its group and (via
|
||||
// failedEntities below) skips any of its rows that resurface in later batches.
|
||||
// - across drains: when a row fails, bumpAttempts also defers its later same-entity siblings
|
||||
// (sets their nextRetryAt) so they can't run in a subsequent drain while the failed row is
|
||||
// still backing off. The failed row itself always retries no later than its siblings.
|
||||
// Exception at the parking boundary: once a row parks (attempts >= MAX_ATTEMPTS) getBatch stops
|
||||
// returning it, so it no longer defers its siblings and they run. Liveness deliberately wins
|
||||
// over strict ordering here — parking is already a Sentry-reported data-loss event.
|
||||
//
|
||||
// failedEntities is hoisted above the batch loop so one failure skips the entity for the rest
|
||||
// of THIS drain call (not just the rest of its batch): it caps a stuck entity at one attempt
|
||||
// per drain, so a single drain can't burn all its attempts to the parking threshold. It does
|
||||
// not by itself provide cross-drain ordering — the sibling deferral in bumpAttempts does that.
|
||||
const failedEntities = new Set<string>()
|
||||
|
||||
while (true) {
|
||||
const rows = await deps.getBatch()
|
||||
if (rows.length === 0) break
|
||||
|
||||
// Group rows by entity, preserving id order within each group. Different entities have
|
||||
// no ordering constraint between them, so their groups run concurrently.
|
||||
const groups = new Map<string, TlaEffectOutbox[]>()
|
||||
for (const row of rows) {
|
||||
const key = entityKey(row)
|
||||
if (failedEntities.has(key)) continue
|
||||
let group = groups.get(key)
|
||||
if (!group) groups.set(key, (group = []))
|
||||
group.push(row)
|
||||
}
|
||||
|
||||
let processedAny = false
|
||||
const tasks = Array.from(groups.entries()).map(([key, group]) => async () => {
|
||||
for (const row of group) {
|
||||
processedAny = true
|
||||
const ok = await processWithTimeout(deps, row)
|
||||
if (!ok) {
|
||||
// A failed entity skips its remaining rows this drain and, via failedEntities,
|
||||
// any rows that resurface in later batches.
|
||||
failedEntities.add(key)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
await runWithConcurrency(tasks, MAX_CONCURRENT_ENTITIES)
|
||||
|
||||
// If every row in the batch was already-failed or failed just now, we'd spin refetching
|
||||
// the same rows; stop and let the alarm retry after backoff.
|
||||
if (!processedAny || rows.every((r) => failedEntities.has(entityKey(r)))) break
|
||||
}
|
||||
await deps.deleteParkedRowsOlderThan(PARKED_ROW_TTL_DAYS)
|
||||
}
|
||||
199
apps/dotcom/sync-worker/src/postgres.ts
Normal file
199
apps/dotcom/sync-worker/src/postgres.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { DB } from '@tldraw/dotcom-shared'
|
||||
import { promiseWithResolve } from '@tldraw/utils'
|
||||
import { Kysely, PostgresDialect, PostgresPool, PostgresPoolClient } from 'kysely'
|
||||
import * as pg from 'pg'
|
||||
import { Logger } from './Logger'
|
||||
import { Environment } from './types'
|
||||
import { writeDataPoint } from './utils/analytics'
|
||||
|
||||
const int8TypeId = 20
|
||||
pg.types.setTypeParser(int8TypeId, (val) => {
|
||||
return parseInt(val, 10)
|
||||
})
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
export function createPostgresConnectionPool(env: Environment, name: string, max: number = 1) {
|
||||
class LoggingClient extends pg.Client {
|
||||
constructor(config?: string | pg.ClientConfig) {
|
||||
super(config)
|
||||
|
||||
this.on('end', () => {
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'postgres_client_end', {
|
||||
blobs: [name],
|
||||
})
|
||||
})
|
||||
|
||||
this.on('error', () => {
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'postgres_client_error', {
|
||||
blobs: [name],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
override connect(callback?: any): any {
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'postgres_client_connect', {
|
||||
blobs: [name],
|
||||
})
|
||||
// The start event above and the timed completion below are deliberately separate
|
||||
// datapoints: a dial that hangs never completes, so hung connections are only
|
||||
// countable as starts minus dones. Completion durations cover TCP + TLS + auth
|
||||
// (through pgbouncer) — the layer where the 2026-08-10 connection stalls lived.
|
||||
const dialStart = Date.now()
|
||||
const done = (outcome: 'ok' | 'error') => {
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'postgres_client_connect_done', {
|
||||
blobs: [name, outcome],
|
||||
doubles: [Date.now() - dialStart],
|
||||
})
|
||||
}
|
||||
if (typeof callback === 'function') {
|
||||
return super.connect((err: Error) => {
|
||||
done(err ? 'error' : 'ok')
|
||||
callback(err)
|
||||
})
|
||||
}
|
||||
const promise = super.connect()
|
||||
promise.then(
|
||||
() => done('ok'),
|
||||
() => done('error')
|
||||
)
|
||||
return promise
|
||||
}
|
||||
}
|
||||
const pool = new pg.Pool({
|
||||
connectionString: env.BOTCOM_POSTGRES_POOLED_CONNECTION_STRING,
|
||||
application_name: name,
|
||||
idleTimeoutMillis: 5_000,
|
||||
max,
|
||||
Client: LoggingClient,
|
||||
})
|
||||
|
||||
// Checkout wait is distinct from dial time: with all `max` clients busy, an acquire queues
|
||||
// without dialing at all, which client-level events can't see. Kysely acquires through the
|
||||
// promise overload; the callback overload passes through untimed.
|
||||
const poolConnect = pool.connect.bind(pool)
|
||||
pool.connect = function (callback?: any): any {
|
||||
if (typeof callback === 'function') return poolConnect(callback)
|
||||
const acquireStart = Date.now()
|
||||
const acquired = (outcome: 'ok' | 'error') => {
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'postgres_pool_acquire', {
|
||||
blobs: [name, outcome],
|
||||
doubles: [Date.now() - acquireStart],
|
||||
})
|
||||
}
|
||||
const promise = poolConnect()
|
||||
promise.then(
|
||||
() => acquired('ok'),
|
||||
() => acquired('error')
|
||||
)
|
||||
return promise
|
||||
} as typeof pool.connect
|
||||
|
||||
const dialect = new PostgresDialect({ pool })
|
||||
|
||||
const db = new Kysely<DB>({
|
||||
dialect,
|
||||
log: ['error'],
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
/**
|
||||
* A pool that creates a fresh pg.Client per connect() call and tears it down
|
||||
* on release, so no idle connections or background timers linger between
|
||||
* requests. This lets the Durable Object hibernate properly.
|
||||
*/
|
||||
export class TLPostgresPool implements PostgresPool {
|
||||
private _lock: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
private env: Environment,
|
||||
private log: Logger
|
||||
) {}
|
||||
|
||||
async connect(): Promise<PostgresPoolClient> {
|
||||
const acquireStart = Date.now()
|
||||
const prevLock = this._lock
|
||||
const released = promiseWithResolve<void>()
|
||||
this._lock = released
|
||||
|
||||
await prevLock
|
||||
|
||||
const client = new pg.Client({
|
||||
connectionString: this.env.BOTCOM_POSTGRES_POOLED_CONNECTION_STRING,
|
||||
application_name: 'user-do',
|
||||
keepAlive: false,
|
||||
// pg waits forever by default. Checkouts here are serialized behind one lock, so a
|
||||
// single stalled dial would wedge every later query in this durable object, and its
|
||||
// teardown with them.
|
||||
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
|
||||
})
|
||||
// Mirror LoggingClient's end/error accounting: the connection-events panel balances
|
||||
// connects against ends to spot leaks, so a client that records a connect but never an
|
||||
// end would read as a permanent leak there.
|
||||
client.on('end', () => {
|
||||
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_end', {
|
||||
blobs: ['user-do'],
|
||||
})
|
||||
})
|
||||
client.on('error', () => {
|
||||
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_error', {
|
||||
blobs: ['user-do'],
|
||||
})
|
||||
})
|
||||
|
||||
// Same start/done pairing as LoggingClient above: a hung dial only ever shows up as a
|
||||
// start with no matching done, and this fresh-client-per-checkout path is otherwise
|
||||
// completely invisible in MEASURE.
|
||||
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_connect', {
|
||||
blobs: ['user-do'],
|
||||
})
|
||||
const dialStart = Date.now()
|
||||
try {
|
||||
await client.connect()
|
||||
} catch (e) {
|
||||
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_connect_done', {
|
||||
blobs: ['user-do', 'error'],
|
||||
doubles: [Date.now() - dialStart],
|
||||
})
|
||||
// A failed dial is also a failed checkout, matching the pool wrapper above — without
|
||||
// this, error acquires drop their lock-wait + dial duration for user-do.
|
||||
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_pool_acquire', {
|
||||
blobs: ['user-do', 'error'],
|
||||
doubles: [Date.now() - acquireStart],
|
||||
})
|
||||
released.resolve(undefined)
|
||||
throw e
|
||||
}
|
||||
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_client_connect_done', {
|
||||
blobs: ['user-do', 'ok'],
|
||||
doubles: [Date.now() - dialStart],
|
||||
})
|
||||
// Lock wait + dial together: this pool serializes checkouts, so a slow holder shows up
|
||||
// here as acquire time even when the dial itself is fast.
|
||||
writeDataPoint(undefined, this.env.MEASURE, this.env, 'postgres_pool_acquire', {
|
||||
blobs: ['user-do', 'ok'],
|
||||
doubles: [Date.now() - acquireStart],
|
||||
})
|
||||
|
||||
return {
|
||||
query: (...args: any[]) => (client.query as any)(...args),
|
||||
release() {
|
||||
client.end().catch(() => {})
|
||||
// Forcefully destroy the TCP socket so it doesn't linger
|
||||
// and block Durable Object hibernation. The graceful end()
|
||||
// above sends the PG Terminate message; destroy() ensures
|
||||
// the socket handle is removed from the event loop immediately.
|
||||
const stream = (client as any).connection?.stream
|
||||
if (stream && typeof stream.destroy === 'function') {
|
||||
stream.destroy()
|
||||
}
|
||||
released.resolve(undefined)
|
||||
},
|
||||
} as PostgresPoolClient
|
||||
}
|
||||
|
||||
async end() {
|
||||
await this._lock
|
||||
}
|
||||
}
|
||||
111
apps/dotcom/sync-worker/src/r2.ts
Normal file
111
apps/dotcom/sync-worker/src/r2.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
|
||||
// Shape of legacy snapshot objects in ROOM_SNAPSHOTS; nothing writes new ones since the
|
||||
// snapshot-link UI was removed, but existing objects are still read.
|
||||
export interface R2Snapshot {
|
||||
parent_slug?: string
|
||||
drawing: RoomSnapshot
|
||||
}
|
||||
|
||||
export function getR2KeyForRoom({ slug, isApp }: { slug: string; isApp: boolean }) {
|
||||
return `${isApp ? 'app_rooms' : 'public_rooms'}/${slug}`
|
||||
}
|
||||
|
||||
export function getR2KeyForSnapshot({
|
||||
parentSlug,
|
||||
snapshotSlug,
|
||||
isApp,
|
||||
}: {
|
||||
parentSlug: string | undefined | null
|
||||
snapshotSlug: string
|
||||
isApp: boolean
|
||||
}) {
|
||||
// We might not have a parent slug. This happens when creating a snapshot from a local room.
|
||||
const slug = parentSlug ? `${parentSlug}/${snapshotSlug}` : snapshotSlug
|
||||
return getR2KeyForRoom({ slug, isApp })
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one R2 operation. Operations default to running inline; a caller inside a shared connection
|
||||
* budget (the durable object's R2 queue) passes its queue, so each operation is one budgeted slot
|
||||
* and a fan-out never holds more connections than the budget allows.
|
||||
*/
|
||||
export type R2ReadScheduler = <T>(read: () => Promise<T>) => Promise<T>
|
||||
|
||||
export function runInline<T>(read: () => Promise<T>): Promise<T> {
|
||||
return read()
|
||||
}
|
||||
|
||||
// R2 has honored `include` on list() since compat date 2022-08-04 (this worker's is far past it),
|
||||
// but the repo's ambient workers-types entrypoint predates the option — declared locally, same
|
||||
// pattern as types.ts.
|
||||
type R2ListOptionsWithInclude = R2ListOptions & {
|
||||
include?: Array<'httpMetadata' | 'customMetadata'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Every object under `prefix` with its custom metadata, plus the number of list calls the walk
|
||||
* spent — callers inside a read budget account for the listing too.
|
||||
*/
|
||||
export async function listAllObjects(
|
||||
bucket: R2Bucket,
|
||||
prefix: string,
|
||||
schedule: R2ReadScheduler = runInline
|
||||
): Promise<{ objects: R2Object[]; ops: number }> {
|
||||
const objects: R2Object[] = []
|
||||
let cursor: string | undefined
|
||||
let ops = 0
|
||||
|
||||
do {
|
||||
// Including metadata makes R2 return shorter pages, so a short page does not mean the
|
||||
// listing is done — `truncated` is the only safe stop condition.
|
||||
const options: R2ListOptionsWithInclude = { prefix, cursor, include: ['customMetadata'] }
|
||||
const page = await schedule(() => bucket.list(options as R2ListOptions))
|
||||
ops++
|
||||
objects.push(...page.objects)
|
||||
cursor = page.truncated ? page.cursor : undefined
|
||||
} while (cursor)
|
||||
|
||||
return { objects, ops }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key under `prefix`, or the first `limit` of them. The limit is passed to R2 too, so a
|
||||
* capped listing is a single page rather than a full walk sliced afterwards.
|
||||
*/
|
||||
export async function listAllObjectKeys(
|
||||
bucket: R2Bucket,
|
||||
prefix: string,
|
||||
limit?: number,
|
||||
schedule: R2ReadScheduler = runInline
|
||||
): Promise<string[]> {
|
||||
const keys: string[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
do {
|
||||
const options = limit === undefined ? { prefix, cursor } : { prefix, cursor, limit }
|
||||
const result = await schedule(() => bucket.list(options))
|
||||
keys.push(...result.objects.map((o) => o.key))
|
||||
if (limit !== undefined && keys.length >= limit) return keys.slice(0, limit)
|
||||
cursor = result.truncated ? result.cursor : undefined
|
||||
} while (cursor)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// R2 deletes at most 1000 keys per call, so deleting everything a `list` returned in one go
|
||||
// throws once a board has more history objects than that (one per persist) — and the cleanup
|
||||
// that called it is left half-done.
|
||||
const MAX_R2_DELETE_KEYS = 1000
|
||||
|
||||
export async function deleteAllObjectsWithPrefix(
|
||||
bucket: R2Bucket,
|
||||
prefix: string,
|
||||
schedule: R2ReadScheduler = runInline
|
||||
) {
|
||||
const keys = await listAllObjectKeys(bucket, prefix, undefined, schedule)
|
||||
for (let i = 0; i < keys.length; i += MAX_R2_DELETE_KEYS) {
|
||||
const batch = keys.slice(i, i + MAX_R2_DELETE_KEYS)
|
||||
await schedule(() => bucket.delete(batch))
|
||||
}
|
||||
}
|
||||
107
apps/dotcom/sync-worker/src/roomEffectHelpers.test.ts
Normal file
107
apps/dotcom/sync-worker/src/roomEffectHelpers.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { TlaFile } from '@tldraw/dotcom-shared'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
FileEffectStallError,
|
||||
RoomNotFoundError,
|
||||
settleWithin,
|
||||
shouldSkipMissingRoomEffect,
|
||||
} from './roomEffectHelpers'
|
||||
|
||||
function file(partial: Partial<TlaFile>): TlaFile {
|
||||
return {
|
||||
id: 'f1',
|
||||
name: 'file',
|
||||
ownerName: '',
|
||||
thumbnail: '',
|
||||
shared: true,
|
||||
sharedLinkType: 'edit',
|
||||
published: false,
|
||||
lastPublished: 0,
|
||||
publishedSlug: 'slug-1',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
isEmpty: false,
|
||||
isDeleted: false,
|
||||
createSource: null,
|
||||
owningGroupId: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('shouldSkipMissingRoomEffect', () => {
|
||||
it('skips a RoomNotFoundError for a deleted file', () => {
|
||||
expect(
|
||||
shouldSkipMissingRoomEffect(new RoomNotFoundError('f1'), file({ isDeleted: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not skip a RoomNotFoundError for a live file', () => {
|
||||
expect(
|
||||
shouldSkipMissingRoomEffect(new RoomNotFoundError('f1'), file({ isDeleted: false }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not skip a different error for a deleted file', () => {
|
||||
expect(shouldSkipMissingRoomEffect(new Error('boom'), file({ isDeleted: true }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settleWithin', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resolves settled when the promise resolves in time', async () => {
|
||||
const result = settleWithin(Promise.resolve('done'), 1000)
|
||||
await expect(result).resolves.toBe('settled')
|
||||
})
|
||||
|
||||
it('resolves settled when the promise rejects in time', async () => {
|
||||
const result = settleWithin(Promise.reject(new Error('boom')), 1000)
|
||||
await expect(result).resolves.toBe('settled')
|
||||
})
|
||||
|
||||
it('resolves timeout when the promise is still pending', async () => {
|
||||
const result = settleWithin(new Promise(() => {}), 1000)
|
||||
vi.advanceTimersByTime(1000)
|
||||
await expect(result).resolves.toBe('timeout')
|
||||
})
|
||||
|
||||
it('swallows a rejection that lands after the timeout', async () => {
|
||||
let reject!: (e: Error) => void
|
||||
const pending = new Promise((_, r) => {
|
||||
reject = r
|
||||
})
|
||||
const result = settleWithin(pending, 1000)
|
||||
vi.advanceTimersByTime(1000)
|
||||
await expect(result).resolves.toBe('timeout')
|
||||
reject(new Error('late'))
|
||||
// A late rejection must not become an unhandled rejection.
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileEffectStallError', () => {
|
||||
it('names the boot sub-stage and its age when the boot is still in progress', () => {
|
||||
const error = new FileEffectStallError(
|
||||
'slug-1',
|
||||
'insert',
|
||||
'storage-load:comments',
|
||||
25000,
|
||||
30000
|
||||
)
|
||||
expect(error.message).toBe(
|
||||
'file insert effect for slug-1 still pending after 30000ms at boot stage storage-load:comments (25000ms in stage)'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports post-boot work when there is no boot stage', () => {
|
||||
const error = new FileEffectStallError('slug-1', 'update', null, null, 30000)
|
||||
expect(error.message).toBe(
|
||||
'file update effect for slug-1 still pending after 30000ms in post-boot work'
|
||||
)
|
||||
})
|
||||
})
|
||||
92
apps/dotcom/sync-worker/src/roomEffectHelpers.ts
Normal file
92
apps/dotcom/sync-worker/src/roomEffectHelpers.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { TlaFile } from '@tldraw/dotcom-shared'
|
||||
|
||||
// A real Error so Sentry shows the slug and a stack; checks are DO-local instanceof.
|
||||
// Lives outside TLFileDurableObject.ts (which pulls in cloudflare:workers) so it can be
|
||||
// imported from plain Node tests.
|
||||
export class RoomNotFoundError extends Error {
|
||||
constructor(slug: string) {
|
||||
super(`Room not found: ${slug}`)
|
||||
this.name = 'RoomNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
// A trashed file's room may never have existed; for live files a missing room is
|
||||
// usually the duplicate-from-source race, which the outbox retry heals.
|
||||
export function shouldSkipMissingRoomEffect(error: unknown, file: TlaFile): boolean {
|
||||
return error instanceof RoomNotFoundError && file.isDeleted
|
||||
}
|
||||
|
||||
// Reported (not thrown) when an outbox file effect is still pending just under the drain's
|
||||
// 30s effect timeout, so Sentry names the cause before the drain bumps the row. Without it a
|
||||
// wedged effect parks a row as a bare EffectTimeoutError (#10541). The guaranteed fact is
|
||||
// that the effect stalled; a boot stage, when present, says where the room boot is stuck —
|
||||
// a null stage means the boot settled and the stall is in post-boot work (e.g. the
|
||||
// per-session permission refresh).
|
||||
export type BootStage =
|
||||
| 'storage-load:sqlite-init'
|
||||
| 'storage-load:r2'
|
||||
| 'storage-load:file-record'
|
||||
| 'storage-load:comments'
|
||||
| 'storage-load:supabase'
|
||||
| 'storage-load:kv-rollout'
|
||||
| 'source-await-persist'
|
||||
| 'source-r2-fetch'
|
||||
| 'source-welcome'
|
||||
| 'source-legacy'
|
||||
| 'source-published'
|
||||
| 'source-r2-put'
|
||||
| 'room-create'
|
||||
|
||||
export class FileEffectStallError extends Error {
|
||||
constructor(
|
||||
slug: string,
|
||||
command: 'insert' | 'update',
|
||||
stage: BootStage | null,
|
||||
stageAgeMs: number | null,
|
||||
ms: number
|
||||
) {
|
||||
super(
|
||||
`file ${command} effect for ${slug} still pending after ${ms}ms` +
|
||||
(stage !== null
|
||||
? ` at boot stage ${stage} (${stageAgeMs}ms in stage)`
|
||||
: ' in post-boot work')
|
||||
)
|
||||
this.name = 'FileEffectStallError'
|
||||
}
|
||||
}
|
||||
|
||||
// Reported when a duplicate-from-source boot gives up waiting for the source room's persist
|
||||
// and proceeds with its last persisted snapshot.
|
||||
export class SourcePersistTimeoutError extends Error {
|
||||
constructor(sourceSlug: string, ms: number) {
|
||||
super(
|
||||
`awaitPersist on source ${sourceSlug} did not settle within ${ms}ms; copying its last persisted snapshot`
|
||||
)
|
||||
this.name = 'SourcePersistTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Await `promise` for at most `ms`; 'timeout' means it was still pending. The promise's
|
||||
* eventual settlement (including rejection) is swallowed — callers use this for best-effort
|
||||
* work they are prepared to proceed without.
|
||||
*/
|
||||
export async function settleWithin(
|
||||
promise: Promise<unknown>,
|
||||
ms: number
|
||||
): Promise<'settled' | 'timeout'> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise.then(
|
||||
() => 'settled' as const,
|
||||
() => 'settled' as const
|
||||
),
|
||||
new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('timeout'), ms)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { assert } from '@tldraw/utils'
|
||||
import { handleExtractBookmarkMetadataRequest } from '@tldraw/worker-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
|
||||
export async function extractBookmarkMetadata(request: IRequest, env: Environment) {
|
||||
if (request.method === 'GET') {
|
||||
// legacy route: extract metadata without saving image
|
||||
return handleExtractBookmarkMetadataRequest({ request })
|
||||
}
|
||||
|
||||
return handleExtractBookmarkMetadataRequest({
|
||||
request,
|
||||
uploadImage: async (headers, body, objectName) => {
|
||||
assert(env.ASSET_UPLOAD_ORIGIN, 'ASSET_UPLOAD_ORIGIN is required')
|
||||
const url = `${env.ASSET_UPLOAD_ORIGIN}/uploads/${objectName}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload image')
|
||||
}
|
||||
|
||||
return url
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
|
||||
// Leaf helper for reading a board's display name out of a room snapshot. Lives in its own module
|
||||
// (rather than getSocialPreview.ts) so the thumbnail/OG surfaces can read board names without
|
||||
// importing getSocialPreview, which itself depends on the thumbnail modules — keeping the import
|
||||
// graph acyclic.
|
||||
|
||||
/**
|
||||
* Reads the board name from a room snapshot's `document` record, returning null if there's no
|
||||
* usable name.
|
||||
*/
|
||||
export function getDocumentNameFromSnapshot(
|
||||
snapshot: RoomSnapshot | undefined | null
|
||||
): string | null {
|
||||
if (!snapshot?.documents) return null
|
||||
for (const { state } of snapshot.documents) {
|
||||
if (state && (state as any).typeName === 'document') {
|
||||
return cleanName((state as any).name)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function cleanName(name: string | null | undefined): string | null {
|
||||
const trimmed = name?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
30
apps/dotcom/sync-worker/src/routes/getReadonlySlug.ts
Normal file
30
apps/dotcom/sync-worker/src/routes/getReadonlySlug.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { GetReadonlySlugResponseBody } from '@tldraw/dotcom-shared'
|
||||
import { lns } from '@tldraw/utils'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
|
||||
// Return a URL to a readonly version of the room
|
||||
export async function getReadonlySlug(request: IRequest, env: Environment): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
if (!roomId) {
|
||||
return new Response('Bad request', {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
let slug = await env.SLUG_TO_READONLY_SLUG.get(roomId)
|
||||
let isLegacy = false
|
||||
|
||||
if (!slug) {
|
||||
// For all newly created rooms we add the readonly slug to the KV store.
|
||||
// If it does not exist there it means we are trying to get a slug for an old room.
|
||||
slug = lns(roomId)
|
||||
isLegacy = true
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
slug,
|
||||
isLegacy,
|
||||
} satisfies GetReadonlySlugResponseBody)
|
||||
)
|
||||
}
|
||||
112
apps/dotcom/sync-worker/src/routes/getRoomHistory.test.ts
Normal file
112
apps/dotcom/sync-worker/src/routes/getRoomHistory.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { IRequest } from 'itty-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createFakeR2 } from '../test/fakeR2'
|
||||
import { Environment } from '../types'
|
||||
import { segmentCustomMetadata } from '../versionChain'
|
||||
import { listVersionTimestamps } from '../versionChainRead'
|
||||
import { getMonthPrefix, getPreviousMonth, getRoomHistory } from './getRoomHistory'
|
||||
|
||||
vi.mock('../utils/tla/getAuth', () => ({ requireAdminAccessToRequest: vi.fn() }))
|
||||
|
||||
describe('history listing during the transition', () => {
|
||||
it('shows legacy versions, keyframes and every version inside a segment as one list', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
const roomKey = 'app_rooms/slug'
|
||||
|
||||
await legacyBucket.put(`${roomKey}/2026-08-01T00:00:00.000Z`, '{}')
|
||||
await chainBucket.put(`${roomKey}/2026-09-01T00:00:00.000Z.k`, '{}')
|
||||
// One segment object standing for two versions — the listing must expand it, not report
|
||||
// the segment's own key as a version.
|
||||
await chainBucket.put(`${roomKey}/2026-09-01T00:00:08.000Z.s`, '{}', {
|
||||
customMetadata: segmentCustomMetadata({
|
||||
keyframeKey: `${roomKey}/2026-09-01T00:00:00.000Z.k`,
|
||||
firstSeq: 1,
|
||||
timestamps: ['2026-09-01T00:00:08.000Z', '2026-09-01T00:00:16.000Z'],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(
|
||||
await listVersionTimestamps({ chainBucket, legacyBucket, roomKey, prefix: '2026-0' })
|
||||
).toEqual([
|
||||
'2026-09-01T00:00:16.000Z',
|
||||
'2026-09-01T00:00:08.000Z',
|
||||
'2026-09-01T00:00:00.000Z',
|
||||
'2026-08-01T00:00:00.000Z',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMonthPrefix', () => {
|
||||
it('uses the UTC month at a local month boundary', () => {
|
||||
expect(getMonthPrefix(new Date('2026-03-01T00:30:00+02:00'))).toBe('2026-02')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPreviousMonth', () => {
|
||||
it.each([
|
||||
['2026-03-29T12:00:00Z', '2026-02-01T00:00:00.000Z'],
|
||||
['2026-03-30T12:00:00Z', '2026-02-01T00:00:00.000Z'],
|
||||
['2026-03-31T12:00:00Z', '2026-02-01T00:00:00.000Z'],
|
||||
['2024-03-31T12:00:00Z', '2024-02-01T00:00:00.000Z'],
|
||||
['2026-01-31T12:00:00Z', '2025-12-01T00:00:00.000Z'],
|
||||
['2026-03-01T00:30:00+02:00', '2026-01-01T00:00:00.000Z'],
|
||||
])('steps from %s to %s without changing the input', (input, expected) => {
|
||||
const date = new Date(input)
|
||||
const originalTime = date.getTime()
|
||||
expect(getPreviousMonth(date).toISOString()).toBe(expected)
|
||||
expect(date.getTime()).toBe(originalTime)
|
||||
})
|
||||
})
|
||||
|
||||
async function listHistory(timestamps: string[], offset: string) {
|
||||
const list = vi.fn(async ({ prefix }: { prefix: string }) => ({
|
||||
objects: timestamps
|
||||
.map((timestamp) => ({ key: `app_rooms/board/${timestamp}` }))
|
||||
.filter(({ key }) => key.startsWith(prefix)),
|
||||
truncated: false,
|
||||
}))
|
||||
const response = await getRoomHistory(
|
||||
{ params: { roomId: 'board' }, query: { offset } } as unknown as IRequest,
|
||||
{
|
||||
// An empty chain bucket: these tests cover the month walk over legacy copies alone.
|
||||
ROOMS_HISTORY: { list: async () => ({ objects: [], truncated: false }) },
|
||||
ROOMS_HISTORY_EPHEMERAL: { list },
|
||||
} as unknown as Environment,
|
||||
true
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
describe('getRoomHistory', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('collects consecutive months once and excludes the pagination offset', async () => {
|
||||
const timestamps = [
|
||||
'2026-03-31T12:00:00.000Z',
|
||||
'2026-03-30T10:00:00.000Z',
|
||||
'2026-02-28T10:00:00.000Z',
|
||||
]
|
||||
await expect(listHistory(timestamps, timestamps[0])).resolves.toEqual({
|
||||
timestamps: timestamps.slice(1),
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
it.each(['!', 'not-a-date'])(
|
||||
'uses now for scanning and filtering invalid offset %s',
|
||||
async (offset) => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-03-31T12:00:00Z'))
|
||||
const timestamps = [
|
||||
'2026-03-31T13:00:00.000Z',
|
||||
'2026-03-30T10:00:00.000Z',
|
||||
'2026-02-28T10:00:00.000Z',
|
||||
]
|
||||
await expect(listHistory(timestamps, offset)).resolves.toEqual({
|
||||
timestamps: timestamps.slice(1),
|
||||
hasMore: false,
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
172
apps/dotcom/sync-worker/src/routes/getRoomHistory.ts
Normal file
172
apps/dotcom/sync-worker/src/routes/getRoomHistory.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { HistoryResponseBody } from '@tldraw/dotcom-shared'
|
||||
import { notFound } from '@tldraw/worker-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { getR2KeyForRoom } from '../r2'
|
||||
import { Environment } from '../types'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||
import { requireAdminAccessToRequest } from '../utils/tla/getAuth'
|
||||
import { isTestFile } from '../utils/tla/isTestFile'
|
||||
import { ChainIndexEntry, listVersionTimestamps, loadChainIndex } from '../versionChainRead'
|
||||
|
||||
export function getMonthPrefix(date: Date): string {
|
||||
return date.toISOString().split('T')[0].substring(0, 7)
|
||||
}
|
||||
|
||||
export function getPreviousMonth(date: Date): Date {
|
||||
// Use day 1 to avoid overflowing shorter months and scanning the same month twice.
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - 1, 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* The chain index is listed once per request and threaded through: this handler probes up to
|
||||
* three dozen month prefixes, and each probe re-listing the whole chain (plus an uncapped legacy
|
||||
* walk) turned a ~7-listing request into hundreds on rooms with long histories.
|
||||
*/
|
||||
interface HistorySource {
|
||||
env: Environment
|
||||
roomKey: string
|
||||
index: ChainIndexEntry[]
|
||||
}
|
||||
|
||||
async function fetchTimestampsForPrefix(
|
||||
source: HistorySource,
|
||||
prefix: string,
|
||||
limit?: number
|
||||
): Promise<string[]> {
|
||||
return await listVersionTimestamps({
|
||||
chainBucket: source.env.ROOMS_HISTORY,
|
||||
legacyBucket: source.env.ROOMS_HISTORY_EPHEMERAL,
|
||||
roomKey: source.roomKey,
|
||||
prefix,
|
||||
index: source.index,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
async function monthHasEntries(source: HistorySource, monthPrefix: string): Promise<boolean> {
|
||||
const timestamps = await fetchTimestampsForPrefix(source, monthPrefix, 1)
|
||||
return timestamps.length > 0
|
||||
}
|
||||
|
||||
export async function getRoomHistory(
|
||||
request: IRequest,
|
||||
env: Environment,
|
||||
isApp: boolean
|
||||
): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
|
||||
if (!roomId) return notFound()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
await requireAdminAccessToRequest(request, env)
|
||||
|
||||
if (isTestFile(roomId)) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
let offset = request.query?.offset as string // offset is the earliest timestamp from the previous page
|
||||
|
||||
const bucketKey = getR2KeyForRoom({ slug: roomId, isApp })
|
||||
const source: HistorySource = {
|
||||
env,
|
||||
roomKey: bucketKey,
|
||||
index: (await loadChainIndex(env.ROOMS_HISTORY, bucketKey)).entries,
|
||||
}
|
||||
|
||||
let allTimestamps: string[] = []
|
||||
|
||||
let currentMonth = new Date()
|
||||
let monthsChecked = 0
|
||||
const maxMonthsToCheck = 12
|
||||
const targetEntryCount = 1000
|
||||
|
||||
if (offset) {
|
||||
// Invalid dates would throw in toISOString when building the month prefix.
|
||||
const parsed = new Date(offset)
|
||||
currentMonth = Number.isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
offset = currentMonth.toISOString()
|
||||
} else {
|
||||
// If we don't have an offset we can check if the room doesn't have too many entries
|
||||
const allTimestampsForRoom = await fetchTimestampsForPrefix(source, '', 1000)
|
||||
|
||||
// If we have fewer than 1000 entries, return them all
|
||||
if (allTimestampsForRoom.length < 1000) {
|
||||
const roomHistory: HistoryResponseBody = {
|
||||
timestamps: allTimestampsForRoom,
|
||||
hasMore: false,
|
||||
}
|
||||
return new Response(JSON.stringify(roomHistory), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Find the first month with entries
|
||||
let foundMonthWithEntries = false
|
||||
while (!foundMonthWithEntries && monthsChecked < maxMonthsToCheck) {
|
||||
const monthPrefix = getMonthPrefix(currentMonth)
|
||||
const hasEntries = await monthHasEntries(source, monthPrefix)
|
||||
|
||||
if (hasEntries) {
|
||||
foundMonthWithEntries = true
|
||||
} else {
|
||||
currentMonth = getPreviousMonth(currentMonth)
|
||||
monthsChecked++
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a month with entries, start collecting timestamps
|
||||
if (foundMonthWithEntries) {
|
||||
let monthsCollected = 0
|
||||
const maxMonthsToCollect = 12
|
||||
|
||||
// Collect timestamps from multiple months until we reach the target count
|
||||
while (allTimestamps.length < targetEntryCount && monthsCollected < maxMonthsToCollect) {
|
||||
const monthPrefix = getMonthPrefix(currentMonth)
|
||||
const monthTimestamps = await fetchTimestampsForPrefix(source, monthPrefix)
|
||||
|
||||
let filteredTimestamps = monthTimestamps
|
||||
if (offset) {
|
||||
// Only include timestamps that are strictly less than the offset
|
||||
// This ensures no duplicates when paginating
|
||||
filteredTimestamps = monthTimestamps.filter((ts) => ts < offset)
|
||||
}
|
||||
|
||||
allTimestamps.push(...filteredTimestamps)
|
||||
|
||||
currentMonth = getPreviousMonth(currentMonth)
|
||||
monthsCollected++
|
||||
}
|
||||
|
||||
allTimestamps = allTimestamps.sort((a, b) => b.localeCompare(a))
|
||||
}
|
||||
|
||||
let hasMore = false
|
||||
if (allTimestamps.length > 0) {
|
||||
// Check if there are more entries in previous months
|
||||
let checkMonth = currentMonth
|
||||
let monthsToCheck = 12
|
||||
|
||||
while (monthsToCheck > 0) {
|
||||
const previousMonthPrefix = getMonthPrefix(checkMonth)
|
||||
const hasMoreEntries = await monthHasEntries(source, previousMonthPrefix)
|
||||
|
||||
if (hasMoreEntries) {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
|
||||
checkMonth = getPreviousMonth(checkMonth)
|
||||
monthsToCheck--
|
||||
}
|
||||
}
|
||||
|
||||
const response: HistoryResponseBody = {
|
||||
timestamps: allTimestamps,
|
||||
hasMore,
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(response), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { UnknownRecord } from '@tldraw/store'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createFakeR2 } from '../test/fakeR2'
|
||||
import { Environment } from '../types'
|
||||
import { segmentCustomMetadata, versionKey } from '../versionChain'
|
||||
import { encodeVersionBody } from '../versionChainCodec'
|
||||
import { buildSnapshotDelta } from '../versionDelta'
|
||||
import { getRoomHistorySnapshot } from './getRoomHistorySnapshot'
|
||||
|
||||
vi.mock('../utils/tla/getAuth', () => ({ requireAdminAccessToRequest: vi.fn() }))
|
||||
|
||||
const captureException = vi.fn()
|
||||
vi.mock('@tldraw/worker-shared', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@tldraw/worker-shared')>()),
|
||||
createSentry: vi.fn(() => ({ captureException })),
|
||||
}))
|
||||
|
||||
const roomKey = 'app_rooms/board'
|
||||
|
||||
function snapshot(clock: number, ids: string[]): RoomSnapshot {
|
||||
return {
|
||||
clock,
|
||||
documentClock: clock,
|
||||
documents: ids.map((id) => ({
|
||||
state: { id, typeName: 'shape' } as UnknownRecord,
|
||||
lastChangedClock: clock,
|
||||
})),
|
||||
tombstones: {},
|
||||
tombstoneHistoryStartsAtClock: 0,
|
||||
schema: { schemaVersion: 2, sequences: {} } as any,
|
||||
}
|
||||
}
|
||||
|
||||
function isoAt(i: number) {
|
||||
return `2026-09-01T00:00:${String(i).padStart(2, '0')}.000Z`
|
||||
}
|
||||
|
||||
async function fetchSnapshot(env: Environment, timestamp: string, ctx?: ExecutionContext) {
|
||||
return await getRoomHistorySnapshot(
|
||||
{ params: { roomId: 'board', timestamp } } as unknown as IRequest,
|
||||
env,
|
||||
true,
|
||||
ctx
|
||||
)
|
||||
}
|
||||
|
||||
describe('getRoomHistorySnapshot', () => {
|
||||
beforeEach(() => captureException.mockClear())
|
||||
|
||||
it('counts the chain listing in x-version-chain-ops on both serve paths', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
const env = {
|
||||
ROOMS_HISTORY: chainBucket,
|
||||
ROOMS_HISTORY_EPHEMERAL: legacyBucket,
|
||||
} as unknown as Environment
|
||||
|
||||
// A keyframe at iso 0 and one segment holding deltas for isos 1 and 2.
|
||||
const versions = [snapshot(1, ['shape:a']), snapshot(2, ['shape:a', 'shape:b'])]
|
||||
const keyframeKey = versionKey(roomKey, isoAt(0), 'keyframe')
|
||||
const encodedKeyframe = await encodeVersionBody(versions[0])
|
||||
await chainBucket.put(keyframeKey, encodedKeyframe.body, {
|
||||
customMetadata: encodedKeyframe.metadata,
|
||||
})
|
||||
const encodedSegment = await encodeVersionBody({
|
||||
v: 1,
|
||||
deltas: [{ t: isoAt(1), delta: buildSnapshotDelta(versions[0], versions[1]) }],
|
||||
})
|
||||
await chainBucket.put(versionKey(roomKey, isoAt(1), 'segment'), encodedSegment.body, {
|
||||
customMetadata: {
|
||||
...encodedSegment.metadata,
|
||||
...segmentCustomMetadata({ keyframeKey, firstSeq: 1, timestamps: [isoAt(1)] }),
|
||||
},
|
||||
})
|
||||
|
||||
// Whole-object path: one listing page plus the keyframe get.
|
||||
const whole = await fetchSnapshot(env, isoAt(0))
|
||||
expect({
|
||||
status: whole.status,
|
||||
ops: whole.headers.get('x-version-chain-ops'),
|
||||
depth: whole.headers.get('x-version-chain-depth'),
|
||||
}).toEqual({ status: 200, ops: '2', depth: '0' })
|
||||
expect(await whole.json()).toEqual(versions[0])
|
||||
|
||||
// Replay path: the listing page, the keyframe and one segment.
|
||||
const replayed = await fetchSnapshot(env, isoAt(1))
|
||||
expect({
|
||||
status: replayed.status,
|
||||
ops: replayed.headers.get('x-version-chain-ops'),
|
||||
depth: replayed.headers.get('x-version-chain-depth'),
|
||||
}).toEqual({ status: 200, ops: '3', depth: '1' })
|
||||
expect(await replayed.json()).toEqual(versions[1])
|
||||
})
|
||||
|
||||
it('serves the legacy copy and reports when the chain read throws', async () => {
|
||||
const legacyBucket = createFakeR2()
|
||||
const timestamp = isoAt(3)
|
||||
await legacyBucket.put(`${roomKey}/${timestamp}`, JSON.stringify(snapshot(3, ['shape:a'])))
|
||||
const boom = new Error('chain listing failed')
|
||||
const env = {
|
||||
ROOMS_HISTORY: {
|
||||
list: vi.fn(async () => {
|
||||
throw boom
|
||||
}),
|
||||
},
|
||||
ROOMS_HISTORY_EPHEMERAL: legacyBucket,
|
||||
} as unknown as Environment
|
||||
|
||||
const response = await fetchSnapshot(env, timestamp, {} as ExecutionContext)
|
||||
|
||||
expect({
|
||||
status: response.status,
|
||||
ops: response.headers.get('x-version-chain-ops'),
|
||||
body: await response.json(),
|
||||
}).toEqual({ status: 200, ops: null, body: snapshot(3, ['shape:a']) })
|
||||
expect(captureException).toHaveBeenCalledWith(boom)
|
||||
})
|
||||
|
||||
it('rethrows without reporting when there is no legacy copy to serve', async () => {
|
||||
const boom = new Error('chain listing failed')
|
||||
const env = {
|
||||
ROOMS_HISTORY: {
|
||||
list: vi.fn(async () => {
|
||||
throw boom
|
||||
}),
|
||||
},
|
||||
ROOMS_HISTORY_EPHEMERAL: createFakeR2(),
|
||||
} as unknown as Environment
|
||||
|
||||
// The worker's catch-all reports what escapes, so a capture here would file it twice.
|
||||
await expect(fetchSnapshot(env, isoAt(4), {} as ExecutionContext)).rejects.toBe(boom)
|
||||
expect(captureException).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
93
apps/dotcom/sync-worker/src/routes/getRoomHistorySnapshot.ts
Normal file
93
apps/dotcom/sync-worker/src/routes/getRoomHistorySnapshot.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createSentry, notFound } from '@tldraw/worker-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { getR2KeyForRoom } from '../r2'
|
||||
import { Environment } from '../types'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||
import { requireAdminAccessToRequest } from '../utils/tla/getAuth'
|
||||
import { isTestFile } from '../utils/tla/isTestFile'
|
||||
import { loadChainIndex, openWholeVersionStream, reconstructVersion } from '../versionChainRead'
|
||||
|
||||
// Get a snapshot of the room at a given point in time
|
||||
export async function getRoomHistorySnapshot(
|
||||
request: IRequest,
|
||||
env: Environment,
|
||||
isApp: boolean,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
|
||||
if (!roomId) return notFound()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
await requireAdminAccessToRequest(request, env)
|
||||
|
||||
if (isTestFile(roomId)) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
const timestamp = request.params.timestamp
|
||||
const roomKey = getR2KeyForRoom({ slug: roomId, isApp })
|
||||
|
||||
const buckets = { chainBucket: env.ROOMS_HISTORY, legacyBucket: env.ROOMS_HISTORY_EPHEMERAL }
|
||||
let result
|
||||
let listOps = 0
|
||||
try {
|
||||
const { entries: index, ops } = await loadChainIndex(env.ROOMS_HISTORY, roomKey)
|
||||
// The listing is part of this request's R2 cost: reconstructVersion counts zero listing
|
||||
// ops for a pre-loaded index, so leaving these out under-reports the header below.
|
||||
listOps = ops
|
||||
// Keyframes and legacy full copies stream straight through — openWholeVersionStream states
|
||||
// the parse-cost rationale. Only a delta replay materializes.
|
||||
const whole = await openWholeVersionStream({ ...buckets, roomKey, timestamp, index })
|
||||
if (whole) {
|
||||
return new Response(whole, {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
// The listing plus the one whole-object get, so the metric reads the same
|
||||
// across both serve paths.
|
||||
'x-version-chain-ops': String(listOps + 1),
|
||||
'x-version-chain-depth': '0',
|
||||
},
|
||||
})
|
||||
}
|
||||
result = await reconstructVersion({ ...buckets, roomKey, timestamp, index })
|
||||
} catch (error) {
|
||||
// A broken chain must not take history down while the legacy full copies still exist.
|
||||
// Serve the copy — the verifier is how the chain gets fixed.
|
||||
const legacy = await env.ROOMS_HISTORY_EPHEMERAL.get(`${roomKey}/${timestamp}`)
|
||||
if (!legacy) throw error
|
||||
// Only the served fallback swallows the error, so only it has to report: a chain that
|
||||
// stopped reconstructing would stay invisible for as long as the copies last. The rethrow
|
||||
// above reaches the worker's catch-all, which reports it; capturing here too files it twice.
|
||||
try {
|
||||
// No ctx in unit tests, and createSentry throws when its env vars are unset; neither may
|
||||
// turn the degraded-but-fine fallback into a 500.
|
||||
const sentry = ctx ? createSentry(ctx, env) : null
|
||||
if (sentry) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
sentry.captureException(error)
|
||||
} else {
|
||||
console.error(error)
|
||||
}
|
||||
} catch {
|
||||
console.error(error)
|
||||
}
|
||||
return new Response(legacy.body, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result.snapshot), {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
// Replay cost is a product metric once history is user-facing: the segment cap is the
|
||||
// lever, and this is what says whether it needs moving.
|
||||
'x-version-chain-ops': String(listOps + result.ops),
|
||||
'x-version-chain-depth': String(result.deltaCount),
|
||||
},
|
||||
})
|
||||
}
|
||||
67
apps/dotcom/sync-worker/src/routes/getRoomSnapshot.ts
Normal file
67
apps/dotcom/sync-worker/src/routes/getRoomSnapshot.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { notFound } from '@tldraw/worker-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { getR2KeyForSnapshot, R2Snapshot } from '../r2'
|
||||
import { Environment } from '../types'
|
||||
import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient'
|
||||
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
|
||||
|
||||
export async function getSnapshotFromR2(
|
||||
env: Environment,
|
||||
roomId: string
|
||||
): Promise<RoomSnapshot | undefined> {
|
||||
const parentSlug = await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.get(roomId)
|
||||
const object = await env.ROOM_SNAPSHOTS.get(
|
||||
getR2KeyForSnapshot({ parentSlug, snapshotSlug: roomId, isApp: false })
|
||||
)
|
||||
if (!object) return undefined
|
||||
return ((await object.json()) as R2Snapshot)?.drawing
|
||||
}
|
||||
|
||||
// Snapshots created before the R2 store live in Supabase.
|
||||
export async function getSnapshotFromSupabase(
|
||||
supabase: NonNullable<ReturnType<typeof createSupabaseClient>>,
|
||||
env: Environment,
|
||||
roomId: string
|
||||
): Promise<RoomSnapshot | undefined> {
|
||||
const result = await supabase
|
||||
.from(getSnapshotsTable(env))
|
||||
.select('drawing')
|
||||
.eq('slug', roomId)
|
||||
.maybeSingle()
|
||||
return result.data?.drawing as RoomSnapshot | undefined
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
const roomId = request.params.roomId
|
||||
if (!roomId) return notFound()
|
||||
|
||||
const r2Data = await getSnapshotFromR2(env, roomId)
|
||||
if (r2Data) return generateReponse(roomId, r2Data)
|
||||
|
||||
// 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()
|
||||
|
||||
const data = await getSnapshotFromSupabase(supabase, env, roomId)
|
||||
if (!data) return notFound()
|
||||
|
||||
// Send back the snapshot!
|
||||
return generateReponse(roomId, data)
|
||||
}
|
||||
117
apps/dotcom/sync-worker/src/routes/getSocialPreview.test.ts
Normal file
117
apps/dotcom/sync-worker/src/routes/getSocialPreview.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { getDocumentNameFromSnapshot } from './getDocumentNameFromSnapshot'
|
||||
import { renderSocialPreview } from './getSocialPreview'
|
||||
|
||||
function snapshotWithDocumentName(name: string): RoomSnapshot {
|
||||
return {
|
||||
schema: {} as any,
|
||||
clock: 0,
|
||||
documents: [
|
||||
{
|
||||
state: { typeName: 'document', id: 'document:document', name, meta: {} } as any,
|
||||
lastChangedClock: 0,
|
||||
},
|
||||
{
|
||||
state: { typeName: 'shape', id: 'shape:a' } as any,
|
||||
lastChangedClock: 0,
|
||||
},
|
||||
],
|
||||
tombstones: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('renderSocialPreview', () => {
|
||||
test('uses "<name> • tldraw.com" as the title when there is a name', () => {
|
||||
const html = renderSocialPreview('My board')
|
||||
expect(html).toContain('<title>My board • tldraw.com</title>')
|
||||
expect(html).toContain('<meta property="og:title" content="My board • tldraw.com" />')
|
||||
expect(html).toContain('<meta name="twitter:title" content="My board • tldraw.com" />')
|
||||
})
|
||||
|
||||
test('falls back to "tldraw.com" when there is no name', () => {
|
||||
const html = renderSocialPreview(null)
|
||||
expect(html).toContain('<title>tldraw.com</title>')
|
||||
expect(html).toContain('<meta property="og:title" content="tldraw.com" />')
|
||||
})
|
||||
|
||||
test('keeps the shared description and image', () => {
|
||||
const html = renderSocialPreview('My board')
|
||||
expect(html).toContain('A free and instant collaborative whiteboarding tool.')
|
||||
expect(html).toContain('https://www.tldraw.com/social-og.png')
|
||||
expect(html).toContain('<meta name="twitter:card" content="summary" />')
|
||||
})
|
||||
|
||||
test('uses the board og-image with a large card when a board image URL is given', () => {
|
||||
const html = renderSocialPreview(
|
||||
'My board',
|
||||
'https://www.tldraw.com/api/app/social-preview/f/board-slug/image'
|
||||
)
|
||||
expect(html).toContain(
|
||||
'<meta property="og:image" content="https://www.tldraw.com/api/app/social-preview/f/board-slug/image" />'
|
||||
)
|
||||
expect(html).toContain(
|
||||
'<meta name="twitter:image" content="https://www.tldraw.com/api/app/social-preview/f/board-slug/image" />'
|
||||
)
|
||||
expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />')
|
||||
expect(html).toContain('<meta property="og:image:width" content="1200" />')
|
||||
expect(html).toContain('<meta property="og:image:height" content="630" />')
|
||||
expect(html).not.toContain('https://www.tldraw.com/social-og.png')
|
||||
expect(html).not.toContain('https://www.tldraw.com/social-twitter.png')
|
||||
})
|
||||
|
||||
test('escapes the board image URL in the meta attributes', () => {
|
||||
const html = renderSocialPreview('My board', 'https://example.com/og?a=1&b="2"')
|
||||
expect(html).toContain('content="https://example.com/og?a=1&b="2""')
|
||||
})
|
||||
|
||||
test('escapes names so they cannot break out of the meta attributes', () => {
|
||||
const html = renderSocialPreview('<script>"&')
|
||||
expect(html).toContain(
|
||||
'<meta property="og:title" content="<script>"& • tldraw.com" />'
|
||||
)
|
||||
expect(html).not.toContain('<script>"&')
|
||||
// the only script in the page is our own bypass redirect
|
||||
expect(html.match(/<script>/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('redirects humans back to the board with the bypass param', () => {
|
||||
// In-app browsers with a crawler token in their user-agent (WhatsApp, Pinterest) get routed
|
||||
// here too. The script sends them back to the board; the param makes the Vercel crawler
|
||||
// route not match on the second request.
|
||||
const html = renderSocialPreview('My board')
|
||||
expect(html).toContain(`url.searchParams.set("no_preview", '1')`)
|
||||
expect(html).toContain('location.replace(url)')
|
||||
expect(html).toContain('<a href="?no_preview=1">Open this board</a>')
|
||||
// the redirect is guarded so it can never reload this page forever, even if the
|
||||
// routing layer serves the stub to a request that already carries the param
|
||||
expect(html).toContain('if (!url.searchParams.get("no_preview"))')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDocumentNameFromSnapshot', () => {
|
||||
test('reads the name from the document record', () => {
|
||||
expect(getDocumentNameFromSnapshot(snapshotWithDocumentName('My board'))).toBe('My board')
|
||||
})
|
||||
|
||||
test('trims surrounding whitespace', () => {
|
||||
expect(getDocumentNameFromSnapshot(snapshotWithDocumentName(' My board '))).toBe('My board')
|
||||
})
|
||||
|
||||
test('returns null for an empty or whitespace-only name', () => {
|
||||
expect(getDocumentNameFromSnapshot(snapshotWithDocumentName(''))).toBe(null)
|
||||
expect(getDocumentNameFromSnapshot(snapshotWithDocumentName(' '))).toBe(null)
|
||||
})
|
||||
|
||||
test('returns null when there is no snapshot or document record', () => {
|
||||
expect(getDocumentNameFromSnapshot(null)).toBe(null)
|
||||
expect(getDocumentNameFromSnapshot(undefined)).toBe(null)
|
||||
expect(
|
||||
getDocumentNameFromSnapshot({
|
||||
schema: {} as any,
|
||||
clock: 0,
|
||||
documents: [{ state: { typeName: 'shape', id: 'shape:a' } as any, lastChangedClock: 0 }],
|
||||
tombstones: {},
|
||||
})
|
||||
).toBe(null)
|
||||
})
|
||||
})
|
||||
257
apps/dotcom/sync-worker/src/routes/getSocialPreview.ts
Normal file
257
apps/dotcom/sync-worker/src/routes/getSocialPreview.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
DEFAULT_THUMBNAIL_HEIGHT,
|
||||
DEFAULT_THUMBNAIL_WIDTH,
|
||||
FILE_PREFIX,
|
||||
PUBLISH_PREFIX,
|
||||
READ_ONLY_LEGACY_PREFIX,
|
||||
READ_ONLY_PREFIX,
|
||||
ROOM_OPEN_MODE,
|
||||
ROOM_PREFIX,
|
||||
RoomOpenMode,
|
||||
SNAPSHOT_PREFIX,
|
||||
SOCIAL_PREVIEW_BYPASS_PARAM,
|
||||
} from '@tldraw/dotcom-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { createPostgresConnectionPool } from '../postgres'
|
||||
import { Environment, ThumbnailBoardKind } from '../types'
|
||||
import { createSupabaseClient } from '../utils/createSupabaseClient'
|
||||
import { getPublicOrigin } from '../utils/getPublicOrigin'
|
||||
import { getSlug } from '../utils/roomOpenMode'
|
||||
import { cleanName, getDocumentNameFromSnapshot } from './getDocumentNameFromSnapshot'
|
||||
import { getSnapshotFromR2, getSnapshotFromSupabase } from './getRoomSnapshot'
|
||||
import { getFileSnapshot } from './tla/getFileSnapshot'
|
||||
import { getPublishedRoomSnapshot } from './tla/getPublishedFile'
|
||||
import { isFileAnonymouslyViewable } from './tla/getSharedFile'
|
||||
import { resolveThumbnailBoard } from './tla/thumbnailRender'
|
||||
|
||||
// These mirror the static social preview metadata in apps/dotcom/client/index.html. They are used
|
||||
// as fallbacks so that bot-rendered previews stay consistent with the rest of the site.
|
||||
const SOCIAL_PREVIEW_DESCRIPTION = 'A free and instant collaborative whiteboarding tool.'
|
||||
const SOCIAL_PREVIEW_IMAGE = 'https://www.tldraw.com/social-og.png'
|
||||
const SOCIAL_PREVIEW_IMAGE_ALT = 'A collaborative whiteboarding tool interface'
|
||||
const SOCIAL_PREVIEW_TWITTER_IMAGE = 'https://www.tldraw.com/social-twitter.png'
|
||||
|
||||
/** Formats the preview title for a board: `<name> • tldraw.com`, or `tldraw.com` when unnamed. */
|
||||
function formatSocialTitle(name: string | null): string {
|
||||
return name ? `${name} • tldraw.com` : 'tldraw.com'
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves a minimal HTML document with social preview (Open Graph / Twitter card) metadata for a
|
||||
* board. Social crawlers (Slack, Discord, Twitter, Facebook, etc.) don't run JavaScript, so the
|
||||
* single-page app's runtime title updates never reach them. Instead, Vercel routes those crawlers
|
||||
* here based on their user-agent, and we render the board's name into the preview title. tldraw's
|
||||
* own link-unfurl bot is routed here the same way, so pasting a board link into a tldraw canvas
|
||||
* picks the name up from this page's metadata too.
|
||||
*
|
||||
* Humans are never routed here — they get the regular single-page app.
|
||||
*/
|
||||
export async function getSocialPreview(request: IRequest, env: Environment): Promise<Response> {
|
||||
const { prefix, slug } = request.params
|
||||
if (!prefix || !slug) {
|
||||
return html(renderSocialPreview(null))
|
||||
}
|
||||
|
||||
// Look up the name and resolve the thumbnail URL in parallel. Both are best-effort: if either
|
||||
// throws we fall back to the generic preview rather than failing the request — a crawler should
|
||||
// always get _something_.
|
||||
const [name, boardImageUrl] = await Promise.all([
|
||||
getBoardName(env, prefix, slug).catch(() => null),
|
||||
getBoardOgImageUrl(request, env, prefix, slug).catch(() => null),
|
||||
])
|
||||
|
||||
return html(renderSocialPreview(name, boardImageUrl))
|
||||
}
|
||||
|
||||
// Shared files (`/f/`) and published boards (`/p/`) get a live board thumbnail as their preview
|
||||
// image. We only emit the thumbnail URL when the board actually resolves to a public, renderable
|
||||
// board — the same gate the image route itself applies (resolveThumbnailBoard). For everything else
|
||||
// that route has no picture of its own to serve and redirects to the site-wide default, so pointing
|
||||
// at it would cost a crawler an extra hop to reach the image this file can name directly.
|
||||
async function getBoardOgImageUrl(
|
||||
request: IRequest,
|
||||
env: Environment,
|
||||
prefix: string,
|
||||
slug: string
|
||||
): Promise<string | null> {
|
||||
const kind = ogBoardKindForPrefix(prefix)
|
||||
if (!kind) return null
|
||||
const resolved = await resolveThumbnailBoard(env, kind, slug, { access: 'public' })
|
||||
if (!resolved.ok) return null
|
||||
return `${getPublicOrigin(request, env)}/api/app/social-preview/${prefix}/${encodeURIComponent(slug)}/image`
|
||||
}
|
||||
|
||||
function ogBoardKindForPrefix(prefix: string): ThumbnailBoardKind | null {
|
||||
if (prefix === FILE_PREFIX) return 'shared_file'
|
||||
if (prefix === PUBLISH_PREFIX) return 'published'
|
||||
return null
|
||||
}
|
||||
|
||||
async function getBoardName(
|
||||
env: Environment,
|
||||
prefix: string,
|
||||
slug: string
|
||||
): Promise<string | null> {
|
||||
switch (prefix) {
|
||||
case FILE_PREFIX:
|
||||
return await getTlaFileName(env, slug)
|
||||
case PUBLISH_PREFIX:
|
||||
return await getPublishedFileName(env, slug)
|
||||
case SNAPSHOT_PREFIX:
|
||||
return await getSnapshotName(env, slug)
|
||||
case ROOM_PREFIX:
|
||||
return await getLegacyRoomName(env, slug, ROOM_OPEN_MODE.READ_WRITE)
|
||||
case READ_ONLY_PREFIX:
|
||||
return await getLegacyRoomName(env, slug, ROOM_OPEN_MODE.READ_ONLY)
|
||||
case READ_ONLY_LEGACY_PREFIX:
|
||||
return await getLegacyRoomName(env, slug, ROOM_OPEN_MODE.READ_ONLY_LEGACY)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Live multiplayer files (`/f/:slug`). Only surface the name for shared files so we don't leak the
|
||||
// names of private boards into link previews.
|
||||
async function getTlaFileName(env: Environment, slug: string): Promise<string | null> {
|
||||
const db = createPostgresConnectionPool(env, 'getSocialPreview')
|
||||
let file
|
||||
try {
|
||||
file = await db
|
||||
.selectFrom('file')
|
||||
.select(['id', 'name', 'shared', 'isDeleted'])
|
||||
.where('id', '=', slug)
|
||||
.executeTakeFirst()
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
// The same gate the OG image on this card goes through, so a trashed (or test) board can't keep
|
||||
// unfurling its name after the room itself started answering not-found.
|
||||
if (!file || !isFileAnonymouslyViewable(file)) return null
|
||||
|
||||
const name = cleanName(file.name)
|
||||
if (name) return name
|
||||
|
||||
// The file has no name set, so fall back to the editor document name — this matches the in-app
|
||||
// title, which uses the file name and then the document name. It lives on the document record in
|
||||
// the file's persisted room snapshot.
|
||||
return getDocumentNameFromSnapshot(await getFileSnapshot(env, slug, true))
|
||||
}
|
||||
|
||||
// Published files (`/p/:slug`). The published page serves a frozen snapshot from R2, and that's what
|
||||
// viewers see, so we take the name from the snapshot's document record rather than the live
|
||||
// `file.name` (which can differ after a rename without republish). getPublishedRoomSnapshot also
|
||||
// enforces that the file is actually published.
|
||||
async function getPublishedFileName(env: Environment, slug: string): Promise<string | null> {
|
||||
const snapshot = await getPublishedRoomSnapshot(env, slug)
|
||||
return getDocumentNameFromSnapshot(snapshot)
|
||||
}
|
||||
|
||||
// Snapshot links (`/s/:slug`). The name lives in the snapshot's document record. Mirrors
|
||||
// getRoomSnapshot: read from R2 first, then fall back to Supabase for older snapshots.
|
||||
async function getSnapshotName(env: Environment, slug: string): Promise<string | null> {
|
||||
const data = await getSnapshotFromR2(env, slug)
|
||||
if (data) return getDocumentNameFromSnapshot(data)
|
||||
|
||||
const supabase = createSupabaseClient(env)
|
||||
if (!supabase) return null
|
||||
return getDocumentNameFromSnapshot(await getSnapshotFromSupabase(supabase, env, slug))
|
||||
}
|
||||
|
||||
// Legacy multiplayer rooms (`/r/`, `/ro/`, `/v/`). The persisted room snapshot lives in R2 and the
|
||||
// name is stored on its document record.
|
||||
async function getLegacyRoomName(
|
||||
env: Environment,
|
||||
slug: string,
|
||||
roomOpenMode: RoomOpenMode
|
||||
): Promise<string | null> {
|
||||
const roomId = await getSlug(env, slug, roomOpenMode)
|
||||
if (!roomId) return null
|
||||
return getDocumentNameFromSnapshot(await getFileSnapshot(env, roomId, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the social preview HTML for a board. When the board has a name we use `<name> •
|
||||
* tldraw.com` as the title, otherwise we fall back to `tldraw.com`. When the board kind supports
|
||||
* rendered OG images (`boardImageUrl` is set) the preview uses the board's own thumbnail with a
|
||||
* large image card; otherwise it keeps the static site-wide images.
|
||||
*
|
||||
* The body redirects to the board with the bypass param set: some in-app browsers used by real
|
||||
* people carry a crawler token in their user-agent (WhatsApp, Pinterest), so they land here too.
|
||||
* The crawlers we target don't run JavaScript, so they only see the metadata; humans get bounced
|
||||
* straight back to the board. Deliberately a script rather than a meta refresh — several crawlers
|
||||
* follow meta refresh redirects without running JavaScript, which would send them to the app shell
|
||||
* and lose the board name.
|
||||
*/
|
||||
export function renderSocialPreview(
|
||||
name: string | null,
|
||||
boardImageUrl: string | null = null
|
||||
): string {
|
||||
const escapedTitle = escapeHtml(formatSocialTitle(name))
|
||||
const description = escapeHtml(SOCIAL_PREVIEW_DESCRIPTION)
|
||||
const ogImage = escapeHtml(boardImageUrl ?? SOCIAL_PREVIEW_IMAGE)
|
||||
const twitterImage = escapeHtml(boardImageUrl ?? SOCIAL_PREVIEW_TWITTER_IMAGE)
|
||||
const twitterCard = boardImageUrl ? 'summary_large_image' : 'summary'
|
||||
const ogImageDimensions = boardImageUrl
|
||||
? `
|
||||
<meta property="og:image:width" content="${DEFAULT_THUMBNAIL_WIDTH}" />
|
||||
<meta property="og:image:height" content="${DEFAULT_THUMBNAIL_HEIGHT}" />`
|
||||
: ''
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapedTitle}</title>
|
||||
<meta name="description" content="${description}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="tldraw" />
|
||||
<meta property="og:title" content="${escapedTitle}" />
|
||||
<meta property="og:description" content="${description}" />
|
||||
<meta property="og:image" content="${ogImage}" />${ogImageDimensions}
|
||||
<meta property="og:image:alt" content="${escapeHtml(SOCIAL_PREVIEW_IMAGE_ALT)}" />
|
||||
<meta name="twitter:card" content="${twitterCard}" />
|
||||
<meta name="twitter:title" content="${escapedTitle}" />
|
||||
<meta name="twitter:description" content="${description}" />
|
||||
<meta name="twitter:image" content="${twitterImage}" />
|
||||
<meta name="twitter:image:alt" content="${escapeHtml(SOCIAL_PREVIEW_IMAGE_ALT)}" />
|
||||
<meta name="twitter:creator" content="@tldraw" />
|
||||
</head>
|
||||
<body>
|
||||
<a href="?${SOCIAL_PREVIEW_BYPASS_PARAM}=1">Open this board</a>
|
||||
<script>
|
||||
var url = new URL(location.href)
|
||||
// only redirect if the bypass param isn't already set, so a routing misconfiguration
|
||||
// can't reload this page forever.
|
||||
if (!url.searchParams.get(${JSON.stringify(SOCIAL_PREVIEW_BYPASS_PARAM)})) {
|
||||
// the '1' is load-bearing: an empty value reads as "param absent" to Vercel's
|
||||
// missing-matching and the redirect would loop. See SOCIAL_PREVIEW_BYPASS_PARAM.
|
||||
url.searchParams.set(${JSON.stringify(SOCIAL_PREVIEW_BYPASS_PARAM)}, '1')
|
||||
location.replace(url)
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function html(body: string): Response {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
// Allow crawlers to cache previews briefly. This means a board's name can be served for
|
||||
// up to two minutes after it's unshared — accepted, because crawler-side preview caches
|
||||
// (Facebook, Slack, Discord) independently retain unfurled names for days regardless of
|
||||
// this header, so shortening it buys no real privacy. Vercel's edge does not cache these
|
||||
// responses (plain max-age without s-maxage passes through), so this only affects the
|
||||
// requesting client's own cache.
|
||||
'cache-control': 'public, max-age=120',
|
||||
},
|
||||
})
|
||||
}
|
||||
26
apps/dotcom/sync-worker/src/routes/joinExistingRoom.ts
Normal file
26
apps/dotcom/sync-worker/src/routes/joinExistingRoom.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { notFound } from '@tldraw/worker-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
import { getRoomDurableObjectId } from '../utils/durableObjects'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||
import { getSlug } from '../utils/roomOpenMode'
|
||||
|
||||
export async function joinExistingRoom(
|
||||
request: IRequest,
|
||||
env: Environment,
|
||||
roomOpenMode: RoomOpenMode
|
||||
): Promise<Response> {
|
||||
const roomId = await getSlug(env, request.params.roomId, roomOpenMode)
|
||||
if (!roomId) return notFound()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
// This needs to be a websocket request!
|
||||
if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
||||
// Set up the durable object for this room
|
||||
const id = getRoomDurableObjectId(env, roomId)
|
||||
return env.TLDR_DOC.get(id).fetch(request)
|
||||
}
|
||||
|
||||
return notFound()
|
||||
}
|
||||
224
apps/dotcom/sync-worker/src/routes/submitFeedback.ts
Normal file
224
apps/dotcom/sync-worker/src/routes/submitFeedback.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { MAX_PROBLEM_DESCRIPTION_LENGTH, SubmitFeedbackRequestBody } from '@tldraw/dotcom-shared'
|
||||
import { assert } from '@tldraw/utils'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { createPostgresConnectionPool } from '../postgres'
|
||||
import { Environment } from '../types'
|
||||
import { isRateLimited } from '../utils/rateLimit'
|
||||
import { requireAuth } from '../utils/tla/getAuth'
|
||||
|
||||
export async function submitFeedback(req: IRequest, env: Environment) {
|
||||
const webhookUrl = env.DISCORD_FEEDBACK_WEBHOOK_URL
|
||||
if (!webhookUrl) {
|
||||
assert(env.IS_LOCAL, 'DISCORD_FEEDBACK_WEBHOOK_URL is not set')
|
||||
}
|
||||
const auth = await requireAuth(req, env)
|
||||
const isLimited = await isRateLimited(env, `submit-feedback:${auth.userId}`)
|
||||
if (isLimited) {
|
||||
return new Response('Rate limited', { status: 429 })
|
||||
}
|
||||
let description: string
|
||||
let allowContact: boolean
|
||||
let url: string
|
||||
try {
|
||||
const data = (await req.json()) as SubmitFeedbackRequestBody
|
||||
description = data.description?.trim()
|
||||
allowContact = data.allowContact
|
||||
url = data.url
|
||||
if (typeof description !== 'string') {
|
||||
throw new Error('Invalid description')
|
||||
}
|
||||
if (typeof allowContact !== 'boolean') {
|
||||
throw new Error('Invalid allowContact')
|
||||
}
|
||||
if (typeof url !== 'string') {
|
||||
throw new Error('Invalid url')
|
||||
}
|
||||
} catch (_e) {
|
||||
return new Response('Invalid JSON', { status: 400 })
|
||||
}
|
||||
|
||||
const userId = allowContact ? await getUserEmail(env, auth.userId) : '~' + auth.userId.slice(-4)
|
||||
|
||||
// Create a Plain thread if configured. Failures are caught so the Discord post still goes through.
|
||||
let plainThreadUrl: string | null = null
|
||||
if (env.PLAIN_API_KEY) {
|
||||
try {
|
||||
const email = allowContact ? userId : null
|
||||
plainThreadUrl = await createPlainThread(env, { description, url, email })
|
||||
} catch (e) {
|
||||
console.error('Failed to create Plain thread:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const extraLines = [
|
||||
url ? `URL: ${url}` : null,
|
||||
plainThreadUrl ? `Plain: ${plainThreadUrl}` : null,
|
||||
].filter(Boolean)
|
||||
const embedDescription = extraLines.length
|
||||
? `${description}\n\n${extraLines.join('\n')}`
|
||||
: description
|
||||
|
||||
const payload = {
|
||||
username: `Feedback (${env.WORKER_NAME ?? 'localhost'})`,
|
||||
content: `User (${userId}) reported a problem`,
|
||||
embeds:
|
||||
description.length > MAX_PROBLEM_DESCRIPTION_LENGTH
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
description: embedDescription,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (webhookUrl) {
|
||||
const formData = new FormData()
|
||||
formData.append('payload_json', JSON.stringify(payload))
|
||||
if (description.length > MAX_PROBLEM_DESCRIPTION_LENGTH) {
|
||||
const descriptionBlob = new Blob([description], { type: 'text/plain' })
|
||||
formData.append('description.txt', descriptionBlob)
|
||||
}
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to send feedback to Discord: ${res.status} ${await res.text()}`)
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Feedback submitted:', payload)
|
||||
}
|
||||
|
||||
return new Response('OK')
|
||||
}
|
||||
|
||||
async function getUserEmail(env: Environment, userId: string) {
|
||||
const pg = createPostgresConnectionPool(env, 'submitFeedback')
|
||||
try {
|
||||
const { email } = await pg
|
||||
.selectFrom('user')
|
||||
.where('id', '=', userId)
|
||||
.select('email')
|
||||
.executeTakeFirstOrThrow()
|
||||
return email
|
||||
} finally {
|
||||
await pg.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
const UPSERT_CUSTOMER_MUTATION = `
|
||||
mutation upsertCustomer($input: UpsertCustomerInput!) {
|
||||
upsertCustomer(input: $input) {
|
||||
customer { id }
|
||||
error { message code }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CREATE_THREAD_MUTATION = `
|
||||
mutation createThread($input: CreateThreadInput!) {
|
||||
createThread(input: $input) {
|
||||
thread { id }
|
||||
error { message code }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface PlainMutationResult {
|
||||
error?: { message: string; code: string }
|
||||
}
|
||||
|
||||
async function plainMutation<T extends PlainMutationResult>(
|
||||
env: Environment,
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
resultKey: string
|
||||
): Promise<T> {
|
||||
const res = await fetch('https://core-api.uk.plain.com/graphql/v1', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${env.PLAIN_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Plain API returned ${res.status}: ${await res.text()}`)
|
||||
}
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data?: Record<string, unknown>
|
||||
errors?: { message: string }[]
|
||||
}
|
||||
if (body.errors?.length) {
|
||||
throw new Error(`Plain GraphQL error: ${body.errors[0].message}`)
|
||||
}
|
||||
|
||||
const result = body.data?.[resultKey] as T | undefined
|
||||
if (result?.error) {
|
||||
throw new Error(`Plain ${resultKey} error: ${result.error.message} (${result.error.code})`)
|
||||
}
|
||||
if (!result) {
|
||||
throw new Error(`Plain ${resultKey} returned no result`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function upsertPlainCustomer(env: Environment, email: string): Promise<string> {
|
||||
const result = await plainMutation<PlainMutationResult & { customer?: { id: string } }>(
|
||||
env,
|
||||
UPSERT_CUSTOMER_MUTATION,
|
||||
{
|
||||
input: {
|
||||
identifier: { emailAddress: email },
|
||||
onCreate: { fullName: email, email: { email, isVerified: false } },
|
||||
onUpdate: {},
|
||||
},
|
||||
},
|
||||
'upsertCustomer'
|
||||
)
|
||||
|
||||
if (!result.customer?.id) {
|
||||
throw new Error('Plain upsertCustomer returned no customer id')
|
||||
}
|
||||
return result.customer.id
|
||||
}
|
||||
|
||||
async function createPlainThread(
|
||||
env: Environment,
|
||||
{ description, url, email }: { description: string; url: string; email: string | null }
|
||||
): Promise<string> {
|
||||
const customerEmail = email ?? 'anonymous-feedback@tldraw.com'
|
||||
const customerId = await upsertPlainCustomer(env, customerEmail)
|
||||
|
||||
const result = await plainMutation<PlainMutationResult & { thread?: { id: string } }>(
|
||||
env,
|
||||
CREATE_THREAD_MUTATION,
|
||||
{
|
||||
input: {
|
||||
customerIdentifier: { customerId },
|
||||
title: 'tldraw.com feedback',
|
||||
description: description.length > 200 ? description.slice(0, 200) + '…' : description,
|
||||
components: [
|
||||
{ componentText: { text: url ? `${description}\n\nURL: ${url}` : description } },
|
||||
],
|
||||
...(env.PLAIN_LABEL_TYPE_ID && { labelTypeIds: [env.PLAIN_LABEL_TYPE_ID] }),
|
||||
},
|
||||
},
|
||||
'createThread'
|
||||
)
|
||||
|
||||
if (!result.thread?.id) {
|
||||
throw new Error('Plain createThread returned no thread id')
|
||||
}
|
||||
|
||||
const threadId = result.thread.id
|
||||
if (env.PLAIN_WORKSPACE_ID) {
|
||||
return `https://app.plain.com/workspace/${env.PLAIN_WORKSPACE_ID}/thread/${threadId}/`
|
||||
}
|
||||
return `https://app.plain.com/thread/${threadId}`
|
||||
}
|
||||
281
apps/dotcom/sync-worker/src/routes/sweepVersionChains.test.ts
Normal file
281
apps/dotcom/sync-worker/src/routes/sweepVersionChains.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { UnknownRecord } from '@tldraw/store'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createFakeR2 } from '../test/fakeR2'
|
||||
import { Environment } from '../types'
|
||||
import { ChainState, PendingDelta } from '../versionChain'
|
||||
import { writeVersionChainEntry } from '../versionChainWrite'
|
||||
import { sweepVersionChains } from './sweepVersionChains'
|
||||
|
||||
const files = vi.hoisted(() => ({ rows: [] as Array<{ id: string; updatedAt: number }> }))
|
||||
const destroy = vi.hoisted(() => vi.fn())
|
||||
// Lets one test spend the whole read budget on a single room. Reaching 800 reads for real would
|
||||
// mean seeding ~800 versions; everything else runs the real verifier.
|
||||
const verifier = vi.hoisted(() => ({ stub: null as null | (() => Promise<any>) }))
|
||||
|
||||
vi.mock('./verifyVersionChain', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./verifyVersionChain')>()
|
||||
return {
|
||||
...actual,
|
||||
verifyRoomVersions: async (args: any) =>
|
||||
verifier.stub ? verifier.stub() : actual.verifyRoomVersions(args),
|
||||
}
|
||||
})
|
||||
|
||||
// The sweep's only Postgres use is one keyset page of the file table, so the pool is faked down to
|
||||
// the builder methods that page uses.
|
||||
vi.mock('../postgres', () => ({
|
||||
createPostgresConnectionPool: () => {
|
||||
const builder: any = {
|
||||
select: () => builder,
|
||||
where: () => builder,
|
||||
orderBy: () => builder,
|
||||
limit: (n: number) => {
|
||||
builder._limit = n
|
||||
return builder
|
||||
},
|
||||
execute: async () => files.rows.slice(0, builder._limit ?? files.rows.length),
|
||||
}
|
||||
return { selectFrom: () => builder, destroy }
|
||||
},
|
||||
}))
|
||||
|
||||
function snapshot(clock: number, ids: string[]): RoomSnapshot {
|
||||
return {
|
||||
clock,
|
||||
documentClock: clock,
|
||||
documents: ids.map((id) => ({
|
||||
state: { id, typeName: 'shape' } as UnknownRecord,
|
||||
lastChangedClock: clock,
|
||||
})),
|
||||
tombstones: {},
|
||||
tombstoneHistoryStartsAtClock: 0,
|
||||
schema: { schemaVersion: 2, sequences: {} } as any,
|
||||
}
|
||||
}
|
||||
|
||||
async function seedChain(
|
||||
chainBucket: R2Bucket,
|
||||
legacyBucket: R2Bucket,
|
||||
roomKey: string,
|
||||
versions: RoomSnapshot[]
|
||||
) {
|
||||
let chain: ChainState | null = null
|
||||
let pending: PendingDelta[] = []
|
||||
for (let i = 0; i < versions.length; i++) {
|
||||
const iso = `2026-09-01T00:00:0${i}.000Z`
|
||||
const result = await writeVersionChainEntry({
|
||||
bucket: chainBucket,
|
||||
roomKey,
|
||||
iso,
|
||||
chain,
|
||||
pending,
|
||||
previous: i === 0 ? null : versions[i - 1],
|
||||
next: versions[i],
|
||||
now: i * 1000,
|
||||
})
|
||||
chain = result.chain
|
||||
pending = result.pending
|
||||
await legacyBucket.put(`${roomKey}/${iso}`, JSON.stringify(versions[i]))
|
||||
}
|
||||
}
|
||||
|
||||
function env(chainBucket: R2Bucket, legacyBucket: R2Bucket): Environment {
|
||||
return {
|
||||
ROOMS_HISTORY: chainBucket,
|
||||
ROOMS_HISTORY_EPHEMERAL: legacyBucket,
|
||||
MEASURE: undefined,
|
||||
} as unknown as Environment
|
||||
}
|
||||
|
||||
const versions = [snapshot(1, ['shape:a']), snapshot(2, ['shape:a', 'shape:b'])]
|
||||
|
||||
describe('sweepVersionChains', () => {
|
||||
beforeEach(() => {
|
||||
files.rows = []
|
||||
destroy.mockClear()
|
||||
verifier.stub = null
|
||||
})
|
||||
|
||||
it('reports a healthy room as verified with no failures', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedChain(chainBucket, legacyBucket, 'app_rooms/file1', versions)
|
||||
files.rows = [{ id: 'file1', updatedAt: 100 }]
|
||||
|
||||
const result = await sweepVersionChains({
|
||||
env: env(chainBucket, legacyBucket),
|
||||
rooms: 10,
|
||||
readsPerRoom: 50,
|
||||
})
|
||||
|
||||
expect(result.swept).toBe(1)
|
||||
expect(result.verified).toBe(1)
|
||||
expect(result.failed).toBe(0)
|
||||
expect(result.failures).toEqual([])
|
||||
// A short page means the table ended, so there is nothing to resume from.
|
||||
expect(result.nextCursor).toBeNull()
|
||||
})
|
||||
|
||||
it('counts a room with no chain as swept but not verified', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
files.rows = [{ id: 'file1', updatedAt: 100 }]
|
||||
|
||||
const result = await sweepVersionChains({
|
||||
env: env(chainBucket, legacyBucket),
|
||||
rooms: 10,
|
||||
readsPerRoom: 50,
|
||||
})
|
||||
|
||||
// The distinction that stops a partial rollout's empty rooms reading as a clean fleet.
|
||||
expect(result.swept).toBe(1)
|
||||
expect(result.verified).toBe(0)
|
||||
expect(result.failed).toBe(0)
|
||||
})
|
||||
|
||||
it('reports a room whose chain disagrees with its legacy copy', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedChain(chainBucket, legacyBucket, 'app_rooms/file1', versions)
|
||||
// Rewrite one full copy so the chain replay and the legacy record disagree.
|
||||
await legacyBucket.put(
|
||||
'app_rooms/file1/2026-09-01T00:00:01.000Z',
|
||||
JSON.stringify(snapshot(2, ['shape:a', 'shape:z']))
|
||||
)
|
||||
files.rows = [{ id: 'file1', updatedAt: 100 }]
|
||||
|
||||
const result = await sweepVersionChains({
|
||||
env: env(chainBucket, legacyBucket),
|
||||
rooms: 10,
|
||||
readsPerRoom: 50,
|
||||
})
|
||||
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.failures).toEqual([
|
||||
{ fileId: 'file1', reason: 'mismatch', detail: 'mismatch at 2026-09-01T00:00:01.000Z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not fail a room whose legacy copy cannot be read', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedChain(chainBucket, legacyBucket, 'app_rooms/file1', versions)
|
||||
// Corrupt one full copy: the comparison for that version cannot run, but the chain itself
|
||||
// replays and hashes clean.
|
||||
await legacyBucket.put('app_rooms/file1/2026-09-01T00:00:01.000Z', 'not json')
|
||||
files.rows = [{ id: 'file1', updatedAt: 100 }]
|
||||
const measure = { writeDataPoint: vi.fn() }
|
||||
|
||||
const result = await sweepVersionChains({
|
||||
env: { ...env(chainBucket, legacyBucket), MEASURE: measure } as unknown as Environment,
|
||||
rooms: 10,
|
||||
readsPerRoom: 50,
|
||||
})
|
||||
|
||||
// A legacy object that will not read is not a broken chain, so it must not count as a failure
|
||||
// or emit the `fail` datapoint the verify-failures alert pages on.
|
||||
expect(result.failed).toBe(0)
|
||||
expect(result.failures).toEqual([])
|
||||
expect(result.legacyReadFailures).toBe(1)
|
||||
const verifyPoints = measure.writeDataPoint.mock.calls
|
||||
.map(([point]) => point.blobs)
|
||||
.filter((blobs) => blobs[0] === 'version_chain_verify')
|
||||
expect(verifyPoints).toEqual([])
|
||||
})
|
||||
|
||||
it('skips test files without reading R2 and still advances the cursor past them', async () => {
|
||||
const legacyBucket = createFakeR2()
|
||||
const list = vi.fn()
|
||||
files.rows = [{ id: 'test_file1', updatedAt: 100 }]
|
||||
|
||||
const result = await sweepVersionChains({
|
||||
env: env({ list } as unknown as R2Bucket, legacyBucket),
|
||||
rooms: 1,
|
||||
readsPerRoom: 50,
|
||||
})
|
||||
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
expect(result.swept).toBe(0)
|
||||
// A full page of test files must not park the walk on the same offset forever.
|
||||
expect(result.nextCursor).toBe('100_test_file1')
|
||||
})
|
||||
|
||||
it('returns a cursor when the page is full and resumes from it', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedChain(chainBucket, legacyBucket, 'app_rooms/file1', versions)
|
||||
files.rows = [
|
||||
{ id: 'file1', updatedAt: 200 },
|
||||
{ id: 'file2', updatedAt: 100 },
|
||||
]
|
||||
|
||||
const first = await sweepVersionChains({
|
||||
env: env(chainBucket, legacyBucket),
|
||||
rooms: 1,
|
||||
readsPerRoom: 50,
|
||||
})
|
||||
|
||||
expect(first.swept).toBe(1)
|
||||
expect(first.nextCursor).toBe('200_file1')
|
||||
})
|
||||
|
||||
it('resumes at the room the read budget stopped on rather than past it', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
files.rows = [
|
||||
{ id: 'file1', updatedAt: 300 },
|
||||
{ id: 'file2', updatedAt: 200 },
|
||||
{ id: 'file3', updatedAt: 100 },
|
||||
]
|
||||
verifier.stub = async () => ({
|
||||
checked: 1,
|
||||
replayed: 1,
|
||||
reads: 900,
|
||||
complete: true,
|
||||
mismatches: [],
|
||||
errors: [],
|
||||
legacyReadFailures: [],
|
||||
})
|
||||
|
||||
const result = await sweepVersionChains({
|
||||
env: env(chainBucket, legacyBucket),
|
||||
rooms: 3,
|
||||
readsPerRoom: 50,
|
||||
})
|
||||
|
||||
expect(result.swept).toBe(1)
|
||||
// file2 was never looked at, and continuation excludes the cursor: pointing it at file2 would
|
||||
// drop that room from the sweep for good.
|
||||
expect(result.nextCursor).toBe('300_file1')
|
||||
})
|
||||
|
||||
it('keeps the cursor when the budget stops the walk on the last page', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
files.rows = [
|
||||
{ id: 'file1', updatedAt: 300 },
|
||||
{ id: 'file2', updatedAt: 200 },
|
||||
]
|
||||
verifier.stub = async () => ({
|
||||
checked: 1,
|
||||
replayed: 1,
|
||||
reads: 900,
|
||||
complete: true,
|
||||
mismatches: [],
|
||||
errors: [],
|
||||
legacyReadFailures: [],
|
||||
})
|
||||
|
||||
const result = await sweepVersionChains({
|
||||
env: env(chainBucket, legacyBucket),
|
||||
rooms: 10,
|
||||
readsPerRoom: 50,
|
||||
})
|
||||
|
||||
// A short page usually means the walk is done, but the budget left file2 unswept, so
|
||||
// reporting completion here would retire the sweep with a room it never read.
|
||||
expect(result.swept).toBe(1)
|
||||
expect(result.nextCursor).toBe('300_file1')
|
||||
})
|
||||
})
|
||||
209
apps/dotcom/sync-worker/src/routes/sweepVersionChains.ts
Normal file
209
apps/dotcom/sync-worker/src/routes/sweepVersionChains.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { IRequest, json } from 'itty-router'
|
||||
import { createPostgresConnectionPool } from '../postgres'
|
||||
import { getR2KeyForRoom } from '../r2'
|
||||
import { Environment } from '../types'
|
||||
import { writeDataPoint } from '../utils/analytics'
|
||||
import { isTestFile } from '../utils/tla/isTestFile'
|
||||
import { verifyRoomVersions } from './verifyVersionChain'
|
||||
|
||||
/** One room the sweep could not verify clean. `detail` is the first mismatch or error message. */
|
||||
export interface SweepFailure {
|
||||
fileId: string
|
||||
reason: 'mismatch' | 'chain-error'
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface SweepResult {
|
||||
/** Rooms examined this batch. */
|
||||
swept: number
|
||||
/**
|
||||
* Rooms that actually had a chain to replay. Counted apart from `swept` because during a
|
||||
* partial rollout most files have no chain at all, and a batch of those would otherwise report
|
||||
* as a clean sweep having verified nothing — the fleet-wide version of `VerifyResult.complete`.
|
||||
*/
|
||||
verified: number
|
||||
/** Rooms whose per-room read budget ran out with entries unvisited. */
|
||||
incomplete: number
|
||||
failed: number
|
||||
/** Versions whose legacy copy would not read. Not failures: the chain itself replayed clean. */
|
||||
legacyReadFailures: number
|
||||
reads: number
|
||||
/** Pass back as `cursor` to continue; null when the batch reached the end of the table. */
|
||||
nextCursor: string | null
|
||||
failures: SweepFailure[]
|
||||
}
|
||||
|
||||
// Deliberately small. A sweep runs in a single worker invocation against the ~1000 subrequest cap,
|
||||
// and every R2 read a room spends counts against it, so the ceiling that matters is
|
||||
// rooms x reads-per-room. Replaying deltas is also CPU-bound (a hash per version over the whole
|
||||
// board), which is what keeps the room count low rather than the read budget alone.
|
||||
const DEFAULT_ROOMS_PER_SWEEP = 10
|
||||
const MAX_ROOMS_PER_SWEEP = 50
|
||||
const DEFAULT_READS_PER_ROOM = 30
|
||||
const MAX_READS_PER_ROOM = 200
|
||||
// Leaves headroom under the subrequest cap for the Postgres connection and the response.
|
||||
const SWEEP_READ_BUDGET = 800
|
||||
|
||||
function clampNumber(raw: unknown, fallback: number, max: number): number {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback
|
||||
return Math.min(Math.floor(value), max)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyset cursor over (updatedAt, id). Both halves are needed: files sharing an `updatedAt` would
|
||||
* otherwise be skipped or repeated across batches depending on which side of the page they fell.
|
||||
*/
|
||||
function encodeCursor(updatedAt: number, id: string): string {
|
||||
return `${updatedAt}_${id}`
|
||||
}
|
||||
|
||||
function decodeCursor(raw: string | undefined): { updatedAt: number; id: string } | null {
|
||||
if (!raw) return null
|
||||
const separator = raw.indexOf('_')
|
||||
if (separator <= 0) return null
|
||||
const updatedAt = Number(raw.slice(0, separator))
|
||||
const id = raw.slice(separator + 1)
|
||||
if (!Number.isFinite(updatedAt) || !id) return null
|
||||
return { updatedAt, id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the version chains of a batch of files, newest-touched first.
|
||||
*
|
||||
* Exists because `verifyVersionChainRoute` answers for one room an operator already suspects, and
|
||||
* nothing else looks: a delta bug that writes cleanly and only fails on read is invisible until a
|
||||
* user opens history. Ordering by `updatedAt` puts the rooms the current write path just touched
|
||||
* at the front, so a bug reaching production is caught in the first batch rather than after a walk
|
||||
* through dormant history.
|
||||
*
|
||||
* Reads R2 directly and never wakes the file's durable object — the chain lives in the bucket, so
|
||||
* sweeping cannot evict live rooms or contend with their persists.
|
||||
*/
|
||||
export async function sweepVersionChains({
|
||||
env,
|
||||
cursor,
|
||||
rooms,
|
||||
readsPerRoom,
|
||||
}: {
|
||||
env: Environment
|
||||
cursor?: string
|
||||
rooms: number
|
||||
readsPerRoom: number
|
||||
}): Promise<SweepResult> {
|
||||
const db = createPostgresConnectionPool(env, '/app/admin/version-chain/sweep')
|
||||
let candidates: Array<{ id: string; updatedAt: number }>
|
||||
try {
|
||||
const start = decodeCursor(cursor)
|
||||
let query = db
|
||||
.selectFrom('file')
|
||||
.select(['id', 'updatedAt'])
|
||||
.where('isDeleted', '=', false)
|
||||
.orderBy('updatedAt', 'desc')
|
||||
.orderBy('id', 'desc')
|
||||
.limit(rooms)
|
||||
if (start) {
|
||||
query = query.where((eb) =>
|
||||
eb.or([
|
||||
eb('updatedAt', '<', start.updatedAt),
|
||||
eb.and([eb('updatedAt', '=', start.updatedAt), eb('id', '<', start.id)]),
|
||||
])
|
||||
)
|
||||
}
|
||||
candidates = await query.execute()
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
|
||||
const result: SweepResult = {
|
||||
swept: 0,
|
||||
verified: 0,
|
||||
incomplete: 0,
|
||||
failed: 0,
|
||||
legacyReadFailures: 0,
|
||||
reads: 0,
|
||||
nextCursor: null,
|
||||
failures: [],
|
||||
}
|
||||
|
||||
let stoppedOnBudget = false
|
||||
for (const file of candidates) {
|
||||
if (isTestFile(file.id)) {
|
||||
// A declined room still advances the cursor, or a page of test files parks the walk on the
|
||||
// same offset forever.
|
||||
result.nextCursor = encodeCursor(file.updatedAt, file.id)
|
||||
continue
|
||||
}
|
||||
if (result.reads >= SWEEP_READ_BUDGET) {
|
||||
stoppedOnBudget = true
|
||||
break
|
||||
}
|
||||
|
||||
const verify = await verifyRoomVersions({
|
||||
chainBucket: env.ROOMS_HISTORY,
|
||||
legacyBucket: env.ROOMS_HISTORY_EPHEMERAL,
|
||||
roomKey: getR2KeyForRoom({ slug: file.id, isApp: true }),
|
||||
limit: Math.min(readsPerRoom, SWEEP_READ_BUDGET - result.reads),
|
||||
})
|
||||
|
||||
result.swept++
|
||||
result.reads += verify.reads
|
||||
if (verify.replayed > 0) result.verified++
|
||||
if (!verify.complete) result.incomplete++
|
||||
if (verify.legacyReadFailures.length > 0) {
|
||||
result.legacyReadFailures += verify.legacyReadFailures.length
|
||||
console.warn(
|
||||
`Version chain sweep could not read ${verify.legacyReadFailures.length} legacy copies. file=${file.id} first=${verify.legacyReadFailures[0].timestamp}: ${verify.legacyReadFailures[0].message}`
|
||||
)
|
||||
}
|
||||
|
||||
const reason =
|
||||
verify.errors.length > 0 ? 'chain-error' : verify.mismatches.length > 0 ? 'mismatch' : null
|
||||
if (reason) {
|
||||
result.failed++
|
||||
const detail =
|
||||
reason === 'chain-error'
|
||||
? `${verify.errors[0].timestamp}: ${verify.errors[0].message}`
|
||||
: `mismatch at ${verify.mismatches[0]}`
|
||||
result.failures.push({ fileId: file.id, reason, detail })
|
||||
// Same event and blob shape the durable object's own verify writes, so one alert covers
|
||||
// both sources. The file id goes to the log rather than the metric, matching the
|
||||
// convention that analytics blobs carry no room identifiers.
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'version_chain_verify', {
|
||||
blobs: ['fail', reason],
|
||||
})
|
||||
console.error(`Version chain sweep failed. file=${file.id} reason=${reason} ${detail}`)
|
||||
}
|
||||
|
||||
// Only once the room has been verified. Continuation excludes the cursor, so advancing past
|
||||
// the room the budget stopped on would drop it from the sweep for good — never looked at,
|
||||
// while the run still reports clean.
|
||||
result.nextCursor = encodeCursor(file.updatedAt, file.id)
|
||||
}
|
||||
|
||||
// The run itself is a datapoint: without it a sweep that stopped running, or one that only ever
|
||||
// reaches rooms with no chain, is indistinguishable from a fleet that is healthy.
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'version_chain_sweep', {
|
||||
doubles: [result.swept, result.verified, result.failed, result.incomplete, result.reads],
|
||||
})
|
||||
|
||||
// A short batch means the table ended, but only a batch that ran to completion is done: a budget
|
||||
// break leaves rooms behind the cursor even on the last page.
|
||||
if (candidates.length < rooms && !stoppedOnBudget) result.nextCursor = null
|
||||
return result
|
||||
}
|
||||
|
||||
export async function sweepVersionChainsRoute(
|
||||
request: IRequest,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
const rooms = clampNumber(request.query?.rooms, DEFAULT_ROOMS_PER_SWEEP, MAX_ROOMS_PER_SWEEP)
|
||||
const readsPerRoom = clampNumber(
|
||||
request.query?.readsPerRoom,
|
||||
DEFAULT_READS_PER_ROOM,
|
||||
MAX_READS_PER_ROOM
|
||||
)
|
||||
const cursor = typeof request.query?.cursor === 'string' ? request.query.cursor : undefined
|
||||
|
||||
return json(await sweepVersionChains({ env, cursor, rooms, readsPerRoom }))
|
||||
}
|
||||
140
apps/dotcom/sync-worker/src/routes/tla/acceptInvite.ts
Normal file
140
apps/dotcom/sync-worker/src/routes/tla/acceptInvite.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { AcceptInviteResponseBody } from '@tldraw/dotcom-shared'
|
||||
import { getIndexBelow, IndexKey } from '@tldraw/utils'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { sql } from 'kysely'
|
||||
import { createPostgresConnectionPool } from '../../postgres'
|
||||
import { Environment } from '../../types'
|
||||
import { getAuth } from '../../utils/tla/getAuth'
|
||||
import { getJoinableWorkspaceFromInvite } from '../../utils/tla/getJoinableWorkspaceFromInvite'
|
||||
|
||||
export async function acceptInvite(request: IRequest, env: Environment): Promise<Response> {
|
||||
const { token } = request.params
|
||||
if (!token) {
|
||||
return Response.json(
|
||||
{ error: true, message: 'Invite token is required' } satisfies AcceptInviteResponseBody,
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Return our own 401 body: the router's generic one has no `message`, which the client shows
|
||||
// as the toast description.
|
||||
const auth = await getAuth(request, env)
|
||||
if (!auth) {
|
||||
return Response.json(
|
||||
{ error: true, message: 'Sign in to accept this invite' } satisfies AcceptInviteResponseBody,
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const db = createPostgresConnectionPool(env, 'acceptInvite')
|
||||
|
||||
try {
|
||||
return await db.transaction().execute(async (tx) => {
|
||||
// First, validate the invite token and get workspace info
|
||||
const workspace = await getJoinableWorkspaceFromInvite(tx, token)
|
||||
|
||||
if (!workspace) {
|
||||
return Response.json(
|
||||
{
|
||||
error: true,
|
||||
message: 'Invalid or expired invite token',
|
||||
} satisfies AcceptInviteResponseBody,
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const existingMember = await tx
|
||||
.selectFrom('group_user')
|
||||
.select('userId')
|
||||
.where('groupId', '=', workspace.id)
|
||||
.where('userId', '=', auth.userId)
|
||||
.executeTakeFirst()
|
||||
|
||||
if (existingMember) {
|
||||
return Response.json({
|
||||
error: false,
|
||||
message: 'You are already a member of this group',
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
alreadyMember: true,
|
||||
} satisfies AcceptInviteResponseBody)
|
||||
}
|
||||
|
||||
// Get the user's information for the group_user record
|
||||
const user = await tx
|
||||
.selectFrom('user')
|
||||
.select(['name', 'color', 'flags'])
|
||||
.where('id', '=', auth.userId)
|
||||
.executeTakeFirst()
|
||||
|
||||
if (!user) {
|
||||
return Response.json(
|
||||
{
|
||||
error: true,
|
||||
message: 'User not found',
|
||||
} satisfies AcceptInviteResponseBody,
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
// Get the lowest index to place new group at the top
|
||||
const lowestIndexGroup = await sql<{
|
||||
index: string
|
||||
// kysely doesn't support 'collate' in the query builder, so we have to use raw sql
|
||||
// collate "C" makes it use straight up byte comparison instead of lexicographic comparison
|
||||
}>`select index from group_user where "userId" = ${auth.userId} order by index collate "C" asc limit 1`.execute(
|
||||
tx
|
||||
)
|
||||
|
||||
// Use tldraw's fractional indexing to place new group at the top
|
||||
const lowestIndex = lowestIndexGroup.rows[0]?.index as IndexKey | undefined
|
||||
// Generate a new index below the current lowest (to place at top); first group gets 'a1'
|
||||
const index = lowestIndex ? getIndexBelow(lowestIndex) : ('a1' as IndexKey)
|
||||
|
||||
// Add user to the group. Two accepts racing past the membership check above (two tabs,
|
||||
// two devices) would otherwise turn the second into a unique violation and a 500 for a
|
||||
// join that succeeded.
|
||||
const inserted = await tx
|
||||
.insertInto('group_user')
|
||||
.values({
|
||||
groupId: workspace.id,
|
||||
userId: auth.userId,
|
||||
userColor: user.color || '#000000',
|
||||
userName: user.name,
|
||||
role: 'member',
|
||||
index,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
|
||||
.executeTakeFirst()
|
||||
|
||||
if (inserted.numInsertedOrUpdatedRows === 0n) {
|
||||
return Response.json({
|
||||
error: false,
|
||||
message: 'You are already a member of this group',
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
alreadyMember: true,
|
||||
} satisfies AcceptInviteResponseBody)
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
error: false,
|
||||
message: 'Successfully joined the group',
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
success: true,
|
||||
} satisfies AcceptInviteResponseBody)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error accepting invite:', error)
|
||||
return Response.json(
|
||||
{
|
||||
error: true,
|
||||
message: 'Internal server error',
|
||||
} satisfies AcceptInviteResponseBody,
|
||||
{ status: 500 }
|
||||
)
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
}
|
||||
331
apps/dotcom/sync-worker/src/routes/tla/boardTools.test.ts
Normal file
331
apps/dotcom/sync-worker/src/routes/tla/boardTools.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BOARD_SEARCH_MAX_TERMS,
|
||||
BOARD_SEARCH_PAGE_SIZE,
|
||||
BoardSearchRow,
|
||||
compareBoardSearchOrder,
|
||||
getBoardSearchResults,
|
||||
isAfterBoardSearchCursor,
|
||||
parseSearchBoardsInput,
|
||||
} from './boardTools'
|
||||
|
||||
function parsedJson(result: ReturnType<typeof getBoardSearchResults>) {
|
||||
const block = result.content[0]
|
||||
if (block.type !== 'text') throw new Error('Expected a text block')
|
||||
return JSON.parse(block.text)
|
||||
}
|
||||
|
||||
function makeRow(overrides: Partial<BoardSearchRow> = {}): BoardSearchRow {
|
||||
return {
|
||||
id: 'board-1',
|
||||
name: 'Roadmap',
|
||||
// A board the caller made: arrival and creation are the same moment.
|
||||
arrivedAt: 1_700_000_000_000,
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_500_000,
|
||||
workspaceName: 'Design',
|
||||
source: 'owned' as const,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makePage(count: number): BoardSearchRow[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
makeRow({
|
||||
id: `board-${index}`,
|
||||
arrivedAt: 1_700_000_000_000 - index,
|
||||
createdAt: 1_700_000_000_000 - index,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('parseSearchBoardsInput', () => {
|
||||
// Listing the newest boards is the tool's other job: it is the only way for a model to answer
|
||||
// "what boards do I have?" without being handed an id first.
|
||||
it('treats a missing query as no filter', () => {
|
||||
expect(parseSearchBoardsInput({})).toEqual({ terms: [], cursor: null })
|
||||
})
|
||||
|
||||
// The wire-legal call a model makes by following the tool's own description literally: this is
|
||||
// the only tool with `required: []`, so omitted `arguments` is a legitimate "list my newest
|
||||
// boards", not a malformed call that should hit `requireArgumentsObject(undefined)`.
|
||||
it('treats wholly omitted arguments the same as an empty object', () => {
|
||||
expect(parseSearchBoardsInput(undefined)).toEqual(parseSearchBoardsInput({}))
|
||||
})
|
||||
|
||||
it('treats a whitespace-only query as no filter', () => {
|
||||
expect(parseSearchBoardsInput({ query: ' ' }).terms).toEqual([])
|
||||
})
|
||||
|
||||
// Terms are ANDed, so word order in the query need not match word order in the name.
|
||||
it('splits a query into terms on whitespace', () => {
|
||||
expect(parseSearchBoardsInput({ query: 'design system' }).terms).toEqual(['design', 'system'])
|
||||
})
|
||||
|
||||
it('refuses a non-string query', () => {
|
||||
expect(() => parseSearchBoardsInput({ query: 12 })).toThrow('query must be a string')
|
||||
})
|
||||
|
||||
// A name is a name, not a document: a pasted paragraph is a model mistake, and every term
|
||||
// becomes its own ILIKE.
|
||||
it('refuses an over-long query', () => {
|
||||
expect(() => parseSearchBoardsInput({ query: 'x'.repeat(201) })).toThrow('200 characters')
|
||||
})
|
||||
|
||||
// A query length limit alone does not bound term count, since whitespace is cheap: each term
|
||||
// becomes its own unindexable ILIKE over an already-unbounded scan.
|
||||
it('refuses more than the maximum number of terms', () => {
|
||||
const query = Array.from({ length: BOARD_SEARCH_MAX_TERMS + 1 }, (_, i) => `t${i}`).join(' ')
|
||||
expect(() => parseSearchBoardsInput({ query })).toThrow(
|
||||
`${BOARD_SEARCH_MAX_TERMS} words or fewer`
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts exactly the maximum number of terms', () => {
|
||||
const query = Array.from({ length: BOARD_SEARCH_MAX_TERMS }, (_, i) => `t${i}`).join(' ')
|
||||
expect(parseSearchBoardsInput({ query }).terms).toHaveLength(BOARD_SEARCH_MAX_TERMS)
|
||||
})
|
||||
|
||||
it('refuses arguments that are not an object', () => {
|
||||
expect(() => parseSearchBoardsInput('boards')).toThrow('Tool arguments must be an object')
|
||||
})
|
||||
|
||||
// The cursor is opaque so that its scheme can change; a model must only ever hand back one it
|
||||
// was given.
|
||||
it('round-trips a cursor from a previous result', () => {
|
||||
const first = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), []))
|
||||
expect(parseSearchBoardsInput({ cursor: first.nextCursor }).cursor).toEqual({
|
||||
arrivedAt: 1_700_000_000_000 - (BOARD_SEARCH_PAGE_SIZE - 1),
|
||||
id: `board-${BOARD_SEARCH_PAGE_SIZE - 1}`,
|
||||
})
|
||||
})
|
||||
|
||||
// A cursor is a position in one query's results. Carried onto a different query it would point
|
||||
// at row 20 of a set the model never saw the start of, with nothing in the result to say so.
|
||||
it('continues a cursor only under the query that minted it', () => {
|
||||
const page = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), ['design']))
|
||||
expect(
|
||||
parseSearchBoardsInput({ query: 'design', cursor: page.nextCursor }).cursor
|
||||
).not.toBeNull()
|
||||
expect(() => parseSearchBoardsInput({ query: 'roadmap', cursor: page.nextCursor })).toThrow(
|
||||
'cursor is from a different query'
|
||||
)
|
||||
expect(() => parseSearchBoardsInput({ cursor: page.nextCursor })).toThrow(
|
||||
'cursor is from a different query'
|
||||
)
|
||||
})
|
||||
|
||||
// Matching is `ilike` and terms are split on whitespace before they are ever compared, so
|
||||
// neither case nor spacing changed which boards the cursor was pointing into.
|
||||
it('treats a requery that differs only in case or spacing as the same query', () => {
|
||||
const page = parsedJson(
|
||||
getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), ['Design', 'System'])
|
||||
)
|
||||
expect(
|
||||
parseSearchBoardsInput({ query: ' design system ', cursor: page.nextCursor }).cursor
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
// The other direction of the same rule: "list my newest boards" is a query too, and a cursor
|
||||
// into it does not carry over to a search.
|
||||
it('refuses a no-query cursor once a query is added', () => {
|
||||
const page = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), []))
|
||||
expect(() => parseSearchBoardsInput({ query: 'design', cursor: page.nextCursor })).toThrow(
|
||||
'cursor is from a different query'
|
||||
)
|
||||
})
|
||||
|
||||
// Fixture board ids in the eval harness are arbitrary strings, and `btoa` throws outside Latin-1.
|
||||
// That throw would happen inside the route's try block and reach a model as a database failure.
|
||||
it('round-trips an id that btoa alone could not encode', () => {
|
||||
// The cursor is minted from the last row *on* the page, so the odd id has to sit there.
|
||||
const rows = makePage(BOARD_SEARCH_PAGE_SIZE + 1)
|
||||
rows[BOARD_SEARCH_PAGE_SIZE - 1] = { ...rows[BOARD_SEARCH_PAGE_SIZE - 1], id: 'brädå:1' }
|
||||
const result = parsedJson(getBoardSearchResults(rows, []))
|
||||
expect(parseSearchBoardsInput({ cursor: result.nextCursor }).cursor).toEqual({
|
||||
arrivedAt: 1_700_000_000_000 - (BOARD_SEARCH_PAGE_SIZE - 1),
|
||||
id: 'brädå:1',
|
||||
})
|
||||
})
|
||||
|
||||
// Every fixture below carries the trailing `:` that makes it a three-field cursor. Without it the
|
||||
// field-count check refuses them first, and the guard each case names is never reached — which is
|
||||
// what happened when the cursor grew its query field and these were not revisited: deleting the
|
||||
// timestamp validation outright left all of them green.
|
||||
//
|
||||
// Nothing downstream can act on a malformed cursor, and silently starting from the first page
|
||||
// would look to a model like the last page repeating itself.
|
||||
it('refuses a cursor it did not issue', () => {
|
||||
expect(() => parseSearchBoardsInput({ cursor: 'not-a-cursor' })).toThrow('cursor is not valid')
|
||||
expect(() => parseSearchBoardsInput({ cursor: btoa('nonsense') })).toThrow(
|
||||
'cursor is not valid'
|
||||
)
|
||||
expect(() => parseSearchBoardsInput({ cursor: 12 })).toThrow('cursor must be a string')
|
||||
expect(() => parseSearchBoardsInput({ cursor: btoa('1:%zz:') })).toThrow('cursor is not valid')
|
||||
})
|
||||
|
||||
// `Number.isInteger` admits 1e300, which binds as an out-of-range int8 and makes Postgres throw:
|
||||
// caller garbage would reach a model as "the board database could not be reached".
|
||||
it('refuses a sort key no timestamp could be', () => {
|
||||
expect(() => parseSearchBoardsInput({ cursor: btoa('1e300:board-1:') })).toThrow(
|
||||
'cursor is not valid'
|
||||
)
|
||||
expect(() => parseSearchBoardsInput({ cursor: btoa('-1:board-1:') })).toThrow(
|
||||
'cursor is not valid'
|
||||
)
|
||||
})
|
||||
|
||||
// `Number('')` is 0, which passes `Number.isSafeInteger(0)`: the forged cursor `btoa(":id:")`
|
||||
// would otherwise decode to `{createdAt: 0, id: 'id'}` and seek strictly below epoch forever,
|
||||
// with no signal to the model that its cursor was the problem.
|
||||
it('refuses a cursor with an empty timestamp half', () => {
|
||||
expect(() => parseSearchBoardsInput({ cursor: btoa(':board-1:') })).toThrow(
|
||||
'cursor is not valid'
|
||||
)
|
||||
})
|
||||
|
||||
// The digit check admits any run of digits, including one past MAX_SAFE_INTEGER. That binds as an
|
||||
// out-of-range int8 and makes Postgres throw, so caller garbage would reach a model as "the board
|
||||
// database could not be reached" rather than as a bad cursor. This is the case
|
||||
// `Number.isSafeInteger` is there for: `1e300` is refused a line earlier, for not being digits.
|
||||
it('refuses an all-digit timestamp too large to be a safe integer', () => {
|
||||
expect(() => parseSearchBoardsInput({ cursor: btoa('99999999999999999999:board-1:') })).toThrow(
|
||||
'cursor is not valid'
|
||||
)
|
||||
})
|
||||
|
||||
// The id half is what makes "where the page ended" a single point rather than a range. Empty, the
|
||||
// cursor would seek on createdAt alone, and every board sharing that timestamp would be re-served
|
||||
// or skipped at the boundary.
|
||||
it('refuses a cursor with an empty id half', () => {
|
||||
expect(() => parseSearchBoardsInput({ cursor: btoa('1::') })).toThrow('cursor is not valid')
|
||||
})
|
||||
|
||||
// Every other string form `Number()` coerces to a finite value but a plain-digit timestamp
|
||||
// never is.
|
||||
it.each(['1e3', ' 5', '+5', 'Infinity'])(
|
||||
'refuses a timestamp half of %j, which Number() would otherwise accept',
|
||||
(timestampPart) => {
|
||||
expect(() => parseSearchBoardsInput({ cursor: btoa(`${timestampPart}:board-1:`) })).toThrow(
|
||||
'cursor is not valid'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// The one statement of result order. The SQL in searchBoards.ts mirrors this, and the eval harness
|
||||
// pages through fixtures with it — if the two disagree, an eval stops being evidence.
|
||||
describe('compareBoardSearchOrder', () => {
|
||||
it('puts the newest-created board first', () => {
|
||||
expect(
|
||||
compareBoardSearchOrder({ arrivedAt: 2, id: 'a' }, { arrivedAt: 1, id: 'a' })
|
||||
).toBeLessThan(0)
|
||||
})
|
||||
|
||||
// Boards created in one batch share a createdAt. Without a unique tiebreaker the cursor cannot
|
||||
// say where a page ended inside that group.
|
||||
it('breaks a tie on id, descending', () => {
|
||||
expect(
|
||||
compareBoardSearchOrder({ arrivedAt: 0, id: 'b' }, { arrivedAt: 0, id: 'a' })
|
||||
).toBeLessThan(0)
|
||||
expect(compareBoardSearchOrder({ arrivedAt: 0, id: 'a' }, { arrivedAt: 0, id: 'a' })).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts an older board after a newer one whatever their ids', () => {
|
||||
expect(
|
||||
compareBoardSearchOrder({ arrivedAt: 0, id: 'z' }, { arrivedAt: 1, id: 'a' })
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAfterBoardSearchCursor', () => {
|
||||
it('accepts only rows that sort after the cursor', () => {
|
||||
const cursor = { arrivedAt: 10, id: 'm' }
|
||||
expect(isAfterBoardSearchCursor({ arrivedAt: 9, id: 'z' }, cursor)).toBe(true)
|
||||
expect(isAfterBoardSearchCursor({ arrivedAt: 11, id: 'a' }, cursor)).toBe(false)
|
||||
})
|
||||
|
||||
// The point of ordering on arrival rather than creation: a board somebody shared with you this
|
||||
// morning leads the list, however long ago its owner made it. Under a createdAt sort it would land
|
||||
// below everything you have made since, which for a long-lived board is the bottom of the page.
|
||||
it('puts a recently shared old board above a board made earlier today', () => {
|
||||
const sharedThisMorning = { arrivedAt: 1_700_000_000_000, id: 'shared' }
|
||||
const mineFromLastWeek = { arrivedAt: 1_699_000_000_000, id: 'mine' }
|
||||
expect(compareBoardSearchOrder(sharedThisMorning, mineFromLastWeek)).toBeLessThan(0)
|
||||
})
|
||||
|
||||
// Descending order means "after" is "less than". Reversed, a page returns itself forever.
|
||||
it('excludes the cursor row itself and takes the tie on id', () => {
|
||||
const cursor = { arrivedAt: 10, id: 'm' }
|
||||
expect(isAfterBoardSearchCursor(cursor, cursor)).toBe(false)
|
||||
expect(isAfterBoardSearchCursor({ arrivedAt: 10, id: 'l' }, cursor)).toBe(true)
|
||||
expect(isAfterBoardSearchCursor({ arrivedAt: 10, id: 'n' }, cursor)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBoardSearchResults', () => {
|
||||
it('shapes a board for the model', () => {
|
||||
const result = parsedJson(getBoardSearchResults([makeRow()], []))
|
||||
expect(result).toEqual({
|
||||
boardCount: 1,
|
||||
boards: [
|
||||
{
|
||||
boardId: 'board-1',
|
||||
name: 'Roadmap',
|
||||
// Same instant for a board the caller made; they diverge only for a shared one.
|
||||
addedAt: '2023-11-14T22:13:20.000Z',
|
||||
createdAt: '2023-11-14T22:13:20.000Z',
|
||||
updatedAt: '2023-11-14T22:21:40.000Z',
|
||||
source: 'owned',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
// "My workspace" on every row of the common case is noise; on a shared workspace's board it is
|
||||
// the thing that identifies where the board lives.
|
||||
it('names the workspace only on boards outside the caller’s own', () => {
|
||||
const result = parsedJson(getBoardSearchResults([makeRow({ source: 'workspace' })], []))
|
||||
expect(result.boards[0]).toMatchObject({ source: 'workspace', workspaceName: 'Design' })
|
||||
})
|
||||
|
||||
// A link-shared board is owned by a workspace the caller is not in. Reporting its name would hand
|
||||
// somebody who was given a link the name of an organisation they have no seat in.
|
||||
it('withholds the workspace name from a link-shared board', () => {
|
||||
const result = parsedJson(getBoardSearchResults([makeRow({ source: 'shared' })], []))
|
||||
expect(result.boards[0].source).toBe('shared')
|
||||
expect(result.boards[0]).not.toHaveProperty('workspaceName')
|
||||
})
|
||||
|
||||
// An unnamed board is titled by its creation date on tldraw.com, in the viewer's locale and
|
||||
// timezone. A stand-in title invented here would be one the caller never sees on their own
|
||||
// screen, and one no query could match, since the column holds ''.
|
||||
it('reports a blank name as blank rather than inventing one', () => {
|
||||
const result = parsedJson(getBoardSearchResults([makeRow({ name: ' ' })], []))
|
||||
expect(result.boards[0].name).toBe('')
|
||||
})
|
||||
|
||||
// The query asks for one row more than a page so that "is there another page" needs no second
|
||||
// count. The extra row must never be shown.
|
||||
it('serves one page and a cursor from the surplus row', () => {
|
||||
const result = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), []))
|
||||
expect(result.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE)
|
||||
expect(result.boards).toHaveLength(BOARD_SEARCH_PAGE_SIZE)
|
||||
expect(result.nextCursor).toEqual(expect.any(String))
|
||||
expect(result.boards.at(-1).boardId).toBe(`board-${BOARD_SEARCH_PAGE_SIZE - 1}`)
|
||||
})
|
||||
|
||||
// A cursor on the last page would have a model fetch an empty page to discover it had finished.
|
||||
it('omits the cursor on the last page', () => {
|
||||
const result = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE), []))
|
||||
expect(result.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE)
|
||||
expect(result.nextCursor).toBeUndefined()
|
||||
})
|
||||
|
||||
// An empty result is a normal result. Models treat isError as failure and retry it.
|
||||
it('returns an empty result rather than an error', () => {
|
||||
const result = getBoardSearchResults([], [])
|
||||
expect(result.isError).toBeUndefined()
|
||||
expect(parsedJson(result)).toEqual({ boardCount: 0, boards: [] })
|
||||
})
|
||||
})
|
||||
1068
apps/dotcom/sync-worker/src/routes/tla/boardTools.ts
Normal file
1068
apps/dotcom/sync-worker/src/routes/tla/boardTools.ts
Normal file
File diff suppressed because it is too large
Load Diff
51
apps/dotcom/sync-worker/src/routes/tla/createFiles.ts
Normal file
51
apps/dotcom/sync-worker/src/routes/tla/createFiles.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CreateFilesRequestBody } from '@tldraw/dotcom-shared'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { createTLSchema } from '@tldraw/tlschema'
|
||||
import { uniqueId } from '@tldraw/utils'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { getR2KeyForRoom } from '../../r2'
|
||||
import { Environment } from '../../types'
|
||||
import { getUserIdFromRequest } from '../../utils/tla/permissions'
|
||||
import { validateSnapshot } from '../../utils/validateSnapshot'
|
||||
|
||||
// Create new files based on snapshots. This is used when dropping .tldr files onto the app.
|
||||
export async function createFiles(request: IRequest, env: Environment): Promise<Response> {
|
||||
// The data sent from the client will include the data for the new room
|
||||
|
||||
const userId = await getUserIdFromRequest(request, env)
|
||||
if (!userId) {
|
||||
return Response.json({ error: true, message: 'No user' }, { status: 401 })
|
||||
}
|
||||
|
||||
const slugs: string[] = []
|
||||
const data = (await request.json()) as CreateFilesRequestBody
|
||||
|
||||
for (const _snapshot of data.snapshots) {
|
||||
// There's a chance the data will be invalid, so we check it first
|
||||
// need to maybe migrate the snapshot
|
||||
const snapshotResult = validateSnapshot(_snapshot)
|
||||
if (!snapshotResult.ok) {
|
||||
return Response.json({ error: true, message: snapshotResult.error }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create the new snapshot
|
||||
const snapshot: RoomSnapshot = {
|
||||
schema: createTLSchema().serialize(),
|
||||
clock: 0,
|
||||
documents: Object.values(snapshotResult.value).map((r) => ({
|
||||
state: r,
|
||||
lastChangedClock: 0,
|
||||
})),
|
||||
tombstones: {},
|
||||
}
|
||||
|
||||
// Create a new slug for the room
|
||||
const newSlug = uniqueId()
|
||||
|
||||
// Bang the snapshot into the database
|
||||
await env.ROOMS.put(getR2KeyForRoom({ slug: newSlug, isApp: true }), JSON.stringify(snapshot))
|
||||
|
||||
slugs.push(newSlug)
|
||||
}
|
||||
return new Response(JSON.stringify({ error: false, slugs }))
|
||||
}
|
||||
160
apps/dotcom/sync-worker/src/routes/tla/evalsLocalMcp.test.ts
Normal file
160
apps/dotcom/sync-worker/src/routes/tla/evalsLocalMcp.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { testRoutes } from '../../testRoutes'
|
||||
import { BOARD_SEARCH_PAGE_SIZE } from './boardTools'
|
||||
import { getEvalsFixtureScreenshotPlan, resetEvalsFixtureSessionsForTests } from './evalsLocalMcp'
|
||||
import { makeSnapshot } from './screenshotTestHelpers'
|
||||
|
||||
const localEnv = { IS_LOCAL: 'true', TLDRAW_ENV: 'development' } as never
|
||||
|
||||
afterEach(resetEvalsFixtureSessionsForTests)
|
||||
|
||||
function request(path: string, method: string, body?: unknown) {
|
||||
return new Request(`http://localhost${path}`, {
|
||||
method,
|
||||
...(body === undefined
|
||||
? null
|
||||
: { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
|
||||
})
|
||||
}
|
||||
|
||||
describe('local eval fixture MCP', () => {
|
||||
it('is unavailable outside local development', async () => {
|
||||
const response = await testRoutes.fetch(request('/app/__test__/evals/plan', 'POST', {}), {
|
||||
IS_LOCAL: 'false',
|
||||
TLDRAW_ENV: 'production',
|
||||
} as never)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('plans fixture screenshots with the real clustering logic', () => {
|
||||
const snapshot = makeSnapshot([{ id: 'page:one', index: 'a1', shapes: 1 }])
|
||||
const plan = getEvalsFixtureScreenshotPlan(snapshot, {
|
||||
'shape:page:one-0': { minX: 0, minY: 0, maxX: 80, maxY: 80, text: 'Q4' },
|
||||
})
|
||||
|
||||
expect(plan).toHaveLength(1)
|
||||
expect(plan[0]).toMatchObject({
|
||||
pageId: 'page:one',
|
||||
shapeIds: ['shape:page:one-0'],
|
||||
})
|
||||
expect(plan[0].clusterIds).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('serves uploaded boards through the real board tools', async () => {
|
||||
const snapshot = makeSnapshot([{ id: 'page:one', index: 'a1', name: 'One', shapes: 1 }])
|
||||
const boardResponse = await testRoutes.fetch(
|
||||
request('/app/__test__/evals/sessions/session/boards/roadmap', 'PUT', {
|
||||
snapshot,
|
||||
measurements: {
|
||||
'shape:page:one-0': { minX: 0, minY: 0, maxX: 80, maxY: 80, text: 'Q4' },
|
||||
},
|
||||
shots: {},
|
||||
}),
|
||||
localEnv
|
||||
)
|
||||
expect(boardResponse.status).toBe(201)
|
||||
|
||||
const mcpResponse = await testRoutes.fetch(
|
||||
request('/app/__test__/evals/sessions/session/mcp', 'POST', {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'get_board_info', arguments: { boardId: 'roadmap' } },
|
||||
}),
|
||||
localEnv
|
||||
)
|
||||
expect(mcpResponse.status).toBe(200)
|
||||
const rpc = (await mcpResponse.json()) as any
|
||||
expect(JSON.parse(rpc.result.content[0].text)).toMatchObject({
|
||||
name: 'My Board',
|
||||
pages: [{ id: 'page:one', name: 'One', hasContent: true }],
|
||||
})
|
||||
})
|
||||
|
||||
it('serves search_boards over the session’s fixture boards', async () => {
|
||||
for (const [boardId, boardName] of [
|
||||
['roadmap', 'Q4 roadmap'],
|
||||
['sketches', 'Loose sketches'],
|
||||
]) {
|
||||
const stored = await testRoutes.fetch(
|
||||
request(`/app/__test__/evals/sessions/search/boards/${boardId}`, 'PUT', {
|
||||
snapshot: makeSnapshot(
|
||||
[{ id: 'page:one', index: 'a1', name: 'One', shapes: 1 }],
|
||||
boardName
|
||||
),
|
||||
measurements: {},
|
||||
shots: {},
|
||||
}),
|
||||
localEnv
|
||||
)
|
||||
expect(stored.status).toBe(201)
|
||||
}
|
||||
|
||||
const mcpResponse = await testRoutes.fetch(
|
||||
request('/app/__test__/evals/sessions/search/mcp', 'POST', {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'search_boards', arguments: { query: 'roadmap' } },
|
||||
}),
|
||||
localEnv
|
||||
)
|
||||
expect(mcpResponse.status).toBe(200)
|
||||
const rpc = (await mcpResponse.json()) as any
|
||||
expect(JSON.parse(rpc.result.content[0].text)).toMatchObject({
|
||||
boardCount: 1,
|
||||
// A plausible timestamp, not the 1970 a bare index would produce: a model reasoning about
|
||||
// how new a board is in an eval reads this field.
|
||||
boards: [
|
||||
{
|
||||
boardId: 'roadmap',
|
||||
name: 'Q4 roadmap',
|
||||
source: 'owned',
|
||||
createdAt: '2026-01-05T09:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
// Fixtures have no `file` row and so no creation times; insertion order stands in for one, so the
|
||||
// ordering and paging the deployed tool applies are both still exercised.
|
||||
it('pages through fixture boards with a cursor', async () => {
|
||||
for (let index = 0; index < BOARD_SEARCH_PAGE_SIZE + 1; index++) {
|
||||
await testRoutes.fetch(
|
||||
request(`/app/__test__/evals/sessions/paged/boards/board-${index}`, 'PUT', {
|
||||
snapshot: makeSnapshot([{ id: 'page:one', index: 'a1', shapes: 1 }], `Board ${index}`),
|
||||
measurements: {},
|
||||
shots: {},
|
||||
}),
|
||||
localEnv
|
||||
)
|
||||
}
|
||||
|
||||
async function searchPage(args: object) {
|
||||
const response = await testRoutes.fetch(
|
||||
request('/app/__test__/evals/sessions/paged/mcp', 'POST', {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'search_boards', arguments: args },
|
||||
}),
|
||||
localEnv
|
||||
)
|
||||
return JSON.parse(((await response.json()) as any).result.content[0].text)
|
||||
}
|
||||
|
||||
const first = await searchPage({})
|
||||
expect(first.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE)
|
||||
expect(first.nextCursor).toEqual(expect.any(String))
|
||||
|
||||
const second = await searchPage({ cursor: first.nextCursor })
|
||||
expect(second.boardCount).toBe(1)
|
||||
expect(second.nextCursor).toBeUndefined()
|
||||
|
||||
// The whole point of a cursor: no board appears on two pages, and none is skipped.
|
||||
const ids = [...first.boards, ...second.boards].map(
|
||||
(board: { boardId: string }) => board.boardId
|
||||
)
|
||||
expect(new Set(ids).size).toBe(BOARD_SEARCH_PAGE_SIZE + 1)
|
||||
})
|
||||
})
|
||||
249
apps/dotcom/sync-worker/src/routes/tla/evalsLocalMcp.ts
Normal file
249
apps/dotcom/sync-worker/src/routes/tla/evalsLocalMcp.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../../types'
|
||||
import { getDocumentNameFromSnapshot } from '../getDocumentNameFromSnapshot'
|
||||
import {
|
||||
BOARD_INFO_TOOL_NAME,
|
||||
BOARD_NOT_FOUND_MESSAGE,
|
||||
CLUSTER_INFO_TOOL_NAME,
|
||||
CLUSTER_SCREENSHOT_TOOL_NAME,
|
||||
PAGE_INFO_TOOL_NAME,
|
||||
SEARCH_BOARDS_TOOL_NAME,
|
||||
ShapeMeasurement,
|
||||
ToolResult,
|
||||
compareBoardSearchOrder,
|
||||
getBoardInfo,
|
||||
getBoardSearchResults,
|
||||
clusterPage,
|
||||
getClusterInfo,
|
||||
getPageInfo,
|
||||
handleMcpJsonRpc,
|
||||
isAfterBoardSearchCursor,
|
||||
parseBoardInfoInput,
|
||||
parseClusterInfoInput,
|
||||
parseClusterScreenshotInput,
|
||||
parsePageInfoInput,
|
||||
parseSearchBoardsInput,
|
||||
pickClusterShapes,
|
||||
resolvePage,
|
||||
toolError,
|
||||
toolPageResult,
|
||||
} from './boardTools'
|
||||
|
||||
const HARNESS_GAP_MARKER = '[harness-gap]'
|
||||
|
||||
// Insertion order is the fixture stand-in for creation time, but a bare index would have every eval
|
||||
// board report a 1970 timestamp — and a model reasoning about "my newest board" reads those. Spaced
|
||||
// an hour apart from a fixed date rather than from `Date.now()`, so two runs over the same fixtures
|
||||
// produce byte-identical results.
|
||||
const FIXTURE_CREATED_AT_BASE_MS = Date.UTC(2026, 0, 5, 9, 0, 0)
|
||||
const FIXTURE_CREATED_AT_STEP_MS = 60 * 60 * 1000
|
||||
|
||||
function fixtureTimestamp(index: number): number {
|
||||
return FIXTURE_CREATED_AT_BASE_MS + index * FIXTURE_CREATED_AT_STEP_MS
|
||||
}
|
||||
|
||||
interface FixtureBoard {
|
||||
snapshot: RoomSnapshot
|
||||
measurements: Record<string, ShapeMeasurement>
|
||||
shots: Record<string, string>
|
||||
}
|
||||
|
||||
interface ScreenshotPlanEntry {
|
||||
pageId: string
|
||||
clusterIds: string[]
|
||||
shapeIds: string[]
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Map<string, FixtureBoard>>()
|
||||
|
||||
function notFoundUnlessLocal(env: Environment): Response | null {
|
||||
return env.IS_LOCAL === 'true' ? null : new Response('Not Found', { status: 404 })
|
||||
}
|
||||
|
||||
function screenshotFileName(clusterIds: string[], theme: 'light' | 'dark') {
|
||||
const key = [...clusterIds]
|
||||
.sort()
|
||||
.join('+')
|
||||
.replace(/[^a-zA-Z0-9+_-]/g, '_')
|
||||
return `${key}.${theme}.png`
|
||||
}
|
||||
|
||||
function textOf(result: ToolResult): string {
|
||||
const block = result.content.find((part) => part.type === 'text')
|
||||
if (!block || block.type !== 'text') throw new Error('Expected a text tool result')
|
||||
return block.text
|
||||
}
|
||||
|
||||
export function getEvalsFixtureScreenshotPlan(
|
||||
snapshot: RoomSnapshot,
|
||||
measurements: Record<string, ShapeMeasurement>
|
||||
): ScreenshotPlanEntry[] {
|
||||
const pages = JSON.parse(textOf(getBoardInfo(snapshot))) as {
|
||||
pages: Array<{ id: string }>
|
||||
}
|
||||
const plan: ScreenshotPlanEntry[] = []
|
||||
for (const page of pages.pages) {
|
||||
const selector = { kind: 'id', id: page.id } as const
|
||||
const resolved = resolvePage(snapshot, selector)
|
||||
if (!resolved.ok) continue
|
||||
const clusters = clusterPage(resolved, measurements)
|
||||
const info = JSON.parse(textOf(getPageInfo(resolved, clusters))) as {
|
||||
clusters: Array<{ id: string }>
|
||||
}
|
||||
const clusterIds = info.clusters.map((cluster) => cluster.id)
|
||||
const sets = [...clusterIds.map((id) => [id]), ...(clusterIds.length > 1 ? [clusterIds] : [])]
|
||||
for (const set of sets) {
|
||||
const picked = pickClusterShapes(clusters, set, selector)
|
||||
if (!picked.ok || picked.shapeIds.length === 0) continue
|
||||
plan.push({ pageId: page.id, clusterIds: set, shapeIds: picked.shapeIds })
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
export async function planEvalsFixtureScreenshots(
|
||||
request: IRequest,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
const unavailable = notFoundUnlessLocal(env)
|
||||
if (unavailable) return unavailable
|
||||
const body = (await request.json()) as {
|
||||
snapshot: RoomSnapshot
|
||||
measurements: Record<string, ShapeMeasurement>
|
||||
}
|
||||
return Response.json({ plan: getEvalsFixtureScreenshotPlan(body.snapshot, body.measurements) })
|
||||
}
|
||||
|
||||
export async function putEvalsFixtureBoard(request: IRequest, env: Environment): Promise<Response> {
|
||||
const unavailable = notFoundUnlessLocal(env)
|
||||
if (unavailable) return unavailable
|
||||
const board = (await request.json()) as FixtureBoard
|
||||
let session = sessions.get(request.params.sessionId)
|
||||
if (!session) {
|
||||
session = new Map()
|
||||
sessions.set(request.params.sessionId, session)
|
||||
}
|
||||
session.set(request.params.boardId, board)
|
||||
return Response.json({ stored: true }, { status: 201 })
|
||||
}
|
||||
|
||||
export function deleteEvalsFixtureSession(request: IRequest, env: Environment): Response {
|
||||
const unavailable = notFoundUnlessLocal(env)
|
||||
if (unavailable) return unavailable
|
||||
sessions.delete(request.params.sessionId)
|
||||
return Response.json({ deleted: true })
|
||||
}
|
||||
|
||||
async function callFixtureTool(
|
||||
name: string,
|
||||
args: unknown,
|
||||
boards: Map<string, FixtureBoard>
|
||||
): Promise<ToolResult> {
|
||||
try {
|
||||
switch (name) {
|
||||
case SEARCH_BOARDS_TOOL_NAME: {
|
||||
const { terms, cursor } = parseSearchBoardsInput(args)
|
||||
// The session is the whole "account": every board in it is one the caller owns. Names
|
||||
// come from the snapshot, since a fixture has no `file` row to carry one, and
|
||||
// insertion order stands in for creation order — so ordering and paging behave the
|
||||
// way they do in production without fixtures needing timestamps.
|
||||
const rows = [...boards.entries()]
|
||||
.map(([id, board], index) => ({
|
||||
id,
|
||||
name: getDocumentNameFromSnapshot(board.snapshot) ?? '',
|
||||
// A fixture session is the caller's own account, so every board arrived by being
|
||||
// made here — the two timestamps are the same value, exactly as `createFile` writes
|
||||
// them in production.
|
||||
arrivedAt: fixtureTimestamp(index),
|
||||
createdAt: fixtureTimestamp(index),
|
||||
updatedAt: fixtureTimestamp(index),
|
||||
workspaceName: '',
|
||||
source: 'owned' as const,
|
||||
}))
|
||||
.filter((row) =>
|
||||
terms.every((term) => row.name.toLowerCase().includes(term.toLowerCase()))
|
||||
)
|
||||
.sort(compareBoardSearchOrder)
|
||||
.filter((row) => !cursor || isAfterBoardSearchCursor(row, cursor))
|
||||
return getBoardSearchResults(rows, terms)
|
||||
}
|
||||
case BOARD_INFO_TOOL_NAME: {
|
||||
const { boardId } = parseBoardInfoInput(args)
|
||||
const board = boards.get(boardId)
|
||||
return board ? getBoardInfo(board.snapshot) : toolError(BOARD_NOT_FOUND_MESSAGE)
|
||||
}
|
||||
case PAGE_INFO_TOOL_NAME: {
|
||||
const { boardId, page } = parsePageInfoInput(args)
|
||||
const board = boards.get(boardId)
|
||||
if (!board) return toolError(BOARD_NOT_FOUND_MESSAGE)
|
||||
const resolved = resolvePage(board.snapshot, page)
|
||||
return resolved.ok
|
||||
? getPageInfo(resolved, clusterPage(resolved, board.measurements))
|
||||
: resolved.result
|
||||
}
|
||||
case CLUSTER_INFO_TOOL_NAME: {
|
||||
const { boardId, page, clusterId } = parseClusterInfoInput(args)
|
||||
const board = boards.get(boardId)
|
||||
if (!board) return toolError(BOARD_NOT_FOUND_MESSAGE)
|
||||
const resolved = resolvePage(board.snapshot, page)
|
||||
return resolved.ok
|
||||
? getClusterInfo(resolved, clusterPage(resolved, board.measurements), clusterId, page)
|
||||
: resolved.result
|
||||
}
|
||||
case CLUSTER_SCREENSHOT_TOOL_NAME: {
|
||||
const { boardId, page, clusterIds, theme } = parseClusterScreenshotInput(args)
|
||||
const board = boards.get(boardId)
|
||||
if (!board) return toolError(BOARD_NOT_FOUND_MESSAGE)
|
||||
const resolved = resolvePage(board.snapshot, page)
|
||||
if (!resolved.ok) return resolved.result
|
||||
const picked = pickClusterShapes(
|
||||
clusterPage(resolved, board.measurements),
|
||||
clusterIds,
|
||||
page
|
||||
)
|
||||
if (!picked.ok) return picked.result
|
||||
const png = board.shots[screenshotFileName(clusterIds, theme)]
|
||||
return png
|
||||
? toolPageResult(resolved.pageName, png)
|
||||
: toolError(
|
||||
`${HARNESS_GAP_MARKER} No screenshot was built for this cluster set. Run against staging or production to see the real render.`
|
||||
)
|
||||
}
|
||||
default:
|
||||
return toolError(`Unknown tool: ${name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
return toolError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
export async function evalsFixtureMcp(request: IRequest, env: Environment): Promise<Response> {
|
||||
const unavailable = notFoundUnlessLocal(env)
|
||||
if (unavailable) return unavailable
|
||||
const boards = sessions.get(request.params.sessionId)
|
||||
if (!boards) return new Response('Eval fixture session not found', { status: 404 })
|
||||
|
||||
let rpcRequest: Parameters<typeof handleMcpJsonRpc>[0]
|
||||
try {
|
||||
rpcRequest = (await request.json()) as Parameters<typeof handleMcpJsonRpc>[0]
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
const reply = await handleMcpJsonRpc(rpcRequest, (name, args) =>
|
||||
callFixtureTool(name, args, boards)
|
||||
)
|
||||
if (reply.kind === 'accepted') return new Response(null, { status: 202 })
|
||||
return Response.json(
|
||||
reply.kind === 'result'
|
||||
? { jsonrpc: '2.0', id: reply.id, result: reply.result }
|
||||
: { jsonrpc: '2.0', id: reply.id, error: { code: reply.code, message: reply.message } }
|
||||
)
|
||||
}
|
||||
|
||||
export function resetEvalsFixtureSessionsForTests() {
|
||||
sessions.clear()
|
||||
}
|
||||
17
apps/dotcom/sync-worker/src/routes/tla/forwardRoomRequest.ts
Normal file
17
apps/dotcom/sync-worker/src/routes/tla/forwardRoomRequest.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { notFound } from '@tldraw/worker-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../../types'
|
||||
import { getRoomDurableObjectId } from '../../utils/durableObjects'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../../utils/roomIdIsTooLong'
|
||||
|
||||
// Forwards a room request to the durable object associated with that room
|
||||
export async function forwardRoomRequest(request: IRequest, env: Environment): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
|
||||
if (!roomId) return notFound()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
// Set up the durable object for this room
|
||||
const id = getRoomDurableObjectId(env, roomId)
|
||||
return env.TLDR_DOC.get(id).fetch(request)
|
||||
}
|
||||
37
apps/dotcom/sync-worker/src/routes/tla/getFileSnapshot.ts
Normal file
37
apps/dotcom/sync-worker/src/routes/tla/getFileSnapshot.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { StatusError } from 'itty-router'
|
||||
import { getR2KeyForRoom } from '../../r2'
|
||||
import { Environment } from '../../types'
|
||||
|
||||
export async function returnFileSnapshot(env: Environment, fileSlug: string, isApp: boolean) {
|
||||
const snapshot = await getFileSnapshot(env, fileSlug, isApp)
|
||||
if (!snapshot) {
|
||||
throw new StatusError(404, 'File not found')
|
||||
}
|
||||
|
||||
const tldrFile = {
|
||||
tldrawFileFormatVersion: 1,
|
||||
schema: snapshot.schema,
|
||||
records: snapshot.documents.map((doc) => doc.state),
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(tldrFile, null, 2), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="${fileSlug}.tldr"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getFileSnapshot(
|
||||
env: Environment,
|
||||
fileSlug: string,
|
||||
isApp: boolean
|
||||
): Promise<RoomSnapshot | null> {
|
||||
const snapshot = await env.ROOMS.get(getR2KeyForRoom({ slug: fileSlug, isApp }))
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
|
||||
return snapshot.json() as Promise<RoomSnapshot>
|
||||
}
|
||||
47
apps/dotcom/sync-worker/src/routes/tla/getInviteInfo.ts
Normal file
47
apps/dotcom/sync-worker/src/routes/tla/getInviteInfo.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { GetInviteInfoResponseBody } from '@tldraw/dotcom-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { createPostgresConnectionPool } from '../../postgres'
|
||||
import { Environment } from '../../types'
|
||||
import { getJoinableWorkspaceFromInvite } from '../../utils/tla/getJoinableWorkspaceFromInvite'
|
||||
|
||||
export async function getInviteInfo(request: IRequest, env: Environment): Promise<Response> {
|
||||
const { token } = request.params
|
||||
if (!token) {
|
||||
return Response.json({ error: true, message: 'Invite token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = createPostgresConnectionPool(env, 'getInviteInfo')
|
||||
|
||||
try {
|
||||
const workspace = await getJoinableWorkspaceFromInvite(db, token)
|
||||
|
||||
if (!workspace) {
|
||||
return Response.json(
|
||||
{
|
||||
error: true,
|
||||
message: 'Invalid or expired invite token',
|
||||
} satisfies GetInviteInfoResponseBody,
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
error: false,
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
isValid: true,
|
||||
inviteSecret: token,
|
||||
} satisfies GetInviteInfoResponseBody)
|
||||
} catch (error) {
|
||||
console.error('Error getting invite info:', error)
|
||||
return Response.json(
|
||||
{
|
||||
error: true,
|
||||
message: 'Internal server error',
|
||||
} satisfies GetInviteInfoResponseBody,
|
||||
{ status: 500 }
|
||||
)
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
}
|
||||
468
apps/dotcom/sync-worker/src/routes/tla/getOgImage.test.ts
Normal file
468
apps/dotcom/sync-worker/src/routes/tla/getOgImage.test.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OG_REPAIR_COOLDOWN_MS } from '../../config'
|
||||
import { getOgImage } from './getOgImage'
|
||||
import { getPublishedFileInfo } from './getPublishedFile'
|
||||
import { getSharedFileInfo } from './getSharedFile'
|
||||
import { getOgImageCacheKey } from './ogImageQueue'
|
||||
import {
|
||||
blobsWithPrefix,
|
||||
failureBlobsOf,
|
||||
indexesOf,
|
||||
makeFakeQueue,
|
||||
makeFakeRoomsBucket,
|
||||
makeFakeThumbnailsBucket,
|
||||
makeScreenshotTestEnv as makeEnv,
|
||||
} from './screenshotTestHelpers'
|
||||
|
||||
vi.mock('./getPublishedFile', () => ({
|
||||
getPublishedFileInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./getSharedFile', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./getSharedFile')>()),
|
||||
getSharedFileInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function makeRequest(prefix: string, slug: string, method = 'GET', headers?: HeadersInit) {
|
||||
return Object.assign(
|
||||
new Request(`https://sync.tldraw.xyz/app/social-preview/${prefix}/${slug}/image`, {
|
||||
method,
|
||||
headers,
|
||||
}),
|
||||
{ params: { prefix, slug } }
|
||||
) as any
|
||||
}
|
||||
|
||||
describe('getOgImage', () => {
|
||||
// A board with no image of its own is sent to the site-wide default rather than having the worker
|
||||
// proxy those bytes: it is a static asset on the client origin, already cached at the edge.
|
||||
it('redirects to the default image on a cold cache', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1751234567890,
|
||||
})
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
const queue = makeFakeQueue()
|
||||
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
|
||||
|
||||
const response = await getOgImage(makeRequest('p', 'published-board'), env)
|
||||
|
||||
expect(response.status).toBe(302)
|
||||
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
|
||||
expect(failureBlobsOf(env)).toEqual(['failure:not_rendered_yet'])
|
||||
// This surface has no follow-up concept, so it records `none` rather than `false` — otherwise
|
||||
// a query for triggered renders would sweep up every og datapoint too.
|
||||
expect(blobsWithPrefix(env, 'followup:')).toEqual(['followup:none'])
|
||||
})
|
||||
|
||||
// The asymmetry this repair exists for: a published snapshot is frozen and its publish effect is
|
||||
// the only thing that ever asks for a render, so an ask lost to a queue failure or a stale pending
|
||||
// marker would leave a generic card until somebody republished.
|
||||
it('asks for a render when a published board has no image at all', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1,
|
||||
})
|
||||
const queue = makeFakeQueue()
|
||||
const env = makeEnv({ THUMBNAILS: makeFakeThumbnailsBucket(), QUEUE: queue })
|
||||
|
||||
await getOgImage(makeRequest('p', 'published-board'), env)
|
||||
|
||||
expect(queue.send).toHaveBeenCalledTimes(1)
|
||||
expect(queue.send).toHaveBeenCalledWith({
|
||||
type: 'og-image-render',
|
||||
kind: 'published',
|
||||
slug: 'published-board',
|
||||
reason: 'crawler',
|
||||
})
|
||||
})
|
||||
|
||||
// The repair is the one render ask an unauthenticated request can cause, so once a repair job has
|
||||
// failed its whole retry budget, crawler traffic must not be able to re-arm another chain until the
|
||||
// cooldown lapses — otherwise a board that cannot render is a Browser Run spend lever for whoever
|
||||
// fetches its URL.
|
||||
it('does not re-ask while a failed repair has the board on cooldown', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1,
|
||||
})
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
await bucket.put(
|
||||
getOgImageCacheKey({ kind: 'published', slug: 'failing-board' }).replace(
|
||||
/\.png$/,
|
||||
'.repair-cooldown'
|
||||
),
|
||||
new Uint8Array().buffer,
|
||||
{ customMetadata: { expiresAt: String(Date.now() + OG_REPAIR_COOLDOWN_MS) } }
|
||||
)
|
||||
const queue = makeFakeQueue()
|
||||
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
|
||||
|
||||
await getOgImage(makeRequest('p', 'failing-board'), env)
|
||||
expect(queue.send).not.toHaveBeenCalled()
|
||||
|
||||
// Once the cooldown lapses, the repair asks again.
|
||||
vi.setSystemTime(Date.parse('2026-01-01T00:00:00Z') + OG_REPAIR_COOLDOWN_MS + 1000)
|
||||
await getOgImage(makeRequest('p', 'failing-board'), env)
|
||||
expect(queue.send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// A shared file gets no such repair, and the reason is that it does not need one: every persist
|
||||
// that advances its document clock re-asks, so a lost ask is made good by the next edit. Rendering
|
||||
// from here would be work whose result nobody comes back for — unfurl platforms resolve a card once
|
||||
// and reuse it for every repost.
|
||||
it('does not ask for a render when a shared file has no image yet', async () => {
|
||||
vi.mocked(getSharedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
shared: true,
|
||||
sharedLinkType: 'view',
|
||||
isDeleted: false,
|
||||
} as any)
|
||||
const queue = makeFakeQueue()
|
||||
const env = makeEnv({
|
||||
THUMBNAILS: makeFakeThumbnailsBucket(),
|
||||
ROOMS: makeFakeRoomsBucket('room-etag-1'),
|
||||
QUEUE: queue,
|
||||
})
|
||||
|
||||
await getOgImage(makeRequest('f', 'file-1'), env)
|
||||
|
||||
expect(queue.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The redirect sits on a board's own permanent OG image URL, so nothing between here and the
|
||||
// crawler may pin it: a shared cache holding this redirect would outlive the render it stands in
|
||||
// for, and keep sending crawlers to the default long after the board has a picture.
|
||||
it('redirects with a short client-only TTL', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1,
|
||||
})
|
||||
const env = makeEnv({ THUMBNAILS: makeFakeThumbnailsBucket(), QUEUE: makeFakeQueue() })
|
||||
|
||||
const response = await getOgImage(makeRequest('p', 'board'), env)
|
||||
|
||||
expect(response.headers.get('cache-control')).toBe('public, max-age=60')
|
||||
})
|
||||
|
||||
it('serves an image whose version matches as a fresh hit', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1,
|
||||
})
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
await bucket.put(
|
||||
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
|
||||
new Uint8Array([1, 2, 3]).buffer,
|
||||
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
|
||||
)
|
||||
const queue = makeFakeQueue()
|
||||
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
|
||||
|
||||
const response = await getOgImage(makeRequest('p', 'cached-board'), env)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('image/png')
|
||||
expect(response.headers.get('x-tldraw-og-cache')).toBe('hit')
|
||||
expect(await response.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer)
|
||||
expect(queue.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The lifetime is short and revalidation is cheap on purpose. Nothing deletes a board's image when
|
||||
// it stops being public, so this route re-checking the share gate is the only thing keeping an
|
||||
// unshared board's thumbnail off the internet — and a cache serving without asking is that check not
|
||||
// happening. `stale-while-revalidate` is absent for the same reason: it would extend serving a day
|
||||
// past expiry.
|
||||
it('serves a fresh hit with a short lifetime and an etag, and no stale-while-revalidate', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1,
|
||||
})
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
await bucket.put(
|
||||
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
|
||||
new Uint8Array([1, 2, 3]).buffer,
|
||||
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
|
||||
)
|
||||
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: makeFakeQueue() })
|
||||
|
||||
const response = await getOgImage(makeRequest('p', 'cached-board'), env)
|
||||
|
||||
expect(response.headers.get('cache-control')).toBe('public, max-age=300')
|
||||
expect(response.headers.get('etag')).toBe('"etag-1"')
|
||||
})
|
||||
|
||||
it('answers a conditional request whose etag still matches with a 304 and no body', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1,
|
||||
})
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
await bucket.put(
|
||||
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
|
||||
new Uint8Array([1, 2, 3]).buffer,
|
||||
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
|
||||
)
|
||||
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: makeFakeQueue() })
|
||||
|
||||
const response = await getOgImage(
|
||||
makeRequest('p', 'cached-board', 'GET', { 'if-none-match': '"etag-1"' }),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(304)
|
||||
expect(await response.text()).toBe('')
|
||||
// The headers still refresh what the caller holds, so the next revalidation is a whole lifetime
|
||||
// away rather than immediate.
|
||||
expect(response.headers.get('cache-control')).toBe('public, max-age=300')
|
||||
expect(response.headers.get('etag')).toBe('"etag-1"')
|
||||
expect(response.headers.get('x-tldraw-og-cache')).toBe('hit')
|
||||
})
|
||||
|
||||
// The bytes are read lazily on a conditional request, so a render landing since the caller cached
|
||||
// has to be noticed and served rather than answered with a 304 for content that has moved.
|
||||
it('serves the new bytes when a conditional request holds a superseded etag', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 2,
|
||||
})
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
const key = getOgImageCacheKey({ kind: 'published', slug: 'cached-board' })
|
||||
await bucket.put(key, new Uint8Array([1, 2, 3]).buffer, {
|
||||
customMetadata: { version: '1', createdAt: String(Date.now()) },
|
||||
})
|
||||
await bucket.put(key, new Uint8Array([4, 5, 6]).buffer, {
|
||||
customMetadata: { version: '2', createdAt: String(Date.now()) },
|
||||
})
|
||||
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: makeFakeQueue() })
|
||||
|
||||
const response = await getOgImage(
|
||||
makeRequest('p', 'cached-board', 'GET', { 'if-none-match': '"etag-1"' }),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.arrayBuffer()).toEqual(new Uint8Array([4, 5, 6]).buffer)
|
||||
expect(response.headers.get('etag')).toBe('"etag-2"')
|
||||
expect(response.headers.get('x-tldraw-og-cache')).toBe('hit')
|
||||
})
|
||||
|
||||
// The share gate runs before the etag is ever looked at, so holding a valid etag for a board that
|
||||
// has since been unshared buys nothing. This is what the short lifetime is protecting.
|
||||
it('sends a conditional request for a board that is no longer shared to the default image', async () => {
|
||||
vi.mocked(getSharedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
shared: false,
|
||||
sharedLinkType: 'view',
|
||||
isDeleted: false,
|
||||
} as any)
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
await bucket.put(
|
||||
getOgImageCacheKey({ kind: 'shared_file', slug: 'file-1' }),
|
||||
new Uint8Array([1, 2, 3]).buffer,
|
||||
{ customMetadata: { version: 'room-etag-1', createdAt: String(Date.now()) } }
|
||||
)
|
||||
const env = makeEnv({
|
||||
THUMBNAILS: bucket,
|
||||
ROOMS: makeFakeRoomsBucket('room-etag-1'),
|
||||
QUEUE: makeFakeQueue(),
|
||||
})
|
||||
|
||||
const response = await getOgImage(
|
||||
makeRequest('f', 'file-1', 'GET', { 'if-none-match': '"etag-1"' }),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(302)
|
||||
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
|
||||
})
|
||||
|
||||
// There is no "too stale to serve". An old picture of this board beats the generic tldraw logo, so a
|
||||
// version mismatch only shortens the cache lifetime — it never withholds the image or asks for a
|
||||
// render, and the image's age is not consulted at all.
|
||||
it('serves a version-mismatched image as stale with a short TTL, however old it is', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 2,
|
||||
})
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
await bucket.put(
|
||||
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
|
||||
new Uint8Array([1, 2, 3]).buffer,
|
||||
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
|
||||
)
|
||||
const queue = makeFakeQueue()
|
||||
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
|
||||
|
||||
// Minutes old and a year old behave identically: both serve the bytes as stale.
|
||||
for (const now of ['2026-01-01T00:30:00Z', '2027-01-01T00:00:00Z']) {
|
||||
vi.setSystemTime(new Date(now))
|
||||
const stale = await getOgImage(makeRequest('p', 'cached-board'), env)
|
||||
expect(stale.status).toBe(200)
|
||||
expect(stale.headers.get('x-tldraw-og-cache')).toBe('stale')
|
||||
expect(stale.headers.get('cache-control')).toBe('public, max-age=300')
|
||||
expect(await stale.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer)
|
||||
}
|
||||
expect(queue.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The route's whole gate: viewable (published, or shared via link) and an image exists. A shared
|
||||
// file with no image yet passes the first and fails the second, so it gets the default.
|
||||
it('serves a shared file that passes its gate but has no image yet', async () => {
|
||||
vi.mocked(getSharedFileInfo).mockResolvedValue({
|
||||
id: 'shared-file',
|
||||
shared: true,
|
||||
isDeleted: false,
|
||||
})
|
||||
const queue = makeFakeQueue()
|
||||
const env = makeEnv({
|
||||
ROOMS: makeFakeRoomsBucket('etag-1'),
|
||||
THUMBNAILS: makeFakeThumbnailsBucket(),
|
||||
QUEUE: queue,
|
||||
})
|
||||
|
||||
const response = await getOgImage(makeRequest('f', 'shared-file'), env)
|
||||
|
||||
expect(response.status).toBe(302)
|
||||
expect(queue.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// An unshared board's thumbnail stays in R2 for owner-facing surfaces behind authz, so the gate is
|
||||
// the only thing keeping it off the public internet. It has to be checked on every request rather
|
||||
// than relied on having deleted the object at unshare time.
|
||||
it('refuses a board that has an image but no longer passes its gate', async () => {
|
||||
vi.mocked(getSharedFileInfo).mockResolvedValue({
|
||||
id: 'unshared-file',
|
||||
shared: false,
|
||||
isDeleted: false,
|
||||
})
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
await bucket.put(
|
||||
getOgImageCacheKey({ kind: 'shared_file', slug: 'unshared-file' }),
|
||||
new Uint8Array([1, 2, 3]).buffer,
|
||||
{ customMetadata: { version: 'etag-1', createdAt: String(Date.now()) } }
|
||||
)
|
||||
const env = makeEnv({ ROOMS: makeFakeRoomsBucket('etag-1'), THUMBNAILS: bucket })
|
||||
|
||||
const response = await getOgImage(makeRequest('f', 'unshared-file'), env)
|
||||
|
||||
expect(response.status).toBe(302)
|
||||
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
|
||||
// Still there, just unreachable from here.
|
||||
expect(
|
||||
bucket.store.has(getOgImageCacheKey({ kind: 'shared_file', slug: 'unshared-file' }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('answers HEAD probes with the same cache headers as a GET, but no body', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1,
|
||||
})
|
||||
const bucket = makeFakeThumbnailsBucket()
|
||||
await bucket.put(
|
||||
getOgImageCacheKey({ kind: 'published', slug: 'cached-board' }),
|
||||
new Uint8Array([1, 2, 3]).buffer,
|
||||
{ customMetadata: { version: '1', createdAt: String(Date.now()) } }
|
||||
)
|
||||
const queue = makeFakeQueue()
|
||||
const env = makeEnv({ THUMBNAILS: bucket, QUEUE: queue })
|
||||
|
||||
const response = await getOgImage(makeRequest('p', 'cached-board', 'HEAD'), env)
|
||||
|
||||
// Same headers a GET would return, so crawlers see the cache status...
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('image/png')
|
||||
expect(response.headers.get('x-tldraw-og-cache')).toBe('hit')
|
||||
// ...but the R2 body is never read.
|
||||
expect((await response.arrayBuffer()).byteLength).toBe(0)
|
||||
expect(queue.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers a HEAD probe on a cold cache with the same redirect a GET would get', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1751234567890,
|
||||
})
|
||||
const queue = makeFakeQueue()
|
||||
const env = makeEnv({ THUMBNAILS: makeFakeThumbnailsBucket(), QUEUE: queue })
|
||||
|
||||
const response = await getOgImage(makeRequest('p', 'published-board', 'HEAD'), env)
|
||||
|
||||
expect(response.status).toBe(302)
|
||||
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
|
||||
// Including the repair ask: some crawlers only ever probe with HEAD, and a published board with
|
||||
// no image has nothing else that will ask for one.
|
||||
expect(queue.send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sends private or unknown boards to the default tldraw OG image', async () => {
|
||||
vi.mocked(getSharedFileInfo).mockResolvedValue({
|
||||
id: 'private-file',
|
||||
shared: false,
|
||||
isDeleted: false,
|
||||
})
|
||||
const queue = makeFakeQueue()
|
||||
|
||||
const response = await getOgImage(
|
||||
makeRequest('f', 'private-file'),
|
||||
makeEnv({ ROOMS: makeFakeRoomsBucket(), QUEUE: queue })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(302)
|
||||
expect(response.headers.get('location')).toBe('https://www.tldraw.com/social-og.png')
|
||||
expect(queue.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// These datapoints carry no board identity at all — no index, no slug, no hash, no derived id.
|
||||
// The dataset answers aggregate spend and failure questions, and a board that resolves must be
|
||||
// indistinguishable from one that doesn't, or the dimension is back by another name.
|
||||
it('writes no board identity on any datapoint, resolved or not', async () => {
|
||||
vi.mocked(getPublishedFileInfo).mockResolvedValue({
|
||||
id: 'file-1',
|
||||
published: true,
|
||||
lastPublished: 1,
|
||||
})
|
||||
vi.mocked(getSharedFileInfo).mockResolvedValue({
|
||||
id: 'private-file',
|
||||
shared: false,
|
||||
isDeleted: false,
|
||||
})
|
||||
const env = makeEnv({
|
||||
ROOMS: makeFakeRoomsBucket('etag-1'),
|
||||
THUMBNAILS: makeFakeThumbnailsBucket(),
|
||||
QUEUE: makeFakeQueue(),
|
||||
})
|
||||
|
||||
// A board that resolves, and one that fails its gate (which returns before writing anything).
|
||||
await getOgImage(makeRequest('p', 'published-slug'), env)
|
||||
await getOgImage(makeRequest('f', 'private-file'), env)
|
||||
|
||||
expect(indexesOf(env)).toEqual([undefined])
|
||||
// Nothing board-shaped anywhere in the blobs either.
|
||||
const written = JSON.stringify((env.MEASURE as any).writeDataPoint.mock.calls)
|
||||
expect(written).not.toContain('published-slug')
|
||||
expect(written).not.toContain('file-1')
|
||||
expect(written).not.toContain('private-file')
|
||||
})
|
||||
})
|
||||
236
apps/dotcom/sync-worker/src/routes/tla/getOgImage.ts
Normal file
236
apps/dotcom/sync-worker/src/routes/tla/getOgImage.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { IRequest } from 'itty-router'
|
||||
import {
|
||||
OG_FALLBACK_MAX_AGE_SECONDS,
|
||||
OG_FRESH_IMAGE_MAX_AGE_SECONDS,
|
||||
OG_STALE_IMAGE_MAX_AGE_SECONDS,
|
||||
} from '../../config'
|
||||
import { Environment, ThumbnailBoardKind } from '../../types'
|
||||
import { getPublicOrigin } from '../../utils/getPublicOrigin'
|
||||
import { enqueueOgImageRender, getOgImageCacheKey, isOgImageRepairOnCooldown } from './ogImageQueue'
|
||||
import {
|
||||
ResolvedThumbnailBoard,
|
||||
resolveThumbnailBoard,
|
||||
writeScreenshotTelemetry,
|
||||
} from './thumbnailRender'
|
||||
import { reportThumbnailError } from './thumbnailShared'
|
||||
|
||||
// Almost a pure read. Two questions and nothing else: is this board publicly viewable (published, or
|
||||
// shared via link), and does a thumbnail for it exist? Both yes, serve it. Anything else, redirect to
|
||||
// the site-wide default. No rendering inline, no rate limiting, no waiting.
|
||||
//
|
||||
// Making the thumbnail exist before the board is ever shared is the job of the publish and edit
|
||||
// triggers (the outbox publish effect, TLFileDurableObject), not of the request that finds it missing:
|
||||
// unfurl platforms cache a URL's card on first resolve, so a render triggered from here lands after
|
||||
// the crawler has already taken the default away. Rendering inline is worse still — captures run
|
||||
// 4-17s, past any crawler's patience. See browser-run-thumbnails.md.
|
||||
//
|
||||
// The one exception is `repairMissingPublishedImage` below, which asks for a render when a *published*
|
||||
// board has no image at all — the single case where no other trigger will ever ask again.
|
||||
const DEFAULT_OG_IMAGE_PATH = '/social-og.png'
|
||||
|
||||
export async function getOgImage(
|
||||
request: IRequest,
|
||||
env: Environment,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<Response> {
|
||||
// Crawlers probe this URL with HEAD before (or instead of) GET, so the route is registered with
|
||||
// .all and HEAD must still return the same cache headers a GET would. Only a real GET reads the
|
||||
// R2 body; any other method is treated as a probe and answers headers only.
|
||||
const wantsBody = request.method === 'GET'
|
||||
const imageUrl = `${getPublicOrigin(request, env)}${DEFAULT_OG_IMAGE_PATH}`
|
||||
const board = await resolveOgBoard(request, env).catch((error) => {
|
||||
// Resolution reads Postgres and R2, so a throw here is infrastructure failing, not a board
|
||||
// that isn't public — but both produce the same default-image redirect. Report it, or an
|
||||
// outage is indistinguishable from a quiet day of unpublished boards.
|
||||
reportThumbnailError(error, {
|
||||
ctx,
|
||||
env,
|
||||
request,
|
||||
surface: 'og_route',
|
||||
// The route prefix only. No board identifier reaches an error report: for a link-shared file
|
||||
// the slug is the file id, which is the capability to view the board.
|
||||
extras: { prefix: request.params.prefix },
|
||||
})
|
||||
return null
|
||||
})
|
||||
if (!board) return redirectToDefaultOgImage(imageUrl)
|
||||
|
||||
const cacheKey = getOgImageCacheKey(board)
|
||||
// A conditional request needs metadata only: if the etag still matches, the answer is a 304 and the
|
||||
// bytes are never read. A HEAD probe needs metadata only for the same reason.
|
||||
const ifNoneMatch = request.headers.get('if-none-match')
|
||||
let cached =
|
||||
wantsBody && !ifNoneMatch
|
||||
? await env.THUMBNAILS?.get(cacheKey)
|
||||
: await env.THUMBNAILS?.head(cacheKey)
|
||||
|
||||
if (cached) {
|
||||
// The caller already holds these bytes. This is the path the short max-age is designed to make
|
||||
// common: a cache revalidates for the price of a 304, and every revalidation re-runs the share
|
||||
// gate above, so an unshared board stops being served within minutes rather than within a day.
|
||||
if (ifNoneMatch && etagMatches(ifNoneMatch, cached.etag)) {
|
||||
writeScreenshotTelemetry(env, { source: 'og', cacheStatus: cacheStatusOf(cached, board) })
|
||||
return notModifiedResponse(cacheParamsOf(cached, board))
|
||||
}
|
||||
|
||||
// A conditional GET whose etag no longer matches: the board has re-rendered since it was cached,
|
||||
// so the bytes are needed after all. Every header below is derived from this second read, so a
|
||||
// render landing between the two cannot make them describe bytes we did not send.
|
||||
if (wantsBody && !('body' in cached)) {
|
||||
cached = await env.THUMBNAILS?.get(cacheKey)
|
||||
if (!cached) return redirectToDefaultOgImage(imageUrl)
|
||||
}
|
||||
|
||||
writeScreenshotTelemetry(env, { source: 'og', cacheStatus: cacheStatusOf(cached, board) })
|
||||
return imageResponse(
|
||||
wantsBody ? await (cached as R2ObjectBody).arrayBuffer() : null,
|
||||
cacheParamsOf(cached, board)
|
||||
)
|
||||
}
|
||||
|
||||
// Never rendered, so the board has nothing of its own to show and the request is sent to the
|
||||
// site-wide default instead. The short max-age is what lets the real image take over as soon as a
|
||||
// publish or an edit lands it.
|
||||
writeScreenshotTelemetry(env, {
|
||||
source: 'og',
|
||||
cacheStatus: 'miss',
|
||||
failureReason: 'not_rendered_yet',
|
||||
})
|
||||
await repairMissingPublishedImage(board, env, ctx)
|
||||
return redirectToDefaultOgImage(imageUrl)
|
||||
}
|
||||
|
||||
async function resolveOgBoard(
|
||||
request: IRequest,
|
||||
env: Environment
|
||||
): Promise<ResolvedThumbnailBoard | null> {
|
||||
const kind = parseOgKind(request.params.prefix)
|
||||
const slug = parseSlug(request.params.slug)
|
||||
if (!kind || !slug) return null
|
||||
// 'public' is load-bearing here: since a thumbnail exists for every board and is never deleted, this
|
||||
// gate is the only thing keeping a private board's off the public internet.
|
||||
const resolved = await resolveThumbnailBoard(env, kind, slug, { access: 'public' })
|
||||
return resolved.ok ? resolved.board : null
|
||||
}
|
||||
|
||||
function parseOgKind(value: unknown): ThumbnailBoardKind | null {
|
||||
if (value === 'p' || value === 'published') return 'published'
|
||||
if (value === 'f' || value === 'shared_file') return 'shared_file'
|
||||
return null
|
||||
}
|
||||
|
||||
function parseSlug(value: unknown) {
|
||||
return typeof value === 'string' && value.length > 0 && !value.includes('/') ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The one case where this route asks for a render, and it is a repair rather than a refresh.
|
||||
*
|
||||
* The two kinds are not symmetric in how many triggers they have. A shared file re-asks on every
|
||||
* persist that advances its document clock, so an ask lost to a queue failure or a stale pending
|
||||
* marker is made good by the next edit. A published board has exactly one trigger — the publish
|
||||
* effect in `publishSnapshots.ts` — and its snapshot is frozen, so nothing ever edits it into
|
||||
* asking again. One lost ask there means a generic card until somebody republishes.
|
||||
*
|
||||
* So this fires only for `published`, and only on a total miss: no image at all, not a stale one.
|
||||
* The pending marker dedupes it while a job is alive, and the repair cooldown bounds it after one
|
||||
* dies: this is the only render ask an unauthenticated request can cause, so once a crawler-triggered
|
||||
* job has burnt its whole retry budget, a board that cannot render costs one retry chain per
|
||||
* OG_REPAIR_COOLDOWN_MS rather than one per marker-clear — traffic an outside caller controls must
|
||||
* not be able to re-arm it faster than that.
|
||||
*
|
||||
* This does not undo the reasoning that removed the general on-miss enqueue. That enqueue was
|
||||
* pointless because the crawler which triggered it has already cached the default by the time the
|
||||
* render lands, and unfurl platforms resolve a card once — both still true. The point here is not to
|
||||
* serve *this* request; it is that without it, nothing else will ever ask.
|
||||
*/
|
||||
async function repairMissingPublishedImage(
|
||||
board: ResolvedThumbnailBoard,
|
||||
env: Environment,
|
||||
ctx: ExecutionContext | undefined
|
||||
) {
|
||||
if (board.kind !== 'published') return
|
||||
if (await isOgImageRepairOnCooldown(env, board)) return
|
||||
const enqueued = enqueueOgImageRender(env, board, { reason: 'crawler' }).catch((error) => {
|
||||
reportThumbnailError(error, {
|
||||
ctx,
|
||||
env,
|
||||
surface: 'og_route',
|
||||
extras: { kind: board.kind, repair: true },
|
||||
})
|
||||
})
|
||||
// Off the response path in production; awaited only when there is no execution context to hand it
|
||||
// to, which is tests.
|
||||
if (ctx) ctx.waitUntil(enqueued)
|
||||
else await enqueued
|
||||
}
|
||||
|
||||
// Whether the image still depicts the board's current content decides the cache lifetime and nothing
|
||||
// else — both are served. There is no "too stale to serve": an old picture of this board beats the
|
||||
// generic tldraw logo, so a mismatch only asks callers back sooner.
|
||||
function cacheStatusOf(cached: R2Object, board: ResolvedThumbnailBoard) {
|
||||
return cached.customMetadata?.version === String(board.version) ? 'hit' : 'stale'
|
||||
}
|
||||
|
||||
function cacheParamsOf(cached: R2Object, board: ResolvedThumbnailBoard): CacheParams {
|
||||
const cacheStatus = cacheStatusOf(cached, board)
|
||||
return {
|
||||
cacheStatus,
|
||||
maxAgeSeconds:
|
||||
cacheStatus === 'hit' ? OG_FRESH_IMAGE_MAX_AGE_SECONDS : OG_STALE_IMAGE_MAX_AGE_SECONDS,
|
||||
etag: cached.httpEtag,
|
||||
version: cached.customMetadata?.version,
|
||||
}
|
||||
}
|
||||
|
||||
interface CacheParams {
|
||||
cacheStatus: 'hit' | 'stale'
|
||||
maxAgeSeconds: number
|
||||
etag?: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
// `if-none-match` is a list, each entry optionally weak-prefixed and quoted; R2's `etag` is the bare
|
||||
// value, so both sides are normalised before comparing.
|
||||
function etagMatches(ifNoneMatch: string, etag: string) {
|
||||
return ifNoneMatch
|
||||
.split(',')
|
||||
.map((candidate) => candidate.trim().replace(/^W\//, '').replace(/^"|"$/g, ''))
|
||||
.some((candidate) => candidate === '*' || candidate === etag)
|
||||
}
|
||||
|
||||
function cacheHeaders({ cacheStatus, maxAgeSeconds, etag, version }: CacheParams) {
|
||||
return {
|
||||
// No `stale-while-revalidate`: it would let a cache keep serving for a day past expiry, which is
|
||||
// a day of serving without the share gate — the same reason the default-image redirect carries
|
||||
// none. See OG_FRESH_IMAGE_MAX_AGE_SECONDS in config.ts.
|
||||
'cache-control': `public, max-age=${maxAgeSeconds}`,
|
||||
'x-tldraw-og-cache': cacheStatus,
|
||||
...(etag ? { etag } : null),
|
||||
...(version ? { 'x-tldraw-og-version': version } : null),
|
||||
}
|
||||
}
|
||||
|
||||
function imageResponse(body: ArrayBuffer | null, params: CacheParams) {
|
||||
return new Response(body, {
|
||||
headers: { 'content-type': 'image/png', ...cacheHeaders(params) },
|
||||
})
|
||||
}
|
||||
|
||||
// A 304 carries no body, and its headers refresh what the caller already holds.
|
||||
function notModifiedResponse(params: CacheParams) {
|
||||
return new Response(null, { status: 304, headers: cacheHeaders(params) })
|
||||
}
|
||||
|
||||
// Sends a request with no board image of its own to the site-wide default. The worker never serves
|
||||
// those bytes itself: the default is a static asset on the client origin, already cached at the edge,
|
||||
// and proxying it would put worker egress in front of every unfurl of an unrendered board.
|
||||
function redirectToDefaultOgImage(imageUrl: string) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: imageUrl,
|
||||
'cache-control': `public, max-age=${OG_FALLBACK_MAX_AGE_SECONDS}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
78
apps/dotcom/sync-worker/src/routes/tla/getPublishedFile.ts
Normal file
78
apps/dotcom/sync-worker/src/routes/tla/getPublishedFile.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { createPostgresConnectionPool } from '../../postgres'
|
||||
import { getR2KeyForRoom } from '../../r2'
|
||||
import { Environment } from '../../types'
|
||||
|
||||
export interface PublishedFileInfo {
|
||||
id: string
|
||||
published: boolean
|
||||
lastPublished: number
|
||||
}
|
||||
|
||||
// Look up the file behind a published slug without loading the room snapshot itself. Returns null
|
||||
// when the slug is unknown; callers decide how to treat unpublished files.
|
||||
export async function getPublishedFileInfo(
|
||||
env: Environment,
|
||||
publishedSlug: string
|
||||
): Promise<PublishedFileInfo | null> {
|
||||
const parentSlug = await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.get(publishedSlug)
|
||||
if (!parentSlug) return null
|
||||
|
||||
// createPostgresConnectionPool news up a pg.Pool; destroy it so idle pools don't pile up in the
|
||||
// isolate across MCP resolves, OG image requests, and queue re-resolves.
|
||||
const db = createPostgresConnectionPool(env, 'getPublishedFileInfo')
|
||||
try {
|
||||
const file = await db
|
||||
.selectFrom('file')
|
||||
.select(['id', 'published', 'lastPublished'])
|
||||
.where('id', '=', parentSlug)
|
||||
.executeTakeFirst()
|
||||
return file ?? null
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPublishedRoomSnapshot(
|
||||
env: Environment,
|
||||
roomId: string
|
||||
): Promise<RoomSnapshot | undefined> {
|
||||
// Re-resolve the published slug on every read so the published gate holds at serve time, not
|
||||
// just when the board was first resolved. A board un-published between resolution and this
|
||||
// read must stop resolving even though its R2 snapshot lingers until the outbox's unpublish
|
||||
// effect deletes it (the DB `published` flag flips immediately; R2 cleanup lags behind it).
|
||||
// Undefined (not a throw) so unknown/unpublished slugs surface as a 404, not a 500.
|
||||
const file = await getPublishedFileInfo(env, roomId)
|
||||
if (!file) return undefined
|
||||
if (!file.published) return undefined
|
||||
|
||||
return (await env.ROOM_SNAPSHOTS.get(
|
||||
getR2KeyForRoom({ slug: `${file.id}/${roomId}`, isApp: true })
|
||||
).then((r) => r?.json())) as RoomSnapshot | undefined
|
||||
}
|
||||
|
||||
// Get a published file from a file's publishedSlug, if there is one.
|
||||
export async function getPublishedFile(request: IRequest, env: Environment): Promise<Response> {
|
||||
const { roomId } = request.params
|
||||
if (!roomId) {
|
||||
return Response.json({ error: true, message: 'Room ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const publishedRoomSnapshot = await getPublishedRoomSnapshot(env, roomId)
|
||||
if (!publishedRoomSnapshot)
|
||||
return Response.json({ error: true, message: 'Room not found' }, { status: 404 })
|
||||
|
||||
const { documents, schema } = publishedRoomSnapshot
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
records: documents.map((d) => d.state),
|
||||
schema: schema,
|
||||
error: false,
|
||||
}),
|
||||
{
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
140
apps/dotcom/sync-worker/src/routes/tla/getSharedFile.test.ts
Normal file
140
apps/dotcom/sync-worker/src/routes/tla/getSharedFile.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
SharedFileInfo,
|
||||
getSharedFileRoomSnapshot,
|
||||
isFileAnonymouslyViewable,
|
||||
isFileRenderable,
|
||||
} from './getSharedFile'
|
||||
|
||||
function makeFile(overrides: Partial<SharedFileInfo> = {}): SharedFileInfo {
|
||||
return { id: 'file-abc', shared: true, isDeleted: false, ...overrides }
|
||||
}
|
||||
|
||||
// The two gates, and the one difference between them: sharing. Rendering happens for every board so
|
||||
// an owner-facing surface has a thumbnail; serving to an anonymous caller does not.
|
||||
describe('isFileRenderable', () => {
|
||||
it('allows a private file — privacy gates serving, not rendering', () => {
|
||||
expect(isFileRenderable(makeFile({ shared: false }))).toBe(true)
|
||||
expect(isFileAnonymouslyViewable(makeFile({ shared: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a missing file', () => {
|
||||
expect(isFileRenderable(null)).toBe(false)
|
||||
})
|
||||
|
||||
// A deleted board has nothing worth depicting, shared or not.
|
||||
it('refuses a deleted file', () => {
|
||||
expect(isFileRenderable(makeFile({ isDeleted: true }))).toBe(false)
|
||||
expect(isFileRenderable(makeFile({ isDeleted: true, shared: false }))).toBe(false)
|
||||
})
|
||||
|
||||
// Reading a test file needs admin auth, so it has no business being pulled through the render page
|
||||
// even though nothing would serve the result anonymously.
|
||||
it('refuses a test file', () => {
|
||||
expect(isFileRenderable(makeFile({ id: 'test_abc' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFileAnonymouslyViewable', () => {
|
||||
it('allows a shared, non-deleted, non-test file', () => {
|
||||
expect(isFileAnonymouslyViewable(makeFile())).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a missing file', () => {
|
||||
expect(isFileAnonymouslyViewable(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a private (unshared) file', () => {
|
||||
expect(isFileAnonymouslyViewable(makeFile({ shared: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a deleted file even if still shared', () => {
|
||||
expect(isFileAnonymouslyViewable(makeFile({ isDeleted: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a test file, which needs admin auth the anonymous tool never has', () => {
|
||||
expect(isFileAnonymouslyViewable(makeFile({ id: 'test_abc' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// The `file` option on getSharedFileRoomSnapshot. It decides whether the gate runs against a freshly
|
||||
// read row or one the caller already holds — never whether the gate runs at all. The Postgres pool is
|
||||
// faked rather than the reader mocked, so these exercise the real query path and can tell a skipped
|
||||
// round trip from a skipped check.
|
||||
const pg = vi.hoisted(() => {
|
||||
const executeTakeFirst = vi.fn()
|
||||
const db: any = {
|
||||
selectFrom: () => db,
|
||||
select: () => db,
|
||||
where: () => db,
|
||||
executeTakeFirst,
|
||||
destroy: vi.fn(),
|
||||
}
|
||||
return { db, executeTakeFirst, createPostgresConnectionPool: vi.fn(() => db) }
|
||||
})
|
||||
|
||||
vi.mock('../../postgres', () => ({
|
||||
createPostgresConnectionPool: pg.createPostgresConnectionPool,
|
||||
}))
|
||||
|
||||
describe('getSharedFileRoomSnapshot', () => {
|
||||
const snapshot = { documents: [], schema: { schemaVersion: 2, sequences: {} } }
|
||||
|
||||
function makeEnv() {
|
||||
return { ROOMS: { get: vi.fn().mockResolvedValue({ json: async () => snapshot }) } } as any
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('reads the file row when none is supplied', async () => {
|
||||
pg.executeTakeFirst.mockResolvedValue(makeFile())
|
||||
|
||||
await expect(
|
||||
getSharedFileRoomSnapshot(makeEnv(), 'file-abc', { access: 'public' })
|
||||
).resolves.toEqual(snapshot)
|
||||
expect(pg.createPostgresConnectionPool).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses a supplied row instead of asking Postgres again', async () => {
|
||||
await expect(
|
||||
getSharedFileRoomSnapshot(makeEnv(), 'file-abc', { access: 'public', file: makeFile() })
|
||||
).resolves.toEqual(snapshot)
|
||||
expect(pg.createPostgresConnectionPool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The point of the option is to skip the round trip, not the check. A row that fails the gate is
|
||||
// refused exactly as a freshly-read one would be, and the snapshot is never read.
|
||||
it('still applies the gate to a supplied row', async () => {
|
||||
const env = makeEnv()
|
||||
|
||||
await expect(
|
||||
getSharedFileRoomSnapshot(env, 'file-abc', {
|
||||
access: 'public',
|
||||
file: makeFile({ shared: false }),
|
||||
})
|
||||
).rejects.toThrow('not shared')
|
||||
|
||||
await expect(
|
||||
getSharedFileRoomSnapshot(env, 'file-abc', {
|
||||
access: 'render',
|
||||
file: makeFile({ isDeleted: true }),
|
||||
})
|
||||
).rejects.toThrow('not renderable')
|
||||
|
||||
expect(pg.createPostgresConnectionPool).not.toHaveBeenCalled()
|
||||
expect(env.ROOMS.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A supplied row is gated under the *caller's* access level, not whichever one it was resolved
|
||||
// under, so handing a render-gated row to a public read cannot widen what that read may see.
|
||||
it('refuses a private row supplied to a public read', async () => {
|
||||
await expect(
|
||||
getSharedFileRoomSnapshot(makeEnv(), 'file-abc', {
|
||||
access: 'public',
|
||||
file: makeFile({ shared: false }),
|
||||
})
|
||||
).rejects.toThrow('not shared')
|
||||
})
|
||||
})
|
||||
96
apps/dotcom/sync-worker/src/routes/tla/getSharedFile.ts
Normal file
96
apps/dotcom/sync-worker/src/routes/tla/getSharedFile.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { createPostgresConnectionPool } from '../../postgres'
|
||||
import { getR2KeyForRoom } from '../../r2'
|
||||
import { Environment, ThumbnailBoardAccess } from '../../types'
|
||||
import { isTestFile } from '../../utils/tla/isTestFile'
|
||||
|
||||
export interface SharedFileInfo {
|
||||
id: string
|
||||
shared: boolean
|
||||
isDeleted: boolean
|
||||
}
|
||||
|
||||
// Look up an app file directly by its id (the `:slug` in tldraw.com/f/:slug) without loading the
|
||||
// room snapshot. Returns null when the file is unknown.
|
||||
export async function getSharedFileInfo(
|
||||
env: Environment,
|
||||
slug: string
|
||||
): Promise<SharedFileInfo | null> {
|
||||
// createPostgresConnectionPool news up a pg.Pool; destroy it so idle pools don't pile up in the
|
||||
// isolate across MCP resolves, OG image requests, and queue re-resolves.
|
||||
const db = createPostgresConnectionPool(env, 'getSharedFileInfo')
|
||||
try {
|
||||
const file = await db
|
||||
.selectFrom('file')
|
||||
.select(['id', 'shared', 'isDeleted'])
|
||||
.where('id', '=', slug)
|
||||
.executeTakeFirst()
|
||||
return file ?? null
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a file is worth rendering a thumbnail of at all: it exists, is not deleted, and is not a test
|
||||
* file (reading one requires admin auth, so it has no business going through the render page). Says
|
||||
* nothing about who may *see* that thumbnail — every board gets one, private ones included, and serving
|
||||
* is gated separately.
|
||||
*/
|
||||
export function isFileRenderable(file: SharedFileInfo | null): file is SharedFileInfo {
|
||||
return !!file && !file.isDeleted && !isTestFile(file.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a file may be served to an anonymous caller — the MCP tool, the OG image route, the
|
||||
* crawler HTML. Renderable, plus actually shared via link. This mirrors the anonymous read gate
|
||||
* enforced by the file room itself (`TLFileDurableObject.onRequest`). `sharedLinkType` (`view` vs
|
||||
* `edit`) is irrelevant to viewing.
|
||||
*/
|
||||
export function isFileAnonymouslyViewable(file: SharedFileInfo | null): file is SharedFileInfo {
|
||||
return isFileRenderable(file) && file.shared === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the gate an access level asks for. The mapping lives here, once, so that resolving a board
|
||||
* and reading its snapshot cannot end up applying different gates to the same access level — which
|
||||
* would mean a board resolving publicly and then being read under the weaker one.
|
||||
*/
|
||||
export function isFileViewableFor(
|
||||
file: SharedFileInfo | null,
|
||||
access: ThumbnailBoardAccess
|
||||
): file is SharedFileInfo {
|
||||
return access === 'public' ? isFileAnonymouslyViewable(file) : isFileRenderable(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the live room snapshot for an app file from R2.
|
||||
*
|
||||
* The gate always runs here — never trust that whatever resolved this board already applied it. What
|
||||
* `file` decides is only whether the row it runs against is re-read or reused:
|
||||
*
|
||||
* - **Omitted (the default): re-read.** The row is fetched fresh, so a board un-shared since the
|
||||
* caller last looked stops resolving. This is what a read separated from its resolve by a network
|
||||
* hop needs — `getThumbnailSnapshot` serves a *private* board's document to the render page inside
|
||||
* a token's window, and the un-share must land inside that window.
|
||||
* - **Supplied: reuse.** For a caller that resolved this board microseconds ago in the same function,
|
||||
* a second query returns the same row and buys nothing but a Postgres connection. The gate is still
|
||||
* applied to the row that was passed, so the check is not skipped — only the round trip is.
|
||||
*
|
||||
* Pass it only where the resolve is genuinely adjacent to the read. Anywhere else, the freshness is
|
||||
* the point.
|
||||
*/
|
||||
export async function getSharedFileRoomSnapshot(
|
||||
env: Environment,
|
||||
slug: string,
|
||||
{ access, file: resolvedFile }: { access: ThumbnailBoardAccess; file?: SharedFileInfo }
|
||||
): Promise<RoomSnapshot | undefined> {
|
||||
const file = resolvedFile ?? (await getSharedFileInfo(env, slug))
|
||||
if (!isFileViewableFor(file, access)) {
|
||||
throw Error(access === 'public' ? 'not shared' : 'not renderable')
|
||||
}
|
||||
|
||||
return (await env.ROOMS.get(getR2KeyForRoom({ slug, isApp: true })).then((r) => r?.json())) as
|
||||
| RoomSnapshot
|
||||
| undefined
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { THUMBNAIL_RENDER_TOKEN_TTL_MS } from '../../config'
|
||||
import { Environment } from '../../types'
|
||||
import {
|
||||
ThumbnailRenderJob,
|
||||
mintThumbnailRenderToken,
|
||||
recordMintedRenderToken,
|
||||
isMintedRenderToken,
|
||||
wasRenderTokenServedSince,
|
||||
} from '../../utils/renderTokens'
|
||||
import { getPublishedRoomSnapshot } from './getPublishedFile'
|
||||
import { getSharedFileRoomSnapshot } from './getSharedFile'
|
||||
import { getThumbnailSnapshot } from './getThumbnailSnapshot'
|
||||
import { makeFakeThumbnailsBucket } from './screenshotTestHelpers'
|
||||
|
||||
vi.mock('./getPublishedFile', () => ({
|
||||
getPublishedRoomSnapshot: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./getSharedFile', () => ({
|
||||
getSharedFileRoomSnapshot: vi.fn(),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const env = { MCP_SCREENSHOT_TOKEN_SECRET: 'test-secret' } as Environment
|
||||
|
||||
function makeJob(overrides: Partial<ThumbnailRenderJob> = {}): ThumbnailRenderJob {
|
||||
return {
|
||||
v: 1,
|
||||
kind: 'published',
|
||||
slug: 'my-board',
|
||||
version: 1751234567890,
|
||||
access: 'render',
|
||||
camera: 'content',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
theme: 'dark',
|
||||
exp: Date.now() + THUMBNAIL_RENDER_TOKEN_TTL_MS,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRequest(token: string | null) {
|
||||
const url = new URL('https://sync.tldraw.xyz/app/thumbnail-render/snapshot')
|
||||
if (token !== null) url.searchParams.set('token', token)
|
||||
return new Request(url) as any
|
||||
}
|
||||
|
||||
describe('getThumbnailSnapshot', () => {
|
||||
it('returns snapshot data and render params for a valid token', async () => {
|
||||
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue({
|
||||
documents: [{ state: { id: 'shape:1', typeName: 'shape' }, lastChangedClock: 0 }],
|
||||
schema: { schemaVersion: 2, sequences: {} },
|
||||
clock: 0,
|
||||
} as any)
|
||||
|
||||
const response = await getThumbnailSnapshot(makeRequest(await mintToken()), env)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as any
|
||||
expect(body).toEqual({
|
||||
error: false,
|
||||
records: [{ id: 'shape:1', typeName: 'shape' }],
|
||||
schema: { schemaVersion: 2, sequences: {} },
|
||||
renderParams: {
|
||||
camera: 'content',
|
||||
// Echoed even though `content` makes the render page ignore them, so a job that omits
|
||||
// `camera` has a viewport to fall back on without a second shape of response.
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
theme: 'dark',
|
||||
},
|
||||
})
|
||||
expect(vi.mocked(getPublishedRoomSnapshot)).toHaveBeenCalledWith(env, 'my-board')
|
||||
})
|
||||
|
||||
it('passes the target pageId through to the render params', async () => {
|
||||
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue({
|
||||
documents: [
|
||||
{
|
||||
state: { id: 'page:abc', typeName: 'page', name: 'A', index: 'a1' },
|
||||
lastChangedClock: 0,
|
||||
},
|
||||
{ state: { id: 'shape:1', typeName: 'shape', parentId: 'page:abc' }, lastChangedClock: 0 },
|
||||
],
|
||||
schema: { schemaVersion: 2, sequences: {} },
|
||||
clock: 0,
|
||||
} as any)
|
||||
|
||||
const response = await getThumbnailSnapshot(
|
||||
makeRequest(await mintToken({ camera: 'content', pageId: 'page:abc' })),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as any
|
||||
expect(body.renderParams).toMatchObject({ camera: 'content', pageId: 'page:abc' })
|
||||
})
|
||||
|
||||
it('returns 404 when the token targets a page that no longer exists in the snapshot', async () => {
|
||||
// A shared file's live snapshot can lose the targeted page to a concurrent edit between the
|
||||
// token being minted and the render reloading the snapshot. Rendering a different page would
|
||||
// return a PNG mislabeled with the original page name, so the endpoint fails instead.
|
||||
vi.mocked(getSharedFileRoomSnapshot).mockResolvedValue({
|
||||
documents: [
|
||||
{
|
||||
state: { id: 'page:still-here', typeName: 'page', name: 'A', index: 'a1' },
|
||||
lastChangedClock: 0,
|
||||
},
|
||||
],
|
||||
schema: { schemaVersion: 2, sequences: {} },
|
||||
clock: 0,
|
||||
} as any)
|
||||
|
||||
const response = await getThumbnailSnapshot(
|
||||
makeRequest(
|
||||
await mintToken({ kind: 'shared_file', slug: 'file-abc', pageId: 'page:deleted' })
|
||||
),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('rejects requests without a token', async () => {
|
||||
const response = await getThumbnailSnapshot(makeRequest(null), env)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects invalid and expired tokens', async () => {
|
||||
const invalid = await getThumbnailSnapshot(makeRequest('bogus.token'), env)
|
||||
expect(invalid.status).toBe(403)
|
||||
|
||||
const expired = await getThumbnailSnapshot(
|
||||
makeRequest(await mintToken({ exp: Date.now() - 1 })),
|
||||
env
|
||||
)
|
||||
expect(expired.status).toBe(403)
|
||||
expect(vi.mocked(getPublishedRoomSnapshot)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the board is no longer published', async () => {
|
||||
vi.mocked(getPublishedRoomSnapshot).mockRejectedValue(Error('not published'))
|
||||
const response = await getThumbnailSnapshot(makeRequest(await mintToken()), env)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
// Every failure answers the same 404 (the render page turns it into an error state, and the
|
||||
// capture surfaces it as a generic render failure), so the report is the only thing that says
|
||||
// what actually broke.
|
||||
it('reports the underlying cause behind the 404', async () => {
|
||||
// No ctx is passed, so reportThumbnailError logs instead of reaching Sentry.
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
vi.mocked(getPublishedRoomSnapshot).mockRejectedValue(new Error('connection terminated'))
|
||||
expect((await getThumbnailSnapshot(makeRequest(await mintToken()), env)).status).toBe(404)
|
||||
expect(logged).toHaveBeenCalledTimes(1)
|
||||
|
||||
logged.mockRestore()
|
||||
})
|
||||
|
||||
it('returns 404 rather than throwing on a partial snapshot with no documents', async () => {
|
||||
// A corrupt R2 payload can have schema metadata but no documents array.
|
||||
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue({
|
||||
schema: { schemaVersion: 2, sequences: {} },
|
||||
clock: 0,
|
||||
} as any)
|
||||
const response = await getThumbnailSnapshot(makeRequest(await mintToken()), env)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('resolves shared-file tokens through the shared-file snapshot source', async () => {
|
||||
vi.mocked(getSharedFileRoomSnapshot).mockResolvedValue({
|
||||
documents: [{ state: { id: 'shape:1', typeName: 'shape' }, lastChangedClock: 0 }],
|
||||
schema: { schemaVersion: 2, sequences: {} },
|
||||
clock: 0,
|
||||
} as any)
|
||||
|
||||
const response = await getThumbnailSnapshot(
|
||||
makeRequest(await mintToken({ kind: 'shared_file', slug: 'file-abc', version: 'etag-1' })),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(vi.mocked(getSharedFileRoomSnapshot)).toHaveBeenCalledWith(env, 'file-abc', {
|
||||
access: 'render',
|
||||
})
|
||||
expect(vi.mocked(getPublishedRoomSnapshot)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The gate comes from the signed job, not from this route. An MCP token is minted `public`, so it
|
||||
// reads under the anonymous gate and stays confined to what the MCP tool could resolve — a board
|
||||
// that went private after minting is refused, where a thumbnail render's `render` token is not.
|
||||
it('reads under the access level the token was minted with', async () => {
|
||||
vi.mocked(getSharedFileRoomSnapshot).mockResolvedValue({
|
||||
documents: [{ state: { id: 'shape:1', typeName: 'shape' }, lastChangedClock: 0 }],
|
||||
schema: { schemaVersion: 2, sequences: {} },
|
||||
clock: 0,
|
||||
} as any)
|
||||
|
||||
await getThumbnailSnapshot(
|
||||
makeRequest(
|
||||
await mintToken({
|
||||
kind: 'shared_file',
|
||||
slug: 'file-abc',
|
||||
version: 'etag-1',
|
||||
access: 'public',
|
||||
})
|
||||
),
|
||||
env
|
||||
)
|
||||
|
||||
expect(vi.mocked(getSharedFileRoomSnapshot)).toHaveBeenCalledWith(env, 'file-abc', {
|
||||
access: 'public',
|
||||
})
|
||||
})
|
||||
|
||||
// Deleted, not un-shared: this route reads with `access: 'render'`, so a private board resolves and
|
||||
// its content is served to the render page. What refuses is a board that is deleted or unknown,
|
||||
// which `isFileRenderable` rejects.
|
||||
it('returns 404 when the board is deleted during the token window', async () => {
|
||||
vi.mocked(getSharedFileRoomSnapshot).mockRejectedValue(Error('not renderable'))
|
||||
const response = await getThumbnailSnapshot(
|
||||
makeRequest(await mintToken({ kind: 'shared_file', slug: 'file-abc', version: 'etag-1' })),
|
||||
env
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// The route is the place this actually protects something: it serves any board's full document now
|
||||
// that thumbnails render for every board, so a valid signature must not be sufficient on its own.
|
||||
describe('getThumbnailSnapshot render token records', () => {
|
||||
function makeEnvWithBucket() {
|
||||
return {
|
||||
MCP_SCREENSHOT_TOKEN_SECRET: 'test-secret',
|
||||
THUMBNAILS: makeFakeThumbnailsBucket(),
|
||||
} as unknown as Environment
|
||||
}
|
||||
|
||||
function snapshotOfOneShape() {
|
||||
return {
|
||||
documents: [{ state: { id: 'shape:1', typeName: 'shape' }, lastChangedClock: 0 }],
|
||||
schema: { schemaVersion: 2, sequences: {} },
|
||||
clock: 0,
|
||||
} as any
|
||||
}
|
||||
|
||||
it('serves a token that was recorded at mint time', async () => {
|
||||
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue(snapshotOfOneShape())
|
||||
const envWithBucket = makeEnvWithBucket()
|
||||
const job = makeJob()
|
||||
const token = await mintThumbnailRenderToken(envWithBucket, job)
|
||||
await recordMintedRenderToken(envWithBucket, job, token)
|
||||
|
||||
const response = await getThumbnailSnapshot(makeRequest(token), envWithBucket)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
// What a dead session reads back to tell a page that ran from one that never did.
|
||||
it('stamps the token record as served once the page fetches its snapshot', async () => {
|
||||
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue(snapshotOfOneShape())
|
||||
const envWithBucket = makeEnvWithBucket()
|
||||
const job = makeJob()
|
||||
const token = await mintThumbnailRenderToken(envWithBucket, job)
|
||||
await recordMintedRenderToken(envWithBucket, job, token)
|
||||
const sessionStart = Date.now()
|
||||
expect(await wasRenderTokenServedSince(envWithBucket, job, token, sessionStart)).toBe(
|
||||
'unreached'
|
||||
)
|
||||
|
||||
const response = await getThumbnailSnapshot(makeRequest(token), envWithBucket)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await wasRenderTokenServedSince(envWithBucket, job, token, sessionStart)).toBe('reached')
|
||||
// Stamping rewrites the record; the mint check must survive it.
|
||||
expect(await isMintedRenderToken(envWithBucket, job, token)).toBe(true)
|
||||
})
|
||||
|
||||
// The case a leaked MCP_SCREENSHOT_TOKEN_SECRET produces: signatures that verify, for any board,
|
||||
// minted by someone who cannot write to our bucket. Refused, and with the same 403 a bad signature
|
||||
// gets — which check failed is not the caller's business.
|
||||
it('refuses a validly signed token with no record, without reading the board', async () => {
|
||||
vi.mocked(getPublishedRoomSnapshot).mockResolvedValue(snapshotOfOneShape())
|
||||
const envWithBucket = makeEnvWithBucket()
|
||||
const forged = await mintThumbnailRenderToken(envWithBucket, makeJob())
|
||||
|
||||
const response = await getThumbnailSnapshot(makeRequest(forged), envWithBucket)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(await response.json()).toEqual({
|
||||
error: true,
|
||||
message: 'Invalid or expired render token',
|
||||
})
|
||||
// Refused before the board is touched, so a forged token cannot even cause a snapshot read.
|
||||
expect(vi.mocked(getPublishedRoomSnapshot)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A capture that crosses a rolling deploy: minted by the previous worker version, which had no
|
||||
// `access` field and wrote no records, then presented to this one. It reads as `public`, so it is
|
||||
// served rather than 403ing on a record that was never going to exist.
|
||||
it('serves a pre-deploy token that has no access field and no record', async () => {
|
||||
vi.mocked(getSharedFileRoomSnapshot).mockResolvedValue(snapshotOfOneShape())
|
||||
const envWithBucket = makeEnvWithBucket()
|
||||
const token = await mintThumbnailRenderToken(
|
||||
envWithBucket,
|
||||
makeJob({ kind: 'shared_file', slug: 'file-abc', version: 'etag-1', access: undefined })
|
||||
)
|
||||
|
||||
const response = await getThumbnailSnapshot(makeRequest(token), envWithBucket)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
// And under the gate that worker version applied, not the wider one.
|
||||
expect(vi.mocked(getSharedFileRoomSnapshot)).toHaveBeenCalledWith(envWithBucket, 'file-abc', {
|
||||
access: 'public',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function mintToken(overrides: Partial<ThumbnailRenderJob> = {}) {
|
||||
return mintThumbnailRenderToken(env, makeJob(overrides))
|
||||
}
|
||||
108
apps/dotcom/sync-worker/src/routes/tla/getThumbnailSnapshot.ts
Normal file
108
apps/dotcom/sync-worker/src/routes/tla/getThumbnailSnapshot.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ThumbnailSnapshotResponseBody } from '@tldraw/dotcom-shared'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../../types'
|
||||
import {
|
||||
isMintedRenderToken,
|
||||
markRenderTokenServed,
|
||||
renderJobAccess,
|
||||
renderParamsForJob,
|
||||
verifyThumbnailRenderToken,
|
||||
} from '../../utils/renderTokens'
|
||||
import { getPublishedRoomSnapshot } from './getPublishedFile'
|
||||
import { getSharedFileRoomSnapshot } from './getSharedFile'
|
||||
import { reportThumbnailError } from './thumbnailShared'
|
||||
|
||||
// Serves snapshot data to the thumbnail render page. Only accepts short-lived render tokens
|
||||
// minted by this worker, so the render page cannot be pointed at arbitrary boards even though
|
||||
// published snapshot data is itself public.
|
||||
export async function getThumbnailSnapshot(
|
||||
request: IRequest,
|
||||
env: Environment,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<Response> {
|
||||
const token = new URL(request.url).searchParams.get('token')
|
||||
if (!token) {
|
||||
return json({ error: true, message: 'token is required' }, 400)
|
||||
}
|
||||
|
||||
const job = await verifyThumbnailRenderToken(env, token)
|
||||
if (!job) {
|
||||
return json({ error: true, message: 'Invalid or expired render token' }, 403)
|
||||
}
|
||||
|
||||
// A valid signature only proves the holder of the secret made this, and a `render` token reads a
|
||||
// private board's full document. So one must also be recorded as ours (see isMintedRenderToken).
|
||||
// Answers the same 403 as a bad signature: which check failed is not the caller's business.
|
||||
if (!(await isMintedRenderToken(env, job, token))) {
|
||||
return json({ error: true, message: 'Invalid or expired render token' }, 403)
|
||||
}
|
||||
|
||||
// The page has called home. Stamped here, before the read, so a session that later dies can be told
|
||||
// apart from one whose page never ran at all (see wasRenderTokenServedSince). Awaited rather than
|
||||
// deferred: a page that errors the instant it has its snapshot ends the session, whose cleanup
|
||||
// deletes the stamp — a deferred write could land after that and stay behind.
|
||||
await markRenderTokenServed(env, job, token)
|
||||
|
||||
// Read under the gate the job was signed with, not a fixed one, so an MCP token stays confined to
|
||||
// what the MCP tool could resolve — including a board that has gone private since it was minted.
|
||||
// Shared files re-check here rather than trusting the mint, so a board deleted inside the token's
|
||||
// window (THUMBNAIL_RENDER_TOKEN_TTL_MS) stops resolving either way.
|
||||
const snapshot = await (
|
||||
job.kind === 'published'
|
||||
? getPublishedRoomSnapshot(env, job.slug)
|
||||
: getSharedFileRoomSnapshot(env, job.slug, { access: renderJobAccess(job) })
|
||||
).catch((error) => {
|
||||
// A load failure and a genuinely missing board both answer 404 here, which the render page
|
||||
// turns into an error state and the capture surfaces as a generic render failure. Report the
|
||||
// real cause so a broken snapshot read doesn't hide behind that.
|
||||
reportThumbnailError(error, {
|
||||
ctx,
|
||||
env,
|
||||
request,
|
||||
surface: 'thumbnail_snapshot',
|
||||
extras: { kind: job.kind },
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
// A corrupt or partial R2 payload can carry schema metadata without a documents array; guard it
|
||||
// so it returns a controlled 404 rather than throwing on the .map below and 500ing the render.
|
||||
if (!snapshot?.schema || !snapshot.documents) {
|
||||
return json({ error: true, message: 'Board not found' }, 404)
|
||||
}
|
||||
|
||||
// The token can target a specific page (the MCP tool labels its result with that page's name,
|
||||
// resolved when the token was minted). Shared files reload a live snapshot that may have changed
|
||||
// since then, so confirm the page still exists: if it was deleted meanwhile, the render page would
|
||||
// silently fall back to whichever page the snapshot opens to and the tool would return a PNG
|
||||
// mislabeled with the original page's name. Fail instead, so the capture surfaces as a retryable
|
||||
// render error rather than a wrong image.
|
||||
if (
|
||||
job.pageId &&
|
||||
!snapshot.documents.some((d) => (d.state as TLRecord | undefined)?.id === job.pageId)
|
||||
) {
|
||||
return json({ error: true, message: 'Page not found' }, 404)
|
||||
}
|
||||
|
||||
// Same reasoning as the page check above, and it matters more here: with every requested shape
|
||||
// gone the render page would have nothing to fit the camera to and would export a blank frame,
|
||||
// which the tool would return as if it were a picture of those shapes. Requiring every id to
|
||||
// still be present turns a stale request into a retryable render error instead.
|
||||
if (job.shapeIds) {
|
||||
const presentIds = new Set(snapshot.documents.map((d) => (d.state as TLRecord | undefined)?.id))
|
||||
if (!job.shapeIds.every((id) => presentIds.has(id as TLRecord['id']))) {
|
||||
return json({ error: true, message: 'Shape not found' }, 404)
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
error: false,
|
||||
records: snapshot.documents.map((d) => d.state) as TLRecord[],
|
||||
schema: snapshot.schema,
|
||||
renderParams: renderParamsForJob(job),
|
||||
})
|
||||
}
|
||||
|
||||
function json(body: ThumbnailSnapshotResponseBody, status = 200) {
|
||||
return Response.json(body, { status })
|
||||
}
|
||||
120
apps/dotcom/sync-worker/src/routes/tla/initUser.ts
Normal file
120
apps/dotcom/sync-worker/src/routes/tla/initUser.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { IndexKey } from '@tldraw/utils'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { createPostgresConnectionPool } from '../../postgres'
|
||||
import { Environment } from '../../types'
|
||||
import { writeDataPoint } from '../../utils/analytics'
|
||||
import { LOAD_ID_HEADER, parseLoadId } from '../../utils/loadId'
|
||||
import { isRateLimited } from '../../utils/rateLimit'
|
||||
import { getClerkClient } from '../../utils/tla/getAuth'
|
||||
|
||||
type InitOutcome = 'existing' | 'created' | 'rate_limited' | 'no_clerk_user' | 'no_email' | 'error'
|
||||
|
||||
// Ensures the user row + home workspace exist before Zero can query. Idempotent: concurrent
|
||||
// first-sign-ins race safely because all three inserts no-op on conflict, so the loser of the
|
||||
// race falls through to the same 200 as the winner instead of hitting a unique violation.
|
||||
export async function initUser(req: IRequest, env: Environment): Promise<Response> {
|
||||
const start = Date.now()
|
||||
const loadId = parseLoadId(req.headers.get(LOAD_ID_HEADER))
|
||||
// Timed into Analytics Engine (joinable on the load id). Every response built here also carries
|
||||
// it as a Server-Timing header for the client's first_load report; a thrown error bubbles to
|
||||
// the shared handler and loses the header, which is fine since the report is not the place to
|
||||
// learn about a 500.
|
||||
const record = (outcome: InitOutcome) => {
|
||||
const ms = Date.now() - start
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'init_user', {
|
||||
blobs: [outcome, ...(loadId ? [loadId] : [])],
|
||||
doubles: [ms],
|
||||
})
|
||||
return ms
|
||||
}
|
||||
const respond = (body: string, status: number, outcome: InitOutcome) => {
|
||||
const ms = record(outcome)
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { 'Server-Timing': `init;dur=${ms};desc=${outcome}` },
|
||||
})
|
||||
}
|
||||
const id = req.params.userId
|
||||
const db = createPostgresConnectionPool(env, '/app/init')
|
||||
try {
|
||||
const existing = await db
|
||||
.selectFrom('user')
|
||||
.where('id', '=', id)
|
||||
.select('id')
|
||||
.executeTakeFirst()
|
||||
if (existing) return respond('ok', 200, 'existing')
|
||||
|
||||
// Only the creation path is rate-limited: existing users hit the cheap SELECT above on
|
||||
// every sign-in and shouldn't burn rate-limit budget or risk a 429 boot-hang.
|
||||
if (await isRateLimited(env, id)) {
|
||||
return respond('Rate limited', 429, 'rate_limited')
|
||||
}
|
||||
|
||||
// auth is checked in the main worker, so the clerk user definitely exists
|
||||
const clerk = getClerkClient(env)
|
||||
const clerkUser = await clerk.users.getUser(id)
|
||||
if (!clerkUser) return respond('Clerk user not found', 404, 'no_clerk_user')
|
||||
|
||||
// A Clerk user can lack an email (e.g. some SSO/social flows); reading [0].emailAddress
|
||||
// on such a user throws and permanently 500s user boot. Fail cleanly with a 400 instead.
|
||||
const email =
|
||||
clerkUser.primaryEmailAddress?.emailAddress ?? clerkUser.emailAddresses[0]?.emailAddress
|
||||
if (!email) return respond('Clerk user has no email address', 400, 'no_email')
|
||||
|
||||
await db.transaction().execute(async (tx) => {
|
||||
const now = Date.now()
|
||||
await tx
|
||||
.insertInto('user')
|
||||
.values({
|
||||
id,
|
||||
name: clerkUser.fullName ?? '',
|
||||
email,
|
||||
avatar: clerkUser.imageUrl,
|
||||
color: '___INIT___',
|
||||
exportFormat: 'png',
|
||||
exportTheme: 'light',
|
||||
exportBackground: true,
|
||||
exportPadding: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// No feature flags on new users; the column is retained for future flags.
|
||||
flags: '',
|
||||
})
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute()
|
||||
await tx
|
||||
.insertInto('group')
|
||||
.values({
|
||||
id,
|
||||
// The home/private workspace defaults to "My workspace" and is renameable.
|
||||
name: 'My workspace',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeleted: false,
|
||||
inviteSecret: null,
|
||||
})
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute()
|
||||
await tx
|
||||
.insertInto('group_user')
|
||||
.values({
|
||||
userId: id,
|
||||
groupId: id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
role: 'owner',
|
||||
index: 'a1' as IndexKey,
|
||||
userName: clerkUser.fullName ?? '',
|
||||
userColor: '',
|
||||
})
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute()
|
||||
})
|
||||
return respond('ok', 200, 'created')
|
||||
} catch (e) {
|
||||
record('error')
|
||||
throw e
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
}
|
||||
450
apps/dotcom/sync-worker/src/routes/tla/mcpAuth.test.ts
Normal file
450
apps/dotcom/sync-worker/src/routes/tla/mcpAuth.test.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { signJwt } from '@clerk/backend/jwt'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Environment } from '../../types'
|
||||
import { isFeatureFlagEnabledForUser } from '../../utils/featureFlags'
|
||||
import {
|
||||
MCP_PROTECTED_RESOURCE_METADATA_FALLBACK_PATH,
|
||||
MCP_PROTECTED_RESOURCE_METADATA_PATH,
|
||||
McpAuthResult,
|
||||
authenticateMcpRequest,
|
||||
getMcpAuthorizationServer,
|
||||
getMcpProtectedResourceMetadata,
|
||||
getMcpResourceUrl,
|
||||
mcpCorsPreflight,
|
||||
withMcpCors,
|
||||
} from './mcpAuth'
|
||||
|
||||
// Nothing in @clerk/backend is mocked. The one thing stubbed is global `fetch`, and only because
|
||||
// there is no Clerk instance to fetch signing keys from: the SDK's JWKS request is answered with a key
|
||||
// set built from a generated pair. Everything the SDK then does with those keys — signature, `typ`,
|
||||
// `exp`, `sub` — runs for real.
|
||||
//
|
||||
// That matters because "`typ: at+jwt` is the only thing separating an access token from a session
|
||||
// token" is the load-bearing claim of the file under test, and a mocked verifier cannot demonstrate
|
||||
// it at all: an earlier version of this file mocked the whole verifier and its two "verification"
|
||||
// tests were assertions about what the mock had been *called with*.
|
||||
vi.mock('../../utils/featureFlags', () => ({ isFeatureFlagEnabledForUser: vi.fn() }))
|
||||
|
||||
const RESOURCE = 'https://www.tldraw.com/api/app/mcp'
|
||||
|
||||
// pk_test_<base64 of "clerk.tldraw.com$">, which is the shape Clerk publishable keys take.
|
||||
const PUBLISHABLE_KEY = `pk_test_${btoa('clerk.tldraw.com$')}`
|
||||
|
||||
// The Clerk instance the publishable key above names, and so the `iss` a real token carries.
|
||||
const ISSUER = 'https://clerk.tldraw.com'
|
||||
|
||||
const KEY_ID = 'test-signing-key'
|
||||
|
||||
// The instance's signing key, a second pair that stands for anybody else's, and the JWKS document the
|
||||
// stubbed fetch hands the SDK.
|
||||
let signingKey: JsonWebKey
|
||||
let foreignKey: JsonWebKey
|
||||
let jwks: { keys: (JsonWebKey & { kid: string })[] }
|
||||
|
||||
async function generateRsaPair() {
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify']
|
||||
)
|
||||
return {
|
||||
privateJwk: await crypto.subtle.exportKey('jwk', pair.privateKey),
|
||||
publicJwk: await crypto.subtle.exportKey('jwk', pair.publicKey),
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const instance = await generateRsaPair()
|
||||
signingKey = instance.privateJwk
|
||||
foreignKey = (await generateRsaPair()).privateJwk
|
||||
jwks = { keys: [{ ...instance.publicJwk, kid: KEY_ID, alg: 'RS256', use: 'sig' }] }
|
||||
})
|
||||
|
||||
/**
|
||||
* A real, signed token. Every default is what Clerk actually issues — RFC 9068's `at+jwt`, the
|
||||
* instance as `iss`, a subject, a live `exp` — so each test overrides exactly the one thing it is
|
||||
* about, and no test can pass because two things were wrong at once.
|
||||
*
|
||||
* No `aud`: Clerk stamps none, so its absence here is the shape of a real token rather than an
|
||||
* omission. See authenticateMcpRequest for what stands in for that binding.
|
||||
*/
|
||||
async function signToken({
|
||||
key,
|
||||
typ = 'at+jwt',
|
||||
// `null` omits the claim entirely, which `undefined` could not: it would fall through to the
|
||||
// default here and quietly sign a perfectly good token.
|
||||
sub = 'user_123' as string | null,
|
||||
exp = (Math.floor(Date.now() / 1000) + 300) as number | null,
|
||||
claims = {} as Record<string, unknown>,
|
||||
}: {
|
||||
key?: JsonWebKey
|
||||
typ?: string
|
||||
sub?: string | null
|
||||
exp?: number | null
|
||||
claims?: Record<string, unknown>
|
||||
} = {}) {
|
||||
const payload: Record<string, unknown> = { iss: ISSUER, ...claims }
|
||||
if (sub !== null) payload.sub = sub
|
||||
if (exp !== null) payload.exp = exp
|
||||
return await signJwt(payload, key ?? signingKey, {
|
||||
algorithm: 'RS256',
|
||||
header: { typ, kid: KEY_ID },
|
||||
})
|
||||
}
|
||||
|
||||
// The SDK caches a fetched key set per secret key for five minutes at module scope, which outlives
|
||||
// `clearAllMocks`. A test that counts JWKS fetches needs a secret key nothing else has warmed.
|
||||
let secretKeyCounter = 0
|
||||
|
||||
function makeEnv(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
CLERK_SECRET_KEY: 'sk_test_secret',
|
||||
CLERK_PUBLISHABLE_KEY: PUBLISHABLE_KEY,
|
||||
MCP_SERVER_URL: RESOURCE,
|
||||
...overrides,
|
||||
} as unknown as Environment
|
||||
}
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}) {
|
||||
return new Request('https://sync.tldraw.xyz/app/mcp', { method: 'POST', headers }) as any
|
||||
}
|
||||
|
||||
const bearer = (token: string) => makeRequest({ authorization: `Bearer ${token}` })
|
||||
|
||||
// Typed through the failure arm rather than a structural cast, so renaming `response` there breaks
|
||||
// here at compile time instead of as an undefined read in every assertion.
|
||||
function responseOf(result: McpAuthResult) {
|
||||
return (result as Extract<McpAuthResult, { ok: false }>).response
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : String(input)
|
||||
if (url.endsWith('/jwks')) return Response.json(jwks)
|
||||
throw new Error(`unexpected fetch during MCP auth: ${url}`)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.mocked(isFeatureFlagEnabledForUser).mockResolvedValue(true)
|
||||
// The refusal cases below are the expected way to see these logged, and a test run that prints them
|
||||
// reads like a failure. Tests that care which branch refused a token assert on the spy.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getMcpResourceUrl', () => {
|
||||
it('uses the configured URL', () => {
|
||||
expect(getMcpResourceUrl(makeRequest(), makeEnv())).toBe(RESOURCE)
|
||||
})
|
||||
|
||||
// The fallback reads the request's own origin, and therefore the Host header. Deployments configure
|
||||
// the var so that a forged Host cannot move the advertised metadata or the challenge pointer to a
|
||||
// host of the caller's choosing.
|
||||
it('falls back to the request origin when unset', () => {
|
||||
expect(getMcpResourceUrl(makeRequest(), makeEnv({ MCP_SERVER_URL: undefined }))).toBe(
|
||||
'https://sync.tldraw.xyz/api/app/mcp'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMcpAuthorizationServer', () => {
|
||||
// Derived from the publishable key rather than configured separately, so the authorization server
|
||||
// clients are sent to cannot drift from the instance whose tokens we accept — a mismatch would have
|
||||
// every client sign in successfully and then be refused.
|
||||
it('derives the Clerk frontend API origin from the publishable key', () => {
|
||||
expect(getMcpAuthorizationServer(makeEnv())).toBe('https://clerk.tldraw.com')
|
||||
})
|
||||
|
||||
it('prefers an explicit override', () => {
|
||||
expect(
|
||||
getMcpAuthorizationServer(makeEnv({ MCP_OAUTH_AUTHORIZATION_SERVER: 'https://auth.example' }))
|
||||
).toBe('https://auth.example')
|
||||
})
|
||||
|
||||
it('returns null for a missing or unparseable key', () => {
|
||||
expect(getMcpAuthorizationServer(makeEnv({ CLERK_PUBLISHABLE_KEY: undefined }))).toBe(null)
|
||||
expect(getMcpAuthorizationServer(makeEnv({ CLERK_PUBLISHABLE_KEY: 'not-a-key' }))).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMcpProtectedResourceMetadata', () => {
|
||||
it('names the resource and its authorization server', async () => {
|
||||
const response = getMcpProtectedResourceMetadata(makeRequest(), makeEnv())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
resource: RESOURCE,
|
||||
authorization_servers: ['https://clerk.tldraw.com'],
|
||||
scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
|
||||
bearer_methods_supported: ['header'],
|
||||
})
|
||||
})
|
||||
|
||||
// The fallback URL clients try when the path-derived one 404s. Both paths answer with the same
|
||||
// document on purpose: the resource stays the MCP endpoint rather than becoming the origin, which
|
||||
// is not protected and which no token is ever minted for.
|
||||
it('names the MCP endpoint, not the origin, on the path-less fallback URL', async () => {
|
||||
expect(MCP_PROTECTED_RESOURCE_METADATA_FALLBACK_PATH).toBe(
|
||||
'/.well-known/oauth-protected-resource'
|
||||
)
|
||||
expect(
|
||||
MCP_PROTECTED_RESOURCE_METADATA_PATH.startsWith(MCP_PROTECTED_RESOURCE_METADATA_FALLBACK_PATH)
|
||||
).toBe(true)
|
||||
|
||||
const response = getMcpProtectedResourceMetadata(makeRequest(), makeEnv())
|
||||
expect(await response.json()).toMatchObject({ resource: RESOURCE })
|
||||
})
|
||||
|
||||
// Advertising a resource with no authorization server would push the failure further along, into
|
||||
// the client, where it reads as a broken server rather than an unconfigured one.
|
||||
it('refuses to advertise a resource it has no authorization server for', () => {
|
||||
expect(
|
||||
getMcpProtectedResourceMetadata(makeRequest(), makeEnv({ CLERK_PUBLISHABLE_KEY: undefined }))
|
||||
.status
|
||||
).toBe(503)
|
||||
})
|
||||
})
|
||||
|
||||
// The endpoint and its metadata are deliberately outside the worker's origin allowlist, which exists
|
||||
// to protect *cookie*-authenticated routes. A browser-context MCP client — the Inspector on
|
||||
// localhost:6274, a web connector — is on no allowlist and never will be.
|
||||
describe('MCP CORS', () => {
|
||||
it('allows any origin and exposes the challenge header', () => {
|
||||
const response = withMcpCors(new Response('ok'))
|
||||
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*')
|
||||
// Without this a browser client can see that it was refused but not the resource_metadata
|
||||
// pointer telling it where to sign in — so it cannot start the flow the 401 is inviting.
|
||||
expect(response.headers.get('Access-Control-Expose-Headers')).toContain('WWW-Authenticate')
|
||||
})
|
||||
|
||||
it('keeps the body and status of the response it wraps', async () => {
|
||||
const response = withMcpCors(Response.json({ error: 'nope' }, { status: 401 }))
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.json()).toEqual({ error: 'nope' })
|
||||
})
|
||||
|
||||
it('answers a preflight with the same headers', () => {
|
||||
const response = mcpCorsPreflight()
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*')
|
||||
expect(response.headers.get('Access-Control-Allow-Headers')).toContain('authorization')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticateMcpRequest', () => {
|
||||
// The pointer is the whole point of the 401: without it a client learns only that it was refused,
|
||||
// not where to send the user to sign in.
|
||||
it('answers 401 with a pointer to the metadata when there is no token', async () => {
|
||||
const result = await authenticateMcpRequest(makeRequest(), makeEnv())
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
const response = responseOf(result)
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get('WWW-Authenticate')).toBe(
|
||||
`Bearer resource_metadata="https://www.tldraw.com${MCP_PROTECTED_RESOURCE_METADATA_PATH}", ` +
|
||||
`scope="openid profile email offline_access"`
|
||||
)
|
||||
})
|
||||
|
||||
// The scope list is what stops a client guessing. Claude's hosted connectors request every scope
|
||||
// the authorization server advertises when the challenge names none, and Clerk advertises scopes
|
||||
// it grants each client only a subset of — so an unnamed list is refused with `invalid_scope` at
|
||||
// the authorization server, before consent and before any request reaches this worker.
|
||||
it('names the scopes a client should ask for', async () => {
|
||||
const result = await authenticateMcpRequest(makeRequest(), makeEnv())
|
||||
|
||||
expect(responseOf(result).headers.get('WWW-Authenticate')).toContain(
|
||||
'scope="openid profile email offline_access"'
|
||||
)
|
||||
})
|
||||
|
||||
// Two places state the scopes and a client may read either, so they cannot be allowed to drift:
|
||||
// a client that asks for what the metadata advertises and a client that asks for what the
|
||||
// challenge names have to end up asking for the same thing.
|
||||
it('names the same scopes in the challenge and the metadata', async () => {
|
||||
const result = await authenticateMcpRequest(makeRequest(), makeEnv())
|
||||
const challenge = responseOf(result).headers.get('WWW-Authenticate') ?? ''
|
||||
const metadata = (await getMcpProtectedResourceMetadata(makeRequest(), makeEnv()).json()) as {
|
||||
scopes_supported: string[]
|
||||
}
|
||||
|
||||
expect(challenge).toContain(`scope="${metadata.scopes_supported.join(' ')}"`)
|
||||
})
|
||||
|
||||
it('accepts a valid token and returns its subject', async () => {
|
||||
const result = await authenticateMcpRequest(bearer(await signToken()), makeEnv())
|
||||
|
||||
expect(result).toEqual({ ok: true, userId: 'user_123' })
|
||||
})
|
||||
|
||||
// The signing keys are the accepting instance's own, fetched from Clerk's Backend API with the
|
||||
// secret key — which is what binds a token to that instance without an `iss` check, and what keeps
|
||||
// the advertised authorization server (derived from the same instance's publishable key) and the
|
||||
// accepted issuer from drifting apart. Fetched once and reused: rebuilt per request it would put a
|
||||
// round trip to Clerk in front of every MCP call, so the second request here must not fetch again.
|
||||
it('fetches the signing keys from the Backend API once and reuses them', async () => {
|
||||
const secretKey = `sk_test_jwks_${secretKeyCounter++}`
|
||||
const env = makeEnv({ CLERK_SECRET_KEY: secretKey })
|
||||
|
||||
await authenticateMcpRequest(bearer(await signToken()), env)
|
||||
await authenticateMcpRequest(bearer(await signToken()), env)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toBe('https://api.clerk.com/v1/jwks')
|
||||
expect(init?.headers).toMatchObject({ Authorization: `Bearer ${secretKey}` })
|
||||
})
|
||||
|
||||
// Without a secret key there is nothing to verify against, and without a derivable authorization
|
||||
// server no client could have been sent anywhere to get a token. Either is our misconfiguration
|
||||
// rather than a bad token, so it is logged as one — but answered like every other refusal, since
|
||||
// naming the difference would describe the deployment to someone guessing at it.
|
||||
it.each([
|
||||
['no secret key', { CLERK_SECRET_KEY: undefined }],
|
||||
['no derivable authorization server', { CLERK_PUBLISHABLE_KEY: undefined }],
|
||||
])('refuses as unconfigured with %s', async (_name, overrides) => {
|
||||
const result = await authenticateMcpRequest(bearer(await signToken()), makeEnv(overrides))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'unconfigured' })
|
||||
expect(responseOf(result).status).toBe(401)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'MCP token verification is unconfigured: no Clerk instance to verify against'
|
||||
)
|
||||
})
|
||||
|
||||
// The ways a token can be wrong, each signed for real and each refused by the SDK's verifier rather
|
||||
// than by a mock returning what the test wanted. `typ` is the one that matters most: Clerk stamps
|
||||
// no `aud` on either kind of token, so the token type is the *only* thing separating an OAuth
|
||||
// access token from an ordinary tldraw.com session JWT. Accepting a session token would make a
|
||||
// website credential enough to drive this server, and the consent step an agent walks a user
|
||||
// through decoration.
|
||||
//
|
||||
// "Another issuer" is not a case of its own any more: with no `iss` check, what refuses a token
|
||||
// from another Clerk instance is that it is signed with a key that is not in our instance's key
|
||||
// set, which is the foreign-key case.
|
||||
describe.each([
|
||||
['a Clerk session JWT rather than an access token', () => signToken({ typ: 'JWT' })],
|
||||
[
|
||||
"a token signed with a key that is not in the instance's key set",
|
||||
() => signToken({ key: foreignKey }),
|
||||
],
|
||||
['an expired token', () => signToken({ exp: Math.floor(Date.now() / 1000) - 3600 })],
|
||||
// A token minted without `exp` would otherwise never expire; the SDK requires the claim.
|
||||
['a token with no expiry at all', () => signToken({ exp: null })],
|
||||
['a token with no subject', () => signToken({ sub: null })],
|
||||
])('refuses %s', (_name, makeToken) => {
|
||||
it('with a 401 that says invalid_token and no detail', async () => {
|
||||
const result = await authenticateMcpRequest(bearer(await makeToken()), makeEnv())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'invalid_token' })
|
||||
const response = responseOf(result)
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get('WWW-Authenticate')).toContain('error="invalid_token"')
|
||||
// Why it failed is not the caller's business — an expired token and one minted for another
|
||||
// resource answer the same — but a client does need to know to re-authenticate rather than
|
||||
// stop. One message covers all of them.
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'invalid_token',
|
||||
error_description: 'The access token is expired, revoked, or issued for another resource',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a token whose expiry is inside the clock tolerance', async () => {
|
||||
// Two seconds past, which a worker whose clock runs slightly fast would produce. Inside the 5s
|
||||
// skew @clerk/backend allows by default.
|
||||
const result = await authenticateMcpRequest(
|
||||
bearer(await signToken({ exp: Math.floor(Date.now() / 1000) - 2 })),
|
||||
makeEnv()
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true, userId: 'user_123' })
|
||||
})
|
||||
|
||||
// Only the SDK's reason and message are logged. The token itself, or a decoded payload, would put
|
||||
// `sub`, `client_id`, `scope` and `jti` — every one of the things the response refuses to disclose —
|
||||
// into a log with a wider audience than the caller.
|
||||
it('logs why verification failed without logging the token or its claims', async () => {
|
||||
const token = await signToken({
|
||||
typ: 'JWT',
|
||||
claims: { client_id: 'client_secretive', scope: 'profile email', jti: 'jti_secretive' },
|
||||
})
|
||||
|
||||
await authenticateMcpRequest(bearer(token), makeEnv())
|
||||
|
||||
const logged = vi.mocked(console.error).mock.calls.flat().join(' ')
|
||||
expect(logged).toContain('MCP token verification failed:')
|
||||
expect(logged).toContain('token-type-mismatch')
|
||||
expect(logged).not.toContain('client_secretive')
|
||||
expect(logged).not.toContain('jti_secretive')
|
||||
expect(logged).not.toContain(token)
|
||||
})
|
||||
|
||||
// 403, not 401: the caller did authenticate and still may not in, which retrying the flow cannot
|
||||
// fix. A 401 here would have clients loop through sign-in forever.
|
||||
it('answers 403 for an authenticated user the flag does not cover', async () => {
|
||||
vi.mocked(isFeatureFlagEnabledForUser).mockResolvedValue(false)
|
||||
|
||||
const result = await authenticateMcpRequest(bearer(await signToken()), makeEnv())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'not_allowlisted' })
|
||||
const response = responseOf(result)
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.headers.get('WWW-Authenticate')).toBe(null)
|
||||
expect(isFeatureFlagEnabledForUser).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'mcp_server_access',
|
||||
'user_123'
|
||||
)
|
||||
})
|
||||
|
||||
// The reason rides on the refusal so the route can put it on a datapoint: during a flag-gated
|
||||
// rollout, "not signed in" and "signed in and not on the list" are the two numbers worth watching
|
||||
// and they call for entirely different responses.
|
||||
it('names no_token distinctly from the other refusals', async () => {
|
||||
const noToken = await authenticateMcpRequest(makeRequest(), makeEnv())
|
||||
|
||||
expect(noToken).toMatchObject({ ok: false, reason: 'no_token' })
|
||||
})
|
||||
|
||||
// The SDK only strips an exact `Bearer ` prefix, so the token is parsed here first and handed to it
|
||||
// re-wrapped; a client sending a lowercase or padded scheme must not be told its credential is bad.
|
||||
it('accepts the bearer scheme case-insensitively and ignores surrounding space', async () => {
|
||||
const token = await signToken()
|
||||
|
||||
expect(
|
||||
await authenticateMcpRequest(
|
||||
makeRequest({ authorization: ` bearer ${token} ` }),
|
||||
makeEnv()
|
||||
)
|
||||
).toEqual({ ok: true, userId: 'user_123' })
|
||||
})
|
||||
|
||||
// Anything that is not a bearer token is treated as no token at all, so the client is told where to
|
||||
// authenticate rather than that its credential was bad.
|
||||
it('treats a non-bearer authorization header as unauthenticated', async () => {
|
||||
const result = await authenticateMcpRequest(
|
||||
makeRequest({ authorization: 'Basic dXNlcjpwYXNz' }),
|
||||
makeEnv()
|
||||
)
|
||||
|
||||
const response = responseOf(result)
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get('WWW-Authenticate')).not.toContain('error=')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
373
apps/dotcom/sync-worker/src/routes/tla/mcpAuth.ts
Normal file
373
apps/dotcom/sync-worker/src/routes/tla/mcpAuth.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../../types'
|
||||
import { isFeatureFlagEnabledForUser } from '../../utils/featureFlags'
|
||||
import { getClerkClient } from '../../utils/tla/getAuth'
|
||||
|
||||
// The OAuth 2.1 resource-server half of the board screenshot MCP server: discovery metadata, bearer
|
||||
// token verification, and the feature flag gate that decides which authenticated users are let in.
|
||||
// The authorization server itself is Clerk — this worker never issues a token, only checks one.
|
||||
//
|
||||
// Why a resource server at all, rather than the session cookie the rest of the worker uses: the
|
||||
// callers here are Claude, ChatGPT and Cursor, which cannot hold a tldraw.com session. MCP's
|
||||
// authorization flow is how they sign a user in on their own, and it is built on OAuth 2.1.
|
||||
|
||||
/**
|
||||
* The MCP endpoint's public path, including the `/api` prefix that is applied upstream and stripped
|
||||
* before the router sees it (see the worker's `fetch`). The public URL is the resource identifier
|
||||
* clients authenticate against, so this is the form that appears in discovery metadata — not the
|
||||
* `/app/mcp` the router matches.
|
||||
*/
|
||||
export const MCP_RESOURCE_PATH = '/api/app/mcp'
|
||||
|
||||
/**
|
||||
* Where protected resource metadata lives, per RFC 9728: the resource's path, appended to
|
||||
* `/.well-known/oauth-protected-resource` at the resource's own origin.
|
||||
*
|
||||
* At the origin, which is why this needs its own route in `wrangler.toml` — the worker is otherwise
|
||||
* only routed `www.tldraw.com/api/*`, and a client that cannot fetch this simply never discovers
|
||||
* where to authenticate. That failure is silent on our side: it produces no request to log.
|
||||
*/
|
||||
export const MCP_PROTECTED_RESOURCE_METADATA_PATH = `/.well-known/oauth-protected-resource${MCP_RESOURCE_PATH}`
|
||||
|
||||
/**
|
||||
* The path-less form of the above, which the MCP authorization spec tells clients to fall back to
|
||||
* when the path-derived URL 404s.
|
||||
*
|
||||
* Served because clients actually do this, not because the origin is itself a protected resource:
|
||||
* `www.tldraw.com` is a web app, and the only OAuth-protected thing on it is the MCP server. Both
|
||||
* URLs therefore answer with the same document, whose `resource` stays `MCP_RESOURCE_PATH` — a
|
||||
* client that fell back to this path was asking about that resource anyway, and pointing it at the
|
||||
* origin instead would advertise a resource no token is ever minted for.
|
||||
*/
|
||||
export const MCP_PROTECTED_RESOURCE_METADATA_FALLBACK_PATH = '/.well-known/oauth-protected-resource'
|
||||
|
||||
/**
|
||||
* The scopes a client should ask for, named in both the `WWW-Authenticate` challenge and the RFC
|
||||
* 9728 metadata.
|
||||
*
|
||||
* Stated rather than left to the client, because a client that is told nothing guesses, and Claude's
|
||||
* hosted connectors guess by requesting every scope the authorization server advertises. Clerk
|
||||
* advertises six — `public_metadata` and `private_metadata` among them — and grants each client a
|
||||
* subset, so an unstated scope list had Claude asking for two it was never granted and Clerk
|
||||
* refusing the whole request with `invalid_scope`, before the consent screen and before any request
|
||||
* reached this worker. Claude Code was unaffected only because it sends no `scope` at all and gets
|
||||
* Clerk's configured defaults.
|
||||
*
|
||||
* What that cost, and the reason this is worth stating even though nothing here reads a scope: the
|
||||
* only fix available from the dashboard was granting Claude the two metadata scopes, widening what
|
||||
* its tokens can reach in Clerk to make an over-request legal. Naming the four here is what makes
|
||||
* revoking them possible.
|
||||
*
|
||||
* The corollary to keep in view: a client asks for what it is offered, so anything added to this
|
||||
* list — a custom tldraw scope, say — is requested by every client immediately and refused for every
|
||||
* one not granted it on the authorization server first.
|
||||
*
|
||||
* These four are what the Clerk CIMD clients are granted. Narrower would serve: nothing here reads a
|
||||
* scope, and `sub` — all this server takes from a token — rides on `openid` alone, with
|
||||
* `offline_access` for the refresh token. `profile` and `email` are kept because they are what
|
||||
* Clerk's defaults already hand Claude Code, and matching them keeps one behaviour across clients
|
||||
* rather than two.
|
||||
*/
|
||||
export const MCP_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
|
||||
|
||||
/**
|
||||
* This server's own identifier: what RFC 9728 metadata advertises as the resource, and what the
|
||||
* `WWW-Authenticate` challenge points a client at.
|
||||
*
|
||||
* Not a test applied to incoming tokens, despite RFC 8707 intending exactly that — see
|
||||
* `authenticateMcpRequest` for why there is no audience check and what stands in for one.
|
||||
*
|
||||
* Deployments set `MCP_SERVER_URL` explicitly rather than letting this be derived from the request,
|
||||
* because the derivation reads the `Host` header: a request carrying a forged one would otherwise
|
||||
* move the advertised metadata and the challenge pointer to a host of the caller's choosing, which
|
||||
* is how a client gets aimed at an authorization server that is not ours. The fallback exists for
|
||||
* local dev and tests, where there is no configured origin and no attacker to speak of.
|
||||
*/
|
||||
export function getMcpResourceUrl(request: Request, env: Environment): string {
|
||||
if (env.MCP_SERVER_URL) return env.MCP_SERVER_URL
|
||||
return new URL(MCP_RESOURCE_PATH, new URL(request.url).origin).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Clerk instance acting as our authorization server, derived from the publishable key rather
|
||||
* than configured separately.
|
||||
*
|
||||
* Derived deliberately: the instance it names is the one whose secret key verifies tokens, so the
|
||||
* authorization server we point clients at and the one whose tokens we accept cannot drift apart
|
||||
* without the two Clerk keys themselves disagreeing. Two vars could drift on their own, and the symptom
|
||||
* would be every client completing a sign-in and then being refused — with nothing in our logs
|
||||
* distinguishing it from a bad token.
|
||||
*
|
||||
* A Clerk publishable key is `pk_(test|live)_<base64 of "frontend-api-host$">`.
|
||||
*/
|
||||
export function getMcpAuthorizationServer(env: Environment): string | null {
|
||||
if (env.MCP_OAUTH_AUTHORIZATION_SERVER) return env.MCP_OAUTH_AUTHORIZATION_SERVER
|
||||
|
||||
const key = env.CLERK_PUBLISHABLE_KEY
|
||||
if (!key) return null
|
||||
const encoded = key.replace(/^pk_(test|live)_/, '')
|
||||
if (encoded === key) return null
|
||||
try {
|
||||
const host = atob(encoded).replace(/\$$/, '')
|
||||
if (!host) return null
|
||||
return `https://${host}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9728 protected resource metadata. Names the authorization server so a client that has only our
|
||||
* URL can find where to sign the user in.
|
||||
*
|
||||
* Cached for an hour: it changes only when the Clerk instance does, and clients fetch it on every
|
||||
* connect.
|
||||
*/
|
||||
export function getMcpProtectedResourceMetadata(request: IRequest, env: Environment): Response {
|
||||
const authorizationServer = getMcpAuthorizationServer(env)
|
||||
if (!authorizationServer) {
|
||||
// Nothing useful to advertise, and advertising a resource with no authorization server would
|
||||
// have clients fail further along with a less legible error than "not configured".
|
||||
return Response.json({ error: 'MCP authorization is not configured' }, { status: 503 })
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
resource: getMcpResourceUrl(request, env),
|
||||
authorization_servers: [authorizationServer],
|
||||
scopes_supported: MCP_SCOPES,
|
||||
bearer_methods_supported: ['header'],
|
||||
resource_documentation: 'https://tldraw.dev',
|
||||
},
|
||||
{ headers: { 'cache-control': 'public, max-age=3600' } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS for the MCP endpoint and its discovery metadata, which are deliberately not held to the
|
||||
* worker's origin allowlist.
|
||||
*
|
||||
* That allowlist exists to stop a page on somebody else's site from riding a visitor's tldraw.com
|
||||
* *cookie*. This endpoint has no cookie to ride — it authenticates a bearer token and nothing else —
|
||||
* so an origin check buys nothing here and costs real clients. A browser-context MCP client (the
|
||||
* Inspector on `localhost:6274`, a web connector fetching directly) is on no allowlist and never will
|
||||
* be, and what it gets instead of a legible refusal is a bare `403 Not allowed` carrying no CORS
|
||||
* headers at all: indistinguishable from "there is no MCP server here". That is precisely the silent
|
||||
* discovery failure the extra `wrangler.toml` route exists to prevent, moved one layer further in.
|
||||
*
|
||||
* Clients holding no `Origin` at all — Claude Desktop, `mcp-remote`, Cursor — were always fine, which
|
||||
* is why this is easy to miss: testing with one of them proves nothing about the others.
|
||||
*/
|
||||
export const MCP_CORS_HEADERS: Record<string, string> = {
|
||||
// Safe as `*` precisely because there are no credentials in play: nothing here reads a cookie, and
|
||||
// a bearer token is something the client already holds rather than something the browser would
|
||||
// attach on its behalf. `*` and credentials are mutually exclusive, and we want the former.
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers':
|
||||
'authorization, content-type, mcp-protocol-version, mcp-method, mcp-name, mcp-session-id',
|
||||
// The whole content of a 401 is its `WWW-Authenticate` challenge — without it a browser client can
|
||||
// see that it was refused but not the `resource_metadata` pointer telling it where to sign in, so
|
||||
// it cannot start the flow the refusal is inviting.
|
||||
'Access-Control-Expose-Headers': 'WWW-Authenticate',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamps the MCP CORS headers onto a response. Set rather than appended, and set before the worker's
|
||||
* own `corsify` runs — which returns a response untouched once it already carries an
|
||||
* `Access-Control-Allow-Origin`, so this wins without having to be special-cased there.
|
||||
*/
|
||||
export function withMcpCors(response: Response): Response {
|
||||
const corsified = new Response(response.body, response)
|
||||
for (const [header, value] of Object.entries(MCP_CORS_HEADERS)) {
|
||||
corsified.headers.set(header, value)
|
||||
}
|
||||
return corsified
|
||||
}
|
||||
|
||||
/**
|
||||
* The preflight answer for the MCP routes. Registered ahead of the router's shared `preflight`, which
|
||||
* answers from the origin allowlist and so would hand a browser client a 204 with no
|
||||
* `Access-Control-Allow-Origin` — a refusal it can't read either.
|
||||
*/
|
||||
export function mcpCorsPreflight(): Response {
|
||||
return withMcpCors(new Response(null, { status: 204 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The `401` that starts an MCP client's sign-in. The `resource_metadata` parameter is the whole
|
||||
* point of it: without that pointer a client knows only that it was refused, not where to go.
|
||||
*/
|
||||
export function mcpUnauthorized(
|
||||
request: Request,
|
||||
env: Environment,
|
||||
{ error, description }: { error?: string; description?: string } = {}
|
||||
): Response {
|
||||
const metadataUrl = new URL(
|
||||
MCP_PROTECTED_RESOURCE_METADATA_PATH,
|
||||
getMcpResourceUrl(request, env)
|
||||
).toString()
|
||||
// `scope` before the error parameters because it is the one a first-contact client acts on: it is
|
||||
// what Claude's hosted connectors read to decide what to ask Clerk for, and without it they ask
|
||||
// for everything Clerk advertises. See MCP_SCOPES.
|
||||
const params = [`resource_metadata="${metadataUrl}"`, `scope="${MCP_SCOPES.join(' ')}"`]
|
||||
if (error) params.push(`error="${error}"`)
|
||||
if (description) params.push(`error_description="${description}"`)
|
||||
|
||||
return Response.json(
|
||||
{ error: error ?? 'unauthorized', error_description: description },
|
||||
{ status: 401, headers: { 'WWW-Authenticate': `Bearer ${params.join(', ')}` } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a request was turned away, as a closed vocabulary. Carried on the refusal so the route can put
|
||||
* it on a datapoint: during a flag-gated rollout the number that matters most is how many callers are
|
||||
* being refused and *which* kind of no they got — "not signed in" and "signed in, not on the list"
|
||||
* call for entirely different responses — and neither was visible anywhere, since the per-call event
|
||||
* is written by the dispatcher, which a refused request never reaches.
|
||||
*
|
||||
* Written by the route rather than here, which is where every other MCP datapoint is written and
|
||||
* which keeps this module free of a dependency on the one that imports it.
|
||||
*/
|
||||
export type McpAuthRefusal = 'no_token' | 'invalid_token' | 'unconfigured' | 'not_allowlisted'
|
||||
|
||||
export type McpAuthResult =
|
||||
| { ok: true; userId: string }
|
||||
| { ok: false; response: Response; reason: McpAuthRefusal }
|
||||
|
||||
/**
|
||||
* Authenticates and authorizes one MCP request: a valid bearer token minted for this resource, for a
|
||||
* user the `mcp_server_access` flag names.
|
||||
*
|
||||
* There is no anonymous path. This endpoint used to serve any caller that named a public board, and
|
||||
* requiring a token retires that deliberately — per-IP rate limits and unattributable Browser Run
|
||||
* spend were the cost of it.
|
||||
*
|
||||
* The two refusals are kept distinct because they mean different things to a client: `401` says
|
||||
* "authenticate, here is where", which a client can act on; `403` says "you did authenticate and you
|
||||
* still may not", which it cannot, and retrying the flow would loop.
|
||||
*/
|
||||
export async function authenticateMcpRequest(
|
||||
request: IRequest,
|
||||
env: Environment
|
||||
): Promise<McpAuthResult> {
|
||||
// One 401 for every way a token can be refused, built once. The reason rides on the result for
|
||||
// telemetry; the *response* says the same thing whichever it was, deliberately — see
|
||||
// INVALID_TOKEN_DESCRIPTION.
|
||||
const invalidToken = (reason: McpAuthRefusal): McpAuthResult => ({
|
||||
ok: false,
|
||||
reason,
|
||||
response: mcpUnauthorized(request, env, {
|
||||
error: 'invalid_token',
|
||||
description: INVALID_TOKEN_DESCRIPTION,
|
||||
}),
|
||||
})
|
||||
|
||||
const token = getBearerToken(request)
|
||||
if (!token) {
|
||||
// No `error` parameter: nothing was presented, so there is nothing to call invalid, and a bare
|
||||
// challenge is what tells a first-contact client to go and authenticate.
|
||||
return { ok: false, reason: 'no_token', response: mcpUnauthorized(request, env) }
|
||||
}
|
||||
|
||||
// Verified by @clerk/backend against the Clerk instance the secret key names: signature (against a
|
||||
// JWKS the SDK fetches from the Backend API once and caches), `sub`, lifetime and token type. Not
|
||||
// `verifyToken`, which is for *session* tokens and refuses an access token on its header alone;
|
||||
// `acceptsToken: 'oauth_token'` is the path that expects RFC 9068's `at+jwt`.
|
||||
//
|
||||
// `typ` is load-bearing rather than pedantry, and is the only thing separating an OAuth access token
|
||||
// from a Clerk *session* JWT. Clerk stamps no `aud` on either, so nothing here can tell them apart
|
||||
// by audience, and a session token — `typ: JWT` — would otherwise be a valid bearer token. That
|
||||
// would make an ordinary tldraw.com website credential enough to drive this server, and the consent
|
||||
// step an agent walks the user through decoration. The SDK answers one with `token-type-mismatch`.
|
||||
//
|
||||
// No `iss` check, where the jose verifier this replaced pinned one: the key set is the accepting
|
||||
// instance's own, so a token any other issuer signed fails on signature, and the authorization
|
||||
// server clients are sent to is derived from the same instance's publishable key.
|
||||
if (!env.CLERK_SECRET_KEY || !getMcpAuthorizationServer(env)) {
|
||||
// Nothing to verify against, or nothing a client could have been sent to. This is our
|
||||
// misconfiguration rather than a bad token, so it is logged as one — but the caller is told only
|
||||
// what every other refusal tells it, since naming the difference would describe our deployment
|
||||
// to someone guessing at it.
|
||||
console.error('MCP token verification is unconfigured: no Clerk instance to verify against')
|
||||
return invalidToken('unconfigured')
|
||||
}
|
||||
|
||||
const state = await getClerkClient(env).authenticateRequest(
|
||||
// The SDK parses `Authorization` itself and only strips an exact `Bearer ` prefix; handing it the
|
||||
// token getBearerToken already accepted keeps a lowercase or padded scheme working, and keeps the
|
||||
// body and every other header out of its hands.
|
||||
new Request(request.url, { headers: { authorization: `Bearer ${token}` } }),
|
||||
{ acceptsToken: 'oauth_token' }
|
||||
)
|
||||
if (!state.isAuthenticated) {
|
||||
// The reason a token failed is not the caller's business — an expired token and one minted for
|
||||
// somebody else's resource answer the same thing — but a client does need to know it should
|
||||
// re-authenticate rather than give up, which is what `invalid_token` says.
|
||||
//
|
||||
// The SDK's reason and message only, never the token or its decoded payload: `sub`, `client_id`,
|
||||
// `scope` and `jti` are every one of the things the response above is careful not to disclose,
|
||||
// written to a log with a wider audience than the caller.
|
||||
console.error('MCP token verification failed:', state.reason, state.message)
|
||||
return invalidToken('invalid_token')
|
||||
}
|
||||
|
||||
// There is deliberately no check here that this token was issued for *this* resource, and its
|
||||
// absence is the part of this file most likely to look like an oversight.
|
||||
//
|
||||
// RFC 8707 would bind a token to the resource it was minted for, via `aud`, so a token the user
|
||||
// granted to somebody else's MCP server could not be replayed against ours. Clerk does not
|
||||
// implement it: it stamps no `aud` on an access token whether or not the client sends a `resource`
|
||||
// parameter, so there is nothing here to compare. An earlier version of this file checked anyway
|
||||
// and, because production enforced unconditionally, would have refused every token ever issued.
|
||||
//
|
||||
// What closes the hole instead lives on the authorization server, where the client registry is:
|
||||
// Clerk's `client_id_metadata_documents_only_allow_pre_registered_clients` refuses to issue tokens
|
||||
// to CIMD clients nobody approved, so a client we have never heard of cannot obtain a token for our
|
||||
// users in the first place. Approving one is a Clerk dashboard action, not a deploy.
|
||||
//
|
||||
// The consequence to keep in mind: that setting is the whole of the protection, and it is invisible
|
||||
// from this repository. If it is ever turned off, every self-registered client in the world can
|
||||
// call this endpoint with a token its user consented to for something else entirely. A `client_id`
|
||||
// allowlist here would be the belt to that setting's braces if we ever want one — the claim is on
|
||||
// every token.
|
||||
|
||||
const userId = state.toAuth().userId
|
||||
|
||||
if (!(await isFeatureFlagEnabledForUser(env, 'mcp_server_access', userId))) {
|
||||
// Deliberately not a 404. The endpoint's existence is already public — it is in the discovery
|
||||
// metadata this same server serves — so hiding it here would cost a legible error and conceal
|
||||
// nothing.
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'not_allowlisted',
|
||||
response: Response.json(
|
||||
{
|
||||
error: 'forbidden',
|
||||
error_description: 'This account does not have access to the tldraw MCP server yet.',
|
||||
},
|
||||
{ status: 403 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, userId }
|
||||
}
|
||||
|
||||
/**
|
||||
* One message for every way a token can be refused. An expired token, one signed by another Clerk
|
||||
* instance, a session token wearing the wrong `typ` and a subjectless one all answer the same thing:
|
||||
* the caller cannot act on the difference, and spelling it out would tell an attacker which of their
|
||||
* guesses was closest.
|
||||
*/
|
||||
const INVALID_TOKEN_DESCRIPTION =
|
||||
'The access token is expired, revoked, or issued for another resource'
|
||||
|
||||
function getBearerToken(request: Request): string | null {
|
||||
const header = request.headers.get('authorization')
|
||||
if (!header) return null
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header.trim())
|
||||
return match ? match[1].trim() : null
|
||||
}
|
||||
211
apps/dotcom/sync-worker/src/routes/tla/mcpClusterIndex.test.ts
Normal file
211
apps/dotcom/sync-worker/src/routes/tla/mcpClusterIndex.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { TLShape } from '@tldraw/tlschema'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Environment } from '../../types'
|
||||
import {
|
||||
ResolvedPageOk,
|
||||
ShapeMeasurement,
|
||||
buildClusterIndex,
|
||||
clusterPage,
|
||||
clustersFromIndex,
|
||||
getClusterInfo,
|
||||
parseClusterIndex,
|
||||
} from './boardTools'
|
||||
import {
|
||||
MAX_CLUSTER_INDEX_LENGTH,
|
||||
readPageClusters,
|
||||
writePageClusterIndex,
|
||||
} from './mcpClusterIndex'
|
||||
import { makeFakeFileDurableObjectNamespace, makeScreenshotTestEnv } from './screenshotTestHelpers'
|
||||
import { ResolvedThumbnailBoard } from './thumbnailRender'
|
||||
|
||||
// The cluster index in isolation: what survives the round trip through storage, what is refused on
|
||||
// the way back, and the two limits that stop a cache from becoming a liability. The tool-level
|
||||
// behaviour it produces — which calls measure and which don't — is in mcpServer.test.
|
||||
|
||||
function makeShape(id: string, x: number): TLShape {
|
||||
return {
|
||||
id,
|
||||
typeName: 'shape',
|
||||
parentId: 'page:a',
|
||||
type: 'geo',
|
||||
x,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
index: 'a1',
|
||||
opacity: 1,
|
||||
isLocked: false,
|
||||
meta: {},
|
||||
props: { w: 80, h: 80 },
|
||||
} as unknown as TLShape
|
||||
}
|
||||
|
||||
function makePage(
|
||||
shapes = [makeShape('shape:one', 0), makeShape('shape:two', 5000)]
|
||||
): ResolvedPageOk {
|
||||
return { ok: true, pageId: 'page:a', pageName: 'Cover', shapes, pageIds: ['page:a'] }
|
||||
}
|
||||
|
||||
const MEASUREMENTS: Record<string, ShapeMeasurement> = {
|
||||
'shape:one': { minX: 0, minY: 0, maxX: 80, maxY: 80, text: 'Checkout total' },
|
||||
'shape:two': { minX: 5000, minY: 0, maxX: 5080, maxY: 80 },
|
||||
}
|
||||
|
||||
const BOARD: ResolvedThumbnailBoard = {
|
||||
kind: 'published',
|
||||
slug: 'abc',
|
||||
version: 'v1',
|
||||
access: 'public',
|
||||
fileId: 'file-1',
|
||||
}
|
||||
|
||||
const SNAPSHOT_VERSION = 'snapshot-v1'
|
||||
|
||||
function makeEnv() {
|
||||
const namespace = makeFakeFileDurableObjectNamespace()
|
||||
return { env: makeScreenshotTestEnv({ TLDR_DOC: namespace }), namespace }
|
||||
}
|
||||
|
||||
describe('the stored index', () => {
|
||||
it('answers exactly what the clusters it was built from would', () => {
|
||||
const page = makePage()
|
||||
const clusters = clusterPage(page, MEASUREMENTS)
|
||||
const selector = { kind: 'ordinal', ordinal: 0 } as const
|
||||
|
||||
const stored = parseClusterIndex(JSON.stringify(buildClusterIndex(clusters)))!
|
||||
const rehydrated = clustersFromIndex(page, stored)!
|
||||
|
||||
expect(getClusterInfo(page, rehydrated, clusters[0].id, selector)).toEqual(
|
||||
getClusterInfo(page, clusters, clusters[0].id, selector)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the measured text and drops the measured bounds', () => {
|
||||
const index = buildClusterIndex(clusterPage(makePage(), MEASUREMENTS))
|
||||
|
||||
// Text is not derivable from the record, so it is stored. Bounds are: they decide which atoms
|
||||
// merge, and that decision is already in `clusters`.
|
||||
expect(index.text).toEqual({ 'shape:one': 'Checkout total' })
|
||||
expect(JSON.stringify(index)).not.toContain('minX')
|
||||
})
|
||||
|
||||
it('refuses a row this build did not write', () => {
|
||||
const index = buildClusterIndex(clusterPage(makePage(), MEASUREMENTS))
|
||||
|
||||
expect(parseClusterIndex('not json')).toBeNull()
|
||||
expect(parseClusterIndex('null')).toBeNull()
|
||||
// A format from another build. The cache key rotates on edits, not on deploys, so this is the
|
||||
// only thing standing between a changed index shape and a tool reading it as current.
|
||||
expect(parseClusterIndex(JSON.stringify({ ...index, v: index.v + 1 }))).toBeNull()
|
||||
expect(parseClusterIndex(JSON.stringify({ ...index, clusters: undefined }))).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to rebuild for a page that has gained shapes', () => {
|
||||
const index = buildClusterIndex(clusterPage(makePage(), MEASUREMENTS))
|
||||
|
||||
// The direction a stored index cannot otherwise notice. Nothing about the index is wrong — it
|
||||
// rebuilds cleanly — it is just short, and answering short is a wrong answer no uncached call
|
||||
// could give. An index built when the page was empty is the worst case: a full page would be
|
||||
// reported as having no clusters at all.
|
||||
const grown = makePage([...makePage().shapes, makeShape('shape:three', 200)])
|
||||
expect(clustersFromIndex(grown, index)).toBeNull()
|
||||
expect(clustersFromIndex(grown, buildClusterIndex([]))).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to rebuild for a page whose shapes it does not name', () => {
|
||||
const index = buildClusterIndex(clusterPage(makePage(), MEASUREMENTS))
|
||||
|
||||
expect(clustersFromIndex(makePage(), index)).not.toBeNull()
|
||||
// One shape gone: the board moved under the index in a way its version did not catch, so it is
|
||||
// refused rather than served with a cluster that is quietly a shape short.
|
||||
expect(clustersFromIndex(makePage([makeShape('shape:one', 0)]), index)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reading and writing', () => {
|
||||
it('stores a page index and reads its clusters back for the same content version', async () => {
|
||||
const { env, namespace } = makeEnv()
|
||||
const page = makePage()
|
||||
const clusters = clusterPage(page, MEASUREMENTS)
|
||||
|
||||
await writePageClusterIndex({ env }, BOARD, page, buildClusterIndex(clusters), SNAPSHOT_VERSION)
|
||||
expect(await readPageClusters({ env }, BOARD, page, SNAPSHOT_VERSION)).toEqual(clusters)
|
||||
expect(namespace.calls.put).toBe(1)
|
||||
|
||||
// The resolved board version can move before R2 does, so it is deliberately not the cache key.
|
||||
const moved = { ...BOARD, version: 'v2' }
|
||||
expect(await readPageClusters({ env }, moved, page, SNAPSHOT_VERSION)).toEqual(clusters)
|
||||
|
||||
// The digest of the snapshot actually read is the key. This catches a publish-gap read where
|
||||
// lastPublished already moved but R2 still held the previous snapshot, even when both snapshots
|
||||
// contain the same shape ids.
|
||||
expect(await readPageClusters({ env }, moved, page, 'snapshot-v2')).toBeNull()
|
||||
|
||||
// And writing under the new snapshot replaces the row rather than adding one, so a file's cache
|
||||
// is bounded by its page count no matter how often it is edited.
|
||||
await writePageClusterIndex({ env }, moved, page, buildClusterIndex(clusters), 'snapshot-v2')
|
||||
expect([...namespace.objects.values()].flatMap((store) => [...store.keys()])).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not store a page whose index is over the size cap', async () => {
|
||||
const { env, namespace } = makeEnv()
|
||||
const shapes = Array.from({ length: 12000 }, (_, i) => makeShape(`shape:${i}`, i * 200))
|
||||
const page = makePage(shapes)
|
||||
const index = buildClusterIndex(clusterPage(page, {}))
|
||||
|
||||
expect(JSON.stringify(index).length).toBeGreaterThan(MAX_CLUSTER_INDEX_LENGTH)
|
||||
await writePageClusterIndex({ env }, BOARD, page, index, SNAPSHOT_VERSION)
|
||||
|
||||
// Nothing written, and nothing thrown: a page this size keeps measuring, which is what it did
|
||||
// before the cache existed.
|
||||
expect([...namespace.objects.values()].flatMap((store) => [...store.keys()])).toHaveLength(0)
|
||||
expect(await readPageClusters({ env }, BOARD, page, SNAPSHOT_VERSION)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a broken durable object as a miss rather than raising', async () => {
|
||||
const broken = {
|
||||
idFromName: (name: string) => ({ toString: () => `do(${name})` }),
|
||||
get: () => ({
|
||||
async getMcpClusterIndex() {
|
||||
throw new Error('nope')
|
||||
},
|
||||
async putMcpClusterIndex() {
|
||||
throw new Error('nope')
|
||||
},
|
||||
}),
|
||||
}
|
||||
const env = makeScreenshotTestEnv({ TLDR_DOC: broken }) as Environment
|
||||
const page = makePage()
|
||||
|
||||
expect(await readPageClusters({ env }, BOARD, page, SNAPSHOT_VERSION)).toBeNull()
|
||||
await expect(
|
||||
writePageClusterIndex(
|
||||
{ env },
|
||||
BOARD,
|
||||
page,
|
||||
buildClusterIndex(clusterPage(page, MEASUREMENTS)),
|
||||
SNAPSHOT_VERSION
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats a malformed stored row as a miss', async () => {
|
||||
const { env, namespace } = makeEnv()
|
||||
const page = makePage()
|
||||
await writePageClusterIndex(
|
||||
{ env },
|
||||
BOARD,
|
||||
page,
|
||||
buildClusterIndex(clusterPage(page, MEASUREMENTS)),
|
||||
SNAPSHOT_VERSION
|
||||
)
|
||||
|
||||
// Not a shape parseClusterIndex checks field by field — reading it throws inside the rebuild,
|
||||
// which the cache read catches and reports. Either way the caller measures.
|
||||
for (const store of namespace.objects.values()) {
|
||||
for (const [key, row] of store) {
|
||||
store.set(key, { ...row, payload: '{"v":1,"clusters":[{}],"text":{}}' })
|
||||
}
|
||||
}
|
||||
expect(await readPageClusters({ env }, BOARD, page, SNAPSHOT_VERSION)).toBeNull()
|
||||
})
|
||||
})
|
||||
126
apps/dotcom/sync-worker/src/routes/tla/mcpClusterIndex.ts
Normal file
126
apps/dotcom/sync-worker/src/routes/tla/mcpClusterIndex.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ShapeCluster } from '@tldraw/dotcom-shared'
|
||||
import { Environment, McpClusterIndexKey } from '../../types'
|
||||
import { getRoomDurableObject } from '../../utils/durableObjects'
|
||||
import {
|
||||
PageClusterIndex,
|
||||
ResolvedPageOk,
|
||||
clustersFromIndex,
|
||||
parseClusterIndex,
|
||||
} from './boardTools'
|
||||
import { ResolvedThumbnailBoard } from './thumbnailRender'
|
||||
import { reportThumbnailError } from './thumbnailShared'
|
||||
|
||||
// The cache that keeps the clustering tools off Browser Run. What it holds and why is documented
|
||||
// where it is built, in boardTools.ts; this is where it is stored and read.
|
||||
//
|
||||
// A cache in the strict sense: every path through here falls back to measuring, nothing depends on a
|
||||
// read succeeding, and a tool never fails because a write did not.
|
||||
//
|
||||
// The store is the file's own Durable Object, addressed by `ResolvedThumbnailBoard.fileId`. That is
|
||||
// the only Durable Object hop in the MCP request path — the tools otherwise read the room's
|
||||
// persisted snapshot straight out of R2 and never talk to the object that wrote it.
|
||||
//
|
||||
// Fresh relative to the snapshot, not to the board. An index is keyed to the content version it was
|
||||
// measured from, so it can never be staler than the snapshot the call is already reading — and that
|
||||
// snapshot is the last persisted one, which for a shared file lags the live room by the persist
|
||||
// interval. A tool carries that same lag whether it hit this cache or measured from scratch, so no
|
||||
// expiry here would shorten it (see "Open questions" in browser-run-thumbnails.md).
|
||||
|
||||
interface CacheContext {
|
||||
env: Environment
|
||||
request?: Request
|
||||
ctx?: ExecutionContext
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceiling on a stored index, above which a page is never cached and keeps measuring.
|
||||
*
|
||||
* A page of a few thousand shapes serializes to tens of kilobytes. The cap is for the outlier, and it
|
||||
* is deliberately generous — the biggest pages are the most expensive to measure, so refusing to
|
||||
* cache them is refusing the cache's best case. What it bounds is the worst case a file can hold:
|
||||
* this times its page count times the two board kinds, paid for as long as the file lives, since rows
|
||||
* are replaced and pruned but never expired. Measured in UTF-16 units, so a page of
|
||||
* non-ASCII text stores more bytes than the number suggests — well inside any storage limit either way.
|
||||
*/
|
||||
export const MAX_CLUSTER_INDEX_LENGTH = 256 * 1024
|
||||
|
||||
function keyFor(
|
||||
board: ResolvedThumbnailBoard,
|
||||
pageId: string,
|
||||
snapshotVersion: string
|
||||
): McpClusterIndexKey {
|
||||
return { kind: board.kind, pageId, version: snapshotVersion }
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's clusters as stored, or null if there isn't a usable index for it.
|
||||
*
|
||||
* "Usable" is decided here in full — the row exists, it is for this content version, it parses as
|
||||
* this build's format, and it names the shapes that are on the page — so a caller gets clusters it
|
||||
* can answer from or nothing at all, and never a third case to handle.
|
||||
*/
|
||||
export async function readPageClusters(
|
||||
{ env, request, ctx }: CacheContext,
|
||||
board: ResolvedThumbnailBoard,
|
||||
page: ResolvedPageOk,
|
||||
snapshotVersion: string
|
||||
): Promise<ShapeCluster[] | null> {
|
||||
try {
|
||||
const stored = await getRoomDurableObject(env, board.fileId).getMcpClusterIndex(
|
||||
keyFor(board, page.pageId, snapshotVersion)
|
||||
)
|
||||
if (!stored) return null
|
||||
|
||||
const index = parseClusterIndex(stored)
|
||||
// A row written by a build with a different format, or one that disagrees with the snapshot,
|
||||
// is a miss rather than an error: the next measure overwrites it.
|
||||
return index && clustersFromIndex(page, index)
|
||||
} catch (error) {
|
||||
// A cache that cannot be read costs a render, and the caller is about to pay it anyway. Worth
|
||||
// reporting all the same: silently falling back means every call re-measures at full price.
|
||||
reportThumbnailError(error, {
|
||||
ctx,
|
||||
env,
|
||||
request,
|
||||
surface: 'mcp_cluster_index_read',
|
||||
extras: { kind: board.kind, pageId: page.pageId },
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a page's index for the content it was measured from.
|
||||
*
|
||||
* Awaited rather than fired off in the background: a background write would leave the very next tool
|
||||
* call — usually get_cluster_info on the clusters just listed — racing the write it depends on.
|
||||
*/
|
||||
export async function writePageClusterIndex(
|
||||
{ env, request, ctx }: CacheContext,
|
||||
board: ResolvedThumbnailBoard,
|
||||
page: ResolvedPageOk,
|
||||
index: PageClusterIndex,
|
||||
snapshotVersion: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const payload = JSON.stringify(index)
|
||||
if (payload.length > MAX_CLUSTER_INDEX_LENGTH) return
|
||||
|
||||
await getRoomDurableObject(env, board.fileId).putMcpClusterIndex(
|
||||
keyFor(board, page.pageId, snapshotVersion),
|
||||
payload,
|
||||
page.pageIds
|
||||
)
|
||||
} catch (error) {
|
||||
// Reported, never raised. The measurements are in hand and the tool's answer is correct; a
|
||||
// failed write costs the next caller a render, which is exactly what they would have paid
|
||||
// without a cache at all.
|
||||
reportThumbnailError(error, {
|
||||
ctx,
|
||||
env,
|
||||
request,
|
||||
surface: 'mcp_cluster_index_write',
|
||||
extras: { kind: board.kind, pageId: page.pageId },
|
||||
})
|
||||
}
|
||||
}
|
||||
2109
apps/dotcom/sync-worker/src/routes/tla/mcpServer.test.ts
Normal file
2109
apps/dotcom/sync-worker/src/routes/tla/mcpServer.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1464
apps/dotcom/sync-worker/src/routes/tla/mcpServer.ts
Normal file
1464
apps/dotcom/sync-worker/src/routes/tla/mcpServer.ts
Normal file
File diff suppressed because it is too large
Load Diff
84
apps/dotcom/sync-worker/src/routes/tla/mcpServerCard.test.ts
Normal file
84
apps/dotcom/sync-worker/src/routes/tla/mcpServerCard.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Environment } from '../../types'
|
||||
import { MCP_SERVER_INFO } from './boardTools'
|
||||
import { SUPPORTED_PROTOCOL_VERSIONS } from './mcpServer'
|
||||
import {
|
||||
MCP_SERVER_CARD_PATH,
|
||||
MCP_SERVER_CARD_WELL_KNOWN_PATH,
|
||||
getMcpServerCard,
|
||||
} from './mcpServerCard'
|
||||
|
||||
const RESOURCE = 'https://www.tldraw.com/api/app/mcp'
|
||||
|
||||
function makeEnv(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return { MCP_SERVER_URL: RESOURCE, ...overrides } as unknown as Environment
|
||||
}
|
||||
|
||||
function makeRequest() {
|
||||
return new Request('https://sync.tldraw.xyz/app/mcp/server-card') as any
|
||||
}
|
||||
|
||||
describe('getMcpServerCard', () => {
|
||||
it('points at the MCP endpoint clients actually connect to', async () => {
|
||||
const card = (await getMcpServerCard(makeRequest(), makeEnv()).json()) as any
|
||||
|
||||
expect(card.remotes).toEqual([
|
||||
{
|
||||
type: 'streamable-http',
|
||||
url: RESOURCE,
|
||||
supportedProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// The card is read before a client connects and never checked against anything afterwards, so
|
||||
// nothing but this stops it drifting away from what the live server answers with.
|
||||
it('reports the same version and title the server hands back on connect', async () => {
|
||||
const card = (await getMcpServerCard(makeRequest(), makeEnv()).json()) as any
|
||||
|
||||
expect(card.version).toBe(MCP_SERVER_INFO.version)
|
||||
expect(card.title).toBe(MCP_SERVER_INFO.title)
|
||||
})
|
||||
|
||||
// `Response.json` would have said `application/json`, and did until a preview deploy showed the
|
||||
// AI Catalog entry pointing here declaring a type the URL did not return. A client that checks
|
||||
// the two against each other refuses on that.
|
||||
it('is served as a Server Card rather than as plain JSON', () => {
|
||||
expect(getMcpServerCard(makeRequest(), makeEnv()).headers.get('content-type')).toBe(
|
||||
'application/mcp-server-card+json'
|
||||
)
|
||||
})
|
||||
|
||||
// Both are schema constraints, and both are the kind of thing that only fails once the document is
|
||||
// published and somebody else's validator rejects it.
|
||||
it('satisfies the schema constraints on name and description', async () => {
|
||||
const card = (await getMcpServerCard(makeRequest(), makeEnv()).json()) as any
|
||||
|
||||
expect(card.name).toMatch(/^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/)
|
||||
expect(card.description.length).toBeLessThanOrEqual(100)
|
||||
expect(card.$schema).toBe(
|
||||
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json'
|
||||
)
|
||||
})
|
||||
|
||||
// Falls back to the request's own origin when nothing is configured, which is what local dev and
|
||||
// preview deployments run on.
|
||||
it('derives the endpoint from the request when no server URL is configured', async () => {
|
||||
const card = (await getMcpServerCard(
|
||||
makeRequest(),
|
||||
makeEnv({ MCP_SERVER_URL: undefined })
|
||||
).json()) as any
|
||||
|
||||
expect(card.remotes[0].url).toBe('https://sync.tldraw.xyz/api/app/mcp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('server card paths', () => {
|
||||
// The `.well-known` alias sits outside the /api/* route pattern, so it only reaches this worker
|
||||
// because wrangler.toml carries a route for the prefix. Getting the path wrong here is invisible
|
||||
// until a request 404s in production.
|
||||
it('places the canonical card under the MCP endpoint and the alias at the origin', () => {
|
||||
expect(MCP_SERVER_CARD_PATH).toBe('/app/mcp/server-card')
|
||||
expect(MCP_SERVER_CARD_WELL_KNOWN_PATH).toBe('/.well-known/mcp/server-card.json')
|
||||
})
|
||||
})
|
||||
88
apps/dotcom/sync-worker/src/routes/tla/mcpServerCard.ts
Normal file
88
apps/dotcom/sync-worker/src/routes/tla/mcpServerCard.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../../types'
|
||||
import { MCP_SERVER_INFO } from './boardTools'
|
||||
import { getMcpResourceUrl } from './mcpAuth'
|
||||
import { SUPPORTED_PROTOCOL_VERSIONS } from './mcpServer'
|
||||
|
||||
// A Server Card is the pre-connection half of MCP discovery: what a client reads to decide whether
|
||||
// it wants this server and how to reach it, without a token. That matters here, where every call to
|
||||
// the server itself is refused without one — an agent that cannot authenticate can still learn where
|
||||
// to go.
|
||||
//
|
||||
// Nothing checks the card against the running server, so the values below are sourced from the same
|
||||
// constants it answers with rather than restated.
|
||||
|
||||
/**
|
||||
* The path the MCP spec reserves for a card: the server's own URL with `/server-card` appended. As
|
||||
* registered on the router — the public form is `/api/app/mcp/server-card`, with the `/api` prefix
|
||||
* stripped upstream.
|
||||
*/
|
||||
export const MCP_SERVER_CARD_PATH = '/app/mcp/server-card'
|
||||
|
||||
/**
|
||||
* A second path for the same document, under `.well-known`.
|
||||
*
|
||||
* The extension's own discovery notes argue against this: a card is not site-wide the way OAuth
|
||||
* metadata is, and the catalog at `/.well-known/ai-catalog.json` is the mechanism that is supposed
|
||||
* to point at cards. It is served anyway because agent-readiness scanners probe it directly, and one
|
||||
* more route pointing at the same handler costs less than being invisible to them. Prefer
|
||||
* {@link MCP_SERVER_CARD_PATH} when linking.
|
||||
*
|
||||
* At the origin rather than under `/api`, so — like the protected resource metadata — it needs its
|
||||
* own `wrangler.toml` route to reach this worker at all.
|
||||
*/
|
||||
export const MCP_SERVER_CARD_WELL_KNOWN_PATH = '/.well-known/mcp/server-card.json'
|
||||
|
||||
/** Not a version to bump: a breaking revision of the card shape publishes a new `vN` URL. */
|
||||
const SERVER_CARD_SCHEMA_URL =
|
||||
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json'
|
||||
|
||||
/**
|
||||
* Reverse-DNS with exactly one slash, which the card schema requires and the runtime
|
||||
* `serverInfo.name` (`tldraw-boards`) does not satisfy — the two identify the same server in two
|
||||
* namespaces that cannot be made textually equal. The human-readable identity travels in `title`
|
||||
* and `description`, which do match.
|
||||
*/
|
||||
const SERVER_CARD_NAME = 'com.tldraw/boards'
|
||||
|
||||
/**
|
||||
* The media type a Server Card is served with, which is not `application/json`.
|
||||
*
|
||||
* Clients ask for this type by `Accept`, and the AI Catalog entry pointing here declares it — a
|
||||
* catalog whose `type` disagrees with what the URL actually returns is the mismatch a strict client
|
||||
* refuses on. `Response.json` would quietly make it `application/json`, so the card is serialised by
|
||||
* hand.
|
||||
*/
|
||||
const SERVER_CARD_MEDIA_TYPE = 'application/mcp-server-card+json'
|
||||
|
||||
export function getMcpServerCard(request: IRequest, env: Environment): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
$schema: SERVER_CARD_SCHEMA_URL,
|
||||
name: SERVER_CARD_NAME,
|
||||
version: MCP_SERVER_INFO.version,
|
||||
title: MCP_SERVER_INFO.title,
|
||||
// The schema caps this at 100 characters.
|
||||
description: 'Search, inspect and screenshot the tldraw.com boards you have access to.',
|
||||
websiteUrl: 'https://tldraw.dev',
|
||||
repository: {
|
||||
url: 'https://github.com/tldraw/tldraw',
|
||||
source: 'github',
|
||||
subfolder: 'apps/dotcom/sync-worker',
|
||||
},
|
||||
remotes: [
|
||||
{
|
||||
type: 'streamable-http',
|
||||
url: getMcpResourceUrl(request, env),
|
||||
supportedProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'content-type': SERVER_CARD_MEDIA_TYPE,
|
||||
'cache-control': 'public, max-age=3600',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
1328
apps/dotcom/sync-worker/src/routes/tla/ogImageQueue.test.ts
Normal file
1328
apps/dotcom/sync-worker/src/routes/tla/ogImageQueue.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
500
apps/dotcom/sync-worker/src/routes/tla/ogImageQueue.ts
Normal file
500
apps/dotcom/sync-worker/src/routes/tla/ogImageQueue.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { DEFAULT_THUMBNAIL_HEIGHT, DEFAULT_THUMBNAIL_WIDTH } from '@tldraw/dotcom-shared'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import {
|
||||
OG_MAX_RENDER_ATTEMPTS,
|
||||
OG_PENDING_MARKER_TTL_MS,
|
||||
OG_REPAIR_COOLDOWN_MS,
|
||||
OG_RETRY_DELAY_SECONDS,
|
||||
} from '../../config'
|
||||
import {
|
||||
Environment,
|
||||
OgImageRenderQueueMessage,
|
||||
OgImageRenderReason,
|
||||
ThumbnailBoardRef,
|
||||
} from '../../types'
|
||||
import { deleteRenderTokenRecord } from '../../utils/renderTokens'
|
||||
import { enumerateBoardPages } from './boardTools'
|
||||
import {
|
||||
ResolvedThumbnailBoard,
|
||||
captureThumbnailScreenshot,
|
||||
loadBoardSnapshot,
|
||||
putThumbnailPng,
|
||||
resolveThumbnailBoard,
|
||||
writeScreenshotTelemetry,
|
||||
summarizeSnapshotContent,
|
||||
} from './thumbnailRender'
|
||||
import { classifyScreenshotFailure, reportThumbnailError } from './thumbnailShared'
|
||||
|
||||
// Queue-backed async board thumbnail generation. Renders are asked for by the things that change a
|
||||
// board's content — publishing (the outbox publish effect) and editing (TLFileDurableObject) — and this
|
||||
// consumer performs the capture out of band, refreshing the R2 cache the GET og-image route reads.
|
||||
// That route only ever reads; the MCP tool must return its image in-band, so it captures inline into
|
||||
// its own bucket. Neither goes through here.
|
||||
//
|
||||
// This path has no cap of any kind, by design. What bounds it is the render debounce upstream in
|
||||
// TLFileDurableObject, which is per-board, so total spend scales with how many boards are edited at
|
||||
// once. See "Request limits" in browser-run-thumbnails.md for why, and why the only rate limiting in
|
||||
// the pipeline lives on the MCP endpoint instead (mcpServer.ts).
|
||||
|
||||
// OG images render a single page as the unfurl preview. Pick the first page (in board order) that
|
||||
// has content, so a board whose first page is empty still gets a meaningful image; fall back to the
|
||||
// first page when none have content, which renders as a blank.
|
||||
function pickOgImagePageId(snapshot: RoomSnapshot): string | undefined {
|
||||
const pages = enumerateBoardPages(snapshot)
|
||||
if (pages.length === 0) return undefined
|
||||
return (pages.find((page) => page.hasContent) ?? pages[0]).id
|
||||
}
|
||||
|
||||
/**
|
||||
* A board's one OG image. The key carries only what can address two objects at once: the board, and
|
||||
* the theme, which a dark-mode card would need.
|
||||
*
|
||||
* Keep it that way, and keep the output dimensions out in particular. This key is the image's sole
|
||||
* address, in a bucket with no expiration rule, so any segment that can change re-addresses every
|
||||
* board's image at once and strands the old objects permanently — unreachable, un-overwritable, one per
|
||||
* board. A size change is a replacement rather than a second object, so it belongs in the object's
|
||||
* metadata, which overwrites in place.
|
||||
*
|
||||
* The trade: a size change serves old-sized images as fresh hits until each board next renders, since
|
||||
* the stored `version` tracks board content rather than render parameters.
|
||||
*/
|
||||
export function getOgImageCacheKey(board: ThumbnailBoardRef) {
|
||||
return `og/${board.kind}/${board.slug}/light.png`
|
||||
}
|
||||
|
||||
export type EnqueueOgImageResult = 'enqueued' | 'already_pending' | 'unavailable'
|
||||
|
||||
// The single-flight marker. Its TTL and the retry constants live together in config.ts, where the
|
||||
// comment on OG_PENDING_MARKER_TTL_MS explains the inequality that binds them.
|
||||
function getOgImagePendingKey(board: ThumbnailBoardRef) {
|
||||
return getOgImageCacheKey(board).replace(/\.png$/, '.pending')
|
||||
}
|
||||
|
||||
// Armed when a crawler-triggered job gives up, consulted only by the OG route's published-board
|
||||
// repair (see OG_REPAIR_COOLDOWN_MS in config.ts). Deliberately not consulted by enqueueOgImageRender
|
||||
// itself: a republish is a genuine new snapshot and must render regardless of how the last attempt
|
||||
// went, so only the one ask an outside caller can cause is metered. For the same reason the publish
|
||||
// trigger *clears* it: the cooldown is evidence about the snapshot that failed, and a republish
|
||||
// replaces that snapshot, so the new one gets its own repair backstop — and re-arms the cooldown
|
||||
// itself if it turns out to fail too.
|
||||
function getOgImageRepairCooldownKey(board: ThumbnailBoardRef) {
|
||||
return getOgImageCacheKey(board).replace(/\.png$/, '.repair-cooldown')
|
||||
}
|
||||
|
||||
export async function isOgImageRepairOnCooldown(
|
||||
env: Environment,
|
||||
board: ThumbnailBoardRef
|
||||
): Promise<boolean> {
|
||||
if (!env.THUMBNAILS) return false
|
||||
// Fail open on a read error: the enqueue this gates needs the same bucket for its pending marker,
|
||||
// so if R2 is genuinely down the ask fails and reports there rather than being silently skipped.
|
||||
const existing = await env.THUMBNAILS.head(getOgImageRepairCooldownKey(board)).catch(() => null)
|
||||
return isMarkerAlive(existing)
|
||||
}
|
||||
|
||||
// The pending and repair-cooldown markers are empty objects whose lifetime lives in `expiresAt`
|
||||
// metadata, since the bucket has no expiration rule of its own.
|
||||
function isMarkerAlive(marker: R2Object | null): boolean {
|
||||
if (!marker) return false
|
||||
const expiresAt = Number(marker.customMetadata?.expiresAt)
|
||||
return Number.isFinite(expiresAt) && expiresAt > Date.now()
|
||||
}
|
||||
|
||||
function putMarker(bucket: R2Bucket, key: string, ttlMs: number, now = Date.now()) {
|
||||
return bucket.put(key, new Uint8Array(), {
|
||||
customMetadata: { expiresAt: String(now + ttlMs) },
|
||||
})
|
||||
}
|
||||
|
||||
export async function enqueueOgImageRender(
|
||||
env: Environment,
|
||||
board: ThumbnailBoardRef,
|
||||
{
|
||||
reason,
|
||||
followUp,
|
||||
firedAt,
|
||||
}: {
|
||||
// Required rather than defaulted: every trigger knows why it is asking, and a default would put
|
||||
// whichever one forgot to say into some other trigger's telemetry bucket.
|
||||
reason: OgImageRenderReason
|
||||
followUp?: boolean
|
||||
/**
|
||||
* When the ask fired, for asks made by the file DO's debounce alarm. The marker's expiry is
|
||||
* stamped from it rather than from the moment the R2 write below lands: the alarm resets the
|
||||
* debouncer's window *before* this function's R2 round trip runs, so a persist can land in
|
||||
* between and start a new max-wait window earlier than the marker's write. Counting the TTL
|
||||
* from the fire keeps that window ending at or past the marker's expiry, which is what lets
|
||||
* OG_RENDER_MAX_WAIT_MS >= OG_PENDING_MARKER_TTL_MS hold by exact equality (both pinned in
|
||||
* ogImageQueue.test.ts). Callers that are not debounced fires omit it.
|
||||
*/
|
||||
firedAt?: number
|
||||
}
|
||||
): Promise<EnqueueOgImageResult> {
|
||||
if (!env.THUMBNAILS || !env.QUEUE) return 'unavailable'
|
||||
|
||||
const pendingKey = getOgImagePendingKey(board)
|
||||
if (isMarkerAlive(await env.THUMBNAILS.head(pendingKey))) {
|
||||
return 'already_pending'
|
||||
}
|
||||
|
||||
await putMarker(env.THUMBNAILS, pendingKey, OG_PENDING_MARKER_TTL_MS, firedAt)
|
||||
|
||||
const message: OgImageRenderQueueMessage = {
|
||||
type: 'og-image-render',
|
||||
kind: board.kind,
|
||||
slug: board.slug,
|
||||
reason,
|
||||
...(followUp ? { followUp } : null),
|
||||
}
|
||||
await env.QUEUE.send(message)
|
||||
return 'enqueued'
|
||||
}
|
||||
|
||||
// The two keys are not symmetric when a board stops being publicly viewable, and the difference is
|
||||
// what the next two functions are for. `og/shared_file/{fileId}/…` is keyed on the file id, so it
|
||||
// stays useful for as long as the board exists and unsharing keeps it: an unshared board's image is
|
||||
// already unreachable, since the only route that serves one re-checks the gate per request.
|
||||
// `og/published/{publishedSlug}/…` depicts a published snapshot, so unpublishing destroys what it was
|
||||
// a picture of — and its key is the published slug, so leaving it would strand an object that a
|
||||
// regenerated publish link could make permanently unreadable. See "Nothing deletes a rendered image"
|
||||
// in browser-run-thumbnails.md.
|
||||
|
||||
// Clears only the pending render marker, keeping the image. Called when a render job is dropped: a
|
||||
// marker left behind would suppress the next legitimate enqueue until it expired.
|
||||
export async function clearOgImagePendingMarker(
|
||||
env: Environment,
|
||||
board: ThumbnailBoardRef
|
||||
): Promise<void> {
|
||||
if (!env.THUMBNAILS) return
|
||||
await env.THUMBNAILS.delete(getOgImagePendingKey(board)).catch(() => {})
|
||||
}
|
||||
|
||||
// Deletes the image as well as the marker and the repair cooldown. Only for `published` boards
|
||||
// losing their publication. Scoped by the `board` passed in, so calling it with `kind: 'published'`
|
||||
// cannot touch the file-keyed image of the same board.
|
||||
export async function deleteOgImage(env: Environment, board: ThumbnailBoardRef): Promise<void> {
|
||||
if (!env.THUMBNAILS) return
|
||||
await Promise.all([
|
||||
env.THUMBNAILS.delete(getOgImageCacheKey(board)).catch(() => {}),
|
||||
env.THUMBNAILS.delete(getOgImagePendingKey(board)).catch(() => {}),
|
||||
env.THUMBNAILS.delete(getOgImageRepairCooldownKey(board)).catch(() => {}),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything this pipeline stores for one board, removed when the file is hard deleted
|
||||
* (`TLFileDurableObject.appFileRecordDidDelete`, alongside the room snapshot and the histories).
|
||||
*
|
||||
* Both kinds go, because each is normally kept for a reason about a board that still exists: the
|
||||
* file-keyed image survives *unsharing* deliberately, and the published-slug image only goes on
|
||||
* *unpublish*. A hard delete leaves nothing to reshare and no snapshot to depict.
|
||||
*
|
||||
* The stakes are orphans rather than tidiness. `og/…` keys carry no version, so a board owns exactly
|
||||
* one object, and `THUMBNAILS` has no lifecycle rule and must never be given one — so anything left
|
||||
* behind here is an object nothing will ever read, overwrite or sweep. MCP screenshots need no
|
||||
* equivalent: their keys carry a content version and their bucket expires them.
|
||||
*
|
||||
* Best effort throughout, because it runs inside a teardown that must complete regardless.
|
||||
*/
|
||||
export async function deleteBoardThumbnails(
|
||||
env: Environment,
|
||||
{ fileId, publishedSlug }: { fileId: string; publishedSlug?: string | null }
|
||||
): Promise<void> {
|
||||
const boards: ThumbnailBoardRef[] = [{ kind: 'shared_file', slug: fileId }]
|
||||
// A file with no published slug never had a published key to delete, and deriving one from an empty
|
||||
// slug would address `og/published//light.png` — some other board's neighbourhood, not this one's.
|
||||
if (publishedSlug) boards.push({ kind: 'published', slug: publishedSlug })
|
||||
await Promise.all(
|
||||
boards.flatMap((board) => [deleteOgImage(env, board), deleteRenderTokenRecord(env, board)])
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The publish effect's ask, and the reporting that goes with it.
|
||||
*
|
||||
* This is the *only* trigger a published board has. Its snapshot is frozen, so nothing edits it into
|
||||
* asking again, where a shared file re-asks on every persist that advances its document clock. So an
|
||||
* ask lost here — thrown, or turned away as `already_pending` by a marker an earlier failure left
|
||||
* behind, or `unavailable` — leaves that board's card generic until it is republished, and none of
|
||||
* those are exceptional enough to notice by themselves. `getOgImage` repairs the outcome on the next
|
||||
* fetch; `reportProblem` is how the cause becomes visible.
|
||||
*/
|
||||
export async function enqueuePublishThumbnailRender(
|
||||
env: Environment,
|
||||
publishedSlug: string,
|
||||
reportProblem: (error: unknown) => void
|
||||
): Promise<void> {
|
||||
const board: ThumbnailBoardRef = { kind: 'published', slug: publishedSlug }
|
||||
// An in-place republish reuses the slug, so a cooldown armed against the previous snapshot's
|
||||
// failure would otherwise outlive the snapshot it was evidence about and block the new one's
|
||||
// repair. Cleared unconditionally and first: even if the enqueue below fails, the next crawl's
|
||||
// repair is exactly the backstop that failure needs.
|
||||
await env.THUMBNAILS?.delete(getOgImageRepairCooldownKey(board)).catch(() => {})
|
||||
try {
|
||||
const result = await enqueueOgImageRender(env, board, { reason: 'publish' })
|
||||
if (result !== 'enqueued') {
|
||||
reportProblem(new Error(`Publish thumbnail enqueue did not take effect: ${result}`))
|
||||
}
|
||||
} catch (error) {
|
||||
reportProblem(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue consumer. Re-resolves the board at render time rather than trusting the enqueued state: a
|
||||
// board deleted or unpublished while queued is dropped without rendering, and the version is re-read
|
||||
// so the render captures the newest content, coalescing a burst of enqueues into one capture.
|
||||
export async function handleOgImageRenderMessage(
|
||||
env: Environment,
|
||||
message: Message<OgImageRenderQueueMessage>,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
const { kind, slug } = message.body
|
||||
const boardRef: ThumbnailBoardRef = { kind, slug }
|
||||
// A message already in the queue may carry no reason; see OgImageRenderQueueMessage.
|
||||
const reason = message.body.reason ?? 'crawler'
|
||||
// Normalised once, for the same reason `reason` is: the flag is optional on the wire (only follow-up
|
||||
// messages set it), and every delivery of one job must record the same value on every path.
|
||||
const followUp = message.body.followUp ?? false
|
||||
const cacheKey = getOgImageCacheKey(boardRef)
|
||||
// The board was deleted, was unpublished, or has no persisted content. Terminal, not transient: ack
|
||||
// rather than retry, since no number of retries brings the board back. Applies the same delete/keep
|
||||
// asymmetry as the effects above.
|
||||
const dropNoLongerViewable = async () => {
|
||||
await (kind === 'published'
|
||||
? deleteOgImage(env, boardRef)
|
||||
: clearOgImagePendingMarker(env, boardRef))
|
||||
writeScreenshotTelemetry(env, {
|
||||
source: 'queue',
|
||||
reason,
|
||||
followUp,
|
||||
cacheStatus: 'miss',
|
||||
failureReason: 'board_not_viewable',
|
||||
})
|
||||
message.ack()
|
||||
}
|
||||
|
||||
try {
|
||||
// 'render' rather than 'public': every board gets a thumbnail, private ones included. The OG
|
||||
// route re-applies the public gate when it serves.
|
||||
const resolved = await resolveThumbnailBoard(env, kind, slug, { access: 'render' })
|
||||
if (!resolved.ok) {
|
||||
await dropNoLongerViewable()
|
||||
return
|
||||
}
|
||||
const board = resolved.board
|
||||
|
||||
// Another consumer (or an earlier retry) may already have rendered this version.
|
||||
const cached = await env.THUMBNAILS?.head(cacheKey)
|
||||
if (cached?.customMetadata?.version === String(board.version)) {
|
||||
await clearOgImagePendingMarker(env, boardRef)
|
||||
writeScreenshotTelemetry(env, { source: 'queue', reason, followUp, cacheStatus: 'hit' })
|
||||
message.ack()
|
||||
return
|
||||
}
|
||||
|
||||
if (!env.THUMBNAILS) {
|
||||
throw new Error('THUMBNAILS bucket is not configured')
|
||||
}
|
||||
|
||||
// No capacity check, by design (see the top of this file). The version check above is what stops
|
||||
// redundant work: everything past this point is a board whose cached thumbnail genuinely no
|
||||
// longer matches its content.
|
||||
|
||||
// Loaded to target the first page that has content, so a board whose first page is empty still
|
||||
// gets a meaningful unfurl image.
|
||||
//
|
||||
// `file` is the row the resolve above already gated on, handed back so this read re-applies the
|
||||
// gate without asking Postgres the same question a second time. Safe precisely here: the two are
|
||||
// microseconds apart in one function, where a re-read would return the row we already hold. The
|
||||
// render page's own read (getThumbnailSnapshot) deliberately does not do this — it is a separate
|
||||
// request, and its re-read is what makes an un-share land inside the token's window.
|
||||
const snapshot = await loadBoardSnapshot(env, board, { access: 'render', file: board.file })
|
||||
if (!snapshot) {
|
||||
// No persisted content. The render page reads the snapshot through the same functions, so it
|
||||
// would 404 and come back as a render failure — after spending a Browser Run slot to learn
|
||||
// what we already know. Retry from here instead, in case content lands shortly after the
|
||||
// enqueue. A read that *fails* throws rather than landing here, and the catch below retries it
|
||||
// the same way, so neither path spends Browser Run.
|
||||
await retryOrDrop(env, message, {
|
||||
reason,
|
||||
followUp,
|
||||
failureReason: 'board_empty',
|
||||
board: boardRef,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// The render page exports the chosen page; the worker screenshots it through the BROWSER
|
||||
// binding and writes the PNG to the cache key the OG route reads.
|
||||
const pageId = pickOgImagePageId(snapshot)
|
||||
const render = await captureThumbnailScreenshot(env, board, {
|
||||
surface: 'og',
|
||||
pageId,
|
||||
theme: 'light',
|
||||
width: DEFAULT_THUMBNAIL_WIDTH,
|
||||
height: DEFAULT_THUMBNAIL_HEIGHT,
|
||||
// `source` is the telemetry surface, not the render pipeline: these sessions belong to the
|
||||
// queue's ledger even though the job is signed for the og pipeline.
|
||||
telemetry: { source: 'queue', reason },
|
||||
content: summarizeSnapshotContent(snapshot, pageId),
|
||||
})
|
||||
await putThumbnailPng(env.THUMBNAILS, cacheKey, render.base64, board.version)
|
||||
await clearOgImagePendingMarker(env, boardRef)
|
||||
await enqueueFollowUpIfBoardMoved(env, message, board, reason, ctx)
|
||||
|
||||
writeScreenshotTelemetry(env, { source: 'queue', reason, followUp, cacheStatus: 'miss' })
|
||||
message.ack()
|
||||
} catch (error) {
|
||||
// Reported once per job, on the delivery that gives up, rather than once per delivery: a board
|
||||
// that fails deterministically fails all OG_MAX_RENDER_ATTEMPTS times, and one problem should not
|
||||
// file three events. A failure that recovers on retry reports nothing, which is correct — the
|
||||
// render landed. Sentry gets the unbounded original; telemetry below gets a bounded reason code,
|
||||
// since raw error.message would blow up that dimension's cardinality.
|
||||
if (message.attempts >= OG_MAX_RENDER_ATTEMPTS) {
|
||||
reportThumbnailError(error, {
|
||||
ctx,
|
||||
env,
|
||||
surface: 'og_queue',
|
||||
extras: { kind, attempts: message.attempts },
|
||||
})
|
||||
}
|
||||
// A board deleted between the resolve above and the snapshot read is retried rather than dropped,
|
||||
// because from here it looks like any other read failure. That costs one extra delivery, not one
|
||||
// extra render: the retry re-resolves at the top and drops before spending any Browser Run.
|
||||
await retryOrDrop(env, message, {
|
||||
reason,
|
||||
followUp,
|
||||
failureReason: classifyScreenshotFailure(error),
|
||||
board: boardRef,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A capture takes seconds, and the board can change during one. A *publish* landing in that window
|
||||
* asks for a render, finds the pending marker this job set, and is turned away — the ask is
|
||||
* *dropped*, not deferred, and nothing ever re-asks for a published board: its snapshot is frozen,
|
||||
* so the render we just wrote would be the last word, showing the previous publication. This check
|
||||
* is what closes that, and it is `published`-only because published boards are the only kind whose
|
||||
* dropped ask stays dropped.
|
||||
*
|
||||
* A shared file's dropped ask is deferred by construction, in two halves pinned in
|
||||
* ogImageQueue.test.ts. A *debounced* fire's ask is only turned away while this job's marker is
|
||||
* alive, which places its persist a full OG_RENDER_DEBOUNCE_MS before the marker's clear — while
|
||||
* the image whose write performs that clear read its snapshot at most THUMBNAIL_RENDER_TIMEOUT_MS
|
||||
* plus the post-capture tail before it, retries included. The debounce being the longer of the two
|
||||
* means the dropped ask's content is already in the image. A *max-wait* fire escapes that bound but
|
||||
* cannot be turned away at all: the fire that enqueued this job reset the debouncer's window, so a
|
||||
* clamped fire lands at or past the marker's TTL. Following up here as well bought nothing worth
|
||||
* its cost — follow-ups were roughly a fifth of shared-file
|
||||
* queue captures in production (measured 2026-08-11 via the `followup` telemetry blob): on a board
|
||||
* that settled, the follow-up merely relocated the render the debounced ask was about to do; on a
|
||||
* board still moving, it rendered a mid-edit state the next debounced render superseded.
|
||||
*
|
||||
* Both halves price the job ending in an image write, which a give-up never does — the asks its
|
||||
* marker turned away deferred into nothing. A known residue, not a regression; see "the deferral
|
||||
* stops at that give-up" in browser-run-thumbnails.md.
|
||||
*
|
||||
* Deliberately never chained. A published board republished without pause would otherwise find
|
||||
* itself stale on every follow-up and render continuously. One extra render per triggered render is
|
||||
* the ceiling.
|
||||
*
|
||||
* Best effort: the image is already written and the marker already cleared, so a failure here loses a
|
||||
* refresh, not the render. It must not turn a completed job into a retry.
|
||||
*/
|
||||
async function enqueueFollowUpIfBoardMoved(
|
||||
env: Environment,
|
||||
message: Message<OgImageRenderQueueMessage>,
|
||||
rendered: ResolvedThumbnailBoard,
|
||||
reason: OgImageRenderReason,
|
||||
ctx?: ExecutionContext
|
||||
) {
|
||||
if (rendered.kind !== 'published') return
|
||||
if (message.body.followUp) return
|
||||
try {
|
||||
const current = await readCurrentPublishedVersion(env, rendered)
|
||||
if (current === null) return
|
||||
if (String(current) === String(rendered.version)) return
|
||||
await enqueueOgImageRender(env, rendered, { reason, followUp: true })
|
||||
} catch (error) {
|
||||
reportThumbnailError(error, {
|
||||
ctx,
|
||||
env,
|
||||
surface: 'og_queue',
|
||||
extras: { kind: rendered.kind, followUpCheck: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The published board's current `lastPublished`, for the "did it move while we were capturing?"
|
||||
* check above and nothing else. `null` means there is nothing to compare against, which is treated
|
||||
* as "don't follow up". A full resolve rather than something lighter, because a published version is
|
||||
* a Postgres column with no R2 shortcut — and publishing is not a hot path.
|
||||
*/
|
||||
async function readCurrentPublishedVersion(
|
||||
env: Environment,
|
||||
board: ResolvedThumbnailBoard
|
||||
): Promise<string | number | null> {
|
||||
const resolved = await resolveThumbnailBoard(env, board.kind, board.slug, { access: 'render' })
|
||||
return resolved.ok ? resolved.board.version : null
|
||||
}
|
||||
|
||||
async function retryOrDrop(
|
||||
env: Environment,
|
||||
message: Message<OgImageRenderQueueMessage>,
|
||||
{
|
||||
reason,
|
||||
followUp,
|
||||
failureReason,
|
||||
board,
|
||||
}: {
|
||||
/**
|
||||
* Passed in already resolved rather than read off the message here, so a delivery that fails is
|
||||
* attributed to the same trigger as one that succeeds. Reading `message.body.reason` directly
|
||||
* would bucket a legacy message with no reason as `none` on this path and `crawler` on the
|
||||
* others, splitting one job's deliveries across two values.
|
||||
*/
|
||||
reason: OgImageRenderReason
|
||||
/** Resolved by the caller for the same reason as `reason` above. */
|
||||
followUp: boolean
|
||||
failureReason: string
|
||||
board: ThumbnailBoardRef
|
||||
}
|
||||
) {
|
||||
// One datapoint per delivery, the opposite of the Sentry report above: three deliveries are three
|
||||
// failures on this ledger, one problem on Sentry's. The Browser Run spend of a delivery that
|
||||
// reached the capture is on the browser_run_session event, written inside the render itself.
|
||||
writeScreenshotTelemetry(env, {
|
||||
source: 'queue',
|
||||
reason,
|
||||
followUp,
|
||||
cacheStatus: 'miss',
|
||||
failureReason,
|
||||
})
|
||||
// attempts counts this delivery, so attempts >= MAX means this was the final try.
|
||||
if (message.attempts < OG_MAX_RENDER_ATTEMPTS) {
|
||||
// Marker kept: a retry is still this job in flight, and the next delivery re-resolves anyway, so
|
||||
// an ask turned away meanwhile costs nothing — it would have rendered the same content.
|
||||
message.retry({ delaySeconds: OG_RETRY_DELAY_SECONDS * message.attempts })
|
||||
return
|
||||
}
|
||||
// Given up, so nothing is in flight and the marker has nothing left to single-flight. Clearing it
|
||||
// rather than letting it lapse means the next ask is acted on immediately instead of being turned
|
||||
// away for the rest of the TTL — which matters most here, since this board has no image at all.
|
||||
await clearOgImagePendingMarker(env, board)
|
||||
// But when the ask that failed was itself the OG route's crawler-triggered repair, "immediately"
|
||||
// is the problem rather than the point: with the marker gone, the next unauthenticated request
|
||||
// would re-arm the whole retry chain, letting traffic an outside caller controls spend Browser Run
|
||||
// on a board that just proved it cannot render. Arm the repair cooldown instead — publish- and
|
||||
// edit-triggered asks don't consult it, so a genuine republish still renders straight away. Best
|
||||
// effort: a cooldown that fails to write costs extra renders, not the ack.
|
||||
if (board.kind === 'published' && reason === 'crawler' && env.THUMBNAILS) {
|
||||
await putMarker(
|
||||
env.THUMBNAILS,
|
||||
getOgImageRepairCooldownKey(board),
|
||||
OG_REPAIR_COOLDOWN_MS
|
||||
).catch(() => {})
|
||||
}
|
||||
message.ack()
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { IRequest } from 'itty-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Environment } from '../../types'
|
||||
import {
|
||||
ThumbnailRenderJob,
|
||||
mintThumbnailRenderToken,
|
||||
recordMintedRenderToken,
|
||||
} from '../../utils/renderTokens'
|
||||
import { putThumbnailRenderResult } from './putThumbnailRenderResult'
|
||||
import { makeFakeThumbnailsBucket, makeScreenshotTestEnv } from './screenshotTestHelpers'
|
||||
|
||||
function makeRequest(body: unknown): IRequest {
|
||||
return { json: async () => body } as unknown as IRequest
|
||||
}
|
||||
|
||||
const JOB: ThumbnailRenderJob = {
|
||||
v: 1,
|
||||
kind: 'published',
|
||||
slug: 'board',
|
||||
version: 1,
|
||||
access: 'public',
|
||||
surface: 'mcp',
|
||||
camera: 'content',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
theme: 'light',
|
||||
exp: Date.now() + 60_000,
|
||||
}
|
||||
|
||||
const TIMINGS = {
|
||||
bootAt: 100,
|
||||
dataAt: 150,
|
||||
mountAt: 900,
|
||||
settledAt: 1400,
|
||||
exportedAt: 2100,
|
||||
}
|
||||
|
||||
describe('the timing beacon', () => {
|
||||
it('writes one datapoint per beacon, carrying the surface', async () => {
|
||||
const env = makeScreenshotTestEnv() as unknown as Environment
|
||||
const token = await mintThumbnailRenderToken(env, JOB)
|
||||
|
||||
const response = await putThumbnailRenderResult(makeRequest({ token, timings: TIMINGS }), env)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const calls = (env.MEASURE as any).writeDataPoint.mock.calls
|
||||
expect(calls).toHaveLength(1)
|
||||
const point = calls[0][0]
|
||||
// writeDataPoint prefixes [eventName, workerName]; the beacon's own blobs follow.
|
||||
expect(point.blobs[0]).toBe('render_page_timings')
|
||||
expect(point.blobs.slice(2)).toEqual(['surface:mcp'])
|
||||
expect(point.doubles).toEqual([100, 150, 900, 1400, 2100])
|
||||
})
|
||||
|
||||
it('refuses a beacon whose token was not signed by us', async () => {
|
||||
const env = makeScreenshotTestEnv() as unknown as Environment
|
||||
const other = makeScreenshotTestEnv({
|
||||
MCP_SCREENSHOT_TOKEN_SECRET: 'someone-elses-secret',
|
||||
}) as unknown as Environment
|
||||
const forged = await mintThumbnailRenderToken(other, JOB)
|
||||
|
||||
const response = await putThumbnailRenderResult(
|
||||
makeRequest({ token: forged, timings: TIMINGS }),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect((env.MEASURE as any).writeDataPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Unlike the snapshot route, the beacon does not require the minted-token record. An MCP record
|
||||
// is keyed per capture and deleted as soon as the session returns, and the page sends this beacon
|
||||
// immediately before signalling ready — so a live capture's beacon regularly lands after its own
|
||||
// record is gone. Requiring it would drop the timings for the fastest renders, which is the wrong
|
||||
// end of the distribution to lose. Both a recorded and an unrecorded token are accepted.
|
||||
it('accepts a signed token whose minted record has already been cleaned up', async () => {
|
||||
const env = makeScreenshotTestEnv({
|
||||
THUMBNAILS: makeFakeThumbnailsBucket(),
|
||||
}) as unknown as Environment
|
||||
const job: ThumbnailRenderJob = { ...JOB, access: 'render' }
|
||||
const unrecorded = await mintThumbnailRenderToken(env, job)
|
||||
|
||||
const accepted = await putThumbnailRenderResult(
|
||||
makeRequest({ token: unrecorded, timings: TIMINGS }),
|
||||
env
|
||||
)
|
||||
expect(accepted.status).toBe(200)
|
||||
|
||||
const recordedJob = { ...job, exp: job.exp + 1 }
|
||||
const recorded = await mintThumbnailRenderToken(env, recordedJob)
|
||||
await recordMintedRenderToken(env, recordedJob, recorded)
|
||||
const stillAccepted = await putThumbnailRenderResult(
|
||||
makeRequest({ token: recorded, timings: TIMINGS }),
|
||||
env
|
||||
)
|
||||
expect(stillAccepted.status).toBe(200)
|
||||
|
||||
expect((env.MEASURE as any).writeDataPoint).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// The signature stays the whole gate, so an expired token is still refused: nothing else bounds
|
||||
// how long a leaked one could keep writing datapoints.
|
||||
it('refuses an expired token', async () => {
|
||||
const env = makeScreenshotTestEnv() as unknown as Environment
|
||||
const expired = await mintThumbnailRenderToken(env, { ...JOB, exp: Date.now() - 1 })
|
||||
|
||||
const response = await putThumbnailRenderResult(
|
||||
makeRequest({ token: expired, timings: TIMINGS }),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect((env.MEASURE as any).writeDataPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses non-finite stamps rather than polluting the dataset', async () => {
|
||||
const env = makeScreenshotTestEnv() as unknown as Environment
|
||||
const token = await mintThumbnailRenderToken(env, JOB)
|
||||
|
||||
const response = await putThumbnailRenderResult(
|
||||
makeRequest({ token, timings: { ...TIMINGS, settledAt: 'NaN' } }),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect((env.MEASURE as any).writeDataPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([null, 42, 'a string'])(
|
||||
'refuses the non-object JSON body %j with a 400',
|
||||
async (body) => {
|
||||
const env = makeScreenshotTestEnv() as unknown as Environment
|
||||
const response = await putThumbnailRenderResult(makeRequest(body), env)
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses timings: null rather than destructuring it', async () => {
|
||||
const env = makeScreenshotTestEnv() as unknown as Environment
|
||||
const token = await mintThumbnailRenderToken(env, JOB)
|
||||
|
||||
const response = await putThumbnailRenderResult(makeRequest({ token, timings: null }), env)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect((env.MEASURE as any).writeDataPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the measure-result branch alone', async () => {
|
||||
const env = makeScreenshotTestEnv() as unknown as Environment
|
||||
const token = await mintThumbnailRenderToken(env, { ...JOB, mode: 'measure' })
|
||||
|
||||
const response = await putThumbnailRenderResult(
|
||||
makeRequest({ token, bounds: { 'shape:a': { x: 0, y: 0, w: 10, h: 10 } } }),
|
||||
env
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ error: false, stored: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
ThumbnailRenderResultRequestBody,
|
||||
ThumbnailRenderTimingsRequestBody,
|
||||
} from '@tldraw/dotcom-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../../types'
|
||||
import { writeDataPoint } from '../../utils/analytics'
|
||||
import { verifyThumbnailRenderToken } from '../../utils/renderTokens'
|
||||
import { ShapeMeasurement } from './boardTools'
|
||||
import { putRenderResult } from './thumbnailRender'
|
||||
|
||||
// How measurements get out of the browser.
|
||||
//
|
||||
// Browser Rendering hands the Worker pixels, not JSON, so a render page asked to measure a page has
|
||||
// to push its answer back. It POSTs here before signalling ready, and the Worker — still blocked on
|
||||
// its own screenshot call — picks the result up as soon as that returns.
|
||||
//
|
||||
// Scoped to the signed job: the storage key is the token, so a result can only ever reach the render
|
||||
// that asked for it.
|
||||
export async function putThumbnailRenderResult(
|
||||
request: IRequest,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
let body: ThumbnailRenderResultRequestBody | ThumbnailRenderTimingsRequestBody
|
||||
try {
|
||||
body = (await request.json()) as
|
||||
| ThumbnailRenderResultRequestBody
|
||||
| ThumbnailRenderTimingsRequestBody
|
||||
} catch {
|
||||
return Response.json({ error: true, message: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
// `null`, numbers and strings are valid JSON too; refused as the caller's mistake here, where
|
||||
// the `in` and property checks below would throw on them and escape as a worker 500.
|
||||
if (!body || typeof body !== 'object') {
|
||||
return Response.json({ error: true, message: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
// The render page's phase-timing beacon, sharing this route because it shares the route's
|
||||
// auth story: a signed token proves the POST comes from a render we asked for. Telemetry only —
|
||||
// nothing downstream waits on it, so it is accepted for any valid token, screenshot or measure.
|
||||
if ('timings' in body) {
|
||||
// `typeof null` is 'object', and these timings get destructured below.
|
||||
if (!body.token || !body.timings || typeof body.timings !== 'object') {
|
||||
return Response.json(
|
||||
{ error: true, message: 'token and timings are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
// Signed and unexpired, but deliberately *not* minted-checked, unlike the snapshot route. An
|
||||
// MCP record is keyed per capture and deleted the moment the session returns (see
|
||||
// deleteMintedRenderToken), while the page sends this beacon and signals ready in the next
|
||||
// statement — so requiring the record would race the worker's own cleanup and lose the
|
||||
// timings for precisely the fastest renders, which is the wrong end to go blind at. The
|
||||
// signature is enough here because this branch hands back no board data and nothing waits on
|
||||
// it: a forged token could only add noise to `render_page_timings`, which is already true of
|
||||
// every `public` job, where the minted check passes vacuously anyway.
|
||||
const job = await verifyThumbnailRenderToken(env, body.token)
|
||||
if (!job) {
|
||||
return Response.json({ error: true, message: 'Invalid render token' }, { status: 403 })
|
||||
}
|
||||
const { bootAt, dataAt, mountAt, settledAt, exportedAt } = body.timings
|
||||
const stamps = [bootAt, dataAt, mountAt, settledAt, exportedAt]
|
||||
if (!stamps.every(Number.isFinite)) {
|
||||
return Response.json({ error: true, message: 'timings must be finite' }, { status: 400 })
|
||||
}
|
||||
// One datapoint per completed export: where the in-browser time went. browser_run_session
|
||||
// prices the whole session; this decomposes it into boot / acquire / mount / settle / export,
|
||||
// which is what ranks the render page's optimisations against each other.
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'render_page_timings', {
|
||||
blobs: [`surface:${job.surface ?? 'og'}`],
|
||||
doubles: stamps,
|
||||
})
|
||||
return Response.json({ error: false })
|
||||
}
|
||||
|
||||
if (!body?.token || !body.bounds || typeof body.bounds !== 'object') {
|
||||
return Response.json({ error: true, message: 'token and bounds are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const job = await verifyThumbnailRenderToken(env, body.token)
|
||||
// The mode is inside the signed payload, so a screenshot token can't be replayed to post a result.
|
||||
if (!job || job.mode !== 'measure') {
|
||||
return Response.json({ error: true, message: 'Invalid render token' }, { status: 403 })
|
||||
}
|
||||
|
||||
const bounds: Record<string, ShapeMeasurement> = {}
|
||||
for (const [shapeId, box] of Object.entries(body.bounds)) {
|
||||
// A non-finite value would produce NaN distances and silently detach that shape from the
|
||||
// minimum spanning tree, so anything malformed is dropped rather than stored.
|
||||
if (
|
||||
!Number.isFinite(box?.x) ||
|
||||
!Number.isFinite(box?.y) ||
|
||||
!Number.isFinite(box?.w) ||
|
||||
!Number.isFinite(box?.h)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
bounds[shapeId] = {
|
||||
minX: box.x,
|
||||
minY: box.y,
|
||||
maxX: box.x + box.w,
|
||||
maxY: box.y + box.h,
|
||||
// Only an editor can answer getText, so whatever it said is kept verbatim.
|
||||
...(typeof box.text === 'string' && box.text ? { text: box.text } : null),
|
||||
}
|
||||
}
|
||||
|
||||
await putRenderResult(env, body.token, bounds)
|
||||
return Response.json({ error: false, stored: Object.keys(bounds).length })
|
||||
}
|
||||
315
apps/dotcom/sync-worker/src/routes/tla/screenshotTestHelpers.ts
Normal file
315
apps/dotcom/sync-worker/src/routes/tla/screenshotTestHelpers.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { vi } from 'vitest'
|
||||
import { Environment } from '../../types'
|
||||
import { base64UrlDecode } from '../../utils/base64'
|
||||
|
||||
// Shared fakes for the Browser Run thumbnail / OG image tests (thumbnailRender,
|
||||
// mcpServer, ogImageQueue, getOgImage). The R2/browser/queue fakes, snapshot builder
|
||||
// and token helpers belong here rather than in any one test file, so that all four exercise the same
|
||||
// stand-ins.
|
||||
|
||||
// Builds a room snapshot with the given pages and per-page shape counts. Shapes are parented
|
||||
// directly to their page, which is what enumerateBoardPages checks for "has content".
|
||||
export function makeSnapshot(
|
||||
pages: Array<{ id: string; index: string; name?: string; shapes: number }>,
|
||||
boardName: string | null = 'My Board'
|
||||
) {
|
||||
const documents: Array<{ state: any }> = [
|
||||
{ state: { typeName: 'document', id: 'document:document', name: boardName ?? '' } },
|
||||
]
|
||||
for (const page of pages) {
|
||||
documents.push({
|
||||
state: { typeName: 'page', id: page.id, name: page.name, index: page.index },
|
||||
})
|
||||
for (let i = 0; i < page.shapes; i++) {
|
||||
documents.push({
|
||||
state: {
|
||||
typeName: 'shape',
|
||||
id: `shape:${page.id}-${i}`,
|
||||
parentId: page.id,
|
||||
type: 'geo',
|
||||
x: i * 100,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
props: { w: 80, h: 80 },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return { documents, schema: { schemaVersion: 2, sequences: {} } } as any
|
||||
}
|
||||
|
||||
// In-memory stand-in for an R2 bucket (THUMBNAILS or MCP_DATA_BUCKET, which have the same shape).
|
||||
// Exposes `store` so tests can inspect or seed
|
||||
// entries directly. Covers get/head/put/delete; entries carry the customMetadata and upload time
|
||||
// the routes read.
|
||||
export function makeFakeThumbnailsBucket() {
|
||||
const store = new Map<
|
||||
string,
|
||||
{ body: ArrayBuffer; customMetadata?: Record<string, string>; uploaded: Date; etag: string }
|
||||
>()
|
||||
// R2 exposes an object's etag twice: `etag` bare and `httpEtag` quoted for the header. The OG route
|
||||
// compares against the first and sends the second, so the fake has to carry both. The value only
|
||||
// has to change when the bytes do, which a counter gives without hashing anything.
|
||||
let version = 0
|
||||
const etagOf = (value: { etag: string }) => ({ etag: value.etag, httpEtag: `"${value.etag}"` })
|
||||
return {
|
||||
store,
|
||||
async get(key: string) {
|
||||
const value = store.get(key)
|
||||
if (!value) return null
|
||||
return {
|
||||
customMetadata: value.customMetadata,
|
||||
uploaded: value.uploaded,
|
||||
...etagOf(value),
|
||||
body: value.body,
|
||||
arrayBuffer: async () => value.body,
|
||||
// R2 objects expose both; the render-result read uses text, the PNG cache uses bytes.
|
||||
text: async () => new TextDecoder().decode(value.body),
|
||||
}
|
||||
},
|
||||
async head(key: string) {
|
||||
const value = store.get(key)
|
||||
if (!value) return null
|
||||
return { customMetadata: value.customMetadata, uploaded: value.uploaded, ...etagOf(value) }
|
||||
},
|
||||
async put(
|
||||
key: string,
|
||||
body: ArrayBuffer | string,
|
||||
options?: { customMetadata?: Record<string, string> }
|
||||
) {
|
||||
store.set(key, {
|
||||
// Real R2 accepts a string body; normalize so arrayBuffer() and text() both work
|
||||
// whichever form the caller used.
|
||||
body: typeof body === 'string' ? new TextEncoder().encode(body).buffer : body,
|
||||
customMetadata: options?.customMetadata,
|
||||
uploaded: new Date(Date.now()),
|
||||
etag: `etag-${++version}`,
|
||||
})
|
||||
},
|
||||
async delete(key: string) {
|
||||
store.delete(key)
|
||||
},
|
||||
// Prefix listing, which the render-token cleanup uses now that a board's records are spread
|
||||
// across one key per surface (and, for MCP, per page and theme). `truncated` is always false:
|
||||
// the real bucket paginates at 1000 and a board never approaches that.
|
||||
async list({ prefix = '' }: { prefix?: string } = {}) {
|
||||
return {
|
||||
truncated: false as const,
|
||||
objects: [...store.keys()]
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => ({ key, ...etagOf(store.get(key)!) })),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory stand-in for the ROOMS bucket. Only `head` is used (to read the persisted snapshot's
|
||||
// etag); pass `null` to simulate a room with no persisted snapshot.
|
||||
export function makeFakeRoomsBucket(etag: string | null = 'room-etag-1') {
|
||||
return {
|
||||
async head(_key: string) {
|
||||
return etag === null ? null : { etag }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The BROWSER binding's `.quickAction('screenshot', body)` returns a Response whose body is the PNG
|
||||
// bytes. [1,2,3] base64-encodes to AQID. Pass a custom impl to simulate failures.
|
||||
export function makeBrowserBinding(
|
||||
screenshot: (body: any) => Promise<Response> = async () =>
|
||||
new Response(new Uint8Array([1, 2, 3]), { status: 200 })
|
||||
) {
|
||||
return { quickAction: vi.fn((_action: string, body: any) => screenshot(body)) }
|
||||
}
|
||||
|
||||
export function makeFakeQueue() {
|
||||
return { send: vi.fn(async (_message: unknown) => undefined) }
|
||||
}
|
||||
|
||||
// Stands in for the render page on a `measure` job: a real one POSTs its measurements to
|
||||
// /app/thumbnail-render/result before signalling ready, so the fake writes them to the same place
|
||||
// the worker reads from. Without this every clustering tool would fail on a missing result, since
|
||||
// they all measure the page before clustering it.
|
||||
export function makeMeasuringBrowserBinding(
|
||||
env: () => Environment,
|
||||
bounds: Record<
|
||||
string,
|
||||
{ minX: number; minY: number; maxX: number; maxY: number; text?: string }
|
||||
> = {}
|
||||
) {
|
||||
return makeBrowserBinding(async (body: any) => {
|
||||
const token = new URL(body.url).searchParams.get('token')
|
||||
const job = token
|
||||
? JSON.parse(new TextDecoder().decode(base64UrlDecode(token.split('.')[0])))
|
||||
: null
|
||||
if (job?.mode === 'measure') {
|
||||
await (env().THUMBNAILS as R2Bucket).put(
|
||||
`render-result/${encodeURIComponent(token!)}.json`,
|
||||
JSON.stringify(bounds)
|
||||
)
|
||||
}
|
||||
return new Response(new Uint8Array([1, 2, 3]), { status: 200 })
|
||||
})
|
||||
}
|
||||
|
||||
// In-memory stand-in for the TLDR_DOC namespace, with the file object's MCP cluster index storage.
|
||||
//
|
||||
// One store per object id, because that is the property the design rests on: the index key carries no
|
||||
// slug precisely because each file has its own object, so a fake that pooled every file into one
|
||||
// store could not tell a right `fileId` from a wrong one.
|
||||
//
|
||||
// Within a store, rows are keyed the way the real table is — (kind, pageId), with the version held as
|
||||
// a value rather than part of the key — so a write for new content replaces the old row exactly as
|
||||
// `INSERT OR REPLACE` does, a read for a stale version misses, and a write prunes the pages the board
|
||||
// no longer has. mcpClusterIndexStorage.test.ts is what holds this fake to the real statements.
|
||||
export function makeFakeFileDurableObjectNamespace() {
|
||||
const objects = new Map<string, Map<string, { version: string; payload: string }>>()
|
||||
const calls = { get: 0, put: 0 }
|
||||
const keyOf = (key: { kind: string; pageId: string }) => `${key.kind}/${key.pageId}`
|
||||
const storeFor = (id: string) => {
|
||||
const existing = objects.get(id)
|
||||
if (existing) return existing
|
||||
const created = new Map<string, { version: string; payload: string }>()
|
||||
objects.set(id, created)
|
||||
return created
|
||||
}
|
||||
|
||||
return {
|
||||
objects,
|
||||
calls,
|
||||
// A legible id rather than an opaque hash, so an assertion that reaches one reads as `do(<name>)`.
|
||||
idFromName: (name: string) => ({ toString: () => `do(${name})` }),
|
||||
get: (id: { toString(): string }) => {
|
||||
const store = storeFor(String(id))
|
||||
return {
|
||||
async getMcpClusterIndex(key: any) {
|
||||
calls.get++
|
||||
const row = store.get(keyOf(key))
|
||||
return row && row.version === key.version ? row.payload : null
|
||||
},
|
||||
async putMcpClusterIndex(key: any, payload: string, livePageIds: string[]) {
|
||||
calls.put++
|
||||
for (const existing of [...store.keys()]) {
|
||||
const [kind, pageId] = existing.split('/')
|
||||
if (kind === key.kind && !livePageIds.includes(pageId)) store.delete(existing)
|
||||
}
|
||||
store.set(keyOf(key), { version: key.version, payload })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function makeScreenshotTestEnv(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
BROWSER: makeBrowserBinding(),
|
||||
MCP_SCREENSHOT_RENDER_ORIGIN: 'https://www.tldraw.com',
|
||||
MCP_SCREENSHOT_TOKEN_SECRET: 'test-secret',
|
||||
MEASURE: { writeDataPoint: vi.fn() },
|
||||
QUEUE: makeFakeQueue(),
|
||||
// The MCP cluster index cache lives on the file's durable object, so the default env has a
|
||||
// working one: a test that measures twice for the same board and page is asserting that the
|
||||
// cache did not hold, which is worth failing on rather than passing by accident.
|
||||
TLDR_DOC: makeFakeFileDurableObjectNamespace(),
|
||||
...overrides,
|
||||
} as unknown as Environment
|
||||
}
|
||||
|
||||
/** The fake TLDR_DOC namespace an env was built with, for tests that assert on cache traffic. */
|
||||
export function clusterIndexStoreOf(env: Environment) {
|
||||
return env.TLDR_DOC as unknown as ReturnType<typeof makeFakeFileDurableObjectNamespace>
|
||||
}
|
||||
|
||||
/** Every cluster index row an env holds, as `<object>|<kind>/<pageId>`, for whole-store assertions. */
|
||||
export function clusterIndexKeysOf(env: Environment) {
|
||||
return [...clusterIndexStoreOf(env).objects].flatMap(([id, store]) =>
|
||||
[...store.keys()].map((key) => `${id}|${key}`)
|
||||
)
|
||||
}
|
||||
|
||||
export function screenshotOf(env: Environment) {
|
||||
return (env.BROWSER as any).quickAction as ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// The datapoints for one event name (blob1). The MCP route writes two events per screenshot tool
|
||||
// call — the render ledger (`mcp_shared_board_screenshot`, request-level) and the spend ledger
|
||||
// (`browser_run_session`, one per browser session) — and they share prefixes (`reason:`), so a test
|
||||
// asserting on one has to say which.
|
||||
export function datapointsNamed(
|
||||
env: Environment,
|
||||
name: string
|
||||
): Array<{ blobs: string[]; doubles: number[] }> {
|
||||
const points: Array<{ blobs: string[]; doubles: number[] }> = (
|
||||
env.MEASURE as any
|
||||
).writeDataPoint.mock.calls.map((call: any[]) => call[0])
|
||||
return points.filter((point) => point.blobs[0] === name)
|
||||
}
|
||||
|
||||
// Pulls the `<prefix>:…` telemetry blob out of every request-level datapoint, so tests can assert on
|
||||
// the low-cardinality dimensions (failure reason codes, and the caller recorded only on failures)
|
||||
// without depending on the order of the blobs array.
|
||||
export function blobsWithPrefix(env: Environment, prefix: string): string[] {
|
||||
return datapointsNamed(env, 'mcp_shared_board_screenshot')
|
||||
.map((point) => point.blobs.find((blob) => blob.startsWith(prefix)))
|
||||
.filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
// The `<prefix>:…` blobs of one event's datapoints, with the prefix stripped, in write order.
|
||||
export function blobValuesOf(env: Environment, name: string, prefix: string): string[] {
|
||||
return datapointsNamed(env, name)
|
||||
.map((point) => point.blobs.find((blob) => blob.startsWith(`${prefix}:`)))
|
||||
.filter((blob): blob is string => Boolean(blob))
|
||||
.map((blob) => blob.slice(prefix.length + 1))
|
||||
}
|
||||
|
||||
export function failureBlobsOf(env: Environment) {
|
||||
return blobsWithPrefix(env, 'failure:')
|
||||
}
|
||||
|
||||
export function callerBlobsOf(env: Environment) {
|
||||
return blobsWithPrefix(env, 'caller:')
|
||||
}
|
||||
|
||||
// Every browser_run_session datapoint, parsed for whole-object assertions: one entry per browser
|
||||
// session actually created, in creation order. `durationMs` is that session's own wall-clock —
|
||||
// request rows carry no durations at all under the split ledger.
|
||||
export function sessionsOf(env: Environment): Array<{
|
||||
source: string
|
||||
mode: string
|
||||
outcome: string
|
||||
reason: string
|
||||
page: string
|
||||
durationMs: number
|
||||
browserMsUsed: number
|
||||
/** records, page shapes, media assets, bbox width, bbox height */
|
||||
content: number[]
|
||||
}> {
|
||||
return datapointsNamed(env, 'browser_run_session').map((point) => {
|
||||
const value = (prefix: string) =>
|
||||
point.blobs.find((blob) => blob.startsWith(prefix))!.slice(prefix.length)
|
||||
return {
|
||||
source: value('source:'),
|
||||
mode: value('mode:'),
|
||||
outcome: value('outcome:'),
|
||||
reason: value('reason:'),
|
||||
page: value('page:'),
|
||||
durationMs: point.doubles[2],
|
||||
browserMsUsed: point.doubles[3],
|
||||
content: point.doubles.slice(4, 9),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The index (index1) of every datapoint written. Always `undefined`, since the dataset carries no
|
||||
// board identity — this exists to pin that, not to read a value out.
|
||||
export function indexesOf(env: Environment): (string | undefined)[] {
|
||||
return (env.MEASURE as any).writeDataPoint.mock.calls.map(
|
||||
(call: any[]) => (call[0].indexes as [string] | undefined)?.[0]
|
||||
)
|
||||
}
|
||||
|
||||
// quickAction is called as quickAction('screenshot', body); the render URL rides in body (arg 1).
|
||||
export function tokenFromScreenshot(env: Environment): string {
|
||||
const body = screenshotOf(env).mock.calls[0]![1] as { url: string }
|
||||
return new URL(body.url).searchParams.get('token')!
|
||||
}
|
||||
391
apps/dotcom/sync-worker/src/routes/tla/searchBoards.test.ts
Normal file
391
apps/dotcom/sync-worker/src/routes/tla/searchBoards.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { DB } from '@tldraw/dotcom-shared'
|
||||
import {
|
||||
CompiledQuery,
|
||||
DatabaseConnection,
|
||||
Kysely,
|
||||
PostgresAdapter,
|
||||
PostgresIntrospector,
|
||||
PostgresQueryCompiler,
|
||||
QueryResult,
|
||||
} from 'kysely'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Environment } from '../../types'
|
||||
import { BOARD_SEARCH_PAGE_SIZE } from './boardTools'
|
||||
|
||||
vi.mock('../../postgres', () => ({ createPostgresConnectionPool: vi.fn() }))
|
||||
|
||||
const { createPostgresConnectionPool } = await import('../../postgres')
|
||||
const { escapeLikePattern, searchAccessibleBoards, selectAccessibleGroupIds } =
|
||||
await import('./searchBoards')
|
||||
|
||||
const env = {} as Environment
|
||||
|
||||
/**
|
||||
* A real Kysely on a driver that records the SQL instead of sending it, and hands back the queued
|
||||
* result sets in order.
|
||||
*
|
||||
* Real rather than a stub builder because the thing under test *is* the SQL: whether the query
|
||||
* joins `file_state` decides whether the ordering can ever be index-served, and whether `id`
|
||||
* carries `COLLATE "C"` decides whether the database agrees with the JS comparator. Neither is
|
||||
* visible in a recording of which builder methods were called.
|
||||
*/
|
||||
function mockPool(resultSets: unknown[][]) {
|
||||
const queries: CompiledQuery[] = []
|
||||
const queued = [...resultSets]
|
||||
const connection: DatabaseConnection = {
|
||||
async executeQuery<R>(compiled: CompiledQuery<unknown>): Promise<QueryResult<R>> {
|
||||
queries.push(compiled)
|
||||
return { rows: (queued.shift() ?? []) as R[] }
|
||||
},
|
||||
async *streamQuery() {},
|
||||
}
|
||||
const db = new Kysely<DB>({
|
||||
dialect: {
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createIntrospector: (instance) => new PostgresIntrospector(instance),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
createDriver: () => ({
|
||||
async init() {},
|
||||
async acquireConnection() {
|
||||
return connection
|
||||
},
|
||||
async beginTransaction() {},
|
||||
async commitTransaction() {},
|
||||
async rollbackTransaction() {},
|
||||
async releaseConnection() {},
|
||||
async destroy() {},
|
||||
}),
|
||||
},
|
||||
})
|
||||
vi.mocked(createPostgresConnectionPool).mockReturnValue(db)
|
||||
return { queries }
|
||||
}
|
||||
|
||||
/** The workspace read is the second query; the first is the workspace-membership lookup. */
|
||||
function fileQuery(queries: CompiledQuery[]) {
|
||||
return queries[1]
|
||||
}
|
||||
|
||||
/** The link-shared read, issued after the workspace one and merged with it. */
|
||||
function sharedQuery(queries: CompiledQuery[]) {
|
||||
return queries[2]
|
||||
}
|
||||
|
||||
/** The caller's home group: its id is their user id, which is what makes a board theirs. */
|
||||
const HOME_MEMBERSHIP = [{ groupId: 'user-1', role: 'owner' }]
|
||||
|
||||
const FILE_ROW = {
|
||||
id: 'board-1',
|
||||
name: 'Roadmap',
|
||||
createdAt: '1699999000000',
|
||||
updatedAt: '1700000000000',
|
||||
ownerName: 'My workspace',
|
||||
owningGroupId: 'user-1',
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks())
|
||||
|
||||
// `%` and `_` are LIKE wildcards. A search for `%` that reached Postgres unescaped would match
|
||||
// every board the caller has, which is the opposite of a search.
|
||||
describe('escapeLikePattern', () => {
|
||||
it('escapes both wildcards and the escape character itself', () => {
|
||||
expect(escapeLikePattern('100%')).toBe('100\\%')
|
||||
expect(escapeLikePattern('a_b')).toBe('a\\_b')
|
||||
expect(escapeLikePattern('c:\\temp')).toBe('c:\\\\temp')
|
||||
})
|
||||
|
||||
it('leaves an ordinary term alone', () => {
|
||||
expect(escapeLikePattern('roadmap')).toBe('roadmap')
|
||||
})
|
||||
})
|
||||
|
||||
// Capability, never role name: `can` is the one place a role's meaning lives, and an unknown role
|
||||
// string out of the database answers false rather than throwing.
|
||||
describe('selectAccessibleGroupIds', () => {
|
||||
it('keeps roles that can access files', () => {
|
||||
expect(
|
||||
selectAccessibleGroupIds([
|
||||
{ groupId: 'g1', role: 'owner' },
|
||||
{ groupId: 'g2', role: 'member' },
|
||||
])
|
||||
).toEqual(['g1', 'g2'])
|
||||
})
|
||||
|
||||
it('drops an unknown role rather than trusting it', () => {
|
||||
expect(selectAccessibleGroupIds([{ groupId: 'g3', role: 'spectator' }])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchAccessibleBoards', () => {
|
||||
// Since the groups model this is the only arm that returns a caller's own boards — their home
|
||||
// group's id is their user id. A home group flagged deleted therefore empties search entirely
|
||||
// while every other tool on this server keeps working, so the predicate has to be pinned.
|
||||
it('excludes deleted workspaces from the membership lookup', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(queries[0].sql).toContain('"group"."isDeleted" = $2')
|
||||
expect(queries[0].parameters).toEqual(['user-1', false])
|
||||
})
|
||||
|
||||
it('asks for one row more than a page, newest created first', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, [FILE_ROW]])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(fileQuery(queries).sql).toContain(
|
||||
'order by "file"."createdAt" desc, file.id collate "C" desc limit'
|
||||
)
|
||||
expect(fileQuery(queries).parameters.at(-1)).toBe(21)
|
||||
})
|
||||
|
||||
// The sort key has to come from `file` alone. A key read through a join to another table can
|
||||
// never be index-ordered, so such a join is what forced Postgres to materialise and sort the
|
||||
// caller's whole candidate set on every page — and `file_state` is also the table that would
|
||||
// quietly widen the scope to link-shared boards. The one join left is the lateral over the
|
||||
// caller's own workspace ids, which reads no table at all.
|
||||
it('reads every row from the file table, joining no other table', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(fileQuery(queries).sql).not.toContain('file_state')
|
||||
expect(fileQuery(queries).sql).not.toContain('group_file')
|
||||
expect(fileQuery(queries).sql.match(/join/g)).toEqual(['join'])
|
||||
})
|
||||
|
||||
// The merge is what keeps the ordering index-served for a caller in more than one workspace:
|
||||
// `"owningGroupId" = ANY(:groups)` is not the single equality the index needs, so it seq-scans
|
||||
// every board in scope. Each arm has to take a whole page — take fewer and a workspace that owns
|
||||
// the whole page cannot fill it.
|
||||
it('takes a page from each workspace and merges them', async () => {
|
||||
const { queries } = mockPool([[{ groupId: 'g1', role: 'member' }, HOME_MEMBERSHIP[0]], []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
const { sql, parameters } = fileQuery(queries)
|
||||
expect(sql).toContain('cross join lateral')
|
||||
expect(sql).not.toContain('owningGroupId" in')
|
||||
expect(parameters[0]).toEqual(['g1', 'user-1'])
|
||||
// One limit, on the arm. The outer limit the SQL used to carry is now the JS merge's slice.
|
||||
expect(parameters.filter((value) => value === 21)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('matches every term as an escaped, case-insensitive substring', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: ['design', '50%'], cursor: null })
|
||||
expect(fileQuery(queries).sql).toContain('"file"."name" ilike $4 and "file"."name" ilike $5')
|
||||
expect(fileQuery(queries).parameters).toContain('%design%')
|
||||
expect(fileQuery(queries).parameters).toContain('%50\\%%')
|
||||
})
|
||||
|
||||
// Test files need admin auth to read, so they are not the caller's to find. Filtered in SQL,
|
||||
// not after: filtering afterwards would let one consume a slot on the page.
|
||||
it('excludes deleted and test files in the query', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(fileQuery(queries).sql).toContain('"file"."isDeleted" = $2')
|
||||
expect(fileQuery(queries).sql).toContain('"file"."id" not like $3')
|
||||
expect(fileQuery(queries).parameters).toContain('test\\_%')
|
||||
})
|
||||
|
||||
it('scopes to the workspaces the caller can access files in', async () => {
|
||||
const { queries } = mockPool([[{ groupId: 'g1', role: 'member' }], []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(fileQuery(queries).sql).toContain('"file"."owningGroupId" = "g"."groupId"')
|
||||
expect(fileQuery(queries).parameters[0]).toEqual(['g1'])
|
||||
})
|
||||
|
||||
// `hasReadAccessToFile` has no `ownerId` branch since #10391, and a legacy row carrying one would
|
||||
// be found here and refused by every tool the model reached for next. It is also what kept the
|
||||
// ordering unindexable: a top-level `OR` makes Postgres answer with an unordered `BitmapOr`.
|
||||
it('mentions ownerId nowhere in the query', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(fileQuery(queries).sql).not.toContain('ownerId')
|
||||
expect(fileQuery(queries).sql).not.toContain(' or ')
|
||||
})
|
||||
|
||||
// An `in ()` against an empty list is a SQL error, and a caller in no workspace can reach no
|
||||
// boards anyway, so there is nothing to ask Postgres.
|
||||
it('runs no file query when the caller is in no workspace', async () => {
|
||||
const { queries } = mockPool([[]])
|
||||
expect(await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })).toEqual([])
|
||||
expect(queries).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Descending order means the next page is strictly *below* the cursor, and the id half is what
|
||||
// stops boards created in one batch, which share a createdAt, from being skipped or repeated
|
||||
// across a page boundary. It has to stay a row comparison: Postgres cannot match the equivalent
|
||||
// `createdAt < :c or (createdAt = :c and id < :i)` to the index, so a late page walks every row
|
||||
// above the cursor and discards it.
|
||||
it('seeks past the cursor with a row comparison rather than counting an offset', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, []])
|
||||
await searchAccessibleBoards(env, 'user-1', {
|
||||
terms: [],
|
||||
cursor: { arrivedAt: 1_700_000_000_000, id: 'board-9' },
|
||||
})
|
||||
expect(fileQuery(queries).sql).toContain('("file"."createdAt", file.id collate "C") < ($4, $5)')
|
||||
expect(fileQuery(queries).parameters).toContain('board-9')
|
||||
})
|
||||
|
||||
it('adds no cursor predicate on the first page', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(fileQuery(queries).sql).not.toContain('collate "C") < ')
|
||||
})
|
||||
|
||||
// `compareBoardSearchOrder` compares ids by UTF-16 code unit, and a tldraw id is drawn from
|
||||
// `A-Za-z0-9_-` — exactly where an ICU or glibc collation parts company with code-unit order.
|
||||
// Without the declared collation the eval harness pages differently from production.
|
||||
it('orders and seeks ids under the same collation the JS comparator uses', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, []])
|
||||
await searchAccessibleBoards(env, 'user-1', {
|
||||
terms: [],
|
||||
cursor: { arrivedAt: 1, id: 'board-9' },
|
||||
})
|
||||
const sql = fileQuery(queries).sql
|
||||
expect(sql.match(/file\.id collate "C"/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
// Both timestamps arrive from pg as strings, because they are int8 columns. Left as strings the
|
||||
// sort key would compare lexicographically and the cursor would encode a quoted number.
|
||||
it('returns the sort key and the edit time as numbers', async () => {
|
||||
mockPool([HOME_MEMBERSHIP, [FILE_ROW]])
|
||||
const rows = await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: 'board-1',
|
||||
name: 'Roadmap',
|
||||
// A workspace board arrives when it is made, so the two are the same instant here.
|
||||
arrivedAt: 1_699_999_000_000,
|
||||
createdAt: 1_699_999_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
workspaceName: 'My workspace',
|
||||
source: 'owned' as const,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// A board is the caller's own when its owning group is their home group, whose id is their user
|
||||
// id. `getBoardSearchResults` reports the workspace name only for the boards that are not.
|
||||
it('marks a board personal when its owning group is the caller', async () => {
|
||||
// Distinct timestamps rather than a tie: the rows are merged through compareBoardSearchOrder
|
||||
// now, which breaks a tie on id *descending* — so two rows sharing one would come back
|
||||
// board-2 first, which is what the database would return too.
|
||||
mockPool([
|
||||
HOME_MEMBERSHIP,
|
||||
[
|
||||
FILE_ROW,
|
||||
{ ...FILE_ROW, id: 'board-2', createdAt: '1699998000000', owningGroupId: 'group-9' },
|
||||
],
|
||||
])
|
||||
const rows = await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(rows.map((row) => [row.id, row.source])).toEqual([
|
||||
['board-1', 'owned'],
|
||||
['board-2', 'workspace'],
|
||||
])
|
||||
})
|
||||
|
||||
// Opening a link-shared board writes a group_file row into the opener's *home* group while the
|
||||
// file stays owned elsewhere, so the caller's own id is the whole of this read's access predicate.
|
||||
it("reads link-shared boards through the caller's home group", async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, [], []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
const { sql, parameters } = sharedQuery(queries)
|
||||
expect(sql).toContain('"group_file"')
|
||||
expect(sql).toContain('"group_file"."groupId" = ')
|
||||
expect(sql).toContain('"file"."shared" = ')
|
||||
expect(parameters).toContain('user-1')
|
||||
})
|
||||
|
||||
// Without this the read returns every board the caller owns, not just a rare mislinked one:
|
||||
// `createFile` writes a group_file row into the owning workspace — the home group, for a personal
|
||||
// board — and sets shared: true, so both of this read's predicates match the caller's own boards.
|
||||
// The caller's own id has to be in the exclusion list, or each one comes back here as well as
|
||||
// from the workspace read.
|
||||
it('excludes boards owned by workspaces the caller is in, their own included', async () => {
|
||||
const { queries } = mockPool([[...HOME_MEMBERSHIP, { groupId: 'g1', role: 'member' }], [], []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
const { sql, parameters } = sharedQuery(queries)
|
||||
expect(sql).toContain('"file"."owningGroupId" not in')
|
||||
// 'user-1' twice: once as the home group this read reads, once as a workspace it excludes.
|
||||
expect(parameters.filter((value) => value === 'user-1')).toHaveLength(2)
|
||||
})
|
||||
|
||||
// The columns `053_group_file_created_at_index.sql` indexes. Sorting or seeking on file.createdAt
|
||||
// here would have no ordered access path at all, since the access key is on the other table.
|
||||
it('sorts and seeks the shared read on the link time, not the file time', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, [], []])
|
||||
await searchAccessibleBoards(env, 'user-1', {
|
||||
terms: [],
|
||||
cursor: { arrivedAt: 1_700_000_000_000, id: 'board-9' },
|
||||
})
|
||||
const { sql } = sharedQuery(queries)
|
||||
expect(sql).toContain('("group_file"."createdAt", group_file."fileId" collate "C") < ')
|
||||
expect(sql).toContain('order by "group_file"."createdAt" desc')
|
||||
expect(sql).not.toContain('order by "file"."createdAt"')
|
||||
})
|
||||
|
||||
// The same filters have to reach both reads, or one side's page is a different search from the
|
||||
// other's and the merged page means nothing.
|
||||
it('applies the same terms to both reads', async () => {
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, [], []])
|
||||
await searchAccessibleBoards(env, 'user-1', { terms: ['design'], cursor: null })
|
||||
for (const query of [fileQuery(queries), sharedQuery(queries)]) {
|
||||
expect(query.sql).toContain('"file"."name" ilike')
|
||||
expect(query.parameters).toContain('%design%')
|
||||
}
|
||||
})
|
||||
|
||||
// The merge is what makes arrival ordering visible: an old board shared with the caller this
|
||||
// morning has to outrank a newer board of their own.
|
||||
it('merges both reads by arrival, newest arrival first', async () => {
|
||||
const oldButJustShared = {
|
||||
...FILE_ROW,
|
||||
id: 'shared-1',
|
||||
createdAt: '1600000000000',
|
||||
arrivedAt: '1700000900000',
|
||||
owningGroupId: 'group-9',
|
||||
}
|
||||
const { queries } = mockPool([HOME_MEMBERSHIP, [FILE_ROW], [oldButJustShared]])
|
||||
const rows = await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(queries).toHaveLength(3)
|
||||
expect(rows.map((row) => [row.id, row.arrivedAt, row.createdAt])).toEqual([
|
||||
['shared-1', 1_700_000_900_000, 1_600_000_000_000],
|
||||
['board-1', 1_699_999_000_000, 1_699_999_000_000],
|
||||
])
|
||||
// Not `workspace`: the caller is not a member of the workspace that owns a link-shared board,
|
||||
// so calling it one would claim standing there that they do not have.
|
||||
expect(rows.map((row) => row.source)).toEqual(['shared', 'owned'])
|
||||
})
|
||||
|
||||
it('takes no more than one page plus the surplus row across both reads', async () => {
|
||||
const page = (prefix: string, from: number) =>
|
||||
Array.from({ length: BOARD_SEARCH_PAGE_SIZE + 1 }, (_, i) => ({
|
||||
...FILE_ROW,
|
||||
id: `${prefix}-${i}`,
|
||||
createdAt: String(from - i),
|
||||
arrivedAt: String(from - i),
|
||||
}))
|
||||
const { queries } = mockPool([
|
||||
HOME_MEMBERSHIP,
|
||||
page('own', 1_700_000_000_000),
|
||||
page('shared', 1_700_000_000_500),
|
||||
])
|
||||
const rows = await searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
expect(queries).toHaveLength(3)
|
||||
expect(rows).toHaveLength(BOARD_SEARCH_PAGE_SIZE + 1)
|
||||
// Both reads offered a full page; the more recently arrived set wins the merge outright.
|
||||
expect(rows.every((row) => row.id.startsWith('shared-'))).toBe(true)
|
||||
})
|
||||
|
||||
// The pool is per call, so a query that throws must still return its connection or pools pile
|
||||
// up in the isolate.
|
||||
it('destroys the pool even when the query throws', async () => {
|
||||
const destroy = vi.fn(async () => {})
|
||||
vi.mocked(createPostgresConnectionPool).mockReturnValue({
|
||||
selectFrom: () => {
|
||||
throw new Error('connection refused')
|
||||
},
|
||||
destroy,
|
||||
} as any)
|
||||
await expect(
|
||||
searchAccessibleBoards(env, 'user-1', { terms: [], cursor: null })
|
||||
).rejects.toThrow('connection refused')
|
||||
expect(destroy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
330
apps/dotcom/sync-worker/src/routes/tla/searchBoards.ts
Normal file
330
apps/dotcom/sync-worker/src/routes/tla/searchBoards.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { DB, can } from '@tldraw/dotcom-shared'
|
||||
import { Kysely, RawBuilder, SqlBool, sql } from 'kysely'
|
||||
import { createPostgresConnectionPool } from '../../postgres'
|
||||
import { Environment } from '../../types'
|
||||
import {
|
||||
BOARD_SEARCH_PAGE_SIZE,
|
||||
BoardSearchCursor,
|
||||
BoardSearchRow,
|
||||
compareBoardSearchOrder,
|
||||
} from './boardTools'
|
||||
|
||||
// The database half of search_boards. The model-facing half — what the tool is called, how its
|
||||
// arguments and cursors are parsed, how results are shaped — lives in boardTools.ts, which stays
|
||||
// free of Postgres so the eval harness can serve the same tool from fixtures.
|
||||
|
||||
// The sort key is `file.createdAt`, and it has to be a column nothing rewrites. A keyset cursor
|
||||
// stops duplicates but cannot save a *mutating* key: a row whose key changes can move from below the
|
||||
// cursor (not yet served) to above it (already passed) and then appear on no page at all. That rules
|
||||
// out the obvious-looking `file.updatedAt`, which `005_update_file_trigger.sql` bumps on any column
|
||||
// change — under it a board edited mid-paging silently vanishes from an agent's results. `createdAt`
|
||||
// is written once by the `createFile` mutator and is in `immutableColumns.file`, and no trigger
|
||||
// touches it, so only inserts and deletes move the set, which a keyset handles by construction.
|
||||
|
||||
// `COLLATE "C"` because `compareBoardSearchOrder` in boardTools.ts compares ids by UTF-16 code unit
|
||||
// and the eval harness pages with it. A tldraw id is drawn from `A-Za-z0-9_-`, which is exactly
|
||||
// where an ICU or glibc collation diverges from code-unit order, so without this the database and
|
||||
// the harness would order the same rows differently. A future index on this ordering has to declare
|
||||
// the same collation or it will not be usable.
|
||||
const FILE_ID = sql<string>`file.id collate "C"`
|
||||
// The same expression over the guest read, where the id comes off the link rather than the file.
|
||||
const LINK_FILE_ID = sql<string>`group_file."fileId" collate "C"`
|
||||
|
||||
/**
|
||||
* One page of the boards a caller may search: the ones their workspaces own, and the ones shared
|
||||
* with them by link.
|
||||
*
|
||||
* This is the scope `hasReadAccessToFile` admits, and the relationship only runs one way: everything
|
||||
* returned here must be a board the other tools will open. A model that finds a board and is then
|
||||
* refused it has no way to interpret that.
|
||||
*
|
||||
* Two reads, because that gate has two arms with nothing in common but their answer, and they are
|
||||
* merged here rather than unioned in SQL so both can use `compareBoardSearchOrder` — the single
|
||||
* statement of this ordering, which the eval harness already pages fixtures with. A `UNION`'s own
|
||||
* `ORDER BY` would be a third copy of it to drift.
|
||||
*
|
||||
* Sequential rather than `Promise.all`: `createPostgresConnectionPool` defaults to one connection,
|
||||
* so issuing them together queues the second behind the first for the same wall-clock.
|
||||
*/
|
||||
export async function searchAccessibleBoards(
|
||||
env: Environment,
|
||||
userId: string,
|
||||
{ terms, cursor }: { terms: string[]; cursor: BoardSearchCursor | null }
|
||||
): Promise<BoardSearchRow[]> {
|
||||
// createPostgresConnectionPool news up a pg.Pool; destroy it so idle pools don't pile up in the
|
||||
// isolate across tool calls.
|
||||
const db = createPostgresConnectionPool(env, 'sync-worker/searchAccessibleBoards')
|
||||
try {
|
||||
const groupIds = await getAccessibleGroupIds(db, userId)
|
||||
// Nothing is in scope, so there are no pages to merge and nothing to ask Postgres.
|
||||
if (!groupIds.length) return []
|
||||
|
||||
const owned = await searchWorkspaceBoards(db, userId, groupIds, terms, cursor)
|
||||
const shared = await searchBoardsSharedWithCaller(db, userId, groupIds, terms, cursor)
|
||||
|
||||
// Lossless because each read took a whole page: the global top page cannot hold a row that is
|
||||
// in neither side's own top page. The surplus row survives the merge too, which is what lets
|
||||
// the caller answer "is there another page" without a second count.
|
||||
return [...owned, ...shared].sort(compareBoardSearchOrder).slice(0, BOARD_SEARCH_PAGE_SIZE + 1)
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// What both reads select from `file`, and what those columns mean once read back. `ownerName` is
|
||||
// misleadingly named: `set_file_owner_details_trigger` (`023_groups.sql`) writes the owning *user's*
|
||||
// name there for a user-owned row and the owning group's name for a group-owned one. Every row these
|
||||
// reads return is group-owned, so on all of them it is a workspace name.
|
||||
const BOARD_COLUMNS = [
|
||||
'file.id',
|
||||
'file.name',
|
||||
'file.createdAt',
|
||||
'file.updatedAt',
|
||||
'file.ownerName',
|
||||
'file.owningGroupId',
|
||||
] as const
|
||||
|
||||
// Both timestamps arrive from pg as strings, because they are int8 columns. Left as strings the sort
|
||||
// key would compare lexicographically and the cursor would encode a quoted number.
|
||||
function toBoardSearchRow(
|
||||
row: {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string | number
|
||||
updatedAt: string | number
|
||||
ownerName: string
|
||||
owningGroupId: string | null
|
||||
},
|
||||
arrivedAt: string | number,
|
||||
source: BoardSearchRow['source']
|
||||
): BoardSearchRow {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
arrivedAt: Number(arrivedAt),
|
||||
createdAt: Number(row.createdAt),
|
||||
updatedAt: Number(row.updatedAt),
|
||||
workspaceName: row.ownerName,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The filters both reads share, applied through one helper because a filter that reached one and not
|
||||
* the other would make that read's page a different search from the rest of the merge — and the
|
||||
* surplus row only means "there is another page" if both were searching the same thing.
|
||||
*/
|
||||
function narrowToTerms<T extends { where: any }>(query: T, terms: string[]): T {
|
||||
let narrowed = query
|
||||
.where('file.isDeleted', '=', false)
|
||||
// Mirrors isTestFile: reading a test file needs admin auth, so it is not the caller's to find.
|
||||
// In SQL rather than after the fact, or one could take a slot on the page.
|
||||
.where('file.id', 'not like', 'test\\_%')
|
||||
for (const term of terms) {
|
||||
narrowed = narrowed.where('file.name', 'ilike', `%${escapeLikePattern(term)}%`)
|
||||
}
|
||||
return narrowed
|
||||
}
|
||||
|
||||
/**
|
||||
* The boards owned by a workspace the caller can access files in — including their own, since a home
|
||||
* group's id is the user's own id.
|
||||
*
|
||||
* One page per workspace, merged, rather than one query over every workspace at once. The index can
|
||||
* only be read in order under a single-equality access predicate, and `"owningGroupId" = ANY(:groups)`
|
||||
* is not one: an array-driven btree scan is unordered before PG 17 and we run 16, so a caller in three
|
||||
* workspaces seq-scanned 30,000 rows to return the first 21. Asking each workspace for its own top page
|
||||
* and merging reads 63.
|
||||
*
|
||||
* `file."createdAt"` is this side's arrival time, not merely a proxy for it: `createFile` writes one
|
||||
* timestamp into the file row and its `group_file` row alike, so for a board made here the two are the
|
||||
* same value. A board *moved* in is the one case they diverge — it keeps its original creation time
|
||||
* rather than sorting by the move — which is a smaller discrepancy than reading the sort key from a
|
||||
* table the index cannot reach.
|
||||
*/
|
||||
async function searchWorkspaceBoards(
|
||||
db: Kysely<DB>,
|
||||
userId: string,
|
||||
groupIds: string[],
|
||||
terms: string[],
|
||||
cursor: BoardSearchCursor | null
|
||||
): Promise<BoardSearchRow[]> {
|
||||
const rows = await db
|
||||
.selectFrom(
|
||||
// Wrapped in a sub-select purely to name the column: `unnest(...) as "g"` gives the one column
|
||||
// the table's own alias, so the arm below would have to read `g.g`.
|
||||
sql<{ groupId: string }>`(select unnest(${sql.val(groupIds)}::text[]) as "groupId")`.as('g')
|
||||
)
|
||||
.crossJoinLateral((eb) => {
|
||||
let arm = narrowToTerms(
|
||||
eb
|
||||
.selectFrom('file')
|
||||
.select(BOARD_COLUMNS)
|
||||
// `file_owning_group_created_at_idx` (`052_file_search_index.sql`) serves this equality
|
||||
// and the ordering together, so each arm reads its page and stops instead of top-N
|
||||
// sorting a whole workspace. What no index helps is the `ilike '%term%'` above: a query
|
||||
// matching nothing still reads every board in scope. The index bounds paging, not
|
||||
// matching.
|
||||
.where('file.owningGroupId', '=', sql.ref<string>('g.groupId')),
|
||||
terms
|
||||
)
|
||||
if (cursor) {
|
||||
arm = arm.where(seekBelow(FILE_ID, sql.ref('file.createdAt'), cursor))
|
||||
}
|
||||
return (
|
||||
arm
|
||||
.orderBy('file.createdAt', 'desc')
|
||||
.orderBy(FILE_ID, 'desc')
|
||||
// One more than a page, so the caller can answer "there is another page" without a second
|
||||
// count query over the same set.
|
||||
.limit(BOARD_SEARCH_PAGE_SIZE + 1)
|
||||
.as('s')
|
||||
)
|
||||
})
|
||||
.select(['s.id', 's.name', 's.createdAt', 's.updatedAt', 's.ownerName', 's.owningGroupId'])
|
||||
.execute()
|
||||
|
||||
// A home group carries its user's id, which is what makes a board the caller's own rather than a
|
||||
// shared workspace's.
|
||||
return rows.map((row) =>
|
||||
toBoardSearchRow(row, row.createdAt, row.owningGroupId === userId ? 'owned' : 'workspace')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The boards shared with the caller by link.
|
||||
*
|
||||
* Opening one writes a `group_file` row into the opener's *home* group while the file stays owned
|
||||
* elsewhere, and `039_tighten_group_file_association.sql` permits exactly that row and no other
|
||||
* cross-workspace link — so the caller's home group is the whole of this access predicate. The scope
|
||||
* invariant holds: `hasReadAccessToFile` grants on `shared` alone, so every row here is one the other
|
||||
* tools will open, and unsharing deletes these rows (`034_fix_unshare_group_file_cleanup.sql`), so
|
||||
* the set cannot outlive the access that justifies it.
|
||||
*
|
||||
* Files whose owning workspace the caller already belongs to are excluded, and that exclusion is not
|
||||
* an edge case — without it this read returns every board the caller owns. `createFile` writes a
|
||||
* `group_file` row into the owning workspace, which for a personal board *is* the home group, and it
|
||||
* sets `shared: true`; so both predicates below match a caller's own boards exactly as well as their
|
||||
* guest links, and every one of them would come back here as well as from `searchWorkspaceBoards`,
|
||||
* twice on one page. The mislinked case it also covers — a guest file whose workspace the caller
|
||||
* later joined — is the rarer half of its job. `getWorkspaceFilesSorted` in the client guards the
|
||||
* same rows the same way.
|
||||
*
|
||||
* Sorted and sought on `group_file."createdAt"`, which is when the caller opened the link. That is
|
||||
* the same thing the other read's `file."createdAt"` means for a board made in a workspace, so the
|
||||
* two merge — and it is the only sort key here an index could reach, since `group_file` carries the
|
||||
* access key.
|
||||
*
|
||||
* No index serves it yet, so this read fetches every guest link the caller has and sorts them: 879
|
||||
* buffers for the heaviest account in production (233 links), against 89 with one. An index on
|
||||
* `group_file("groupId", "createdAt" DESC)` is the fix and is deliberately a separate deploy — a
|
||||
* migration run that locks both `file` and `group_file` can deadlock against a concurrent file move,
|
||||
* which takes those two in the opposite order, and one that locks only one of them cannot. The
|
||||
* ordering here is already what that index will want, so adding it is a migration and nothing else.
|
||||
*/
|
||||
async function searchBoardsSharedWithCaller(
|
||||
db: Kysely<DB>,
|
||||
userId: string,
|
||||
groupIds: string[],
|
||||
terms: string[],
|
||||
cursor: BoardSearchCursor | null
|
||||
): Promise<BoardSearchRow[]> {
|
||||
let query = narrowToTerms(
|
||||
db
|
||||
.selectFrom('group_file')
|
||||
.innerJoin('file', 'file.id', 'group_file.fileId')
|
||||
.select([...BOARD_COLUMNS, 'group_file.createdAt as arrivedAt'])
|
||||
.where('group_file.groupId', '=', userId)
|
||||
.where('file.shared', '=', true)
|
||||
// Load-bearing rather than tidy-up: the caller's home group id is in `groupIds`, so this is
|
||||
// what keeps their own boards' owning rows out of a read whose predicates above match them.
|
||||
// Safe as a plain `not in` only because `050_drop_legacy_owner_columns.sql` made
|
||||
// `owningGroupId` NOT NULL: while legacy files carried NULL there, this would have dropped
|
||||
// every one of them silently, since `NULL not in (...)` is NULL rather than true.
|
||||
.where('file.owningGroupId', 'not in', groupIds),
|
||||
terms
|
||||
)
|
||||
if (cursor) {
|
||||
query = query.where(seekBelow(LINK_FILE_ID, sql.ref('group_file.createdAt'), cursor))
|
||||
}
|
||||
const rows = await query
|
||||
.orderBy('group_file.createdAt', 'desc')
|
||||
.orderBy(LINK_FILE_ID, 'desc')
|
||||
.limit(BOARD_SEARCH_PAGE_SIZE + 1)
|
||||
.execute()
|
||||
|
||||
// Shared by construction: this read only ever returns boards owned outside the caller's own
|
||||
// workspaces, reached through the guest link opening one leaves behind.
|
||||
return rows.map((row) => toBoardSearchRow(row, row.arrivedAt, 'shared'))
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyset seek, over whichever pair of columns this read sorts by.
|
||||
*
|
||||
* Seek, not offset: an insert anywhere above the cursor shifts every later row by one, so an offset
|
||||
* would re-serve one board and skip another at the boundary. This asks for rows strictly below where
|
||||
* the last page ended, over the same two expressions the ORDER BY sorts on — sort by one thing and
|
||||
* seek by another and a page boundary lands somewhere the sort did not put it.
|
||||
*
|
||||
* A row comparison, not the equivalent `arrivedAt < :a or (arrivedAt = :a and id < :i)`. Postgres
|
||||
* keeps that OR as a filter, so a late page walks every row above the cursor and throws it away: page
|
||||
* 500 of a 10k-board workspace read 9,927 rows to return 21. A row comparison is matchable against
|
||||
* the index — PG16 truncates it to the leading columns the index actually has, seeks on the
|
||||
* timestamp, and rechecks the whole comparison as a filter. Same rows, 26 read.
|
||||
*
|
||||
* The `id` half is the tiebreaker: boards created in one batch share a timestamp, so without
|
||||
* comparing ids among rows that share a key, a page boundary landing inside such a group would drop
|
||||
* the rest of it. It mirrors `compareBoardSearchOrder` in boardTools.ts — change one and the eval
|
||||
* harness starts paging differently from production.
|
||||
*/
|
||||
function seekBelow(
|
||||
id: RawBuilder<string>,
|
||||
arrivedAt: RawBuilder<unknown>,
|
||||
cursor: BoardSearchCursor
|
||||
) {
|
||||
return sql<SqlBool>`(${arrivedAt}, ${id}) < (${cursor.arrivedAt}, ${cursor.id})`
|
||||
}
|
||||
|
||||
// The workspaces whose files this caller may reach. Deleted groups are excluded here rather than
|
||||
// left to the file rows, so a deleted workspace cannot widen the id list at all.
|
||||
//
|
||||
// Since the groups model this is also the arm that returns a caller's *own* boards — a user's home
|
||||
// group has `group.id === user.id`. So a home group flagged deleted empties search completely while
|
||||
// every other tool on this server keeps working.
|
||||
async function getAccessibleGroupIds(db: Kysely<DB>, userId: string): Promise<string[]> {
|
||||
const memberships = await db
|
||||
.selectFrom('group_user')
|
||||
.innerJoin('group', 'group.id', 'group_user.groupId')
|
||||
.select(['group_user.groupId', 'group_user.role'])
|
||||
.where('group_user.userId', '=', userId)
|
||||
.where('group.isDeleted', '=', false)
|
||||
.execute()
|
||||
return selectAccessibleGroupIds(memberships)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters memberships down to the workspaces whose files the caller may read.
|
||||
*
|
||||
* Asks `can(role, 'accessFiles')` rather than comparing role names, so the meaning of a role stays
|
||||
* in the roles table — and an unknown role string out of the database answers false rather than
|
||||
* throwing.
|
||||
*/
|
||||
export function selectAccessibleGroupIds(
|
||||
memberships: Array<{ groupId: string; role: string }>
|
||||
): string[] {
|
||||
return memberships
|
||||
.filter((membership) => can(membership.role, 'accessFiles'))
|
||||
.map((membership) => membership.groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a search term for use inside a LIKE/ILIKE pattern.
|
||||
*
|
||||
* `%` and `_` are wildcards, so a term carrying one must not be read as a pattern: a search for `%`
|
||||
* would otherwise match every board the caller has. The backslash is in the class because it is the
|
||||
* escape character itself; `String.replace` replaces each match independently in a single pass, so
|
||||
* the escapes this adds are never rescanned.
|
||||
*/
|
||||
export function escapeLikePattern(value: string): string {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
|
||||
}
|
||||
154
apps/dotcom/sync-worker/src/routes/tla/thumbnailRender.test.ts
Normal file
154
apps/dotcom/sync-worker/src/routes/tla/thumbnailRender.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { getShapeClusters } from '@tldraw/dotcom-shared'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { enumerateBoardPages } from './boardTools'
|
||||
import { makeSnapshot } from './screenshotTestHelpers'
|
||||
import { buildThumbnailRenderUrl, summarizeSnapshotContent } from './thumbnailRender'
|
||||
|
||||
describe('enumerateBoardPages', () => {
|
||||
it('lists pages in fractional-index order with names and content flags', () => {
|
||||
const pages = enumerateBoardPages(
|
||||
makeSnapshot([
|
||||
{ id: 'page:b', name: 'Ideas', index: 'a2', shapes: 1 },
|
||||
{ id: 'page:a', name: 'Cover', index: 'a1', shapes: 2 },
|
||||
{ id: 'page:c', name: '', index: 'a3', shapes: 0 },
|
||||
])
|
||||
)
|
||||
expect(pages).toEqual([
|
||||
{ index: 0, id: 'page:a', name: 'Cover', hasContent: true },
|
||||
{ index: 1, id: 'page:b', name: 'Ideas', hasContent: true },
|
||||
{ index: 2, id: 'page:c', name: 'Page 3', hasContent: false },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildThumbnailRenderUrl', () => {
|
||||
it('builds the render page URL with the token', () => {
|
||||
const url = new URL(buildThumbnailRenderUrl('https://render.example', 'the-token'))
|
||||
expect(url.pathname).toBe('/__thumbnail-render')
|
||||
expect(url.searchParams.get('token')).toBe('the-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getShapeClusters', () => {
|
||||
const shape = (id: string, parentId = 'page:a') =>
|
||||
({
|
||||
typeName: 'shape',
|
||||
id,
|
||||
parentId,
|
||||
type: 'geo',
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
props: { w: 10, h: 10 },
|
||||
}) as any
|
||||
|
||||
it('creates one stable cluster per top-level shape', () => {
|
||||
const shapes = [shape('shape:a'), shape('shape:b')]
|
||||
const first = getShapeClusters(shapes, 'page:a')
|
||||
const second = getShapeClusters([...shapes].reverse(), 'page:a')
|
||||
|
||||
expect(first).toHaveLength(2)
|
||||
expect(new Set(first.map((cluster) => cluster.id)).size).toBe(2)
|
||||
expect(
|
||||
Object.fromEntries(first.flatMap((cluster) => cluster.shapes.map((s) => [s.id, cluster.id])))
|
||||
).toEqual(
|
||||
Object.fromEntries(second.flatMap((cluster) => cluster.shapes.map((s) => [s.id, cluster.id])))
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps frames and groups with their descendants', () => {
|
||||
const frame = shape('shape:frame')
|
||||
const child = shape('shape:child', 'shape:frame')
|
||||
const grandchild = shape('shape:grandchild', 'shape:child')
|
||||
const cluster = getShapeClusters([frame, child, grandchild], 'page:a')[0]
|
||||
|
||||
expect(cluster.numberOfShapes).toBe(3)
|
||||
expect(cluster.shapes.map((s) => s.id)).toEqual([
|
||||
'shape:frame',
|
||||
'shape:child',
|
||||
'shape:grandchild',
|
||||
])
|
||||
})
|
||||
|
||||
it('changes a cluster id when its membership changes', () => {
|
||||
const frame = shape('shape:frame')
|
||||
const before = getShapeClusters([frame], 'page:a')[0]
|
||||
const after = getShapeClusters([frame, shape('shape:child', frame.id)], 'page:a')[0]
|
||||
|
||||
expect(after.id).not.toBe(before.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeSnapshotContent', () => {
|
||||
const doc = (state: Record<string, unknown>) => ({ state, lastChangedClock: 0 })
|
||||
const snapshot = {
|
||||
clock: 0,
|
||||
schema: { schemaVersion: 2, sequences: {} },
|
||||
documents: [
|
||||
doc({ id: 'document:document', typeName: 'document' }),
|
||||
doc({ id: 'page:a', typeName: 'page' }),
|
||||
doc({ id: 'page:b', typeName: 'page' }),
|
||||
doc({
|
||||
id: 'shape:1',
|
||||
typeName: 'shape',
|
||||
type: 'geo',
|
||||
parentId: 'page:a',
|
||||
x: 100,
|
||||
y: 50,
|
||||
props: { w: 200, h: 100 },
|
||||
}),
|
||||
doc({
|
||||
id: 'shape:2',
|
||||
typeName: 'shape',
|
||||
type: 'draw',
|
||||
parentId: 'page:a',
|
||||
x: 1000,
|
||||
y: 700,
|
||||
props: {},
|
||||
}),
|
||||
doc({
|
||||
id: 'shape:3',
|
||||
typeName: 'shape',
|
||||
type: 'geo',
|
||||
parentId: 'shape:1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: { w: 10, h: 10 },
|
||||
}),
|
||||
doc({
|
||||
id: 'shape:4',
|
||||
typeName: 'shape',
|
||||
type: 'geo',
|
||||
parentId: 'page:b',
|
||||
x: -5000,
|
||||
y: 0,
|
||||
props: { w: 10, h: 10 },
|
||||
}),
|
||||
doc({ id: 'asset:img', typeName: 'asset', type: 'image' }),
|
||||
doc({ id: 'asset:vid', typeName: 'asset', type: 'video' }),
|
||||
doc({ id: 'asset:bm', typeName: 'asset', type: 'bookmark' }),
|
||||
],
|
||||
} as any
|
||||
|
||||
it('counts the rendered page and sizes its top-level shapes', () => {
|
||||
// The child inside shape:1 and the shape on page:b are not top-level on page:a; the draw
|
||||
// stroke has no stored size and counts as a point.
|
||||
expect(summarizeSnapshotContent(snapshot, 'page:a')).toEqual({
|
||||
records: 10,
|
||||
pageShapes: 2,
|
||||
mediaAssets: 2,
|
||||
bboxWidth: 900,
|
||||
bboxHeight: 650,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports zero size for a page with nothing on it', () => {
|
||||
expect(summarizeSnapshotContent(snapshot, 'page:none')).toEqual({
|
||||
records: 10,
|
||||
pageShapes: 0,
|
||||
mediaAssets: 2,
|
||||
bboxWidth: 0,
|
||||
bboxHeight: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
928
apps/dotcom/sync-worker/src/routes/tla/thumbnailRender.ts
Normal file
928
apps/dotcom/sync-worker/src/routes/tla/thumbnailRender.ts
Normal file
@@ -0,0 +1,928 @@
|
||||
import {
|
||||
DEFAULT_THUMBNAIL_HEIGHT,
|
||||
DEFAULT_THUMBNAIL_WIDTH,
|
||||
THUMBNAIL_RENDER_PATH,
|
||||
THUMBNAIL_RENDER_TIMEOUT_MS,
|
||||
getThumbnailScreenshotRequestBody,
|
||||
} from '@tldraw/dotcom-shared'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { THUMBNAIL_RENDER_TOKEN_TTL_MS } from '../../config'
|
||||
import { getR2KeyForRoom } from '../../r2'
|
||||
import {
|
||||
Environment,
|
||||
OgImageRenderReason,
|
||||
ThumbnailBoardAccess,
|
||||
ThumbnailBoardKind,
|
||||
ThumbnailBoardRef,
|
||||
ThumbnailRenderSurface,
|
||||
} from '../../types'
|
||||
import { writeDataPoint } from '../../utils/analytics'
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer } from '../../utils/base64'
|
||||
import {
|
||||
RenderPageReach,
|
||||
ThumbnailRenderJob,
|
||||
deleteMintedRenderToken,
|
||||
mintThumbnailRenderToken,
|
||||
recordMintedRenderToken,
|
||||
wasRenderTokenServedSince,
|
||||
} from '../../utils/renderTokens'
|
||||
import { ShapeMeasurement } from './boardTools'
|
||||
import { getPublishedFileInfo, getPublishedRoomSnapshot } from './getPublishedFile'
|
||||
import {
|
||||
SharedFileInfo,
|
||||
getSharedFileInfo,
|
||||
getSharedFileRoomSnapshot,
|
||||
isFileViewableFor,
|
||||
} from './getSharedFile'
|
||||
import {
|
||||
BoardSnapshotReadError,
|
||||
BrowserRenderError,
|
||||
classifyScreenshotFailure,
|
||||
} from './thumbnailShared'
|
||||
|
||||
// The render-and-cache core shared by every Browser Run screenshot surface: the MCP screenshot
|
||||
// tool (mcpServer.ts), the OG image route (getOgImage.ts), and the OG render queue
|
||||
// consumer (ogImageQueue.ts). Owns board resolution, snapshot loading, page enumeration, the
|
||||
// Browser Rendering invocation, and the shared telemetry writer. The surfaces own their own protocol
|
||||
// handling, cache keys, and retry/backoff policies.
|
||||
//
|
||||
// Owns no rate limiting: the pipeline's only limiters live in mcpServer.ts, so a new
|
||||
// surface built on these helpers cannot pick one up by accident.
|
||||
|
||||
// A board a screenshot surface has resolved. Whether it is *publicly viewable* depends on the access
|
||||
// the caller asked for — see ThumbnailBoardAccess. The version rotates when the rendered content
|
||||
// changes (lastPublished for published boards, the persisted room snapshot's R2 etag for shared
|
||||
// files), so it can key the thumbnail caches.
|
||||
export interface ResolvedThumbnailBoard extends ThumbnailBoardRef {
|
||||
version: string | number
|
||||
/**
|
||||
* The file this board's content belongs to: the slug itself for a shared file, and the parent file
|
||||
* a published slug resolves to for a published board.
|
||||
*
|
||||
* Carried because the file's Durable Object is addressed by it, and that object is where the MCP
|
||||
* server caches a page's cluster index (see mcpClusterIndex.ts). Kept off `ThumbnailBoardRef`,
|
||||
* which is the *identity* of a board and keys its caches: two kinds can share a file id, so a key
|
||||
* built from this would collide a published board's index with its file's.
|
||||
*/
|
||||
fileId: string
|
||||
/**
|
||||
* The gate this board was resolved under, carried so a render cannot be minted under a weaker one
|
||||
* than the resolution used. `captureThumbnailScreenshot` signs it into the job, and the snapshot
|
||||
* route reads the board back under it.
|
||||
*/
|
||||
access: ThumbnailBoardAccess
|
||||
/**
|
||||
* The `file` row this resolution gated on, for `shared_file` boards only. Carried so a caller that
|
||||
* reads the snapshot in the same breath can hand it back to `loadBoardSnapshot` instead of asking
|
||||
* Postgres the same question twice — see the `file` option on `getSharedFileRoomSnapshot` for when
|
||||
* that is and is not appropriate. Never signed into a render job: the job carries the gate, and a
|
||||
* row that travelled inside a token could not be re-checked on the way back.
|
||||
*/
|
||||
file?: SharedFileInfo
|
||||
}
|
||||
|
||||
export type ResolveThumbnailBoardResult =
|
||||
| { ok: true; board: ResolvedThumbnailBoard }
|
||||
| { ok: false; reason: 'not_found' | 'board_empty' }
|
||||
|
||||
/**
|
||||
* Resolves a board slug of a known kind, applying the gate the caller asked for (see
|
||||
* `ThumbnailBoardAccess`). `access: 'public'` is the only thing standing between a private board and
|
||||
* the public internet, and it is re-applied per request rather than inferred from what is in R2 —
|
||||
* necessarily, since nothing deletes a thumbnail when a board stops being public.
|
||||
*
|
||||
* `board_empty` means the board passed its gate but has no persisted room content; `not_found` covers
|
||||
* everything the gate refused.
|
||||
*/
|
||||
export async function resolveThumbnailBoard(
|
||||
env: Environment,
|
||||
kind: ThumbnailBoardKind,
|
||||
slug: string,
|
||||
{
|
||||
access,
|
||||
file: knownFile,
|
||||
}: {
|
||||
access: ThumbnailBoardAccess
|
||||
/**
|
||||
* The row a caller has *just* read for this same file, to be gated again rather than fetched a
|
||||
* second time. The MCP path's access check reads `file` and this then selects a strict subset of
|
||||
* the same columns microseconds later, so without this every tool call opens two Postgres pools
|
||||
* for one row — three when the published fallback runs. Opt in per call site, like the same
|
||||
* option on `loadBoardSnapshot`: the gate below is applied either way, so what a caller takes on
|
||||
* is the staleness of the row, not a weaker check.
|
||||
*/
|
||||
file?: SharedFileInfo
|
||||
}
|
||||
): Promise<ResolveThumbnailBoardResult> {
|
||||
if (kind === 'published') {
|
||||
// A published board's whole identity is its published slug, so there is no weaker gate to
|
||||
// apply: an unpublished board has no published snapshot to render in the first place.
|
||||
const publishedFile = await getPublishedFileInfo(env, slug)
|
||||
if (!publishedFile?.published) return { ok: false, reason: 'not_found' }
|
||||
return {
|
||||
ok: true,
|
||||
board: {
|
||||
kind,
|
||||
slug,
|
||||
version: publishedFile.lastPublished,
|
||||
access,
|
||||
// The row this published slug resolved through, which is the file that owns the snapshot.
|
||||
fileId: publishedFile.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const file = knownFile ?? (await getSharedFileInfo(env, slug))
|
||||
if (!isFileViewableFor(file, access)) return { ok: false, reason: 'not_found' }
|
||||
|
||||
// The persisted room's R2 etag rotates when the board content changes, so it keys the
|
||||
// thumbnail cache without a separate content-version field.
|
||||
const persisted = await env.ROOMS.head(getR2KeyForRoom({ slug, isApp: true }))
|
||||
if (!persisted) return { ok: false, reason: 'board_empty' }
|
||||
|
||||
return { ok: true, board: { kind, slug, version: persisted.etag, access, file, fileId: file.id } }
|
||||
}
|
||||
|
||||
// Reads a resolved board's snapshot, keeping the two outcomes callers must tell apart distinct.
|
||||
// `null` means one thing only: an empty board, with no persisted room content. Anything the readers
|
||||
// throw — Postgres, R2, a malformed payload, or the gate they re-check as they read — is wrapped as a
|
||||
// BoardSnapshotReadError, so a database outage reaches telemetry under its own reason code instead of
|
||||
// as an empty board.
|
||||
export async function loadBoardSnapshot(
|
||||
env: Environment,
|
||||
board: ThumbnailBoardRef,
|
||||
{
|
||||
access,
|
||||
file,
|
||||
}: {
|
||||
access: ThumbnailBoardAccess
|
||||
/**
|
||||
* The row a caller has *just* resolved this board against, to be gated again rather than
|
||||
* re-fetched. Opt in per call site rather than reading it off `board`, so a surface takes the
|
||||
* staleness that comes with it deliberately. See `getSharedFileRoomSnapshot`.
|
||||
*/
|
||||
file?: SharedFileInfo
|
||||
}
|
||||
): Promise<RoomSnapshot | null> {
|
||||
try {
|
||||
const snapshot =
|
||||
board.kind === 'published'
|
||||
? await getPublishedRoomSnapshot(env, board.slug)
|
||||
: await getSharedFileRoomSnapshot(env, board.slug, { access, file })
|
||||
return snapshot ?? null
|
||||
} catch (error) {
|
||||
// Keep the original message in the wrapper's own text as well as its `cause`, so the Sentry
|
||||
// event title still names the real failure rather than reading as a generic read error.
|
||||
throw new BoardSnapshotReadError(
|
||||
`Could not read board snapshot: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a render job's token and records it as ours, in one step because the two halves must not be
|
||||
* separable.
|
||||
*
|
||||
* A token minted without a record fails its own check at the snapshot route and surfaces as a generic
|
||||
* render failure — and the way that arrived the first time was exactly this: a second mint site that
|
||||
* did the first half only, leaving every measure token unrecorded. Both mint sites go through here so
|
||||
* a third cannot repeat it.
|
||||
*
|
||||
* Recorded before the browser can present the token, so the snapshot route can tell one we minted from
|
||||
* one merely signed with our secret. Awaited, not fired off: the render is about to depend on it
|
||||
* having landed. A no-op for `public` jobs — see recordMintedRenderToken.
|
||||
*/
|
||||
async function mintRecordedRenderToken(env: Environment, job: ThumbnailRenderJob) {
|
||||
const token = await mintThumbnailRenderToken(env, job)
|
||||
await recordMintedRenderToken(env, job, token)
|
||||
return token
|
||||
}
|
||||
|
||||
export function buildThumbnailRenderUrl(renderOrigin: string, token: string) {
|
||||
const url = new URL(THUMBNAIL_RENDER_PATH, renderOrigin)
|
||||
url.searchParams.set('token', token)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function getRenderOrigin(env: Environment) {
|
||||
// Staging and production set this in wrangler.toml to their own client origin. Local dev sets it
|
||||
// to the local client; previews configure it explicitly when they need to exercise this path.
|
||||
if (!env.MCP_SCREENSHOT_RENDER_ORIGIN) {
|
||||
throw new Error(
|
||||
`MCP_SCREENSHOT_RENDER_ORIGIN is not configured. It must point at an origin that serves the ${THUMBNAIL_RENDER_PATH} render page.`
|
||||
)
|
||||
}
|
||||
return env.MCP_SCREENSHOT_RENDER_ORIGIN
|
||||
}
|
||||
|
||||
// Renders one content-fit page of a resolved board through the render page and returns the PNG.
|
||||
// Mints the short-lived signed render job, so the browser session only ever visits the
|
||||
// tldraw-owned render page; callers decide where (and whether) to cache the result.
|
||||
export async function captureThumbnailScreenshot(
|
||||
env: Environment,
|
||||
board: ResolvedThumbnailBoard,
|
||||
{
|
||||
surface,
|
||||
pageId,
|
||||
shapeIds,
|
||||
theme,
|
||||
width,
|
||||
height,
|
||||
telemetry,
|
||||
capture,
|
||||
content,
|
||||
}: {
|
||||
/** Which pipeline is asking. Signed into the job; namespaces the minted-token record. */
|
||||
surface: ThumbnailRenderSurface
|
||||
/**
|
||||
* The single page to render. When omitted, the render page exports whichever page the
|
||||
* snapshot opens to (used by OG images).
|
||||
*/
|
||||
pageId?: string
|
||||
/**
|
||||
* Restricts the export to these shapes rather than the whole page: the render page fits the
|
||||
* camera to their common bounds and draws only them.
|
||||
*/
|
||||
shapeIds?: string[]
|
||||
theme: 'light' | 'dark'
|
||||
width: number
|
||||
height: number
|
||||
/**
|
||||
* Who this session is for on the spend ledger. Required so a new caller cannot create
|
||||
* browser sessions that never appear on it; `mode` is stamped here, since this path is by
|
||||
* definition a screenshot render.
|
||||
*/
|
||||
telemetry: { source: BrowserRunSessionContext['source']; reason?: OgImageRenderReason }
|
||||
/**
|
||||
* `live` skips the in-page export and lets the screenshot rasterize the live canvas, after the
|
||||
* page has pruned it to the requested shapes. Signed into the job so the render page reads it
|
||||
* with the rest of its parameters. See ThumbnailRenderParams.
|
||||
*/
|
||||
capture?: 'live'
|
||||
/** What the board holds, for the ledger (see summarizeSnapshotContent). Never identifies it. */
|
||||
content?: RenderContentSummary
|
||||
}
|
||||
): Promise<{ base64: string; durationMs: number }> {
|
||||
const job: ThumbnailRenderJob = {
|
||||
v: 1,
|
||||
kind: board.kind,
|
||||
slug: board.slug,
|
||||
version: board.version,
|
||||
// Taken from the resolution rather than the caller, so a surface cannot ask for a board under
|
||||
// one gate and render it under another.
|
||||
access: board.access,
|
||||
surface,
|
||||
camera: 'content',
|
||||
...(pageId ? { pageId } : null),
|
||||
...(shapeIds?.length ? { shapeIds } : null),
|
||||
...(capture ? { capture } : null),
|
||||
// Ignored while `camera` is 'content', which is what every surface mints; carried because the
|
||||
// job type keeps the explicit-viewport path available (see ThumbnailRenderJob).
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
width,
|
||||
height,
|
||||
theme,
|
||||
exp: Date.now() + THUMBNAIL_RENDER_TOKEN_TTL_MS,
|
||||
}
|
||||
const token = await mintRecordedRenderToken(env, job)
|
||||
try {
|
||||
return await runRenderSession(env, buildThumbnailRenderUrl(getRenderOrigin(env), token), {
|
||||
width,
|
||||
height,
|
||||
session: { source: telemetry.source, mode: 'screenshot', reason: telemetry.reason, capture },
|
||||
content,
|
||||
pageReached: (since) => wasRenderTokenServedSince(env, job, token, since),
|
||||
})
|
||||
} finally {
|
||||
// The MCP surface keys its records by token, so nothing later overwrites this one and it has to
|
||||
// be dropped here — the same cleanup a measure gets, for the same reason. In `finally` because a
|
||||
// failed render leaves exactly the orphan a successful one would, and the snapshot fetch the
|
||||
// record guards is over either way by the time the session ends. A no-op for OG, whose per-board
|
||||
// key must outlive the capture; see deleteMintedRenderToken.
|
||||
await deleteMintedRenderToken(env, job, token)
|
||||
}
|
||||
}
|
||||
|
||||
/** What a browser session was created for, stamped on its `browser_run_session` datapoint. */
|
||||
export interface BrowserRunSessionContext {
|
||||
source: 'mcp' | 'og' | 'queue'
|
||||
mode: 'measure' | 'screenshot'
|
||||
/** The queue trigger, on sessions the queue runs. Request-path sessions have none. */
|
||||
reason?: OgImageRenderReason
|
||||
/** `live` when the render was asked to rasterize the live canvas; absent means the page exports. */
|
||||
capture?: 'live'
|
||||
}
|
||||
|
||||
/**
|
||||
* What a board holds, as numbers, for the session ledger. Numbers only: doubles carry no cardinality,
|
||||
* and none of these can identify a board (see "No board identifier leaves this pipeline" in
|
||||
* browser-run-thumbnails.md). Sizes come straight off the records, so the box is the union of top-level
|
||||
* shapes' positions and stored sizes — rotation ignored, sizeless shapes (draw strokes) counted as
|
||||
* points. A proxy, not geometry; the question it answers is whether render time and failure track
|
||||
* what is on the board, which a proxy resolves.
|
||||
*/
|
||||
export interface RenderContentSummary {
|
||||
records: number
|
||||
/** Shapes parented directly to the rendered page. */
|
||||
pageShapes: number
|
||||
/** Image and video asset records in the document, whichever page uses them. */
|
||||
mediaAssets: number
|
||||
bboxWidth: number
|
||||
bboxHeight: number
|
||||
}
|
||||
|
||||
const EMPTY_CONTENT_SUMMARY: RenderContentSummary = {
|
||||
records: 0,
|
||||
pageShapes: 0,
|
||||
mediaAssets: 0,
|
||||
bboxWidth: 0,
|
||||
bboxHeight: 0,
|
||||
}
|
||||
|
||||
export function summarizeSnapshotContent(
|
||||
snapshot: RoomSnapshot,
|
||||
pageId: string | undefined
|
||||
): RenderContentSummary {
|
||||
let pageShapes = 0
|
||||
let mediaAssets = 0
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const { state } of snapshot.documents) {
|
||||
const record = state as {
|
||||
typeName?: string
|
||||
type?: string
|
||||
parentId?: string
|
||||
x?: number
|
||||
y?: number
|
||||
props?: { w?: unknown; h?: unknown }
|
||||
}
|
||||
if (record.typeName === 'asset') {
|
||||
if (record.type === 'image' || record.type === 'video') mediaAssets++
|
||||
continue
|
||||
}
|
||||
if (record.typeName !== 'shape' || (pageId && record.parentId !== pageId)) continue
|
||||
pageShapes++
|
||||
const x = record.x ?? 0
|
||||
const y = record.y ?? 0
|
||||
const w = typeof record.props?.w === 'number' ? record.props.w : 0
|
||||
const h = typeof record.props?.h === 'number' ? record.props.h : 0
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, x + w)
|
||||
maxY = Math.max(maxY, y + h)
|
||||
}
|
||||
return {
|
||||
records: snapshot.documents.length,
|
||||
pageShapes,
|
||||
mediaAssets,
|
||||
bboxWidth: pageShapes ? maxX - minX : 0,
|
||||
bboxHeight: pageShapes ? maxY - minY : 0,
|
||||
}
|
||||
}
|
||||
|
||||
// The Browser Run spend ledger: one datapoint per browser session actually created, written here at
|
||||
// the choke point every session flows through rather than by each surface's own bookkeeping. This is
|
||||
// deliberately a separate event from `mcp_shared_board_screenshot`, which stays request-level: that
|
||||
// event answers cache and refusal questions, this one answers spend. Analytics Engine cannot join,
|
||||
// so each event carries the dimensions its own questions need — `mode` is what separates a measure
|
||||
// render from a screenshot render, which the request event cannot express.
|
||||
function writeBrowserRunSessionTelemetry(
|
||||
env: Environment,
|
||||
session: BrowserRunSessionContext,
|
||||
{
|
||||
outcome,
|
||||
durationMs,
|
||||
width,
|
||||
height,
|
||||
page,
|
||||
browserMsUsed,
|
||||
content = EMPTY_CONTENT_SUMMARY,
|
||||
}: {
|
||||
/** `ok`, or the bounded browser failure code for a session that died. */
|
||||
outcome: string
|
||||
durationMs: number
|
||||
width: number
|
||||
height: number
|
||||
/** `reached` on every session that produced a capture; see wasRenderTokenServedSince for the rest. */
|
||||
page: RenderPageReach
|
||||
/** See TimedCapture.browserMsUsed; 0 for sessions that died before a response. */
|
||||
browserMsUsed: number
|
||||
content?: RenderContentSummary
|
||||
}
|
||||
) {
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'browser_run_session', {
|
||||
blobs: [
|
||||
`source:${session.source}`,
|
||||
`mode:${session.mode}`,
|
||||
`outcome:${outcome}`,
|
||||
`reason:${session.reason ?? 'none'}`,
|
||||
// Appended: existing blob positions must not shift.
|
||||
`page:${page}`,
|
||||
// Appended after `page:` for the same reason. `none` on measures, which draw nothing; a
|
||||
// screenshot render exports unless it asked for live.
|
||||
`capture:${session.mode === 'measure' ? 'none' : (session.capture ?? 'export')}`,
|
||||
],
|
||||
doubles: [
|
||||
width,
|
||||
height,
|
||||
durationMs,
|
||||
browserMsUsed,
|
||||
content.records,
|
||||
content.pageShapes,
|
||||
content.mediaAssets,
|
||||
content.bboxWidth,
|
||||
content.bboxHeight,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// One Browser Run session against the render page, returning whatever the Quick Action captured.
|
||||
// Only the capture path wants those pixels: they are editor.toImage's output, which the page
|
||||
// displays as a full-viewport image to be caught. A measure runs no export and discards the image —
|
||||
// the session is only how the page gets to run, and its answer comes back through the result
|
||||
// endpoint.
|
||||
//
|
||||
// A failed render marks an error state rather than the ready one, so it returns as a failure
|
||||
// immediately instead of burning the timeout (see THUMBNAIL_SETTLED_SELECTOR /
|
||||
// THUMBNAIL_CAPTURE_SELECTOR in @tldraw/dotcom-shared).
|
||||
async function runRenderSession(
|
||||
env: Environment,
|
||||
renderUrl: string,
|
||||
{
|
||||
width,
|
||||
height,
|
||||
session,
|
||||
content,
|
||||
pageReached,
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
session: BrowserRunSessionContext
|
||||
content?: RenderContentSummary
|
||||
/** Asked only for a session that died, with the session's start; see RenderPageReach. */
|
||||
pageReached?(since: number): Promise<RenderPageReach>
|
||||
}
|
||||
): Promise<{ base64: string; durationMs: number }> {
|
||||
// Built once and handed to whichever transport runs, so the wait strategy, capture target and
|
||||
// timeout cannot drift between Browser Run and its development stand-in.
|
||||
const requestBody = getThumbnailScreenshotRequestBody({
|
||||
renderUrl,
|
||||
width,
|
||||
height,
|
||||
timeoutMs: THUMBNAIL_RENDER_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
// Local dev has no route to Browser Run, so it points this at a screenshot service instead (the
|
||||
// client's dev server, which can drive Playwright). Selected on the var being set rather than on
|
||||
// an environment name, so only an environment that configures one can take this path.
|
||||
let timed: TimedCapture
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
const capture = env.LOCAL_SCREENSHOT_SERVICE_URL
|
||||
? callLocalScreenshotService(env.LOCAL_SCREENSHOT_SERVICE_URL, requestBody)
|
||||
: callBrowserRun(env, requestBody)
|
||||
// Measures are exempt from the worker-side deadline, and it is the *result* that makes them
|
||||
// so: the render page POSTs its measurements before signalling ready, and the result route
|
||||
// accepts any unexpired signed token, so abandoning the wait early would let that POST land
|
||||
// after measurePageShapes' cleanup has already deleted the result key — stranding an object
|
||||
// in a bucket that must never get a lifecycle rule. The deadline exists for the OG pipeline's
|
||||
// invariants, which never price a measure; a measure stays bounded by the quick action's own
|
||||
// per-phase timers.
|
||||
timed = await (session.mode === 'measure'
|
||||
? capture
|
||||
: abandonAtRenderTimeout(capture, startedAt))
|
||||
} catch (error) {
|
||||
// A BrowserRenderError is a session that existed and died, so it lands on the ledger with the
|
||||
// time it held its browser. Anything else never created a session and records nothing.
|
||||
if (error instanceof BrowserRenderError) {
|
||||
writeBrowserRunSessionTelemetry(env, session, {
|
||||
outcome: classifyScreenshotFailure(error),
|
||||
durationMs: error.durationMs,
|
||||
width,
|
||||
height,
|
||||
page: (await pageReached?.(startedAt)) ?? 'unknown',
|
||||
browserMsUsed: 0,
|
||||
content,
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const buffer = timed.buffer
|
||||
if (buffer.byteLength === 0) {
|
||||
// The session ran to completion — the spend is real — it just produced nothing usable.
|
||||
writeBrowserRunSessionTelemetry(env, session, {
|
||||
outcome: 'empty_render',
|
||||
durationMs: timed.durationMs,
|
||||
width,
|
||||
height,
|
||||
page: 'reached',
|
||||
browserMsUsed: timed.browserMsUsed,
|
||||
content,
|
||||
})
|
||||
throw new Error('Render produced an empty screenshot')
|
||||
}
|
||||
|
||||
writeBrowserRunSessionTelemetry(env, session, {
|
||||
outcome: 'ok',
|
||||
durationMs: timed.durationMs,
|
||||
width,
|
||||
height,
|
||||
page: 'reached',
|
||||
browserMsUsed: timed.browserMsUsed,
|
||||
content,
|
||||
})
|
||||
return { base64: arrayBufferToBase64(buffer), durationMs: timed.durationMs }
|
||||
}
|
||||
|
||||
type ThumbnailScreenshotRequestBody = ReturnType<typeof getThumbnailScreenshotRequestBody>
|
||||
|
||||
// A capture that came back OK, fully read, and how long the whole thing took. The transports read
|
||||
// the body themselves rather than handing back a Response: the read has to happen inside
|
||||
// abandonAtRenderTimeout's deadline, or a body stream that stalls after a 200's headers would hold
|
||||
// the delivery unbounded — past every bound the pipeline prices a capture at. Each transport throws
|
||||
// its own kind of failure; only Browser Run's carries the status, body detail and timeout budget
|
||||
// that BrowserRenderError exists to hold.
|
||||
interface TimedCapture {
|
||||
buffer: ArrayBuffer
|
||||
durationMs: number
|
||||
/** Browser wall clock billed for the session, or 0 where the transport has none (local dev). */
|
||||
browserMsUsed: number
|
||||
}
|
||||
|
||||
// Both transports read the billing header through this, so the ledger column means one thing.
|
||||
function browserMsUsedOf(response: Response) {
|
||||
return Number(response.headers.get('X-Browser-Ms-Used')) || 0
|
||||
}
|
||||
|
||||
async function callBrowserRun(
|
||||
env: Environment,
|
||||
requestBody: ThumbnailScreenshotRequestBody
|
||||
): Promise<TimedCapture> {
|
||||
if (!env.BROWSER) {
|
||||
throw new Error(
|
||||
'Browser Rendering is not configured. Set the BROWSER binding (local dev needs Cloudflare credentials).'
|
||||
)
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
// Browser Rendering `/screenshot` Quick Action, invoked straight through the binding (no
|
||||
// puppeteer, no API token). Requires compatibility_date >= 2026-03-24 for `quickAction`.
|
||||
const response = await env.BROWSER.quickAction('screenshot', requestBody)
|
||||
const durationMs = Date.now() - startedAt
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BrowserRenderError({
|
||||
status: response.status,
|
||||
detail: await readBrowserErrorDetail(response),
|
||||
durationMs,
|
||||
timeoutMs: THUMBNAIL_RENDER_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
// A body stream that fails is still a session that existed and spent, so it is wrapped into the
|
||||
// error type the ledger recognises rather than escaping as a raw stream error and vanishing from
|
||||
// the spend record.
|
||||
const buffer = await response.arrayBuffer().catch((error) => {
|
||||
throw new BrowserRenderError({
|
||||
status: response.status,
|
||||
detail: `Reading the capture body failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
durationMs: Date.now() - startedAt,
|
||||
timeoutMs: THUMBNAIL_RENDER_TIMEOUT_MS,
|
||||
})
|
||||
})
|
||||
return { buffer, durationMs: Date.now() - startedAt, browserMsUsed: browserMsUsedOf(response) }
|
||||
}
|
||||
|
||||
// The request cannot cap its own total: `gotoOptions.timeout` and `waitForSelector.timeout` are
|
||||
// sequential phases that each get the full render budget (see getThumbnailScreenshotRequestBody),
|
||||
// so a page that stalls through both holds the call for roughly twice THUMBNAIL_RENDER_TIMEOUT_MS.
|
||||
// Everything sized against "a capture takes at most the render timeout" — the pending-marker TTL
|
||||
// against the retry chain, the edit debounce outlasting a capture, both pinned in
|
||||
// ogImageQueue.test.ts — needs the single budget to be a real ceiling, so it is enforced here on
|
||||
// the whole call. The abandoned browser session is Cloudflare's to reap and still spends its
|
||||
// remainder — this bounds the delivery, not the spend.
|
||||
async function abandonAtRenderTimeout(
|
||||
capture: Promise<TimedCapture>,
|
||||
startedAt: number
|
||||
): Promise<TimedCapture> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
capture,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new BrowserRenderError({
|
||||
// No response was received, so there is no status; the detail is what classifies
|
||||
// this as a browser_timeout (see classifyBrowserRenderFailure).
|
||||
status: 0,
|
||||
detail: `Capture abandoned by the worker at the ${THUMBNAIL_RENDER_TIMEOUT_MS} ms render timeout`,
|
||||
durationMs: Date.now() - startedAt,
|
||||
timeoutMs: THUMBNAIL_RENDER_TIMEOUT_MS,
|
||||
})
|
||||
)
|
||||
}, THUMBNAIL_RENDER_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
// Only the timer needs cleanup: an abandoned capture's eventual rejection is already handled,
|
||||
// since Promise.race subscribes to every input.
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// How much of Cloudflare's error body to keep. It is a short JSON object in practice; the cap is
|
||||
// there so a proxy's HTML error page can't put a document into a Sentry event.
|
||||
const MAX_BROWSER_ERROR_DETAIL_LENGTH = 500
|
||||
|
||||
// Reads why Browser Run refused, which the status cannot say on its own (see BrowserRenderError).
|
||||
// Failure-proof by construction: this runs on a path that is already failing, so a body that won't
|
||||
// read or won't parse must degrade to "no detail" rather than replace the status we do have with a
|
||||
// second error.
|
||||
async function readBrowserErrorDetail(response: Response): Promise<string | undefined> {
|
||||
try {
|
||||
const text = (await response.text()).trim()
|
||||
if (!text) return undefined
|
||||
return truncate(extractBrowserErrorMessages(text) ?? text)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Cloudflare's error body is `{"success":false,"errors":[{"code":…,"message":"…"}]}`, and the
|
||||
// messages are the whole readable part. Anything else — a proxy's HTML, a plain string, a shape we
|
||||
// don't recognise — falls back to the raw text.
|
||||
function extractBrowserErrorMessages(text: string): string | undefined {
|
||||
try {
|
||||
const body = JSON.parse(text)
|
||||
if (!Array.isArray(body?.errors)) return undefined
|
||||
const messages = body.errors
|
||||
.map((error: unknown) => (error as { message?: unknown } | null)?.message)
|
||||
.filter((message: unknown): message is string => typeof message === 'string' && !!message)
|
||||
return messages.length > 0 ? messages.join('; ') : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text: string) {
|
||||
return text.length > MAX_BROWSER_ERROR_DETAIL_LENGTH
|
||||
? `${text.slice(0, MAX_BROWSER_ERROR_DETAIL_LENGTH)}…`
|
||||
: text
|
||||
}
|
||||
|
||||
// Development stand-in for the Browser Rendering call above. Sent the same request body, and its
|
||||
// response decoded by the same code, so everything either side of the call is the production path.
|
||||
// The browser is a local Playwright one though, not Browser Run, so a render that works here is not
|
||||
// evidence that it works in production.
|
||||
async function callLocalScreenshotService(
|
||||
serviceUrl: string,
|
||||
requestBody: ThumbnailScreenshotRequestBody
|
||||
): Promise<TimedCapture> {
|
||||
const startedAt = Date.now()
|
||||
const response = await fetch(serviceUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Local screenshot service failed (${response.status}): ${await response.text()}`
|
||||
)
|
||||
}
|
||||
// A dev server that has not loaded the screenshot plugin answers this path with the client's
|
||||
// index.html — a 200 full of bytes that would otherwise be cached as if it were a thumbnail.
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('image/png')) {
|
||||
throw new Error(
|
||||
`Local screenshot service returned ${contentType || 'no content type'}, expected image/png. Is the client dev server running with the thumbnail screenshot plugin?`
|
||||
)
|
||||
}
|
||||
const buffer = await response.arrayBuffer()
|
||||
return { buffer, durationMs: Date.now() - startedAt, browserMsUsed: browserMsUsedOf(response) }
|
||||
}
|
||||
|
||||
// Writes one rendered PNG to a thumbnail cache, stamping the content version (so a stale version
|
||||
// can be detected) alongside any surface-specific metadata.
|
||||
// --- Measuring a page in a real editor ----------------------------------------------------------
|
||||
//
|
||||
// A Worker cannot size a shape: autosizing text needs font metrics, and several shapes store no size
|
||||
// at all. So when clustering needs geometry, the render page is driven in `measure` mode — it loads
|
||||
// the same snapshot, waits for fonts, reads editor.getShapePageBounds for every shape, and POSTs the
|
||||
// result back. Stashed under the job's own token and read once, so it is a rendezvous for a single
|
||||
// in-flight render rather than a cache with a lifetime to manage.
|
||||
|
||||
function getRenderResultKey(token: string) {
|
||||
return `render-result/${encodeURIComponent(token)}.json`
|
||||
}
|
||||
|
||||
export async function putRenderResult(
|
||||
env: Environment,
|
||||
token: string,
|
||||
bounds: Record<string, ShapeMeasurement>
|
||||
) {
|
||||
if (!env.THUMBNAILS) return
|
||||
await env.THUMBNAILS.put(getRenderResultKey(token), JSON.stringify(bounds), {
|
||||
httpMetadata: { contentType: 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures every shape on a page through a real editor, returning `shapeId -> bounds and text`.
|
||||
*
|
||||
* Costs one Browser Rendering session, the same as a screenshot — there is no cheaper way to get
|
||||
* geometry the Worker cannot compute. Callers own the rate limiting that implies; the session
|
||||
* itself lands on the `browser_run_session` spend ledger with `mode:measure`, written at the render
|
||||
* choke point rather than by callers.
|
||||
*/
|
||||
export async function measurePageShapes(
|
||||
env: Environment,
|
||||
board: ResolvedThumbnailBoard,
|
||||
pageId: string,
|
||||
{ surface }: { surface: ThumbnailRenderSurface }
|
||||
): Promise<Record<string, ShapeMeasurement>> {
|
||||
const job: ThumbnailRenderJob = {
|
||||
v: 1,
|
||||
kind: board.kind,
|
||||
slug: board.slug,
|
||||
version: board.version,
|
||||
// Taken from the resolution rather than the caller, same as captureThumbnailScreenshot: a
|
||||
// measure token for a private board reads the whole document through the snapshot route, so it
|
||||
// carries the gate the board resolved under rather than defaulting to `public`.
|
||||
access: board.access,
|
||||
surface,
|
||||
mode: 'measure',
|
||||
pageId,
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
// Nothing is exported, but the viewport still has to be a sane size: shapes are measured
|
||||
// against a laid-out document, not a zero-sized one.
|
||||
width: DEFAULT_THUMBNAIL_WIDTH,
|
||||
height: DEFAULT_THUMBNAIL_HEIGHT,
|
||||
theme: 'light',
|
||||
exp: Date.now() + THUMBNAIL_RENDER_TOKEN_TTL_MS,
|
||||
}
|
||||
// Checked before the render rather than after it: the read below cannot work without the bucket, and
|
||||
// discovering that once a full browser session has already been spent makes the failure cost a
|
||||
// capture it was never going to use.
|
||||
if (!env.THUMBNAILS) throw new Error('THUMBNAILS bucket is not configured')
|
||||
|
||||
const token = await mintRecordedRenderToken(env, job)
|
||||
const key = getRenderResultKey(token)
|
||||
|
||||
try {
|
||||
// Awaited for the ready signal, not for the image it returns; see runRenderSession.
|
||||
await runRenderSession(env, buildThumbnailRenderUrl(getRenderOrigin(env), token), {
|
||||
width: DEFAULT_THUMBNAIL_WIDTH,
|
||||
height: DEFAULT_THUMBNAIL_HEIGHT,
|
||||
// The measure runs only on the MCP surface today; `surface` names the render pipeline for the
|
||||
// signed job, not the telemetry source, so this is stated rather than derived.
|
||||
session: { source: 'mcp', mode: 'measure' },
|
||||
pageReached: (since) => wasRenderTokenServedSince(env, job, token, since),
|
||||
})
|
||||
|
||||
const stored = await env.THUMBNAILS.get(key)
|
||||
if (!stored) throw new Error('The render page did not report any measurements')
|
||||
return JSON.parse(await stored.text())
|
||||
} finally {
|
||||
// Both of this capture's leftovers, dropped on the failure path as well as the success one.
|
||||
//
|
||||
// The record is keyed by its own token, so concurrent measures of one page cannot invalidate each
|
||||
// other — which also means nothing later overwrites it. The result object is keyed the same way,
|
||||
// and the render page POSTs it *before* signalling ready: a Quick Action that then times out —
|
||||
// chronically a few percent of renders — leaves an object nothing will ever read or overwrite, in
|
||||
// a bucket that deliberately carries no lifecycle rule.
|
||||
//
|
||||
// Best effort on the result key, and not for the sake of a tidy bucket: throwing here would
|
||||
// replace a measure's real answer, or its real error, with a cleanup failure.
|
||||
await deleteMintedRenderToken(env, job, token)
|
||||
try {
|
||||
await env.THUMBNAILS.delete(key)
|
||||
} catch {
|
||||
// Ignored — see above.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Writes one rendered PNG to a thumbnail cache, stamping the content version (so a stale version
|
||||
// can be detected) alongside any surface-specific metadata.
|
||||
export async function putThumbnailPng(
|
||||
bucket: R2Bucket,
|
||||
key: string,
|
||||
base64: string,
|
||||
version: string | number,
|
||||
extraMetadata?: Record<string, string>
|
||||
) {
|
||||
await bucket.put(key, base64ToArrayBuffer(base64), {
|
||||
httpMetadata: { contentType: 'image/png' },
|
||||
customMetadata: {
|
||||
version: String(version),
|
||||
createdAt: String(Date.now()),
|
||||
...extraMetadata,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The request-level datapoint writer for every screenshot surface, so they share a dataset and blob
|
||||
// layout and one dashboard covers them all; the source blob distinguishes mcp (the tool), og (the
|
||||
// GET route) and queue (the render consumer). The dataset name covers all three despite its mcp_
|
||||
// prefix, and must keep doing so: renaming it would split the dashboard's history.
|
||||
//
|
||||
// This event answers cache and refusal questions — hit rates by source, who was refused and why,
|
||||
// what was rate-limited. What requests *spend* lives on the separate `browser_run_session` event
|
||||
// (see writeBrowserRunSessionTelemetry): one request can hold zero, one, or two browser sessions,
|
||||
// so durations on request rows could only ever be sums that answer neither ledger cleanly.
|
||||
//
|
||||
// Carries **no board identity**: no index, no slug, no hash, no derived id. Not an omission — these
|
||||
// datapoints answer aggregate cache and failure-rate questions, and the cost of that choice is a
|
||||
// dataset that cannot say which board is failing. See "No board identifier leaves this pipeline" in
|
||||
// browser-run-thumbnails.md.
|
||||
export function writeScreenshotTelemetry(
|
||||
env: Environment,
|
||||
data: {
|
||||
source: 'mcp' | 'og' | 'queue'
|
||||
/**
|
||||
* Which trigger asked for this render — what attributes a render to publishing or editing.
|
||||
* Only meaningful on queue datapoints; the request paths have no trigger and record `none`.
|
||||
*/
|
||||
reason?: OgImageRenderReason
|
||||
/**
|
||||
* Whether this delivery is the follow-up a completed render enqueues when a `published` board —
|
||||
* the only kind that follows up — moved during its capture (see `enqueueFollowUpIfBoardMoved`),
|
||||
* rather than the ask a trigger made. Separates
|
||||
* the two halves of render spend, which `reason` cannot: a follow-up inherits the reason of the
|
||||
* job it follows, so both land in the same bucket. Only meaningful on queue datapoints; the
|
||||
* request paths have no follow-up and record `none`.
|
||||
*/
|
||||
followUp?: boolean
|
||||
/**
|
||||
* What the PNG cache did for this request. `none` when the request never consulted it: the
|
||||
* info tools have no cache at all, and a screenshot request refused before the cache read
|
||||
* (bad input, rate-limited caller, unresolvable board) says nothing about cache health.
|
||||
* Excluding `none` is what keeps a hit-rate-by-source panel honest.
|
||||
*/
|
||||
cacheStatus: 'hit' | 'stale' | 'miss' | 'none'
|
||||
/**
|
||||
* What the MCP cluster index cache did, which decides whether the call ran a measure render.
|
||||
* Its own dimension rather than part of `cacheStatus`, which is the PNG cache: the two save
|
||||
* different things, and one blob mixing them would make either hit rate unreadable. `none` on
|
||||
* every OG row and on any MCP row that never reached the clustering step.
|
||||
*/
|
||||
clusterCacheStatus?: 'hit' | 'miss' | 'none'
|
||||
/**
|
||||
* Hashed identity of whoever asked, for surfaces that have one. Recorded only on rate-limited
|
||||
* rows — see below.
|
||||
*
|
||||
* This is a hashed user id on the MCP surface, which now requires authentication; it replaced
|
||||
* a hashed client IP, and holds the same blob position so the dashboard panels reading it did
|
||||
* not have to move. The OG surfaces have no caller at all and leave it unset.
|
||||
*/
|
||||
callerHash?: string
|
||||
failureReason?: string
|
||||
rateLimitAllowed?: boolean
|
||||
}
|
||||
) {
|
||||
const rateLimitAllowed = data.rateLimitAllowed ?? true
|
||||
// Only on rate-limited rows, which is the whole question a per-caller blob answers: who to look at
|
||||
// when spend spikes or a limit keeps firing.
|
||||
//
|
||||
// Deliberately narrower than "any failure". Cardinality is about distinct *values*, not row count,
|
||||
// and the failure set is mostly routine model mistakes — a wrong board id, a stale cluster id, a
|
||||
// page that moved. Nearly every caller produces one of those eventually, so gating on failure
|
||||
// would leave the number of distinct callers converging on the number of users, which is the cost
|
||||
// the gate exists to avoid. Rate limits fire rarely and only for callers worth naming.
|
||||
writeDataPoint(undefined, env.MEASURE, env, 'mcp_shared_board_screenshot', {
|
||||
blobs: [
|
||||
`source:${data.source}`,
|
||||
`cache:${data.cacheStatus}`,
|
||||
`failure:${data.failureReason ?? 'none'}`,
|
||||
`rate_limit:${rateLimitAllowed ? 'allowed' : 'blocked'}`,
|
||||
`caller:${!rateLimitAllowed && data.callerHash ? data.callerHash : 'none'}`,
|
||||
// Appended rather than slotted in beside `source`, so the existing blob positions (and the
|
||||
// dashboard panels reading them) don't shift.
|
||||
`reason:${data.reason ?? 'none'}`,
|
||||
// Appended for the same reason. `none` rather than `false` for the surfaces that have no
|
||||
// follow-up concept at all, so a query for triggered renders can say `followup:false` and mean
|
||||
// it, instead of sweeping up every og and mcp datapoint too.
|
||||
`followup:${data.followUp ?? 'none'}`,
|
||||
// Appended, like the two above, so nothing shifts. Only the MCP clustering tools ever set it.
|
||||
`clusters:${data.clusterCacheStatus ?? 'none'}`,
|
||||
],
|
||||
doubles: [
|
||||
DEFAULT_THUMBNAIL_WIDTH,
|
||||
DEFAULT_THUMBNAIL_HEIGHT,
|
||||
// Durations moved to the browser_run_session event; the sentinel holds this position (and
|
||||
// the ones after it) so historical rows and the panels reading them keep their meaning.
|
||||
-1,
|
||||
-1,
|
||||
rateLimitAllowed ? 1 : 0,
|
||||
],
|
||||
})
|
||||
}
|
||||
204
apps/dotcom/sync-worker/src/routes/tla/thumbnailShared.test.ts
Normal file
204
apps/dotcom/sync-worker/src/routes/tla/thumbnailShared.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Environment } from '../../types'
|
||||
import {
|
||||
BoardSnapshotReadError,
|
||||
BrowserRenderError,
|
||||
classifyScreenshotFailure,
|
||||
RateLimiterUnavailableError,
|
||||
describeThumbnailFailure,
|
||||
reportThumbnailError,
|
||||
} from './thumbnailShared'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function makeBrowserRenderError(
|
||||
overrides: Partial<ConstructorParameters<typeof BrowserRenderError>[0]> = {}
|
||||
) {
|
||||
return new BrowserRenderError({
|
||||
status: 422,
|
||||
detail: undefined,
|
||||
durationMs: 1_000,
|
||||
timeoutMs: 45_000,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('BrowserRenderError', () => {
|
||||
// Sentry groups on the message, so the detail has to stay off it: a body that names the board or
|
||||
// the failing asset would turn one recurring issue into a new one per distinct string.
|
||||
it('keeps the status-only message that Sentry groups on', () => {
|
||||
const error = makeBrowserRenderError({ detail: 'Navigation timeout of 45000 ms exceeded' })
|
||||
expect(error.message).toBe('Browser Rendering screenshot failed (422)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyScreenshotFailure', () => {
|
||||
// Matched by type and first: a binding outage means the work was never attempted, so every other
|
||||
// reason here would name a subsystem that is fine.
|
||||
it('names a rate limiter outage as itself', () => {
|
||||
expect(classifyScreenshotFailure(new RateLimiterUnavailableError(new Error('down')))).toBe(
|
||||
'rate_limiter_unavailable'
|
||||
)
|
||||
expect(describeThumbnailFailure('rate_limiter_unavailable')).toBe(
|
||||
'a rate limit could not be checked'
|
||||
)
|
||||
})
|
||||
|
||||
// 422 is Cloudflare's answer to a crashed page, an out-of-memory render, and every one of its
|
||||
// timers expiring alike, so the status can't split them and the response body has to.
|
||||
it('reads the timer out of the response body', () => {
|
||||
const error = makeBrowserRenderError({
|
||||
detail: 'Navigation timeout of 45000 ms exceeded',
|
||||
durationMs: 900,
|
||||
})
|
||||
expect(classifyScreenshotFailure(error)).toBe('browser_timeout')
|
||||
})
|
||||
|
||||
// The other half of the same 422: our own render page marked data-thumbnail-error, the capture
|
||||
// selector (success-only) was therefore absent, and the call came back long before the budget.
|
||||
it('classifies an early failure with no timer named as a render failure', () => {
|
||||
const error = makeBrowserRenderError({
|
||||
detail: 'Element with selector "body[data-thumbnail-ready=\\"true\\"]" not found',
|
||||
durationMs: 3_000,
|
||||
})
|
||||
expect(classifyScreenshotFailure(error)).toBe('browser_failed')
|
||||
})
|
||||
|
||||
// Fallback for a body that says nothing useful: a call that spent essentially the whole budget
|
||||
// waited a timer out, whatever it was called.
|
||||
it('falls back to the elapsed budget when the body names no timer', () => {
|
||||
expect(classifyScreenshotFailure(makeBrowserRenderError({ durationMs: 44_000 }))).toBe(
|
||||
'browser_timeout'
|
||||
)
|
||||
expect(classifyScreenshotFailure(makeBrowserRenderError({ durationMs: 5_000 }))).toBe(
|
||||
'browser_failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('still distinguishes a snapshot read failure from a render failure', () => {
|
||||
expect(classifyScreenshotFailure(new BoardSnapshotReadError('postgres is down'))).toBe(
|
||||
'snapshot_read_error'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps classifying the untyped errors the surfaces still throw', () => {
|
||||
expect(classifyScreenshotFailure(new Error('Browser Rendering is not configured.'))).toBe(
|
||||
'not_configured'
|
||||
)
|
||||
expect(classifyScreenshotFailure(new Error('Render produced an empty screenshot'))).toBe(
|
||||
'empty_render'
|
||||
)
|
||||
expect(classifyScreenshotFailure(new Error('something else entirely'))).toBe('render_error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeThumbnailFailure', () => {
|
||||
// 'the render failed' is nonsense for a tool that starts no render — search_boards and
|
||||
// get_board_info both fail this way when Postgres does, and both prefix it with their own
|
||||
// summary ("Could not search boards: …").
|
||||
it('describes a board lookup failure without mentioning rendering', () => {
|
||||
expect(describeThumbnailFailure('board_lookup_error')).toBe(
|
||||
'the board database could not be reached'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reportThumbnailError', () => {
|
||||
// Without this the report says only "Browser Rendering screenshot failed (422)", which is equally
|
||||
// true of a crashed page, an OOM render and a timeout — the whole point of reporting is to know
|
||||
// which. Tests get no ExecutionContext, so reporting takes its console fallback.
|
||||
it('reports the cause Cloudflare gave alongside the caller-supplied extras', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const error = makeBrowserRenderError({
|
||||
detail: 'Navigation timeout of 45000 ms exceeded',
|
||||
durationMs: 45_100,
|
||||
})
|
||||
|
||||
reportThumbnailError(error, {
|
||||
ctx: undefined,
|
||||
env: {} as Environment,
|
||||
surface: 'og_queue',
|
||||
extras: { kind: 'published' },
|
||||
})
|
||||
|
||||
expect(consoleError).toHaveBeenCalledExactlyOnceWith(
|
||||
'[thumbnails:og_queue]',
|
||||
{
|
||||
kind: 'published',
|
||||
browser_render_status: 422,
|
||||
browser_render_detail: 'Navigation timeout of 45000 ms exceeded',
|
||||
browser_render_duration_ms: 45_100,
|
||||
browser_render_timeout_ms: 45_000,
|
||||
browser_render_reason: 'browser_timeout',
|
||||
},
|
||||
error
|
||||
)
|
||||
})
|
||||
|
||||
// An empty body is itself a fact worth seeing, so it is recorded rather than omitted.
|
||||
it('records the absence of a response body', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
reportThumbnailError(makeBrowserRenderError(), {
|
||||
ctx: undefined,
|
||||
env: {} as Environment,
|
||||
surface: 'mcp_screenshot',
|
||||
})
|
||||
|
||||
expect(consoleError.mock.calls[0]![1]).toMatchObject({
|
||||
browser_render_detail: '(no response body)',
|
||||
})
|
||||
})
|
||||
|
||||
// The request is used for its method and user agent and nothing else. It is deliberately never
|
||||
// handed to createSentry, which passes one to Toucan with `allowedSearchParams: /(.*)/` — that
|
||||
// would record the full URL and every query parameter, and on these routes the URL is the
|
||||
// sensitive part: a link-shared file's id sits in the OG route's path, and a signed render token
|
||||
// (a live capability to read the board's whole snapshot) sits in the snapshot route's query.
|
||||
it('takes no URL or query parameter from the request', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const request = new Request(
|
||||
'https://sync.tldraw.xyz/api/app/thumbnail-render/snapshot?token=super-secret-render-token',
|
||||
{ headers: { 'user-agent': 'Twitterbot/1.0' } }
|
||||
)
|
||||
|
||||
reportThumbnailError(new Error('nope'), {
|
||||
ctx: undefined,
|
||||
env: {} as Environment,
|
||||
request,
|
||||
surface: 'thumbnail_snapshot',
|
||||
extras: { kind: 'shared_file' },
|
||||
})
|
||||
|
||||
const context = consoleError.mock.calls[0]![1]
|
||||
expect(context).toEqual({
|
||||
kind: 'shared_file',
|
||||
request_method: 'GET',
|
||||
request_user_agent: 'Twitterbot/1.0',
|
||||
})
|
||||
const serialised = JSON.stringify(context)
|
||||
expect(serialised).not.toContain('super-secret-render-token')
|
||||
expect(serialised).not.toContain('thumbnail-render')
|
||||
expect(serialised).not.toContain('sync.tldraw.xyz')
|
||||
})
|
||||
|
||||
it('leaves errors that are not render failures alone', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const error = new BoardSnapshotReadError('postgres is down')
|
||||
|
||||
reportThumbnailError(error, {
|
||||
ctx: undefined,
|
||||
env: {} as Environment,
|
||||
surface: 'og_queue',
|
||||
extras: { kind: 'shared_file' },
|
||||
})
|
||||
|
||||
expect(consoleError).toHaveBeenCalledExactlyOnceWith(
|
||||
'[thumbnails:og_queue]',
|
||||
{ kind: 'shared_file' },
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
219
apps/dotcom/sync-worker/src/routes/tla/thumbnailShared.ts
Normal file
219
apps/dotcom/sync-worker/src/routes/tla/thumbnailShared.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { createSentry } from '@tldraw/worker-shared'
|
||||
import { Environment } from '../../types'
|
||||
|
||||
// Leaf helpers shared by the board-reading and thumbnail/OG-image surfaces
|
||||
// (get{Published,SharedFile}, the render core in thumbnailRender.ts, mcpServer.ts,
|
||||
// the OG route, and the OG queue consumer). This module imports nothing from those files so it can
|
||||
// be depended on from any of them without creating an import cycle.
|
||||
|
||||
// A rate limit binding could not be consulted. Distinct from every other failure on these routes
|
||||
// because nothing the caller did caused it and nothing they can do fixes it: the work was never
|
||||
// attempted, so telling them the database or the renderer failed sends them, and whoever reads the
|
||||
// dashboard, at a subsystem that is fine.
|
||||
export class RateLimiterUnavailableError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super('Rate limiter unavailable', { cause })
|
||||
this.name = 'RateLimiterUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
// A board's snapshot could not be read: Postgres, R2, or a malformed payload. Distinct from a render
|
||||
// failure so telemetry can tell "the database is down" apart from "Chrome fell over".
|
||||
export class BoardSnapshotReadError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options)
|
||||
this.name = 'BoardSnapshotReadError'
|
||||
}
|
||||
}
|
||||
|
||||
// A Browser Run `/screenshot` call that came back non-OK. The status alone says almost nothing:
|
||||
// Cloudflare answers 422 for every "the page did not cooperate" outcome — a crashed page, an
|
||||
// out-of-memory render, any Quick Action timer expiring — and our own render page marking
|
||||
// `data-thumbnail-error` lands there too, because the capture selector only exists on the success
|
||||
// path. What separates them is the response body and how much of the timeout budget the call spent,
|
||||
// so both are carried here rather than in the message.
|
||||
//
|
||||
// `message` stays exactly `Browser Rendering screenshot failed (<status>)`. Sentry groups on it, so a
|
||||
// varying detail string there would shatter one recurring issue into a stream of new ones;
|
||||
// `reportThumbnailError` lifts these fields into the event's context instead.
|
||||
export class BrowserRenderError extends Error {
|
||||
readonly status: number
|
||||
readonly detail: string | undefined
|
||||
readonly durationMs: number
|
||||
readonly timeoutMs: number
|
||||
|
||||
constructor(options: {
|
||||
status: number
|
||||
detail: string | undefined
|
||||
durationMs: number
|
||||
timeoutMs: number
|
||||
}) {
|
||||
super(`Browser Rendering screenshot failed (${options.status})`)
|
||||
this.name = 'BrowserRenderError'
|
||||
this.status = options.status
|
||||
this.detail = options.detail
|
||||
this.durationMs = options.durationMs
|
||||
this.timeoutMs = options.timeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
// Maps an arbitrary render error to a bounded set of reason codes for the `failure` telemetry blob.
|
||||
// Raw `error.message` strings are unbounded and must never reach that dimension, so keep this a
|
||||
// small, stable vocabulary. The typed cases are matched first and by type: a failed snapshot read is
|
||||
// not a render that failed, and filing it as one sends a dashboard reader to the wrong subsystem.
|
||||
//
|
||||
// A board deleted or unpublished between a surface resolving it and reading its snapshot lands in
|
||||
// `snapshot_read_error` with everything else the readers throw. That race is a few milliseconds wide,
|
||||
// so separating it out isn't worth a dedicated error type threaded through every reporting path.
|
||||
export function classifyScreenshotFailure(error: unknown): string {
|
||||
if (error instanceof RateLimiterUnavailableError) return 'rate_limiter_unavailable'
|
||||
if (error instanceof BoardSnapshotReadError) return 'snapshot_read_error'
|
||||
if (error instanceof BrowserRenderError) return classifyBrowserRenderFailure(error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (/not configured/i.test(message)) return 'not_configured'
|
||||
if (/timeout|timed out/i.test(message)) return 'browser_timeout'
|
||||
if (/empty screenshot/i.test(message)) return 'empty_render'
|
||||
if (/Browser Rendering screenshot failed/i.test(message)) return 'browser_failed'
|
||||
return 'render_error'
|
||||
}
|
||||
|
||||
// Splits a Browser Run failure into `browser_timeout` and `browser_failed`, which the status cannot
|
||||
// do (see BrowserRenderError). Reads the response body, which usually names the timer, and falls back
|
||||
// to how much of the budget the call spent: one that used essentially the whole timeout waited a timer
|
||||
// out, one that returned early failed for another reason. Note the message is no use here — a Browser
|
||||
// Run failure's never contains the word "timeout", so matching on it reports a timeout rate of zero.
|
||||
const BROWSER_TIMEOUT_DURATION_FRACTION = 0.9
|
||||
function classifyBrowserRenderFailure(error: BrowserRenderError): string {
|
||||
if (/timeout|timed out|timed-out/i.test(error.detail ?? '')) return 'browser_timeout'
|
||||
if (error.durationMs >= error.timeoutMs * BROWSER_TIMEOUT_DURATION_FRACTION) {
|
||||
return 'browser_timeout'
|
||||
}
|
||||
return 'browser_failed'
|
||||
}
|
||||
|
||||
// Caller-facing explanation for a classified failure, as a clause to follow a tool's own prefix.
|
||||
// Derived from the bounded reason code and never from `error.message`: these tools answer anonymous,
|
||||
// unauthenticated callers, and Postgres and R2 errors carry internal hostnames, ports, and database
|
||||
// usernames (the pool is built from BOTCOM_POSTGRES_POOLED_CONNECTION_STRING). The unbounded
|
||||
// original still reaches Sentry through reportThumbnailError, which is where it is useful.
|
||||
export function describeThumbnailFailure(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'snapshot_read_error':
|
||||
return "the board's saved content could not be read"
|
||||
case 'not_configured':
|
||||
return 'rendering is not configured'
|
||||
case 'browser_timeout':
|
||||
return 'rendering timed out'
|
||||
case 'board_lookup_error':
|
||||
// The tools that fail this way start no render, so the default 'the render failed' would
|
||||
// be a plainly wrong thing to tell a caller.
|
||||
return 'the board database could not be reached'
|
||||
case 'rate_limiter_unavailable':
|
||||
// Deliberately not phrased as being rate limited: the caller is inside their budget, and a
|
||||
// message saying otherwise would have them back off for a minute that would not help.
|
||||
return 'a rate limit could not be checked'
|
||||
default:
|
||||
return 'the render failed'
|
||||
}
|
||||
}
|
||||
|
||||
// Which swallowing surface an error came from. Set as a Sentry tag so they can be filtered apart,
|
||||
// and kept a closed union so the tag's values stay a small, stable set.
|
||||
export type ThumbnailErrorSurface =
|
||||
| 'og_route'
|
||||
| 'og_queue'
|
||||
| 'thumbnail_snapshot'
|
||||
| 'mcp_board_info'
|
||||
| 'mcp_board_search'
|
||||
| 'mcp_screenshot'
|
||||
// Kept apart from 'mcp_screenshot': the render succeeded and the caller still got their PNG, so
|
||||
// this never means "screenshots are broken" — it means the cache isn't absorbing them and every
|
||||
// call is re-spending Browser Run.
|
||||
| 'mcp_screenshot_cache_write'
|
||||
// The cluster index cache in the file's Durable Object. Neither of these is ever a caller-visible
|
||||
// failure — both fall back to measuring the page — but both mean the clustering tools are paying
|
||||
// for a browser session per call again, which is the thing that cache exists to stop.
|
||||
| 'mcp_cluster_index_read'
|
||||
| 'mcp_cluster_index_write'
|
||||
|
||||
// Every thumbnail/OG surface swallows its own errors — the OG route falls back to the default image,
|
||||
// the snapshot route 404s, the MCP tools return a tool error, the queue retries or drops. Right for
|
||||
// callers, but it leaves a bounded telemetry reason code as the only trace, which says a board stopped
|
||||
// rendering and nothing about why. Reporting here is what keeps these paths diagnosable.
|
||||
//
|
||||
// `ctx` supplies the waitUntil that lets the report outlive the response — route handlers get one from
|
||||
// the router, the queue consumer from the worker entrypoint. Without one (unit tests) we log instead,
|
||||
// since createSentry throws when SENTRY_DSN and friends are unset.
|
||||
export function reportThumbnailError(
|
||||
error: unknown,
|
||||
{
|
||||
ctx,
|
||||
env,
|
||||
request,
|
||||
surface,
|
||||
extras,
|
||||
}: {
|
||||
ctx: ExecutionContext | undefined
|
||||
env: Environment
|
||||
request?: Request
|
||||
surface: ThumbnailErrorSurface
|
||||
extras?: Record<string, unknown>
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const context = {
|
||||
...extras,
|
||||
...safeRequestContext(request),
|
||||
...browserRenderContextOf(error),
|
||||
}
|
||||
// `request` is deliberately NOT handed to createSentry, which passes one straight to Toucan with
|
||||
// `allowedSearchParams: /(.*)/` — recording the full URL and every query parameter. On these
|
||||
// routes the URL is the sensitive part: `/app/social-preview/f/<id>/image` carries a link-shared
|
||||
// file's id, and the render-snapshot route carries a signed token that can read the board's whole
|
||||
// contents until it expires. safeRequestContext takes what is useful instead.
|
||||
const sentry = ctx ? createSentry(ctx, env) : null
|
||||
if (!sentry) {
|
||||
console.error(`[thumbnails:${surface}]`, context, error)
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
sentry.withScope((scope) => {
|
||||
scope.setTag('thumbnail_surface', surface)
|
||||
scope.setExtras(context)
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
sentry.captureException(error)
|
||||
})
|
||||
} catch (_e) {
|
||||
// Reporting runs inside handlers whose whole point is to swallow failure, so it must never be
|
||||
// the thing that throws: a missing Sentry env var would otherwise turn a degraded-but-fine
|
||||
// response into a 500.
|
||||
}
|
||||
}
|
||||
|
||||
// The parts of a request worth attaching that carry no board identity. The URL is excluded (see the
|
||||
// call site); the user agent is kept because on the OG route it names the crawler that tripped over
|
||||
// the board, and the method because a HEAD probe and a GET behave differently there. Not the client
|
||||
// IP — telemetry records a hash of that, on failures only, which is where it earns its keep.
|
||||
function safeRequestContext(request: Request | undefined): Record<string, unknown> | null {
|
||||
if (!request) return null
|
||||
return {
|
||||
request_method: request.method,
|
||||
request_user_agent: request.headers.get('user-agent') ?? 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// The fields BrowserRenderError keeps out of its message, lifted into the event context so they don't
|
||||
// file a new Sentry issue per distinct detail string. Without them an event says only "Browser
|
||||
// Rendering screenshot failed (422)", which is true of a crash, an OOM render and a timeout alike.
|
||||
function browserRenderContextOf(error: unknown): Record<string, unknown> | null {
|
||||
if (!(error instanceof BrowserRenderError)) return null
|
||||
return {
|
||||
browser_render_status: error.status,
|
||||
// Cloudflare's own explanation of the failure, verbatim (truncated at the throw site). Absent
|
||||
// when the response carried no body, which is itself worth being able to see.
|
||||
browser_render_detail: error.detail ?? '(no response body)',
|
||||
browser_render_duration_ms: error.durationMs,
|
||||
browser_render_timeout_ms: error.timeoutMs,
|
||||
browser_render_reason: classifyScreenshotFailure(error),
|
||||
}
|
||||
}
|
||||
40
apps/dotcom/sync-worker/src/routes/tla/uploads.ts
Normal file
40
apps/dotcom/sync-worker/src/routes/tla/uploads.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { handleUserAssetUpload } from '@tldraw/worker-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { createPostgresConnectionPool } from '../../postgres'
|
||||
import { Environment } from '../../types'
|
||||
import { getAuth } from '../../utils/tla/getAuth'
|
||||
import { hasWriteAccessToFile } from '../../utils/tla/hasWriteAccessToFile'
|
||||
|
||||
export async function upload(request: IRequest, env: Environment): Promise<Response> {
|
||||
const { body, url, headers } = request
|
||||
const auth = await getAuth(request, env)
|
||||
const userId = auth?.userId || null
|
||||
const searchParams = new URL(url).searchParams
|
||||
const fileId = searchParams.get('fileId')
|
||||
if (!fileId) return Response.json({ error: 'File id is required' }, { status: 400 })
|
||||
|
||||
const objectName = request.params.objectName
|
||||
if (!objectName) return Response.json({ error: 'Object name is required' }, { status: 400 })
|
||||
|
||||
const db = createPostgresConnectionPool(env, 'sync-worker')
|
||||
let canWrite = false
|
||||
try {
|
||||
canWrite = await hasWriteAccessToFile(db, fileId, userId)
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
if (!canWrite) {
|
||||
return Response.json({ error: 'Could not upload the file' }, { status: 403 })
|
||||
}
|
||||
|
||||
const res = await handleUserAssetUpload({
|
||||
body,
|
||||
headers,
|
||||
bucket: env.UPLOADS,
|
||||
objectName,
|
||||
})
|
||||
if (res.status === 200) {
|
||||
await env.QUEUE.send({ type: 'asset-upload', objectName, fileId, userId })
|
||||
}
|
||||
return res
|
||||
}
|
||||
493
apps/dotcom/sync-worker/src/routes/verifyVersionChain.test.ts
Normal file
493
apps/dotcom/sync-worker/src/routes/verifyVersionChain.test.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import { UnknownRecord } from '@tldraw/store'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createFakeR2 } from '../test/fakeR2'
|
||||
import { Environment } from '../types'
|
||||
import { ChainState, PendingDelta, segmentCustomMetadata, versionKey } from '../versionChain'
|
||||
import { encodeVersionBody } from '../versionChainCodec'
|
||||
import { writeVersionChainEntry } from '../versionChainWrite'
|
||||
import { buildSnapshotDelta } from '../versionDelta'
|
||||
import { verifyRoomVersions, verifyVersionChainRoute } from './verifyVersionChain'
|
||||
|
||||
vi.mock('../utils/tla/getAuth', () => ({ requireAdminAccessToRequest: vi.fn() }))
|
||||
|
||||
const roomKey = 'app_rooms/slug'
|
||||
|
||||
function snapshot(clock: number, ids: string[]): RoomSnapshot {
|
||||
return {
|
||||
clock,
|
||||
documentClock: clock,
|
||||
documents: ids.map((id) => ({
|
||||
state: { id, typeName: 'shape' } as UnknownRecord,
|
||||
lastChangedClock: clock,
|
||||
})),
|
||||
tombstones: {},
|
||||
tombstoneHistoryStartsAtClock: 0,
|
||||
schema: { schemaVersion: 2, sequences: {} } as any,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors what dual-write produces: a chain in one bucket, full copies in the other. */
|
||||
async function seedDualWrite(
|
||||
chainBucket: R2Bucket,
|
||||
legacyBucket: R2Bucket,
|
||||
versions: RoomSnapshot[]
|
||||
) {
|
||||
let chain: ChainState | null = null
|
||||
let pending: PendingDelta[] = []
|
||||
|
||||
for (let i = 0; i < versions.length; i++) {
|
||||
const iso = `2026-09-01T00:00:0${i}.000Z`
|
||||
const result = await writeVersionChainEntry({
|
||||
bucket: chainBucket,
|
||||
roomKey,
|
||||
iso,
|
||||
chain,
|
||||
pending,
|
||||
previous: i === 0 ? null : versions[i - 1],
|
||||
next: versions[i],
|
||||
now: i * 1000,
|
||||
})
|
||||
chain = result.chain
|
||||
pending = result.pending
|
||||
await legacyBucket.put(`${roomKey}/${iso}`, JSON.stringify(versions[i]))
|
||||
}
|
||||
}
|
||||
|
||||
describe('verifyRoomVersions under clock skew', () => {
|
||||
it('verifies a chain whose later segment has an earlier key', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
const versions = [
|
||||
snapshot(1, ['shape:a']),
|
||||
snapshot(2, ['shape:a', 'shape:b']),
|
||||
snapshot(3, ['shape:a', 'shape:b', 'shape:c']),
|
||||
]
|
||||
const iso = (s: number) => `2026-09-01T00:00:${String(s).padStart(2, '0')}.000Z`
|
||||
const keyframeKey = versionKey(roomKey, iso(10), 'keyframe')
|
||||
const kf = await encodeVersionBody(versions[0])
|
||||
await chainBucket.put(keyframeKey, kf.body, { customMetadata: kf.metadata })
|
||||
await legacyBucket.put(`${roomKey}/${iso(10)}`, JSON.stringify(versions[0]))
|
||||
const put = async (at: number, firstSeq: number, prev: RoomSnapshot, next: RoomSnapshot) => {
|
||||
const deltas = [{ t: iso(at), delta: buildSnapshotDelta(prev, next) }]
|
||||
const encoded = await encodeVersionBody({ v: 1, deltas })
|
||||
await chainBucket.put(versionKey(roomKey, iso(at), 'segment'), encoded.body, {
|
||||
customMetadata: {
|
||||
...encoded.metadata,
|
||||
...segmentCustomMetadata({ keyframeKey, firstSeq, timestamps: [iso(at)] }),
|
||||
},
|
||||
})
|
||||
await legacyBucket.put(`${roomKey}/${iso(at)}`, JSON.stringify(next))
|
||||
}
|
||||
// Sequence 1 at :20, then the clock steps back: sequence 2 lands under :15.
|
||||
await put(20, 1, versions[0], versions[1])
|
||||
await put(15, 2, versions[1], versions[2])
|
||||
|
||||
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })).toEqual({
|
||||
checked: 3,
|
||||
replayed: 3,
|
||||
// One listing, then a get and a legacy get for the keyframe and for each segment.
|
||||
reads: 7,
|
||||
complete: true,
|
||||
mismatches: [],
|
||||
errors: [],
|
||||
legacyReadFailures: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyRoomVersions after cut-over', () => {
|
||||
it('stops on the read budget when no legacy copy answers', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
// Chain only, as the bucket looks once dual-write is off: nothing increments a comparison
|
||||
// counter, so a budget keyed off one would walk all twenty chains.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const iso = `2026-09-01T00:00:${String(i).padStart(2, '0')}.000Z`
|
||||
const kf = await encodeVersionBody(snapshot(i + 1, [`shape:${i}`]))
|
||||
await chainBucket.put(versionKey(roomKey, iso, 'keyframe'), kf.body, {
|
||||
customMetadata: kf.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// One listing, then two reads per keyframe: three keyframes fit, the fourth never starts.
|
||||
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 7 })).toEqual({
|
||||
checked: 0,
|
||||
replayed: 3,
|
||||
reads: 7,
|
||||
complete: false,
|
||||
mismatches: [],
|
||||
errors: [],
|
||||
legacyReadFailures: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyRoomVersions with a limit', () => {
|
||||
it('spends the budget on the newest chain first', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
// Two chains: an old one whose legacy copy is wrong, and a new one that is fine.
|
||||
const old = snapshot(1, ['shape:old'])
|
||||
const recent = snapshot(2, ['shape:new'])
|
||||
for (const [iso, version, legacy] of [
|
||||
['2026-08-01T00:00:00.000Z', old, snapshot(9, ['shape:tampered'])],
|
||||
['2026-09-01T00:00:00.000Z', recent, recent],
|
||||
] as const) {
|
||||
const kf = await encodeVersionBody(version)
|
||||
await chainBucket.put(versionKey(roomKey, iso, 'keyframe'), kf.body, {
|
||||
customMetadata: kf.metadata,
|
||||
})
|
||||
await legacyBucket.put(`${roomKey}/${iso}`, JSON.stringify(legacy))
|
||||
}
|
||||
|
||||
// One listing plus the two reads the first keyframe costs, so the second never starts.
|
||||
const limited = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 3 })
|
||||
const full = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
|
||||
|
||||
// With budget for one version, the newest chain is the one that gets checked.
|
||||
expect(limited).toEqual({
|
||||
checked: 1,
|
||||
replayed: 1,
|
||||
reads: 3,
|
||||
complete: false,
|
||||
mismatches: [],
|
||||
errors: [],
|
||||
legacyReadFailures: [],
|
||||
})
|
||||
expect(full.mismatches).toEqual(['2026-08-01T00:00:00.000Z'])
|
||||
})
|
||||
|
||||
it('marks a run the listing alone exhausted as incomplete', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedDualWrite(chainBucket, legacyBucket, [snapshot(1, ['shape:a'])])
|
||||
|
||||
// The listing spends the whole budget before the keyframe is read: nothing is verified, and
|
||||
// `complete: false` is the only thing separating this result from a clean room.
|
||||
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 1 })).toEqual({
|
||||
checked: 0,
|
||||
replayed: 0,
|
||||
reads: 1,
|
||||
complete: false,
|
||||
mismatches: [],
|
||||
errors: [],
|
||||
legacyReadFailures: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyRoomVersions on a segment the read path rejects', () => {
|
||||
const versions = [
|
||||
snapshot(1, ['shape:a']),
|
||||
snapshot(2, ['shape:a', 'shape:b']),
|
||||
snapshot(3, ['shape:a', 'shape:b', 'shape:c']),
|
||||
]
|
||||
|
||||
async function seedAndFindSegment() {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedDualWrite(chainBucket, legacyBucket, versions)
|
||||
const listing = await chainBucket.list({ prefix: `${roomKey}/` })
|
||||
const segment = listing.objects.find((o) => o.key.endsWith('.s'))!
|
||||
const object = (await chainBucket.get(segment.key))!
|
||||
return { chainBucket, legacyBucket, segment, metadata: object.customMetadata! }
|
||||
}
|
||||
|
||||
it('reports a body that does not begin with what its metadata lists', async () => {
|
||||
const { chainBucket, legacyBucket, segment, metadata } = await seedAndFindSegment()
|
||||
// Same first delta, then a version the listing never promised in place of the second.
|
||||
const torn = await encodeVersionBody({
|
||||
v: 1,
|
||||
deltas: [
|
||||
{ t: '2026-09-01T00:00:01.000Z', delta: buildSnapshotDelta(versions[0], versions[1]) },
|
||||
{ t: '2026-09-01T00:00:09.000Z', delta: buildSnapshotDelta(versions[1], versions[2]) },
|
||||
],
|
||||
})
|
||||
await chainBucket.put(segment.key, torn.body, { customMetadata: metadata })
|
||||
|
||||
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
|
||||
|
||||
expect(result.errors.map((e) => e.message)).toEqual([
|
||||
expect.stringMatching(/does not match its metadata/),
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a segment written in an unknown format', async () => {
|
||||
const { chainBucket, legacyBucket, segment, metadata } = await seedAndFindSegment()
|
||||
const future = await encodeVersionBody({ v: 2, deltas: [] })
|
||||
await chainBucket.put(segment.key, future.body, { customMetadata: metadata })
|
||||
|
||||
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
|
||||
|
||||
expect(result.errors.map((e) => e.message)).toEqual([
|
||||
expect.stringMatching(/unknown version segment format/),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyRoomVersions on an orphaned segment', () => {
|
||||
it('reports a segment whose keyframe is not in the index', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
const versions = [snapshot(1, ['shape:a']), snapshot(2, ['shape:a', 'shape:b'])]
|
||||
await seedDualWrite(chainBucket, legacyBucket, versions)
|
||||
// A segment left behind by a chain whose keyframe object is gone.
|
||||
const orphanIso = '2026-08-01T00:00:01.000Z'
|
||||
const missingKeyframeKey = versionKey(roomKey, '2026-08-01T00:00:00.000Z', 'keyframe')
|
||||
const encoded = await encodeVersionBody({
|
||||
v: 1,
|
||||
deltas: [{ t: orphanIso, delta: buildSnapshotDelta(versions[0], versions[1]) }],
|
||||
})
|
||||
await chainBucket.put(versionKey(roomKey, orphanIso, 'segment'), encoded.body, {
|
||||
customMetadata: {
|
||||
...encoded.metadata,
|
||||
...segmentCustomMetadata({
|
||||
keyframeKey: missingKeyframeKey,
|
||||
firstSeq: 1,
|
||||
timestamps: [orphanIso],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
|
||||
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
timestamp: orphanIso,
|
||||
message: expect.stringMatching(/references missing keyframe/),
|
||||
},
|
||||
])
|
||||
// The intact chain still verifies clean around the orphan.
|
||||
expect(result.mismatches).toEqual([])
|
||||
expect(result.checked).toBe(2)
|
||||
})
|
||||
|
||||
it('reports the orphan even when the budget stops the walk', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
const orphanIso = '2026-08-01T00:00:01.000Z'
|
||||
const encoded = await encodeVersionBody({
|
||||
v: 1,
|
||||
deltas: [
|
||||
{
|
||||
t: orphanIso,
|
||||
delta: buildSnapshotDelta(snapshot(1, ['shape:a']), snapshot(2, ['shape:a', 'shape:b'])),
|
||||
},
|
||||
],
|
||||
})
|
||||
await chainBucket.put(versionKey(roomKey, orphanIso, 'segment'), encoded.body, {
|
||||
customMetadata: {
|
||||
...encoded.metadata,
|
||||
...segmentCustomMetadata({
|
||||
keyframeKey: versionKey(roomKey, '2026-08-01T00:00:00.000Z', 'keyframe'),
|
||||
firstSeq: 1,
|
||||
timestamps: [orphanIso],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// The listing alone exhausts the budget; the orphan check costs no reads and still runs.
|
||||
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 1 })
|
||||
|
||||
expect(result.errors.map((e) => e.message)).toEqual([
|
||||
expect.stringMatching(/references missing keyframe/),
|
||||
])
|
||||
expect(result.replayed).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyRoomVersions on an unreadable segment', () => {
|
||||
const iso = (s: number) => `2026-09-01T00:00:0${s}.000Z`
|
||||
const versions = [
|
||||
snapshot(1, ['shape:a']),
|
||||
snapshot(2, ['shape:a', 'shape:b']),
|
||||
snapshot(3, ['shape:a', 'shape:b', 'shape:c']),
|
||||
]
|
||||
|
||||
/** A keyframe and two segments, where the one at `brokenSeq` has lost its chain reference. */
|
||||
async function seedWithBrokenSegment(
|
||||
chainBucket: R2Bucket,
|
||||
legacyBucket: R2Bucket,
|
||||
brokenSeq: 1 | 2
|
||||
) {
|
||||
const keyframeKey = versionKey(roomKey, iso(0), 'keyframe')
|
||||
const kf = await encodeVersionBody(versions[0])
|
||||
await chainBucket.put(keyframeKey, kf.body, { customMetadata: kf.metadata })
|
||||
await legacyBucket.put(`${roomKey}/${iso(0)}`, JSON.stringify(versions[0]))
|
||||
|
||||
for (const firstSeq of [1, 2] as const) {
|
||||
const encoded = await encodeVersionBody({
|
||||
v: 1,
|
||||
deltas: [
|
||||
{
|
||||
t: iso(firstSeq),
|
||||
delta: buildSnapshotDelta(versions[firstSeq - 1], versions[firstSeq]),
|
||||
},
|
||||
],
|
||||
})
|
||||
// The broken one keeps its body metadata and loses the chain reference, the way a
|
||||
// half-written custom metadata set would.
|
||||
await chainBucket.put(versionKey(roomKey, iso(firstSeq), 'segment'), encoded.body, {
|
||||
customMetadata:
|
||||
firstSeq === brokenSeq
|
||||
? encoded.metadata
|
||||
: {
|
||||
...encoded.metadata,
|
||||
...segmentCustomMetadata({
|
||||
keyframeKey,
|
||||
firstSeq,
|
||||
timestamps: [iso(firstSeq)],
|
||||
}),
|
||||
},
|
||||
})
|
||||
await legacyBucket.put(`${roomKey}/${iso(firstSeq)}`, JSON.stringify(versions[firstSeq]))
|
||||
}
|
||||
}
|
||||
|
||||
it('reports a trailing segment that leaves no sequence gap behind it', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedWithBrokenSegment(chainBucket, legacyBucket, 2)
|
||||
|
||||
// Without the rejection report this is a clean room: the walk replays the keyframe and
|
||||
// sequence 1, then simply ends, and nothing is left to trip the sequence check.
|
||||
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })).toEqual({
|
||||
checked: 2,
|
||||
replayed: 2,
|
||||
reads: 5,
|
||||
complete: true,
|
||||
mismatches: [],
|
||||
errors: [
|
||||
{
|
||||
timestamp: iso(2),
|
||||
message: expect.stringMatching(/has no readable chain reference/),
|
||||
},
|
||||
],
|
||||
legacyReadFailures: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a mid-chain segment directly, not only as the gap it leaves', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedWithBrokenSegment(chainBucket, legacyBucket, 1)
|
||||
|
||||
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
|
||||
|
||||
expect(result.errors).toEqual([
|
||||
{ timestamp: iso(1), message: expect.stringMatching(/has no readable chain reference/) },
|
||||
{ timestamp: iso(2), message: expect.stringMatching(/expected at sequence 1, found 2/) },
|
||||
])
|
||||
expect(result.mismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('reports the segment even when the budget stops the walk', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
const encoded = await encodeVersionBody({
|
||||
v: 1,
|
||||
deltas: [{ t: iso(1), delta: buildSnapshotDelta(versions[0], versions[1]) }],
|
||||
})
|
||||
await chainBucket.put(versionKey(roomKey, iso(1), 'segment'), encoded.body, {
|
||||
customMetadata: encoded.metadata,
|
||||
})
|
||||
|
||||
// The listing alone exhausts the budget; reading the index is what found this, so it is
|
||||
// reported whether or not the walk gets to run.
|
||||
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 1 })
|
||||
|
||||
expect(result.errors.map((e) => e.message)).toEqual([
|
||||
expect.stringMatching(/has no readable chain reference/),
|
||||
])
|
||||
expect(result.replayed).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyRoomVersions', () => {
|
||||
it('reports no mismatches when reconstruction matches the full copies', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedDualWrite(chainBucket, legacyBucket, [
|
||||
snapshot(1, ['shape:a']),
|
||||
snapshot(2, ['shape:a', 'shape:b']),
|
||||
])
|
||||
|
||||
expect(await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })).toEqual({
|
||||
checked: 2,
|
||||
replayed: 2,
|
||||
reads: 5,
|
||||
complete: true,
|
||||
mismatches: [],
|
||||
errors: [],
|
||||
legacyReadFailures: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps replaying the chain when a legacy read fails', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedDualWrite(chainBucket, legacyBucket, [
|
||||
snapshot(1, ['shape:a']),
|
||||
snapshot(2, ['shape:a', 'shape:b']),
|
||||
])
|
||||
const flakyIso = '2026-09-01T00:00:00.000Z'
|
||||
const flakyLegacy = {
|
||||
...legacyBucket,
|
||||
get: async (key: string) => {
|
||||
if (key === `${roomKey}/${flakyIso}`) throw new Error('network connection lost')
|
||||
return legacyBucket.get(key)
|
||||
},
|
||||
} as unknown as R2Bucket
|
||||
|
||||
// The failed read is an error naming the legacy side, not a broken chain: the replay
|
||||
// carries on and the next version still gets compared.
|
||||
expect(
|
||||
await verifyRoomVersions({ chainBucket, legacyBucket: flakyLegacy, roomKey, limit: 20 })
|
||||
).toEqual({
|
||||
checked: 1,
|
||||
replayed: 2,
|
||||
reads: 5,
|
||||
complete: true,
|
||||
mismatches: [],
|
||||
errors: [],
|
||||
legacyReadFailures: [
|
||||
{
|
||||
timestamp: flakyIso,
|
||||
message: expect.stringMatching(/^legacy copy read failed: network connection lost/),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('names the timestamp when a reconstruction disagrees', async () => {
|
||||
const chainBucket = createFakeR2()
|
||||
const legacyBucket = createFakeR2()
|
||||
await seedDualWrite(chainBucket, legacyBucket, [
|
||||
snapshot(1, ['shape:a']),
|
||||
snapshot(2, ['shape:a', 'shape:b']),
|
||||
])
|
||||
await legacyBucket.put(
|
||||
`${roomKey}/2026-09-01T00:00:01.000Z`,
|
||||
JSON.stringify(snapshot(2, ['shape:wrong']))
|
||||
)
|
||||
|
||||
const result = await verifyRoomVersions({ chainBucket, legacyBucket, roomKey, limit: 20 })
|
||||
|
||||
expect(result.mismatches).toEqual(['2026-09-01T00:00:01.000Z'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyVersionChainRoute', () => {
|
||||
it('skips test rooms without touching R2', async () => {
|
||||
const list = vi.fn()
|
||||
const response = await verifyVersionChainRoute(
|
||||
{ params: { roomId: 'test_board' }, query: {} } as unknown as IRequest,
|
||||
{ ROOMS_HISTORY: { list }, ROOMS_HISTORY_EPHEMERAL: { list } } as unknown as Environment,
|
||||
true
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
258
apps/dotcom/sync-worker/src/routes/verifyVersionChain.ts
Normal file
258
apps/dotcom/sync-worker/src/routes/verifyVersionChain.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { notFound } from '@tldraw/worker-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { getR2KeyForRoom } from '../r2'
|
||||
import { canonicalJson } from '../snapshotUtils'
|
||||
import { Environment } from '../types'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||
import { requireAdminAccessToRequest } from '../utils/tla/getAuth'
|
||||
import { isTestFile } from '../utils/tla/isTestFile'
|
||||
import { decodeVersionBody } from '../versionChainCodec'
|
||||
import { loadChainIndex, readSegmentDeltas, SegmentIndexEntry } from '../versionChainRead'
|
||||
import { applySnapshotDelta, versionEnvelopeHash } from '../versionDelta'
|
||||
|
||||
export interface VerifyResult {
|
||||
/** Versions compared against a legacy full copy. Zero once dual-write is off. */
|
||||
checked: number
|
||||
/** Versions replayed, whether or not a legacy copy existed to compare them against. */
|
||||
replayed: number
|
||||
/** R2 reads spent, listing included. */
|
||||
reads: number
|
||||
/**
|
||||
* False when the read budget stopped the walk with index entries still unvisited. On a room
|
||||
* whose listing alone exhausts the budget, `mismatches` and `errors` come back empty having
|
||||
* verified nothing — this is what says that result is not a clean room.
|
||||
*/
|
||||
complete: boolean
|
||||
mismatches: string[]
|
||||
/** Chain faults only: a version that cannot be reconstructed from what is in the bucket. */
|
||||
errors: Array<{ timestamp: string; message: string }>
|
||||
/**
|
||||
* Versions the legacy comparison could not check. Not chain faults: a legacy copy that will not
|
||||
* read says nothing about the chain, and counting it as one would page on R2 flakes.
|
||||
*/
|
||||
legacyReadFailures: Array<{ timestamp: string; message: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares every reconstructable version against the full copy dual-write left in the legacy
|
||||
* bucket. This is the gate on turning the full copies off — until it runs clean on live traffic,
|
||||
* the only evidence the encoding is exact comes from a 16-room sample.
|
||||
*
|
||||
* It keeps running after cut-over on the recorded hash alone, which is why `limit` counts R2 reads
|
||||
* and not comparisons: with no legacy copies left to compare against, a comparison counter never
|
||||
* moves and the walk would never stop.
|
||||
*/
|
||||
export async function verifyRoomVersions({
|
||||
chainBucket,
|
||||
legacyBucket,
|
||||
roomKey,
|
||||
limit,
|
||||
}: {
|
||||
chainBucket: R2Bucket
|
||||
legacyBucket: R2Bucket
|
||||
roomKey: string
|
||||
limit: number
|
||||
}): Promise<VerifyResult> {
|
||||
const { entries, ops, rejected } = await loadChainIndex(chainBucket, roomKey)
|
||||
|
||||
// A set: a version can fail both the hash check and the legacy comparison, and it is one
|
||||
// mismatch, not two.
|
||||
const mismatches = new Set<string>()
|
||||
const errors: Array<{ timestamp: string; message: string }> = []
|
||||
const legacyReadFailures: Array<{ timestamp: string; message: string }> = []
|
||||
let checked = 0
|
||||
let replayed = 0
|
||||
// Flagged at the break sites, not derived from `reads >= limit` at the end: the budget may
|
||||
// overshoot on the version in flight, and a run that replayed everything is complete even so.
|
||||
let complete = true
|
||||
// Seeded with the listing: those pages are subrequests too, and on a room with a long history
|
||||
// they are a real share of the budget.
|
||||
let reads = ops
|
||||
// Checked between versions rather than in front of each read, so a run can overshoot by the two
|
||||
// reads one version costs. That is noise against the subrequest cap; an unbounded walk is not.
|
||||
const withinBudget = () => reads < limit
|
||||
|
||||
const compareToLegacy = async (snapshot: RoomSnapshot, timestamp: string) => {
|
||||
let expected: RoomSnapshot
|
||||
try {
|
||||
reads++
|
||||
const legacyObject = await legacyBucket.get(`${roomKey}/${timestamp}`)
|
||||
// Nothing to compare against for versions written before dual-write started, or for any
|
||||
// version at all once it stops.
|
||||
if (!legacyObject) return
|
||||
expected = (await decodeVersionBody(legacyObject)) as RoomSnapshot
|
||||
} catch (e: any) {
|
||||
// Caught here, not in the callers: their catches mark the chain broken and stop the
|
||||
// replay, and a flaky legacy read is neither.
|
||||
legacyReadFailures.push({
|
||||
timestamp,
|
||||
message: `legacy copy read failed: ${String(e?.message ?? e)}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
checked++
|
||||
if (canonical(snapshot) !== canonical(expected)) mismatches.add(timestamp)
|
||||
}
|
||||
|
||||
// Each chain replays once, front to back, comparing every intermediate state against the
|
||||
// legacy full copy at the same timestamp. Reconstructing per version would refetch the same
|
||||
// keyframe and segments once per version — quadratic over a chain for no extra coverage,
|
||||
// since this fold is exactly the fold reconstruction performs.
|
||||
//
|
||||
// Segments are grouped under their keyframe and ordered by sequence, exactly as reconstruction
|
||||
// orders them — never by key. Keys are wall-clock timestamps, and a durable object re-created
|
||||
// on a host whose clock runs behind opens a later segment under an earlier key; walking the
|
||||
// index in key order would fail the rollout gate on a chain that history reads serve fine.
|
||||
// Newest chain first: the budget bounds the work, and the versions a rollout gate must see are
|
||||
// the ones the current write path just produced. An active room writes ~2,000 versions a day,
|
||||
// so oldest-first would spend the whole budget on history and never reach today's code.
|
||||
const keyframes = entries
|
||||
.filter((entry) => entry.kind === 'keyframe')
|
||||
.sort((a, b) => b.key.localeCompare(a.key))
|
||||
|
||||
// A segment whose keyframe is not in the index belongs to no walk below, so without this a
|
||||
// clean result would hide versions that 500 once the legacy copies are off. Judged against the
|
||||
// index rather than what the walk claimed, and before the budget can stop anything: it costs no
|
||||
// reads, and an early break must not misreport unwalked segments as orphans.
|
||||
const keyframeKeys = new Set(keyframes.map((keyframe) => keyframe.key))
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'segment' && !keyframeKeys.has(entry.keyframeKey)) {
|
||||
errors.push({
|
||||
timestamp: entry.timestamps[0],
|
||||
message: `segment ${entry.key} references missing keyframe ${entry.keyframeKey}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The same class as the orphans above, one step earlier: these never reached the index, so the
|
||||
// check above cannot see them and the walk below never misses them. A rejected segment at the
|
||||
// end of a chain leaves no sequence gap behind it, which is exactly how an unreadable tail
|
||||
// passes as a clean room.
|
||||
for (const object of rejected) {
|
||||
errors.push({
|
||||
timestamp: object.timestamp,
|
||||
message: `segment ${object.key} has no readable chain reference`,
|
||||
})
|
||||
}
|
||||
|
||||
for (const keyframe of keyframes) {
|
||||
if (!withinBudget()) {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
const segments = entries
|
||||
.filter(
|
||||
(entry): entry is SegmentIndexEntry =>
|
||||
entry.kind === 'segment' && entry.keyframeKey === keyframe.key
|
||||
)
|
||||
.sort((a, b) => a.firstSeq - b.firstSeq)
|
||||
|
||||
let state: RoomSnapshot
|
||||
try {
|
||||
reads++
|
||||
const object = await chainBucket.get(keyframe.key)
|
||||
if (!object) throw new Error(`keyframe ${keyframe.key} is missing`)
|
||||
state = (await decodeVersionBody(object)) as RoomSnapshot
|
||||
replayed++
|
||||
await compareToLegacy(state, keyframe.timestamps[0])
|
||||
} catch (e: any) {
|
||||
errors.push({ timestamp: keyframe.timestamps[0], message: String(e?.message ?? e) })
|
||||
continue
|
||||
}
|
||||
|
||||
let expectedSeq = 1
|
||||
for (const segment of segments) {
|
||||
if (!withinBudget()) {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
try {
|
||||
if (segment.firstSeq !== expectedSeq) {
|
||||
throw new Error(
|
||||
`segment ${segment.key} expected at sequence ${expectedSeq}, found ${segment.firstSeq}`
|
||||
)
|
||||
}
|
||||
reads++
|
||||
// The same read as reconstruction, so a segment that verifies here also reads.
|
||||
const deltas = await readSegmentDeltas(chainBucket, segment)
|
||||
for (const { t, delta } of deltas) {
|
||||
if (!withinBudget()) {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
state = applySnapshotDelta(state, delta)
|
||||
replayed++
|
||||
// Intra-chain check, independent of the legacy copies — after cut-over the
|
||||
// recorded hash is the only witness (see versionEnvelopeHash).
|
||||
if (delta.hash !== versionEnvelopeHash(state)) mismatches.add(t)
|
||||
await compareToLegacy(state, t)
|
||||
}
|
||||
expectedSeq += segment.timestamps.length
|
||||
} catch (e: any) {
|
||||
errors.push({ timestamp: segment.timestamps[0], message: String(e?.message ?? e) })
|
||||
// A broken link invalidates every later state in this chain; the next keyframe
|
||||
// starts a fresh replay.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checked,
|
||||
replayed,
|
||||
reads,
|
||||
complete,
|
||||
mismatches: [...mismatches],
|
||||
errors,
|
||||
legacyReadFailures,
|
||||
}
|
||||
}
|
||||
|
||||
/** Record order is unstable between persists, so compare content and not serialization order. */
|
||||
function canonical(snapshot: RoomSnapshot): string {
|
||||
return JSON.stringify({
|
||||
clock: snapshot.clock,
|
||||
documentClock: snapshot.documentClock,
|
||||
// The schema decides how a restore migrates, so a reconstruction that dropped or swapped
|
||||
// it must read as a mismatch, not a pass.
|
||||
schema: canonicalJson(snapshot.schema ?? null),
|
||||
tombstoneHistoryStartsAtClock: snapshot.tombstoneHistoryStartsAtClock,
|
||||
tombstones: Object.fromEntries(Object.entries(snapshot.tombstones ?? {}).sort()),
|
||||
documents: [...snapshot.documents]
|
||||
.sort((a, b) => String(a.state.id).localeCompare(String(b.state.id)))
|
||||
.map((d) => [d.state.id, d.lastChangedClock, canonicalJson(d.state)]),
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyVersionChainRoute(
|
||||
request: IRequest,
|
||||
env: Environment,
|
||||
isApp: boolean
|
||||
): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
if (!roomId) return notFound()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
await requireAdminAccessToRequest(request, env)
|
||||
|
||||
if (isTestFile(roomId)) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
// R2 reads, not versions: an unbounded run walks into the per-invocation subrequest cap mid-way
|
||||
// and reports nothing. Capped below that limit rather than at it, to leave room for the reads
|
||||
// the last version in the budget is already spending.
|
||||
const requested = Number(request.query.limit ?? 200)
|
||||
const limit = Math.min(Number.isFinite(requested) && requested > 0 ? requested : 200, 900)
|
||||
const result = await verifyRoomVersions({
|
||||
chainBucket: env.ROOMS_HISTORY,
|
||||
legacyBucket: env.ROOMS_HISTORY_EPHEMERAL,
|
||||
roomKey: getR2KeyForRoom({ slug: roomId, isApp }),
|
||||
limit,
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
361
apps/dotcom/sync-worker/src/snapshotUtils.test.ts
Normal file
361
apps/dotcom/sync-worker/src/snapshotUtils.test.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { SerializedSchema } from '@tldraw/store'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import { createTLSchema } from '@tldraw/tlschema'
|
||||
import {
|
||||
canonicalJson,
|
||||
generateSnapshotChunks,
|
||||
getLastDocumentChangeClock,
|
||||
getSnapshotFingerprint,
|
||||
getSnapshotMetadata,
|
||||
isSameFingerprint,
|
||||
readPersistedFingerprint,
|
||||
resolvePersistedFingerprint,
|
||||
} from './snapshotUtils'
|
||||
|
||||
describe('generateSnapshotChunks', () => {
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
function chunksToString(chunks: Generator<Uint8Array>): string {
|
||||
let result = ''
|
||||
for (const chunk of chunks) {
|
||||
result += decoder.decode(chunk)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
test('generates valid JSON for empty snapshot', () => {
|
||||
const schema = createTLSchema()
|
||||
const snapshot: RoomSnapshot = {
|
||||
schema: schema.serialize(),
|
||||
clock: 0,
|
||||
documents: [],
|
||||
tombstones: {},
|
||||
}
|
||||
|
||||
const result = chunksToString(generateSnapshotChunks(snapshot))
|
||||
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed).toEqual(snapshot)
|
||||
})
|
||||
|
||||
test('generates valid JSON that can be parsed', () => {
|
||||
const schema = createTLSchema()
|
||||
const doc1 = schema.types.document.create({ name: 'Test Doc' })
|
||||
const doc2 = schema.types.page.create({ name: 'Page 1', index: 'a1' })
|
||||
|
||||
const snapshot: RoomSnapshot = {
|
||||
schema: schema.serialize(),
|
||||
clock: 10,
|
||||
documents: [
|
||||
{ state: doc1, lastChangedClock: 5 },
|
||||
{ state: doc2, lastChangedClock: 8 },
|
||||
],
|
||||
tombstones: {
|
||||
[doc1.id]: 3,
|
||||
},
|
||||
}
|
||||
|
||||
const result = chunksToString(generateSnapshotChunks(snapshot))
|
||||
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed).toEqual(snapshot)
|
||||
})
|
||||
|
||||
test('generates chunks correctly', () => {
|
||||
const schema = createTLSchema()
|
||||
const documentRecord = schema.types.document.create({ name: 'Test' })
|
||||
|
||||
const snapshot: RoomSnapshot = {
|
||||
schema: schema.serialize(),
|
||||
clock: 1,
|
||||
documents: [{ state: documentRecord, lastChangedClock: 1 }],
|
||||
tombstones: {},
|
||||
}
|
||||
|
||||
const chunks = generateSnapshotChunks(snapshot)
|
||||
const chunksArray = Array.from(chunks)
|
||||
|
||||
// Verify we get multiple chunks
|
||||
expect(chunksArray.length).toBeGreaterThan(1)
|
||||
|
||||
chunksArray.forEach((chunk) => {
|
||||
expect(chunk).toBeInstanceOf(Uint8Array)
|
||||
})
|
||||
|
||||
// Verify the combined result is valid JSON
|
||||
const result = chunksToString(generateSnapshotChunks(snapshot))
|
||||
expect(() => JSON.parse(result)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
function makeSnapshot(partial: Partial<RoomSnapshot>): RoomSnapshot {
|
||||
return {
|
||||
schema: createTLSchema().serialize(),
|
||||
documents: [],
|
||||
tombstones: {},
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function doc(id: string, lastChangedClock: number): RoomSnapshot['documents'][number] {
|
||||
return { state: { id: id as any, typeName: 'shape' }, lastChangedClock }
|
||||
}
|
||||
|
||||
describe('getLastDocumentChangeClock', () => {
|
||||
test('is 0 for an empty snapshot', () => {
|
||||
expect(getLastDocumentChangeClock(makeSnapshot({}))).toBe(0)
|
||||
})
|
||||
|
||||
test('is the max lastChangedClock across documents', () => {
|
||||
const snapshot = makeSnapshot({
|
||||
documents: [doc('a', 3), doc('b', 11), doc('c', 7)],
|
||||
})
|
||||
expect(getLastDocumentChangeClock(snapshot)).toBe(11)
|
||||
})
|
||||
|
||||
test('includes tombstone clocks, so a delete moves the clock', () => {
|
||||
const snapshot = makeSnapshot({
|
||||
documents: [doc('a', 3)],
|
||||
tombstones: { b: 12 },
|
||||
})
|
||||
expect(getLastDocumentChangeClock(snapshot)).toBe(12)
|
||||
})
|
||||
|
||||
test('includes tombstoneHistoryStartsAtClock, so pruning moves the clock', () => {
|
||||
const snapshot = makeSnapshot({
|
||||
documents: [doc('a', 3)],
|
||||
tombstones: {},
|
||||
tombstoneHistoryStartsAtClock: 15,
|
||||
})
|
||||
expect(getLastDocumentChangeClock(snapshot)).toBe(15)
|
||||
})
|
||||
|
||||
test('ignores the shared clock counter that object-lane (comment) writes bump', () => {
|
||||
// A comment write advances documentClock without touching any document, so two
|
||||
// snapshots that differ only in documentClock must report the same clock.
|
||||
const before = makeSnapshot({ documents: [doc('a', 10)], documentClock: 10 })
|
||||
const after = makeSnapshot({ documents: [doc('a', 10)], documentClock: 21, clock: 21 })
|
||||
expect(getLastDocumentChangeClock(after)).toBe(getLastDocumentChangeClock(before))
|
||||
expect(getLastDocumentChangeClock(after)).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
function schemaWithSequences(sequences: Record<string, number>): SerializedSchema {
|
||||
return { schemaVersion: 2, sequences }
|
||||
}
|
||||
|
||||
describe('getSnapshotFingerprint', () => {
|
||||
test('changes when a migration advances the schema but touches no record', () => {
|
||||
// migrateStorage always writes the migrated schema, but only calls storage.set for records
|
||||
// whose content actually changed — so a migration that applies to no record on this board
|
||||
// leaves every clock untouched. The clock alone reads that as a no-op, which
|
||||
// would leave the migrated schema unpersisted forever.
|
||||
const documents = [doc('a', 10)]
|
||||
const before = makeSnapshot({ documents, schema: schemaWithSequences({ store: 4 }) })
|
||||
const after = makeSnapshot({ documents, schema: schemaWithSequences({ store: 5 }) })
|
||||
|
||||
expect(getLastDocumentChangeClock(after)).toBe(getLastDocumentChangeClock(before))
|
||||
expect(isSameFingerprint(getSnapshotFingerprint(after), getSnapshotFingerprint(before))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('is unchanged for a snapshot that differs only in the shared clock counter', () => {
|
||||
const documents = [doc('a', 10)]
|
||||
const before = makeSnapshot({ documents, documentClock: 10 })
|
||||
const after = makeSnapshot({ documents, documentClock: 21, clock: 21 })
|
||||
expect(isSameFingerprint(getSnapshotFingerprint(after), getSnapshotFingerprint(before))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores key order in the serialized schema', () => {
|
||||
// Key order carries no meaning, and treating it as a change would re-upload every board.
|
||||
const a = makeSnapshot({ schema: schemaWithSequences({ x: 1, y: 2 }) })
|
||||
const b = makeSnapshot({ schema: { sequences: { y: 2, x: 1 }, schemaVersion: 2 } })
|
||||
expect(getSnapshotFingerprint(a).schemaHash).toBe(getSnapshotFingerprint(b).schemaHash)
|
||||
})
|
||||
|
||||
test('distinguishes a missing schema from any present one', () => {
|
||||
const missing = getSnapshotFingerprint(makeSnapshot({ schema: undefined }))
|
||||
const present = getSnapshotFingerprint(makeSnapshot({}))
|
||||
expect(missing.schemaHash).not.toBe(present.schemaHash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSameFingerprint', () => {
|
||||
test('never matches an unknown (null) fingerprint on either side', () => {
|
||||
const fingerprint = getSnapshotFingerprint(makeSnapshot({}))
|
||||
expect(isSameFingerprint(null, fingerprint)).toBe(false)
|
||||
expect(isSameFingerprint(fingerprint, null)).toBe(false)
|
||||
expect(isSameFingerprint(null, null)).toBe(false)
|
||||
})
|
||||
|
||||
test('does not match when only the document clock differs', () => {
|
||||
const a = getSnapshotFingerprint(makeSnapshot({ documents: [doc('a', 1)] }))
|
||||
const b = getSnapshotFingerprint(makeSnapshot({ documents: [doc('a', 2)] }))
|
||||
expect(isSameFingerprint(a, b)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSnapshotMetadata', () => {
|
||||
test('stamps documentClock but keeps it out of the fingerprint', () => {
|
||||
// documentClock is the counter shared with the object lane, so a comment write moves it
|
||||
// without changing a byte of the document. It is recorded for diagnostics only — comparing
|
||||
// on it would force an upload for every comment and defeat the dedupe entirely.
|
||||
const documents = [doc('shape:a', 10)]
|
||||
const beforeComment = makeSnapshot({ documents, documentClock: 10 })
|
||||
const afterComment = makeSnapshot({ documents, documentClock: 21, clock: 21 })
|
||||
|
||||
expect(getSnapshotMetadata(beforeComment).documentClock).toBe('10')
|
||||
expect(getSnapshotMetadata(afterComment).documentClock).toBe('21')
|
||||
expect(
|
||||
isSameFingerprint(getSnapshotFingerprint(afterComment), getSnapshotFingerprint(beforeComment))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('a stamp still matches after comments have moved the clock past it', async () => {
|
||||
// The rooms object keeps the clock of the last document write, so its stamp goes stale as
|
||||
// comments accumulate. Reading it back must still dedupe against the live snapshot.
|
||||
const documents = [doc('shape:a', 10)]
|
||||
const atUpload = makeSnapshot({ documents, documentClock: 10 })
|
||||
const afterComments = makeSnapshot({ documents, documentClock: 99, clock: 99 })
|
||||
|
||||
const read = await readPersistedFingerprint(
|
||||
{ head: async () => ({ customMetadata: getSnapshotMetadata(atUpload) }) } as any,
|
||||
'key'
|
||||
)
|
||||
|
||||
expect(isSameFingerprint(read, getSnapshotFingerprint(afterComments))).toBe(true)
|
||||
})
|
||||
|
||||
test('omits documentClock when the snapshot has no clock at all', () => {
|
||||
expect(getSnapshotMetadata(makeSnapshot({})).documentClock).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePersistedFingerprint', () => {
|
||||
// Each head() call returns the next entry, then repeats the last one.
|
||||
function countingBucket(results: Array<{ customMetadata?: Record<string, string> } | null>) {
|
||||
const state = { calls: 0 }
|
||||
const bucket = {
|
||||
head: async (_key: string) => results[Math.min(state.calls++, results.length - 1)],
|
||||
} as any
|
||||
return { bucket, state }
|
||||
}
|
||||
|
||||
test('does not re-read a stamp the failing persist itself just wrote', async () => {
|
||||
// The reported failure: a persist finds no stamp, writes the rooms object (stamping it),
|
||||
// then fails on the history upload. Its retry re-resolves. If that re-read R2 it would get
|
||||
// back its own fresh stamp, match the current fingerprint, take the skip path, and drop the
|
||||
// history entry it still owes.
|
||||
const snapshot = makeSnapshot({ documents: [doc('shape:a', 7)] })
|
||||
const stampedByTheFailedAttempt = getSnapshotFingerprint(snapshot)
|
||||
const { bucket, state } = countingBucket([
|
||||
null,
|
||||
{ customMetadata: getSnapshotMetadata(snapshot) },
|
||||
])
|
||||
|
||||
const firstAttempt = await resolvePersistedFingerprint(undefined, bucket, 'key')
|
||||
expect(firstAttempt).toBe(null)
|
||||
|
||||
const retry = await resolvePersistedFingerprint(firstAttempt, bucket, 'key')
|
||||
|
||||
expect(retry).toBe(null)
|
||||
expect(isSameFingerprint(retry, stampedByTheFailedAttempt)).toBe(false)
|
||||
expect(state.calls).toBe(1)
|
||||
})
|
||||
|
||||
test('reads R2 when nothing has been looked up yet', async () => {
|
||||
const snapshot = makeSnapshot({ documents: [doc('shape:a', 9)] })
|
||||
const fingerprint = getSnapshotFingerprint(snapshot)
|
||||
const { bucket, state } = countingBucket([{ customMetadata: getSnapshotMetadata(snapshot) }])
|
||||
|
||||
expect(await resolvePersistedFingerprint(undefined, bucket, 'key')).toEqual(fingerprint)
|
||||
expect(state.calls).toBe(1)
|
||||
})
|
||||
|
||||
test('returns an already-known fingerprint without reading R2', async () => {
|
||||
const fingerprint = getSnapshotFingerprint(makeSnapshot({ documents: [doc('shape:a', 3)] }))
|
||||
const { bucket, state } = countingBucket([null])
|
||||
|
||||
expect(await resolvePersistedFingerprint(fingerprint, bucket, 'key')).toEqual(fingerprint)
|
||||
expect(state.calls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readPersistedFingerprint', () => {
|
||||
function bucketWithHead(result: { customMetadata?: Record<string, string> } | null) {
|
||||
return { head: async (_key: string) => result } as any
|
||||
}
|
||||
|
||||
test('round-trips the fingerprint written by getSnapshotMetadata', async () => {
|
||||
const snapshot = makeSnapshot({ documents: [doc('a', 42)] })
|
||||
const written = getSnapshotFingerprint(snapshot)
|
||||
const customMetadata = getSnapshotMetadata(snapshot)
|
||||
|
||||
const read = await readPersistedFingerprint(bucketWithHead({ customMetadata }), 'key')
|
||||
|
||||
expect(read).toEqual(written)
|
||||
expect(read?.lastDocumentChangeClock).toBe(42)
|
||||
})
|
||||
|
||||
test('is null when the object does not exist', async () => {
|
||||
expect(await readPersistedFingerprint(bucketWithHead(null), 'key')).toBe(null)
|
||||
})
|
||||
|
||||
test('is null for a legacy object with no metadata', async () => {
|
||||
expect(await readPersistedFingerprint(bucketWithHead({}), 'key')).toBe(null)
|
||||
})
|
||||
|
||||
test('is null for malformed metadata', async () => {
|
||||
expect(
|
||||
await readPersistedFingerprint(
|
||||
bucketWithHead({
|
||||
customMetadata: { lastDocumentChangeClock: 'not-a-number', schemaHash: 'a' },
|
||||
}),
|
||||
'key'
|
||||
)
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
test('is null when the schema hash is absent, so the object is re-uploaded', async () => {
|
||||
expect(
|
||||
await readPersistedFingerprint(
|
||||
bucketWithHead({ customMetadata: { lastDocumentChangeClock: '42' } }),
|
||||
'key'
|
||||
)
|
||||
).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canonicalJson', () => {
|
||||
it('produces the same string for a live value and for its JSON round-trip', () => {
|
||||
const live = {
|
||||
b: undefined,
|
||||
a: 1,
|
||||
// Index 2 is a hole, which JSON writes as null just like the explicit undefined.
|
||||
nested: {
|
||||
z: undefined,
|
||||
y: Object.assign([1, undefined], { 3: 3 }),
|
||||
x: { fn: () => 1, ok: 'yes' },
|
||||
},
|
||||
when: new Date(0),
|
||||
nan: NaN,
|
||||
list: [{ q: undefined, p: 2 }],
|
||||
}
|
||||
const roundTripped = JSON.parse(JSON.stringify(live))
|
||||
|
||||
expect(canonicalJson(live)).toBe(canonicalJson(roundTripped))
|
||||
expect(canonicalJson(live)).toBe(
|
||||
'{"a":1,"list":[{"p":2}],"nan":null,"nested":{"x":{"ok":"yes"},"y":[1,null,null,3]},"when":"1970-01-01T00:00:00.000Z"}'
|
||||
)
|
||||
})
|
||||
|
||||
it('sorts keys at every depth and leaves clean JSON otherwise untouched', () => {
|
||||
const value = { b: { d: 1, c: [3, 2] }, a: null }
|
||||
|
||||
expect(canonicalJson(value)).toBe('{"a":null,"b":{"c":[3,2],"d":1}}')
|
||||
expect(canonicalJson(value)).toBe(JSON.stringify({ a: null, b: { c: [3, 2], d: 1 } }))
|
||||
})
|
||||
})
|
||||
225
apps/dotcom/sync-worker/src/snapshotUtils.ts
Normal file
225
apps/dotcom/sync-worker/src/snapshotUtils.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
|
||||
// R2 custom metadata keys describing the snapshot in the object body. Let a `head()` answer
|
||||
// "what is persisted?" without downloading the object.
|
||||
const LAST_DOCUMENT_CHANGE_CLOCK_METADATA_KEY = 'lastDocumentChangeClock'
|
||||
const SCHEMA_HASH_METADATA_KEY = 'schemaHash'
|
||||
// Diagnostic only — deliberately absent from SnapshotFingerprint, see getSnapshotMetadata.
|
||||
const DOCUMENT_CLOCK_METADATA_KEY = 'documentClock'
|
||||
|
||||
/**
|
||||
* Identifies the content of a snapshot. Two snapshots with equal fingerprints serialize to the
|
||||
* same document bytes, so re-uploading one over the other is pure waste.
|
||||
*
|
||||
* Not called a version: in this worker a "version" is an entry in the version cache — a
|
||||
* point-in-time snapshot the history UI lists and offers to restore. A fingerprint identifies
|
||||
* content, and many versions of a board can share one.
|
||||
*/
|
||||
export interface SnapshotFingerprint {
|
||||
lastDocumentChangeClock: number
|
||||
schemaHash: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest clock at which a document was changed, deleted (tombstones), or pruned
|
||||
* (tombstoneHistoryStartsAtClock) — i.e. the maximum `lastChangedClock` across the document lane.
|
||||
*
|
||||
* This is deliberately NOT `snapshot.documentClock`: that counter is shared with the object lane,
|
||||
* so a comment or reaction write bumps it without changing anything `getSnapshot()` returns.
|
||||
*/
|
||||
export function getLastDocumentChangeClock(snapshot: RoomSnapshot): number {
|
||||
let clock = snapshot.tombstoneHistoryStartsAtClock ?? 0
|
||||
for (const { lastChangedClock } of snapshot.documents) {
|
||||
if (lastChangedClock > clock) clock = lastChangedClock
|
||||
}
|
||||
if (snapshot.tombstones) {
|
||||
for (const tombstoneClock of Object.values(snapshot.tombstones)) {
|
||||
if (tombstoneClock > clock) clock = tombstoneClock
|
||||
}
|
||||
}
|
||||
return clock
|
||||
}
|
||||
|
||||
/**
|
||||
* A hash of the snapshot's serialized schema, tracked alongside the document clock because
|
||||
* `migrateStorage` writes a migrated schema but only calls `set` for records whose content
|
||||
* actually changed. A migration that applies to no record on this board therefore leaves every
|
||||
* clock untouched, and the clock alone would read that as nothing to persist — stranding the
|
||||
* migrated schema in the DO's SQLite while R2 keeps the old one forever.
|
||||
*/
|
||||
function getSchemaHash(snapshot: RoomSnapshot): string {
|
||||
return fnv1a64(canonicalJson(snapshot.schema))
|
||||
}
|
||||
|
||||
export function getSnapshotFingerprint(snapshot: RoomSnapshot): SnapshotFingerprint {
|
||||
return {
|
||||
lastDocumentChangeClock: getLastDocumentChangeClock(snapshot),
|
||||
schemaHash: getSchemaHash(snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The R2 metadata stamped on a persisted snapshot: the {@link SnapshotFingerprint} the next
|
||||
* persist compares against, plus `documentClock` recording the clock the object was written at.
|
||||
*
|
||||
* `documentClock` is deliberately not part of the fingerprint and no read path compares it. It is
|
||||
* the counter shared with the object lane, so a comment write moves it without changing a byte of
|
||||
* the document — comparing on it would force an upload per comment and defeat the dedupe. It is
|
||||
* stamped so a history entry records when it was taken; on the rooms object it therefore reads as
|
||||
* the clock at the last document write, not the room's current clock.
|
||||
*/
|
||||
export function getSnapshotMetadata(snapshot: RoomSnapshot): Record<string, string> {
|
||||
const fingerprint = getSnapshotFingerprint(snapshot)
|
||||
const metadata: Record<string, string> = {
|
||||
[LAST_DOCUMENT_CHANGE_CLOCK_METADATA_KEY]: String(fingerprint.lastDocumentChangeClock),
|
||||
[SCHEMA_HASH_METADATA_KEY]: fingerprint.schemaHash,
|
||||
}
|
||||
const documentClock = snapshot.documentClock ?? snapshot.clock
|
||||
if (documentClock !== undefined) {
|
||||
metadata[DOCUMENT_CLOCK_METADATA_KEY] = String(documentClock)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the fingerprint stamped on an R2 object, or null if the object is missing or predates
|
||||
* stamping. Null means "unknown" — callers must treat it as "not persisted" and write, never as
|
||||
* "up to date".
|
||||
*/
|
||||
export async function readPersistedFingerprint(
|
||||
bucket: R2Bucket,
|
||||
key: string
|
||||
): Promise<SnapshotFingerprint | null> {
|
||||
const head = await bucket.head(key)
|
||||
const rawClock = head?.customMetadata?.[LAST_DOCUMENT_CHANGE_CLOCK_METADATA_KEY]
|
||||
const schemaHash = head?.customMetadata?.[SCHEMA_HASH_METADATA_KEY]
|
||||
// A half-stamped object tells us nothing about the half that's missing.
|
||||
if (rawClock === undefined || !schemaHash) return null
|
||||
const lastDocumentChangeClock = Number(rawClock)
|
||||
if (!Number.isFinite(lastDocumentChangeClock)) return null
|
||||
return { lastDocumentChangeClock, schemaHash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the fingerprint stamped on an R2 object at most once, returning `known` untouched after
|
||||
* that. `undefined` means "not looked up yet"; `null` means "looked up, no usable stamp", and the
|
||||
* two must stay distinct.
|
||||
*
|
||||
* Looking it up a second time would let a persist observe its own partial write. A persist that
|
||||
* stamps the rooms object and then fails before writing the history entry retries with the
|
||||
* fingerprint it resolved beforehand; re-reading would hand it back its own fresh stamp, so it
|
||||
* would conclude everything was persisted and drop the history entry it still owes.
|
||||
*/
|
||||
export async function resolvePersistedFingerprint(
|
||||
known: SnapshotFingerprint | null | undefined,
|
||||
bucket: R2Bucket,
|
||||
key: string
|
||||
): Promise<SnapshotFingerprint | null> {
|
||||
if (known !== undefined) return known
|
||||
return await readPersistedFingerprint(bucket, key)
|
||||
}
|
||||
|
||||
/** Unknown (null) never matches, so an unstamped object is always re-uploaded. */
|
||||
export function isSameFingerprint(
|
||||
a: SnapshotFingerprint | null,
|
||||
b: SnapshotFingerprint | null
|
||||
): boolean {
|
||||
if (!a || !b) return false
|
||||
return a.lastDocumentChangeClock === b.lastDocumentChangeClock && a.schemaHash === b.schemaHash
|
||||
}
|
||||
|
||||
/**
|
||||
* `JSON.stringify` with object keys sorted, so a serializer emitting the same object in a
|
||||
* different key order cannot read as a change. (This is why `getHashForObject` from @tldraw/utils
|
||||
* isn't used: it hashes `JSON.stringify` output directly, so key order changes the result.)
|
||||
*
|
||||
* Follows `JSON.stringify`'s own rules for what survives: properties whose value is `undefined`, a
|
||||
* function or a symbol are omitted, such array items and holes become `null`, non-finite numbers
|
||||
* become `null`, and `toJSON` is honoured. That contract is the point — the version chain hashes a
|
||||
* snapshot from live objects when it is written and from decoded JSON when it is read back, and a
|
||||
* naive key-sorted walk emits `{"a":undefined}` on one side and `{}` on the other. For any value
|
||||
* `v`, `canonicalJson(v) === canonicalJson(JSON.parse(JSON.stringify(v)))`.
|
||||
*
|
||||
* A top-level `undefined` yields the string `'undefined'` rather than no string at all.
|
||||
*/
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return canonicalize(value) ?? 'undefined'
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): string | undefined {
|
||||
if (value !== null && typeof value === 'object' && typeof (value as any).toJSON === 'function') {
|
||||
value = (value as any).toJSON()
|
||||
}
|
||||
if (value === null || typeof value !== 'object') {
|
||||
// Covers primitives exactly as JSON.stringify does, including NaN → null and dropping
|
||||
// undefined, functions and symbols (for which it returns undefined).
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
// Array.from, not map: map skips holes, and a hole must serialize as null like JSON does.
|
||||
return `[${Array.from(value, (item) => canonicalize(item) ?? 'null').join(',')}]`
|
||||
}
|
||||
const entries: string[] = []
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const encoded = canonicalize((value as any)[key])
|
||||
if (encoded !== undefined) entries.push(`${JSON.stringify(key)}:${encoded}`)
|
||||
}
|
||||
return `{${entries.join(',')}}`
|
||||
}
|
||||
|
||||
// FNV-1a run with two offsets. Wider than @tldraw/utils' 32-bit getHashForString because a
|
||||
// collision here silently skips an upload that was owed; 64 bits makes that implausible. Staying
|
||||
// non-cryptographic is fine — this only answers "did the schema change?", and a false mismatch
|
||||
// costs one redundant upload rather than losing a migration.
|
||||
export function fnv1a64(value: string): string {
|
||||
let h1 = 0x811c9dc5
|
||||
let h2 = 0xc59d1c81
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const char = value.charCodeAt(i)
|
||||
h1 = Math.imul(h1 ^ char, 0x01000193)
|
||||
h2 = Math.imul(h2 ^ char, 0x85ebca6b)
|
||||
}
|
||||
return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
function toJsonString(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return 'null'
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
export function* generateSnapshotChunks(snapshot: RoomSnapshot): Generator<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
yield encoder.encode('{')
|
||||
|
||||
const keys = Object.keys(snapshot) as Array<keyof RoomSnapshot>
|
||||
let isFirstKey = true
|
||||
|
||||
for (const key of keys) {
|
||||
if (isFirstKey) {
|
||||
isFirstKey = false
|
||||
} else {
|
||||
yield encoder.encode(',')
|
||||
}
|
||||
|
||||
yield encoder.encode(`"${key}":`)
|
||||
|
||||
const value = snapshot[key]
|
||||
if (Array.isArray(value)) {
|
||||
yield encoder.encode('[')
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (i > 0) {
|
||||
yield encoder.encode(',')
|
||||
}
|
||||
yield encoder.encode(toJsonString(value[i]))
|
||||
}
|
||||
yield encoder.encode(']')
|
||||
} else {
|
||||
yield encoder.encode(toJsonString(value))
|
||||
}
|
||||
}
|
||||
|
||||
yield encoder.encode('}')
|
||||
}
|
||||
66
apps/dotcom/sync-worker/src/test/fakeR2.ts
Normal file
66
apps/dotcom/sync-worker/src/test/fakeR2.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
interface FakeObject {
|
||||
body: Uint8Array
|
||||
customMetadata: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Enough of R2 for the version chain read paths: prefix listing in key order with `include`,
|
||||
* `startAfter`, `get`, `put`, `head` and `delete`. Bodies are held as bytes because chain objects
|
||||
* are gzipped. Deliberately not a general R2 mock.
|
||||
*/
|
||||
export function createFakeR2(): R2Bucket {
|
||||
const objects = new Map<string, FakeObject>()
|
||||
|
||||
const toR2Object = (key: string, object: FakeObject, includeMetadata = true) => ({
|
||||
key,
|
||||
customMetadata: includeMetadata ? object.customMetadata : undefined,
|
||||
size: object.body.byteLength,
|
||||
// A real ReadableStream, so code that pipes `body` through a transform works unchanged.
|
||||
body: new Blob([object.body as unknown as BlobPart]).stream(),
|
||||
text: async () => new TextDecoder().decode(object.body),
|
||||
json: async () => JSON.parse(new TextDecoder().decode(object.body)),
|
||||
arrayBuffer: async () =>
|
||||
object.body.buffer.slice(
|
||||
object.body.byteOffset,
|
||||
object.body.byteOffset + object.body.byteLength
|
||||
),
|
||||
})
|
||||
|
||||
return {
|
||||
async put(key: string, body: any, options?: any) {
|
||||
objects.set(key, {
|
||||
body: typeof body === 'string' ? new TextEncoder().encode(body) : new Uint8Array(body),
|
||||
customMetadata: options?.customMetadata ?? {},
|
||||
})
|
||||
return toR2Object(key, objects.get(key)!) as any
|
||||
},
|
||||
async get(key: string) {
|
||||
const object = objects.get(key)
|
||||
return object ? (toR2Object(key, object) as any) : null
|
||||
},
|
||||
async head(key: string) {
|
||||
const object = objects.get(key)
|
||||
return object ? (toR2Object(key, object) as any) : null
|
||||
},
|
||||
async delete(keys: string | string[]) {
|
||||
for (const key of Array.isArray(keys) ? keys : [keys]) objects.delete(key)
|
||||
},
|
||||
async list(options?: any) {
|
||||
const matching = [...objects.keys()]
|
||||
.filter((key) => (options?.prefix ? key.startsWith(options.prefix) : true))
|
||||
.filter((key) => (options?.startAfter ? key > options.startAfter : true))
|
||||
// The cursor is the previous page's last key, so paging code driven by `truncated`
|
||||
// actually advances instead of looping on page one forever.
|
||||
.filter((key) => (options?.cursor ? key > options.cursor : true))
|
||||
.sort()
|
||||
const limit = options?.limit ?? 1000
|
||||
const page = matching.slice(0, limit)
|
||||
const includeMetadata = options?.include?.includes('customMetadata') ?? false
|
||||
return {
|
||||
objects: page.map((key) => toR2Object(key, objects.get(key)!, includeMetadata)),
|
||||
truncated: matching.length > page.length,
|
||||
cursor: page[page.length - 1],
|
||||
} as any
|
||||
},
|
||||
} as unknown as R2Bucket
|
||||
}
|
||||
150
apps/dotcom/sync-worker/src/testRoutes.ts
Normal file
150
apps/dotcom/sync-worker/src/testRoutes.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { TlaUserPartial } from '@tldraw/dotcom-shared'
|
||||
import { DEFAULT_INITIAL_SNAPSHOT } from '@tldraw/sync-core'
|
||||
import { IndexKey, lns, uniqueId } from '@tldraw/utils'
|
||||
import { createRouter, notFound } from '@tldraw/worker-shared'
|
||||
import { createPostgresConnectionPool } from './postgres'
|
||||
import { getR2KeyForRoom, getR2KeyForSnapshot } from './r2'
|
||||
import {
|
||||
deleteEvalsFixtureSession,
|
||||
evalsFixtureMcp,
|
||||
planEvalsFixtureScreenshots,
|
||||
putEvalsFixtureBoard,
|
||||
} from './routes/tla/evalsLocalMcp'
|
||||
import { isDebugLogging, type Environment } from './types'
|
||||
import { getFileEffectProcessor, getRoomDurableObject } from './utils/durableObjects'
|
||||
|
||||
interface CreateLegacyRoomBody {
|
||||
slug?: string
|
||||
readonlySlug?: string
|
||||
legacyReadonlySlug?: string
|
||||
snapshotSlug?: string
|
||||
historyTimestamp?: string
|
||||
}
|
||||
|
||||
export const testRoutes = createRouter<Environment>()
|
||||
.all('/app/__test__/*', (_, env) => {
|
||||
if (!isDebugLogging(env)) return notFound()
|
||||
return undefined
|
||||
})
|
||||
// Per-test DB isolation for the e2e suites: reset the user's prefs and delete every
|
||||
// workspace they belong to (FK cascades take group_user, group_file and owned files;
|
||||
// the file deletes flow through the effect outbox like any other delete), then recreate
|
||||
// the home workspace. Local-only — isDebugLogging above also passes on preview, which is
|
||||
// a real shared deployment.
|
||||
.post('/app/__test__/user/:userId/prepare-for-test', async (req, env) => {
|
||||
if (env.IS_LOCAL !== 'true') return notFound()
|
||||
const userId = req.params.userId
|
||||
const db = createPostgresConnectionPool(env, '/app/__test__/prepare-for-test')
|
||||
try {
|
||||
await db.transaction().execute(async (tx) => {
|
||||
const user = await tx
|
||||
.selectFrom('user')
|
||||
.where('id', '=', userId)
|
||||
.select('id')
|
||||
.executeTakeFirst()
|
||||
if (!user) return
|
||||
|
||||
await tx
|
||||
.updateTable('user')
|
||||
.set({
|
||||
flags: '',
|
||||
allowAnalyticsCookie: null,
|
||||
enhancedA11yMode: null,
|
||||
colorScheme: null,
|
||||
locale: null,
|
||||
exportBackground: true,
|
||||
exportPadding: true,
|
||||
exportFormat: 'png',
|
||||
inputMode: null,
|
||||
} satisfies Omit<TlaUserPartial, 'id'>)
|
||||
.where('id', '=', userId)
|
||||
.execute()
|
||||
|
||||
const userGroups = await tx
|
||||
.selectFrom('group_user')
|
||||
.where('userId', '=', userId)
|
||||
.select('groupId')
|
||||
.execute()
|
||||
const groupIds = userGroups.map((g) => g.groupId)
|
||||
if (groupIds.length > 0) {
|
||||
await tx.deleteFrom('group').where('id', 'in', groupIds).execute()
|
||||
}
|
||||
|
||||
await tx
|
||||
.insertInto('group')
|
||||
.values({
|
||||
id: userId,
|
||||
name: 'My workspace',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
isDeleted: false,
|
||||
inviteSecret: null,
|
||||
})
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute()
|
||||
await tx
|
||||
.insertInto('group_user')
|
||||
.values({
|
||||
userId,
|
||||
groupId: userId,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
role: 'owner',
|
||||
index: 'a1' as IndexKey,
|
||||
userColor: '',
|
||||
userName: '',
|
||||
})
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute()
|
||||
})
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
// Best-effort nudge so the deleted files' outbox effects (session kicks, R2 cleanup)
|
||||
// land before the next test rather than on the 30s sweep.
|
||||
await getFileEffectProcessor(env)
|
||||
.poke()
|
||||
.catch(() => {})
|
||||
return new Response('ok')
|
||||
})
|
||||
.post('/app/__test__/evals/plan', planEvalsFixtureScreenshots)
|
||||
.put('/app/__test__/evals/sessions/:sessionId/boards/:boardId', putEvalsFixtureBoard)
|
||||
.post('/app/__test__/evals/sessions/:sessionId/mcp', evalsFixtureMcp)
|
||||
.delete('/app/__test__/evals/sessions/:sessionId', deleteEvalsFixtureSession)
|
||||
.post('/app/__test__/legacy-room', async (req, env) => {
|
||||
const body = (await req.json().catch(() => ({}))) as CreateLegacyRoomBody
|
||||
const slug = body.slug ?? uniqueId()
|
||||
const readonlySlug = body.readonlySlug ?? uniqueId()
|
||||
const legacyReadonlySlug = body.legacyReadonlySlug ?? uniqueId()
|
||||
const legacyReadonlyActualSlug = lns(legacyReadonlySlug)
|
||||
const snapshotSlug = body.snapshotSlug ?? `v2_c_${uniqueId()}`
|
||||
const historyTimestamp = body.historyTimestamp ?? new Date().toISOString()
|
||||
const snapshot = DEFAULT_INITIAL_SNAPSHOT
|
||||
|
||||
await Promise.all([
|
||||
getRoomDurableObject(env, slug).__admin__createLegacyRoom(slug),
|
||||
getRoomDurableObject(env, legacyReadonlyActualSlug).__admin__createLegacyRoom(
|
||||
legacyReadonlyActualSlug
|
||||
),
|
||||
env.SLUG_TO_READONLY_SLUG.put(slug, readonlySlug),
|
||||
env.READONLY_SLUG_TO_SLUG.put(readonlySlug, slug),
|
||||
env.ROOMS_HISTORY_EPHEMERAL.put(
|
||||
`${getR2KeyForRoom({ slug, isApp: false })}/${historyTimestamp}`,
|
||||
JSON.stringify(snapshot)
|
||||
),
|
||||
env.SNAPSHOT_SLUG_TO_PARENT_SLUG.put(snapshotSlug, slug),
|
||||
env.ROOM_SNAPSHOTS.put(
|
||||
getR2KeyForSnapshot({ parentSlug: slug, snapshotSlug, isApp: false }),
|
||||
JSON.stringify({ parent_slug: slug, drawing: snapshot })
|
||||
),
|
||||
])
|
||||
|
||||
return Response.json({
|
||||
slug,
|
||||
readonlySlug,
|
||||
legacyReadonlySlug,
|
||||
snapshotSlug,
|
||||
historyTimestamp,
|
||||
})
|
||||
})
|
||||
.all('*', notFound)
|
||||
358
apps/dotcom/sync-worker/src/types.ts
Normal file
358
apps/dotcom/sync-worker/src/types.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
// https://developers.cloudflare.com/analytics/analytics-engine/
|
||||
|
||||
import { Queue } from '@cloudflare/workers-types'
|
||||
import { RoomSnapshot } from '@tldraw/sync-core'
|
||||
import type { TLFileDurableObject } from './TLFileDurableObject'
|
||||
import type { TLFileEffectProcessor } from './TLFileEffectProcessor'
|
||||
import type { TLLoggerDurableObject } from './TLLoggerDurableObject'
|
||||
import type { KeyframeReason } from './versionChain'
|
||||
|
||||
// The Browser Rendering binding's Quick Actions method. Cloudflare exposes `env.BROWSER.quickAction`
|
||||
// so a Worker can call the Quick Actions endpoints (`screenshot`, `pdf`, …) straight through the
|
||||
// binding — no API token, no @cloudflare/puppeteer. Requires compatibility_date >= 2026-03-24. Not
|
||||
// in @cloudflare/workers-types yet, so the small surface we use is declared here; it resolves to a
|
||||
// standard `Response` (PNG bytes for `screenshot`, with an `X-Browser-Ms-Used` header).
|
||||
export interface BrowserBinding {
|
||||
quickAction(action: 'screenshot', options: unknown): Promise<Response>
|
||||
}
|
||||
|
||||
// This type isn't available in @cloudflare/workers-types yet
|
||||
export interface Analytics {
|
||||
writeDataPoint(data: {
|
||||
blobs?: string[]
|
||||
doubles?: number[]
|
||||
indexes?: [string] // only one here
|
||||
}): void
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
// bindings
|
||||
TLDR_DOC: DurableObjectNamespace<TLFileDurableObject>
|
||||
TL_FILE_EFFECTS: DurableObjectNamespace<TLFileEffectProcessor>
|
||||
TL_LOGGER: DurableObjectNamespace<TLLoggerDurableObject>
|
||||
|
||||
BOTCOM_POSTGRES_CONNECTION_STRING: string
|
||||
BOTCOM_POSTGRES_POOLED_CONNECTION_STRING: string
|
||||
|
||||
DISCORD_FEEDBACK_WEBHOOK_URL?: string
|
||||
PLAIN_API_KEY?: string
|
||||
PLAIN_LABEL_TYPE_ID?: string
|
||||
PLAIN_WORKSPACE_ID?: string
|
||||
|
||||
MEASURE: Analytics | undefined
|
||||
|
||||
// Workers Static Assets binding — reads the committed default welcome snapshot
|
||||
// (assets/welcome-snapshot.json) at seed time; the worker never serves the directory
|
||||
// publicly (run_worker_first is set).
|
||||
ASSETS: Fetcher
|
||||
|
||||
ROOMS: R2Bucket
|
||||
ROOMS_HISTORY_EPHEMERAL: R2Bucket
|
||||
// Delta chains. ROOMS_HISTORY_EPHEMERAL keeps every version written before cut-over, so both
|
||||
// buckets stay on the read path until the standing history is compacted.
|
||||
ROOMS_HISTORY: R2Bucket
|
||||
|
||||
ROOM_SNAPSHOTS: R2Bucket
|
||||
SNAPSHOT_SLUG_TO_PARENT_SLUG: KVNamespace
|
||||
|
||||
UPLOADS: R2Bucket
|
||||
|
||||
SLUG_TO_READONLY_SLUG: KVNamespace
|
||||
READONLY_SLUG_TO_SLUG: KVNamespace
|
||||
|
||||
FEATURE_FLAGS: KVNamespace
|
||||
|
||||
CF_VERSION_METADATA: WorkerVersionMetadata
|
||||
|
||||
// env vars
|
||||
SUPABASE_URL: string | undefined
|
||||
SUPABASE_KEY: string | undefined
|
||||
|
||||
CLERK_SECRET_KEY: string | undefined
|
||||
CLERK_PUBLISHABLE_KEY: string | undefined
|
||||
|
||||
TLDRAW_ENV: string | undefined
|
||||
SENTRY_DSN: string | undefined
|
||||
SENTRY_CSP_REPORT_URI: string | undefined
|
||||
IS_LOCAL: string | undefined
|
||||
WORKER_NAME: string | undefined
|
||||
ASSET_UPLOAD_ORIGIN: string | undefined
|
||||
USER_CONTENT_URL: string | undefined
|
||||
MULTIPLAYER_SERVER: string | undefined
|
||||
|
||||
HEALTH_CHECK_BEARER_TOKEN: string | undefined
|
||||
HEALTH_CHECK_DB_SIZE_THRESHOLD_GB: string | undefined
|
||||
HEALTH_CHECK_CHANGELOG_SIZE_THRESHOLD_MB: string | undefined
|
||||
HEALTH_CHECK_WAL_SIZE_THRESHOLD_MB: string | undefined
|
||||
|
||||
ANALYTICS_API_URL: string | undefined
|
||||
ANALYTICS_API_TOKEN: string | undefined
|
||||
|
||||
RATE_LIMITER: RateLimit
|
||||
// Rate limit bindings for the Browser Run-backed MCP screenshot tool, declared in wrangler.toml.
|
||||
// All three bound what an agent calling the public MCP endpoint can spend; board thumbnail
|
||||
// rendering is subject to none of them. Separate bindings because a binding carries one `limit`
|
||||
// applied per key, so budgets with different numbers cannot share one. The route falls back to an
|
||||
// isolate-local guard when they are absent (local dev, tests).
|
||||
/** One per-account budget (`user:`) across every Browser Run-spending MCP tool. */
|
||||
MCP_SCREENSHOT_RATE_LIMITER: RateLimit | undefined
|
||||
/** Per-board Browser Run captures, applied only on cache misses. Measures are not counted here. */
|
||||
MCP_SERVER_BOARD_RATE_LIMITER: RateLimit | undefined
|
||||
/** Total Browser Run sessions the tools spend, captures and measures alike, on one shared key. */
|
||||
MCP_SERVER_BROWSER_RATE_LIMITER: RateLimit | undefined
|
||||
/** Per-account `search_boards` calls. Bounds Postgres, not Browser Run, which search never spends. */
|
||||
MCP_SERVER_SEARCH_RATE_LIMITER: RateLimit | undefined
|
||||
|
||||
QUEUE: Queue<QueueMessage>
|
||||
|
||||
// R2 cache for board OG images (`og/…` keys), their pending-render markers and render token
|
||||
// records. None of those keys carry a version, so a board costs one object per key however often it
|
||||
// re-renders, nothing accumulates, and the bucket must have NO expiration rule — the current
|
||||
// thumbnail has to outlive any lifecycle window.
|
||||
// Optional so tests and unconfigured environments degrade to cacheless rendering.
|
||||
THUMBNAILS: R2Bucket | undefined
|
||||
|
||||
// R2 storage for MCP tool output (`mcp/…` keys, screenshots today). Its own bucket rather than a
|
||||
// prefix inside THUMBNAILS because these keys include the board's content version, so the set grows
|
||||
// without bound and needs the expiration rule that would be actively wrong on THUMBNAILS. See "Why
|
||||
// two buckets" in browser-run-thumbnails.md.
|
||||
// Optional on the same terms as THUMBNAILS.
|
||||
MCP_DATA_BUCKET: R2Bucket | undefined
|
||||
|
||||
// Cloudflare Browser Rendering binding. The worker takes thumbnails by calling the binding's
|
||||
// `quickAction` Quick Actions method (e.g. `screenshot`) directly — no @cloudflare/puppeteer and
|
||||
// no API token. Chrome runs in Cloudflare's fleet, not in this isolate. The dev binding is
|
||||
// deliberately NOT marked `remote` (that would make plain `wrangler dev` require a
|
||||
// CLOUDFLARE_API_TOKEN, breaking the credential-free e2e stack), so under `wrangler dev` it is a
|
||||
// non-functional local binding and the render path fails closed; real local captures need
|
||||
// `wrangler dev --remote` with credentials or a preview deploy. Undefined in tests.
|
||||
BROWSER: BrowserBinding | undefined
|
||||
// Kill switch for the MCP screenshot server (POST /app/mcp). Absent means enabled, so an
|
||||
// environment that never configured it behaves as it did before the flag existed. Anything other
|
||||
// than 'true' turns the endpoint off, so a typo fails in the safe direction. Editing this var in
|
||||
// the Cloudflare dashboard takes the server down without a rebuild or a code deploy — but the
|
||||
// next deploy restores the wrangler.toml value, so follow an emergency flip with a config change.
|
||||
MCP_SERVER_ENABLED: string | undefined
|
||||
/**
|
||||
* Whether `search_boards` will match on board names. Unset means yes, so previews, local dev and
|
||||
* tests keep working; production sets it to "false" while the unindexed `ILIKE` scan it drives is
|
||||
* still being watched.
|
||||
*/
|
||||
MCP_SEARCH_NAME_MATCHING_ENABLED: string | undefined
|
||||
// Origin serving the client thumbnail render page (THUMBNAIL_RENDER_PATH). Set per
|
||||
// environment in wrangler.toml.
|
||||
MCP_SCREENSHOT_RENDER_ORIGIN: string | undefined
|
||||
// 'true' opts MCP screenshots into live-canvas capture: the page prunes the canvas to the
|
||||
// requested shapes, settles and fits, and the screenshotting browser rasterizes it instead of
|
||||
// the page exporting itself. Skips the export phase, the expensive part on heavy boards, at the
|
||||
// cost of the export path's pixel-exact sizing. Off everywhere it is unset.
|
||||
THUMBNAIL_RENDER_LIVE_CAPTURE: string | undefined
|
||||
// HMAC secret for short-lived thumbnail render job tokens.
|
||||
MCP_SCREENSHOT_TOKEN_SECRET: string | undefined
|
||||
// The MCP server's public URL, and the resource identifier it advertises in RFC 9728 protected
|
||||
// resource metadata and in the `WWW-Authenticate` challenge. Not compared against anything on an
|
||||
// incoming token: Clerk stamps no `aud`, so there is no audience binding to check — see
|
||||
// authenticateMcpRequest for what stands in for one. Set per environment in wrangler.toml;
|
||||
// previews have no vars block there, so deploy-dotcom.ts injects it as a deploy var. Left unset,
|
||||
// it is derived from the request's own origin, which is fine locally and wrong anywhere a Host
|
||||
// header can be forged — see getMcpResourceUrl.
|
||||
MCP_SERVER_URL: string | undefined
|
||||
// Overrides the OAuth authorization server advertised to MCP clients. Normally unset: the value is
|
||||
// derived from CLERK_PUBLISHABLE_KEY so it cannot drift from the instance whose tokens we verify.
|
||||
MCP_OAUTH_AUTHORIZATION_SERVER: string | undefined
|
||||
// Development only: a local HTTP screenshot service to use instead of the BROWSER binding, which
|
||||
// cannot reach Browser Run in local dev. Set in [env.dev.vars] to the client dev server's
|
||||
// screenshot endpoint; unset everywhere else, which is what keeps deployed environments on
|
||||
// Browser Run.
|
||||
LOCAL_SCREENSHOT_SERVICE_URL: string | undefined
|
||||
}
|
||||
|
||||
export function isDebugLogging(env: Environment) {
|
||||
return env.TLDRAW_ENV === 'development' || env.TLDRAW_ENV === 'preview'
|
||||
}
|
||||
|
||||
/**
|
||||
* The word a boolean-ish env var holds: trimmed, lowercased, with unset and empty folded together.
|
||||
* Used by MCP_SERVER_ENABLED. Kept as a shared helper rather than inlined so a second
|
||||
* boolean-ish var cannot arrive parsing its value differently — each call site keeps its own
|
||||
* fail-safe direction, this owns what a value *is*.
|
||||
*/
|
||||
export function envFlagWord(value: string | undefined): string | undefined {
|
||||
const word = value?.trim().toLowerCase()
|
||||
return word ? word : undefined
|
||||
}
|
||||
|
||||
export interface DBLoadResult {
|
||||
snapshot: RoomSnapshot
|
||||
roomSizeMB: number
|
||||
}
|
||||
|
||||
// Events written by TLFileDurableObject. None of them carry a room id: the object serves exactly
|
||||
// one room, and its `writeEvent` indexes every data point on that object's durable object id. A
|
||||
// roomId here would only ever restate what the object already knows, while implying call sites can
|
||||
// attribute an event to some other room.
|
||||
export type TLServerEvent =
|
||||
| {
|
||||
type: 'client'
|
||||
name: 'room_create' | 'room_reopen' | 'enter' | 'leave' | 'last_out'
|
||||
instanceId: string
|
||||
// `enter` only: the client bundle's build timestamp from the `?v=` connect param,
|
||||
// so bundle age is queryable per connect. Absent = a bundle from before the param,
|
||||
// or a param that didn't validate as an epoch-ms number.
|
||||
clientBuildTimestamp?: string
|
||||
}
|
||||
| {
|
||||
type: 'client'
|
||||
name: 'rate_limited'
|
||||
userId: string | undefined
|
||||
}
|
||||
| {
|
||||
type: 'room'
|
||||
name:
|
||||
| 'failed_load_from_db'
|
||||
| 'failed_persist_to_db'
|
||||
| 'failed_persist_comments_to_db'
|
||||
| 'comment_author_deleted_prune'
|
||||
| 'comment_thread_emptied_prune'
|
||||
| 'comment_soft_delete_prune'
|
||||
| 'comment_reaction_orphan_prune'
|
||||
| 'room_empty'
|
||||
| 'fail_persist'
|
||||
}
|
||||
| {
|
||||
type: 'room'
|
||||
name: 'room_start'
|
||||
/**
|
||||
* How many hibernated sockets this boot resumed. Zero means a cold boot, and anything
|
||||
* higher means the durable object woke with clients still attached — which nothing else
|
||||
* in the dataset distinguishes, since both emit the same `room_start`.
|
||||
*/
|
||||
resumedSockets: number
|
||||
}
|
||||
// Discriminated on `wrote`: only a keyframe carries the reason that forced it.
|
||||
| ({
|
||||
type: 'version_chain_write'
|
||||
bytes: number
|
||||
depth: number
|
||||
} & ({ wrote: 'keyframe'; reason: KeyframeReason } | { wrote: 'delta' }))
|
||||
// Discriminated on `outcome`: only a failure or a skip carries a reason.
|
||||
| ({
|
||||
/** A cadence keyframe retired a chain; did that chain reconstruct the state it claims? */
|
||||
type: 'version_chain_verify'
|
||||
} & (
|
||||
| { outcome: 'ok' }
|
||||
| { outcome: 'fail'; reason: 'missing' | 'legacy-fallback' | 'head-mismatch' | 'error' }
|
||||
// Not a failure: the chain was left unchecked because reconstructing it here would risk
|
||||
// the isolate's memory. Carries the keyframe size so the threshold can be tuned.
|
||||
| { outcome: 'skipped'; reason: 'keyframe-size'; keyframeBytes: number }
|
||||
))
|
||||
| {
|
||||
/** A chain write failed in dual mode and was swallowed so the persist could complete. */
|
||||
type: 'version_chain_error'
|
||||
}
|
||||
| {
|
||||
type: 'send_message'
|
||||
messageType: string
|
||||
messageLength: number
|
||||
}
|
||||
| {
|
||||
type: 'persist_success'
|
||||
attempts: number
|
||||
/**
|
||||
* Whether this board is link-shared. The shared fraction of *actively edited* boards is what
|
||||
* sizes thumbnail spend, and nothing else can answer it: Postgres knows which files are shared
|
||||
* but not which are being edited, and this event's index is one-way, so the dataset cannot be
|
||||
* joined back to a file row.
|
||||
*
|
||||
* `unknown` is an app file whose record has not loaded yet; `legacy` a non-app room, which has
|
||||
* no shareable board identity; `deleted` an app file whose record has been deleted. All three
|
||||
* stay distinct from `private` so the denominator is honest, and all are computed by
|
||||
* `getBoardRenderState`, which is also what gates the render.
|
||||
*/
|
||||
sharedState: 'shared' | 'private' | 'unknown' | 'legacy' | 'deleted'
|
||||
}
|
||||
|
||||
export interface AssetUploadQueueMessage {
|
||||
type: 'asset-upload'
|
||||
objectName: string
|
||||
fileId: string
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The two kinds of publicly viewable board the thumbnail/OG screenshot surfaces render:
|
||||
* `published` is a frozen tldraw.com/p/:slug snapshot; `shared_file` is the live snapshot of an
|
||||
* anonymously-shared tldraw.com/f/:slug file.
|
||||
*/
|
||||
export type ThumbnailBoardKind = 'published' | 'shared_file'
|
||||
|
||||
/**
|
||||
* Which board, in which namespace. Everything that keys, enqueues, or cleans up a thumbnail takes
|
||||
* this and nothing more — the pair is what a cache key is built from, so a function that took only a
|
||||
* slug could silently address the wrong board's image.
|
||||
*/
|
||||
export interface ThumbnailBoardRef {
|
||||
kind: ThumbnailBoardKind
|
||||
slug: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Which page of which board a stored MCP cluster index belongs to. The object it is stored in is
|
||||
* already the board's file, so this addresses a page within that. See mcpClusterIndexStorage.ts.
|
||||
*/
|
||||
export interface McpClusterIndexKey {
|
||||
kind: ThumbnailBoardKind
|
||||
pageId: string
|
||||
/** The board's content version, so an index is only read back for the content it was built from. */
|
||||
version: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a board a caller is entitled to. `public` is the anonymous gate: the board must be
|
||||
* shared via link. `render` is for generating a thumbnail we will store but not necessarily serve
|
||||
* publicly, so it only requires that the board exists and has content.
|
||||
*
|
||||
* Required at every call site rather than defaulted: a default would be wrong for half of them, and
|
||||
* silence is the wrong way to pick a gate. It also rides inside the signed render job, so the gate a
|
||||
* board was resolved under is the same one the snapshot route applies when it is read.
|
||||
*/
|
||||
export type ThumbnailBoardAccess = 'public' | 'render'
|
||||
|
||||
/**
|
||||
* Which pipeline asked for a render. `og` covers the social-preview route and its queue consumer;
|
||||
* `mcp` is the board screenshot MCP server.
|
||||
*
|
||||
* Unlike `OgImageRenderReason` this is not telemetry — it is signed into the render job and
|
||||
* namespaces the minted-token record, so two surfaces rendering the same board at the same time do
|
||||
* not overwrite each other's proof of mint. See `recordMintedRenderToken`.
|
||||
*/
|
||||
export type ThumbnailRenderSurface = 'og' | 'mcp'
|
||||
|
||||
// What prompted a board thumbnail render, so renders can be attributed to the thing that asked for
|
||||
// them. `publish` and `edit` are the trigger producers; `crawler` is the OG route's published-board
|
||||
// repair (`repairMissingPublishedImage`) and doubles as the fallback for a queued message that
|
||||
// carries no reason of its own. Telemetry with one exception: when a job burns its whole retry
|
||||
// budget, the consumer arms the repair cooldown only if a crawler asked (see `retryOrDrop`), so
|
||||
// traffic an outside caller controls cannot re-arm the retry chain on a board that just proved it
|
||||
// cannot render.
|
||||
export type OgImageRenderReason = 'crawler' | 'publish' | 'edit'
|
||||
|
||||
// Asks the queue consumer to render a board's OG image through Browser Run and refresh the R2
|
||||
// cache read by GET /app/social-preview/:prefix/:slug/image. Board state (share gate, content
|
||||
// version) is deliberately not carried in the message; the consumer re-resolves it at render time.
|
||||
export interface OgImageRenderQueueMessage {
|
||||
type: 'og-image-render'
|
||||
kind: ThumbnailBoardKind
|
||||
slug: string
|
||||
// Optional only because a message may already be in the queue without one; every producer sets it.
|
||||
reason?: OgImageRenderReason
|
||||
/**
|
||||
* Set on a job the consumer enqueued for itself, having found a `published` board changed while it
|
||||
* was capturing — the one kind whose dropped ask nothing re-asks for. Shared files never get one:
|
||||
* the DO's debounce alarm re-asks by construction (see enqueueFollowUpIfBoardMoved). A follow-up
|
||||
* never spawns another: a board republished without pause would otherwise render continuously.
|
||||
*/
|
||||
followUp?: boolean
|
||||
}
|
||||
|
||||
export type QueueMessage = AssetUploadQueueMessage | OgImageRenderQueueMessage
|
||||
159
apps/dotcom/sync-worker/src/undeleteFile.test.ts
Normal file
159
apps/dotcom/sync-worker/src/undeleteFile.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { TlaFile } from '@tldraw/dotcom-shared'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { undeleteFile } from './undeleteFile'
|
||||
|
||||
function makeFile(overrides: Partial<TlaFile> = {}): TlaFile {
|
||||
return {
|
||||
id: 'file-1',
|
||||
name: 'My file',
|
||||
owningGroupId: 'group-9',
|
||||
ownerName: '',
|
||||
thumbnail: '',
|
||||
shared: true,
|
||||
sharedLinkType: 'edit',
|
||||
published: false,
|
||||
lastPublished: 0,
|
||||
publishedSlug: '',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
isEmpty: false,
|
||||
isDeleted: true,
|
||||
createSource: undefined,
|
||||
...overrides,
|
||||
} as TlaFile
|
||||
}
|
||||
|
||||
// Stubs the chains undeleteFile uses:
|
||||
// selectFrom('file').where().selectAll().executeTakeFirst()
|
||||
// selectFrom('group').select().where().executeTakeFirst()
|
||||
// updateTable('file').set().where().execute()
|
||||
// insertInto(table).values().onConflict().execute()
|
||||
function makeFakeDb(
|
||||
fileRow: TlaFile | undefined,
|
||||
opts: {
|
||||
// Use 'none' to simulate a missing group row; omit for a live (isDeleted: false) group.
|
||||
groupRow?: { isDeleted: boolean } | 'none'
|
||||
} = {}
|
||||
) {
|
||||
const { groupRow = { isDeleted: false } } = opts
|
||||
const resolvedGroupRow = groupRow === 'none' ? undefined : groupRow
|
||||
const updates: Array<{ table: string; values: any }> = []
|
||||
const inserts: Array<{ table: string; values: any }> = []
|
||||
let transactionCount = 0
|
||||
const db: any = {
|
||||
transaction: () => ({
|
||||
execute: async (cb: (tx: any) => Promise<any>) => {
|
||||
transactionCount++
|
||||
return cb(db)
|
||||
},
|
||||
}),
|
||||
selectFrom: (table: string) => {
|
||||
if (table === 'file') {
|
||||
return {
|
||||
where: () => ({
|
||||
selectAll: () => ({ executeTakeFirst: async () => fileRow }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'group') {
|
||||
return {
|
||||
select: () => ({
|
||||
where: () => ({ executeTakeFirst: async () => resolvedGroupRow }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected selectFrom('${table}')`)
|
||||
},
|
||||
updateTable: (table: string) => ({
|
||||
set: (values: any) => ({
|
||||
where: () => ({
|
||||
execute: async () => {
|
||||
updates.push({ table, values })
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insertInto: (table: string) => ({
|
||||
values: (values: any) => ({
|
||||
onConflict: () => ({
|
||||
execute: async () => {
|
||||
inserts.push({ table, values })
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
return { db: db as any, updates, inserts, getTransactionCount: () => transactionCount }
|
||||
}
|
||||
|
||||
describe('undeleteFile', () => {
|
||||
it('returns not_found for a missing file and writes nothing', async () => {
|
||||
const { db, updates, inserts } = makeFakeDb(undefined)
|
||||
expect(await undeleteFile(db, 'nope')).toEqual({ result: 'not_found' })
|
||||
expect(updates).toEqual([])
|
||||
expect(inserts).toEqual([])
|
||||
})
|
||||
|
||||
it('returns not_deleted for a live file and writes nothing', async () => {
|
||||
const file = makeFile({ isDeleted: false })
|
||||
const { db, updates, inserts } = makeFakeDb(file)
|
||||
expect(await undeleteFile(db, file.id)).toEqual({ result: 'not_deleted', file })
|
||||
expect(updates).toEqual([])
|
||||
expect(inserts).toEqual([])
|
||||
})
|
||||
|
||||
it('bumps lastPublished when restoring a published file, to trigger a republish', async () => {
|
||||
const file = makeFile({ published: true, lastPublished: 123 })
|
||||
const { db, updates } = makeFakeDb(file)
|
||||
await undeleteFile(db, file.id)
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
table: 'file',
|
||||
values: {
|
||||
isDeleted: false,
|
||||
updatedAt: expect.any(Number),
|
||||
lastPublished: expect.any(Number),
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(updates[0].values.lastPublished).not.toBe(123)
|
||||
})
|
||||
|
||||
it('restores the group_file link for a group-owned file', async () => {
|
||||
const file = makeFile()
|
||||
const { db, inserts } = makeFakeDb(file, { groupRow: { isDeleted: false } })
|
||||
const result = await undeleteFile(db, file.id)
|
||||
expect(result.result).toBe('restored')
|
||||
expect(inserts).toEqual([
|
||||
{
|
||||
table: 'group_file',
|
||||
values: {
|
||||
fileId: 'file-1',
|
||||
groupId: 'group-9',
|
||||
createdAt: expect.any(Number),
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('returns group_deleted and writes nothing when the owning group is soft-deleted', async () => {
|
||||
const file = makeFile()
|
||||
const { db, updates, inserts } = makeFakeDb(file, {
|
||||
groupRow: { isDeleted: true },
|
||||
})
|
||||
expect(await undeleteFile(db, file.id)).toEqual({ result: 'group_deleted', file })
|
||||
expect(updates).toEqual([])
|
||||
expect(inserts).toEqual([])
|
||||
})
|
||||
|
||||
it('returns group_deleted and writes nothing when the owning group row is missing', async () => {
|
||||
const file = makeFile()
|
||||
const { db, updates, inserts } = makeFakeDb(file, {
|
||||
groupRow: 'none',
|
||||
})
|
||||
expect(await undeleteFile(db, file.id)).toEqual({ result: 'group_deleted', file })
|
||||
expect(updates).toEqual([])
|
||||
expect(inserts).toEqual([])
|
||||
})
|
||||
})
|
||||
55
apps/dotcom/sync-worker/src/undeleteFile.ts
Normal file
55
apps/dotcom/sync-worker/src/undeleteFile.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { DB, TlaFile } from '@tldraw/dotcom-shared'
|
||||
import { Kysely } from 'kysely'
|
||||
|
||||
export type UndeleteFileResult =
|
||||
| { result: 'not_found' }
|
||||
| { result: 'not_deleted'; file: TlaFile }
|
||||
| { result: 'group_deleted'; file: TlaFile }
|
||||
| { result: 'restored'; file: TlaFile }
|
||||
|
||||
// Restores a soft-deleted file: clears isDeleted and re-creates the owning group's group_file
|
||||
// link (if owningGroupId). The caller pokes the file effect processor's outbox after a
|
||||
// successful restore; Zero replicates the row changes to clients on its own.
|
||||
export async function undeleteFile(db: Kysely<DB>, fileId: string): Promise<UndeleteFileResult> {
|
||||
return db.transaction().execute(async (tx) => {
|
||||
const file = await tx.selectFrom('file').where('id', '=', fileId).selectAll().executeTakeFirst()
|
||||
if (!file) return { result: 'not_found' }
|
||||
if (!file.isDeleted) return { result: 'not_deleted', file }
|
||||
|
||||
// owningGroupId stays optional in the client schema until #10592 tightens it, a release
|
||||
// after the NOT NULL migration has replicated everywhere; this guard goes with that.
|
||||
if (file.owningGroupId) {
|
||||
const group = await tx
|
||||
.selectFrom('group')
|
||||
.select(['isDeleted'])
|
||||
.where('id', '=', file.owningGroupId)
|
||||
.executeTakeFirst()
|
||||
if (!group || group.isDeleted) return { result: 'group_deleted', file }
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
await tx
|
||||
.updateTable('file')
|
||||
.set({
|
||||
isDeleted: false,
|
||||
updatedAt: now,
|
||||
// Bumping lastPublished on a published file produces a publish transition, so the
|
||||
// effect re-uploads current content - otherwise the published URL stays dead until
|
||||
// the user manually republishes (restoring from trash produces no publish transition
|
||||
// of its own).
|
||||
...(file.published ? { lastPublished: now } : {}),
|
||||
})
|
||||
.where('id', '=', fileId)
|
||||
.execute()
|
||||
|
||||
if (file.owningGroupId) {
|
||||
await tx
|
||||
.insertInto('group_file')
|
||||
.values({ fileId, groupId: file.owningGroupId, createdAt: now, updatedAt: now })
|
||||
.onConflict((oc) => oc.columns(['fileId', 'groupId']).doNothing())
|
||||
.execute()
|
||||
}
|
||||
|
||||
return { result: 'restored', file }
|
||||
})
|
||||
}
|
||||
34
apps/dotcom/sync-worker/src/utils/analytics.ts
Normal file
34
apps/dotcom/sync-worker/src/utils/analytics.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createSentry } from '@tldraw/worker-shared'
|
||||
import { Analytics, Environment } from '../types'
|
||||
|
||||
export interface EventData {
|
||||
blobs?: string[]
|
||||
indexes?: [string]
|
||||
doubles?: number[]
|
||||
}
|
||||
|
||||
export function writeDataPoint(
|
||||
sentry: ReturnType<typeof createSentry> | undefined,
|
||||
measure: Analytics | undefined,
|
||||
env: Environment,
|
||||
name: string,
|
||||
{ blobs, indexes, doubles }: EventData
|
||||
) {
|
||||
try {
|
||||
measure?.writeDataPoint({
|
||||
// We put the worker name in the second spot for legacy reasons: when we first introduced analytics
|
||||
// we only included the name. If we were to change the order it would be hard to query old data.
|
||||
blobs: [name, env.WORKER_NAME ?? 'development-tldraw-multiplayer', ...(blobs ?? [])],
|
||||
doubles,
|
||||
indexes,
|
||||
})
|
||||
} catch {
|
||||
// // eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
// sentry?.withScope((scope) => {
|
||||
// scope.setExtra('name', name)
|
||||
// // eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
// sentry.captureException(e)
|
||||
// })
|
||||
// console.error('Failed to write data point', e)
|
||||
}
|
||||
}
|
||||
28
apps/dotcom/sync-worker/src/utils/base64.test.ts
Normal file
28
apps/dotcom/sync-worker/src/utils/base64.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer } from './base64'
|
||||
|
||||
describe('arrayBufferToBase64', () => {
|
||||
const proto = Uint8Array.prototype as { toBase64?(): string }
|
||||
const original = proto.toBase64
|
||||
|
||||
afterEach(() => {
|
||||
if (original) proto.toBase64 = original
|
||||
else delete proto.toBase64
|
||||
})
|
||||
|
||||
it('round-trips through the chunked fallback', () => {
|
||||
delete proto.toBase64
|
||||
const bytes = new Uint8Array(0x8000 * 3 + 7).map((_, i) => i % 251)
|
||||
const base64 = arrayBufferToBase64(bytes.buffer)
|
||||
expect(new Uint8Array(base64ToArrayBuffer(base64))).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('prefers the native encoder when the runtime has one', () => {
|
||||
const native = vi.fn(function (this: Uint8Array) {
|
||||
return `native:${this.length}`
|
||||
})
|
||||
proto.toBase64 = native
|
||||
expect(arrayBufferToBase64(new Uint8Array([1, 2, 3]).buffer)).toBe('native:3')
|
||||
expect(native).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
41
apps/dotcom/sync-worker/src/utils/base64.ts
Normal file
41
apps/dotcom/sync-worker/src/utils/base64.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Chunked base64 helpers. String.fromCharCode(...bytes) overflows the engine's argument limit on
|
||||
// large buffers (a PNG screenshot is megabytes), so encoding walks the bytes in 32k chunks.
|
||||
|
||||
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
// workerd (V8 14+) has the native encoder, which skips the intermediate binary string that
|
||||
// matters when the .tldr download inlines multi-MB assets inside a memory-capped DO. Node < 25
|
||||
// under vitest does not have it.
|
||||
if (typeof bytes.toBase64 === 'function') return bytes.toBase64()
|
||||
return btoa(bytesToBinaryString(bytes))
|
||||
}
|
||||
|
||||
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
return binaryStringToBytes(atob(base64)).buffer
|
||||
}
|
||||
|
||||
// Base64url (RFC 4648 §5): the URL-safe alphabet with padding stripped, used for the signed
|
||||
// render tokens that ride in the render page's query string.
|
||||
export function base64UrlEncode(bytes: Uint8Array): string {
|
||||
return btoa(bytesToBinaryString(bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export function base64UrlDecode(value: string): Uint8Array<ArrayBuffer> {
|
||||
return binaryStringToBytes(atob(value.replace(/-/g, '+').replace(/_/g, '/')))
|
||||
}
|
||||
|
||||
function bytesToBinaryString(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
||||
function binaryStringToBytes(binary: string): Uint8Array<ArrayBuffer> {
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { vi } from 'vitest'
|
||||
import { Environment } from '../types'
|
||||
import { createSupabaseClient } from './createSupabaseClient'
|
||||
|
||||
const SUPABASE_URL = 'https://stub.supabase.co'
|
||||
const SUPABASE_KEY = 'stub-anon-key'
|
||||
const env = { SUPABASE_URL, SUPABASE_KEY } as Environment
|
||||
|
||||
describe('createSupabaseClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('schedules no timers, so the durable object that builds it can hibernate', async () => {
|
||||
const client = createSupabaseClient(env)
|
||||
expect(client).toBeDefined()
|
||||
|
||||
// GoTrueClient arms its auto-refresh interval asynchronously, from initialize()'s finally
|
||||
// block — flush the microtask queue so any timer it would arm has been armed.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
// Negative control: prove this harness catches the timer when it is armed. If a supabase-js
|
||||
// bump moves the auto-refresh interval somewhere these fake timers can't see, this fails
|
||||
// rather than letting the test above pass vacuously.
|
||||
it('a client with default auth options does arm the auto-refresh interval', async () => {
|
||||
createClient(SUPABASE_URL, SUPABASE_KEY)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
26
apps/dotcom/sync-worker/src/utils/createSupabaseClient.ts
Normal file
26
apps/dotcom/sync-worker/src/utils/createSupabaseClient.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { Environment } from '../types'
|
||||
|
||||
// `autoRefreshToken` is what keeps a durable object out of hibernation. GoTrueClient's constructor
|
||||
// calls initialize(), whose finally block always calls _handleVisibilityChange(); outside a browser
|
||||
// (`isBrowser()` checks for `window` and `document`, neither of which exist in workerd) that branch
|
||||
// calls startAutoRefresh() whenever `autoRefreshToken` is enabled (which it is by default), arming
|
||||
// a 30 second setInterval that nothing ever clears.
|
||||
// A durable object cannot hibernate while any timer is scheduled, so a single one of these makes
|
||||
// TLFileDurableObject billable for its entire residency — it constructs one of these in its
|
||||
// constructor, on every wake.
|
||||
// https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/
|
||||
//
|
||||
// We never sign a user in through this client — it reads legacy rooms with the anon key — so there
|
||||
// is no token to refresh, no session to persist, and no URL to detect a session in.
|
||||
export function createSupabaseClient(env: Environment) {
|
||||
return env.SUPABASE_URL && env.SUPABASE_KEY
|
||||
? createClient(env.SUPABASE_URL, env.SUPABASE_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function noSupabaseSorry() {
|
||||
return new Response(JSON.stringify({ error: true, message: 'Could not create supabase client' }))
|
||||
}
|
||||
27
apps/dotcom/sync-worker/src/utils/durableObjects.ts
Normal file
27
apps/dotcom/sync-worker/src/utils/durableObjects.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
|
||||
import { TLFileDurableObject } from '../TLFileDurableObject'
|
||||
import type { TLFileEffectProcessor } from '../TLFileEffectProcessor'
|
||||
import { TLLoggerDurableObject } from '../TLLoggerDurableObject'
|
||||
import { Environment } from '../types'
|
||||
|
||||
export function getFileEffectProcessor(env: Environment) {
|
||||
return env.TL_FILE_EFFECTS.get(env.TL_FILE_EFFECTS.idFromName('0'), {
|
||||
locationHint: 'weur',
|
||||
}) as any as TLFileEffectProcessor
|
||||
}
|
||||
|
||||
export function getLogger(env: Environment) {
|
||||
return env.TL_LOGGER.get(env.TL_LOGGER.idFromName('logger')) as any as TLLoggerDurableObject
|
||||
}
|
||||
|
||||
export function getRoomDurableObjectId(env: Environment, roomId: string) {
|
||||
return env.TLDR_DOC.idFromName(`/${ROOM_PREFIX}/${roomId}`)
|
||||
}
|
||||
|
||||
export function getRoomDurableObject(env: Environment, roomId: string) {
|
||||
return env.TLDR_DOC.get(getRoomDurableObjectId(env, roomId)) as any as TLFileDurableObject
|
||||
}
|
||||
|
||||
export function getRoomDurableObjectById(env: Environment, objectId: string) {
|
||||
return env.TLDR_DOC.get(env.TLDR_DOC.idFromString(objectId)) as any as TLFileDurableObject
|
||||
}
|
||||
430
apps/dotcom/sync-worker/src/utils/featureFlags.test.ts
Normal file
430
apps/dotcom/sync-worker/src/utils/featureFlags.test.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
evaluateFlagForUser,
|
||||
getAllFeatureFlagValues,
|
||||
getFeatureFlagValue,
|
||||
getFeatureFlags,
|
||||
hashToPercentage,
|
||||
parseAllowlistEmails,
|
||||
setFeatureFlag,
|
||||
} from './featureFlags'
|
||||
|
||||
vi.mock('./tla/getAuth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
function makeEnv(
|
||||
kvData: Record<string, string> = {},
|
||||
tldrawEnv = 'production'
|
||||
): { FEATURE_FLAGS: any; TLDRAW_ENV: string } {
|
||||
return {
|
||||
TLDRAW_ENV: tldrawEnv,
|
||||
FEATURE_FLAGS: {
|
||||
get: vi.fn(async (key: string) => kvData[key] ?? null),
|
||||
put: vi.fn(async () => {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('hashToPercentage', () => {
|
||||
it('returns a number between 0 and 99 inclusive', () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const result = hashToPercentage(`user-${i}`, 'some_flag')
|
||||
expect(result).toBeGreaterThanOrEqual(0)
|
||||
expect(result).toBeLessThanOrEqual(99)
|
||||
}
|
||||
})
|
||||
|
||||
it('produces a reasonable spread across buckets', () => {
|
||||
const buckets = new Set<number>()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
buckets.add(hashToPercentage(`user-${i}`, 'some_flag'))
|
||||
}
|
||||
expect(buckets.size).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateFlagForUser', () => {
|
||||
it('returns false when flag is disabled regardless of type', () => {
|
||||
expect(
|
||||
evaluateFlagForUser({ type: 'boolean', enabled: false, description: '' }, 'test', 'user-1')
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
evaluateFlagForUser(
|
||||
{ type: 'percentage', enabled: false, percentage: 100, description: '' },
|
||||
'test',
|
||||
'user-1'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for enabled boolean flags', () => {
|
||||
expect(
|
||||
evaluateFlagForUser({ type: 'boolean', enabled: true, description: '' }, 'test', 'user-1')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for enabled boolean flags even without userId', () => {
|
||||
expect(
|
||||
evaluateFlagForUser({ type: 'boolean', enabled: true, description: '' }, 'test', null)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for percentage flags when userId is null', () => {
|
||||
expect(
|
||||
evaluateFlagForUser(
|
||||
{ type: 'percentage', enabled: true, percentage: 100, description: '' },
|
||||
'test',
|
||||
null
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for percentage 100 (all users)', () => {
|
||||
// 100% should include everyone since hash produces 0-99
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(
|
||||
evaluateFlagForUser(
|
||||
{ type: 'percentage', enabled: true, percentage: 100, description: '' },
|
||||
'some_flag',
|
||||
`user-${i}`
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns false for percentage 0 (no users)', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(
|
||||
evaluateFlagForUser(
|
||||
{ type: 'percentage', enabled: true, percentage: 0, description: '' },
|
||||
'some_flag',
|
||||
`user-${i}`
|
||||
)
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('includes a subset of users for intermediate percentages', () => {
|
||||
let included = 0
|
||||
const total = 1000
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (
|
||||
evaluateFlagForUser(
|
||||
{ type: 'percentage', enabled: true, percentage: 50, description: '' },
|
||||
'some_flag',
|
||||
`user-${i}`
|
||||
)
|
||||
) {
|
||||
included++
|
||||
}
|
||||
}
|
||||
// Should be roughly 50% — allow wide margin for hash distribution
|
||||
expect(included).toBeGreaterThan(total * 0.3)
|
||||
expect(included).toBeLessThan(total * 0.7)
|
||||
})
|
||||
|
||||
it('rollout is monotonic: increasing percentage never removes existing users', () => {
|
||||
const flag = 'some_flag'
|
||||
const users = Array.from({ length: 200 }, (_, i) => `user-${i}`)
|
||||
const makeFlag = (pct: number) =>
|
||||
({ type: 'percentage', enabled: true, percentage: pct, description: '' }) as const
|
||||
|
||||
const at10 = new Set(users.filter((u) => evaluateFlagForUser(makeFlag(10), flag, u)))
|
||||
const at25 = new Set(users.filter((u) => evaluateFlagForUser(makeFlag(25), flag, u)))
|
||||
const at50 = new Set(users.filter((u) => evaluateFlagForUser(makeFlag(50), flag, u)))
|
||||
const at100 = new Set(users.filter((u) => evaluateFlagForUser(makeFlag(100), flag, u)))
|
||||
|
||||
// Every user in a smaller bucket must also be in every larger bucket
|
||||
for (const u of at10) expect(at25.has(u)).toBe(true)
|
||||
for (const u of at25) expect(at50.has(u)).toBe(true)
|
||||
for (const u of at50) expect(at100.has(u)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// The type a percentage rollout cannot stand in for: a percentage buckets users by hash, so it gives
|
||||
// you *a* subset of the right size but never *the* subset you picked.
|
||||
describe('evaluateFlagForUser (allowlist)', () => {
|
||||
const flag = (userIds: string[], enabled = true) =>
|
||||
({
|
||||
type: 'allowlist',
|
||||
enabled,
|
||||
users: userIds.map((userId) => ({ userId, email: `${userId}@example.com` })),
|
||||
description: '',
|
||||
}) as const
|
||||
|
||||
it('is on only for the named users', () => {
|
||||
expect(evaluateFlagForUser(flag(['user-1', 'user-2']), 'test', 'user-1')).toBe(true)
|
||||
expect(evaluateFlagForUser(flag(['user-1', 'user-2']), 'test', 'user-2')).toBe(true)
|
||||
expect(evaluateFlagForUser(flag(['user-1', 'user-2']), 'test', 'user-3')).toBe(false)
|
||||
})
|
||||
|
||||
it('is off for everyone when the master toggle is off', () => {
|
||||
expect(evaluateFlagForUser(flag(['user-1'], false), 'test', 'user-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('is off for an anonymous caller', () => {
|
||||
expect(evaluateFlagForUser(flag(['user-1']), 'test', null)).toBe(false)
|
||||
})
|
||||
|
||||
it('is off for an empty list', () => {
|
||||
expect(evaluateFlagForUser(flag([]), 'test', 'user-1')).toBe(false)
|
||||
})
|
||||
|
||||
// The value comes from KV, where a hand-edited entry can arrive as anything. A malformed list must
|
||||
// deny rather than admit — the alternative fails open on a flag whose whole job is to keep people
|
||||
// out.
|
||||
it('denies when users is missing or not an array of entries', () => {
|
||||
expect(
|
||||
evaluateFlagForUser({ type: 'allowlist', enabled: true, description: '' } as any, 't', 'u')
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateFlagForUser(
|
||||
{ type: 'allowlist', enabled: true, users: 'user-1', description: '' } as any,
|
||||
't',
|
||||
'u'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateFlagForUser(
|
||||
{ type: 'allowlist', enabled: true, users: ['user-1'], description: '' } as any,
|
||||
't',
|
||||
'user-1'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// The admin edits an allowlist as emails; this is the input half of that. Resolution to user ids
|
||||
// happens against the database in the admin route.
|
||||
describe('parseAllowlistEmails', () => {
|
||||
it('splits on newlines and commas, trims, lowercases, and drops blanks', () => {
|
||||
expect(parseAllowlistEmails(' Friend@Example.com \n\nother@example.com, third@x.co ')).toEqual([
|
||||
'friend@example.com',
|
||||
'other@example.com',
|
||||
'third@x.co',
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts an array as well as a block of text', () => {
|
||||
expect(parseAllowlistEmails(['a@example.com', 'b@example.org'])).toEqual([
|
||||
'a@example.com',
|
||||
'b@example.org',
|
||||
])
|
||||
})
|
||||
|
||||
it('drops duplicates that differ only in case or whitespace', () => {
|
||||
expect(parseAllowlistEmails('a@example.com\nA@Example.com\n a@example.com ')).toEqual([
|
||||
'a@example.com',
|
||||
])
|
||||
})
|
||||
|
||||
it('treats empty input as an empty list rather than an error', () => {
|
||||
expect(parseAllowlistEmails('')).toEqual([])
|
||||
expect(parseAllowlistEmails('\n\n, ,\n')).toEqual([])
|
||||
expect(parseAllowlistEmails(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
// Rejected before the lookup runs, so the admin gets "that isn't an email" rather than the
|
||||
// blanker "no account for that".
|
||||
it('rejects anything that is not an email address', () => {
|
||||
expect(() => parseAllowlistEmails('tldraw.com')).toThrow('not an email address')
|
||||
expect(() => parseAllowlistEmails('@tldraw.com')).toThrow('not an email address')
|
||||
expect(() => parseAllowlistEmails('friend@localhost')).toThrow('not an email address')
|
||||
expect(() => parseAllowlistEmails('a@b@example.com')).toThrow('not an email address')
|
||||
expect(() => parseAllowlistEmails('friend @example.com')).toThrow('not an email address')
|
||||
})
|
||||
|
||||
it('names the offending entry so the admin knows which line to fix', () => {
|
||||
expect(() => parseAllowlistEmails('ok@example.com\ntldraw.com')).toThrow('"tldraw.com"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFeatureFlagValue', () => {
|
||||
it('returns defaults when KV has no value', async () => {
|
||||
const env = makeEnv()
|
||||
const value = await getFeatureFlagValue(env as any, 'rum_enabled')
|
||||
expect(value).toMatchObject({ type: 'percentage', enabled: false, percentage: 0 })
|
||||
})
|
||||
|
||||
it('merges KV value over defaults', async () => {
|
||||
const env = makeEnv({
|
||||
rum_enabled: JSON.stringify({ enabled: true, percentage: 25 }),
|
||||
})
|
||||
const value = await getFeatureFlagValue(env as any, 'rum_enabled')
|
||||
expect(value).toMatchObject({
|
||||
type: 'percentage',
|
||||
enabled: true,
|
||||
percentage: 25,
|
||||
})
|
||||
// Description should come from defaults
|
||||
expect(value.description).toBeTruthy()
|
||||
})
|
||||
|
||||
// The defaults table is the schema and KV holds only state, so a stored `type` never gets a say.
|
||||
// `"allowList"` — a capital L — used to reach evaluateFlagForUser as a type none of its arms
|
||||
// matched, and the fall-through admitted everyone holding any token.
|
||||
it('discards a stored type that disagrees with the default', async () => {
|
||||
const env = makeEnv({
|
||||
mcp_server_access: JSON.stringify({ type: 'allowList', enabled: true }),
|
||||
})
|
||||
const value = await getFeatureFlagValue(env as any, 'mcp_server_access')
|
||||
expect(value.type).toBe('allowlist')
|
||||
expect(evaluateFlagForUser(value, 'mcp_server_access', 'user-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns defaults on KV error', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const env = makeEnv()
|
||||
env.FEATURE_FLAGS.get = vi.fn(async () => {
|
||||
throw new Error('KV down')
|
||||
})
|
||||
const value = await getFeatureFlagValue(env as any, 'rum_enabled')
|
||||
expect(value).toMatchObject({ type: 'percentage', enabled: false })
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setFeatureFlag', () => {
|
||||
it('updates enabled', async () => {
|
||||
const env = makeEnv()
|
||||
await setFeatureFlag(env as any, 'rum_enabled', { type: 'percentage', enabled: true })
|
||||
expect(env.FEATURE_FLAGS.put).toHaveBeenCalledWith(
|
||||
'rum_enabled',
|
||||
expect.stringContaining('"enabled":true')
|
||||
)
|
||||
})
|
||||
|
||||
it('updates percentage', async () => {
|
||||
const env = makeEnv()
|
||||
await setFeatureFlag(env as any, 'rum_enabled', { type: 'percentage', percentage: 42 })
|
||||
const putCall = env.FEATURE_FLAGS.put.mock.calls[0]
|
||||
const stored = JSON.parse(putCall[1])
|
||||
expect(stored.percentage).toBe(42)
|
||||
})
|
||||
|
||||
// Replaced rather than merged, so removing someone is an ordinary save.
|
||||
it('replaces the allowlist wholesale', async () => {
|
||||
const env = makeEnv({
|
||||
mcp_server_access: JSON.stringify({
|
||||
type: 'allowlist',
|
||||
enabled: true,
|
||||
users: [
|
||||
{ userId: 'user-1', email: 'one@example.com' },
|
||||
{ userId: 'user-2', email: 'two@example.com' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
await setFeatureFlag(env as any, 'mcp_server_access', {
|
||||
type: 'allowlist',
|
||||
users: [{ userId: 'user-2', email: 'two@example.com' }],
|
||||
})
|
||||
expect(JSON.parse(env.FEATURE_FLAGS.put.mock.calls[0][1]).users).toEqual([
|
||||
{ userId: 'user-2', email: 'two@example.com' },
|
||||
])
|
||||
})
|
||||
|
||||
// A field that means nothing for this flag's type is refused rather than dropped. It used to be
|
||||
// dropped in silence, and the admin route still answered `{success: true, users: […]}` — so an
|
||||
// allowlist sent to a percentage flag reported a save that stored nothing anywhere.
|
||||
it('refuses an update naming a different type than the flag', async () => {
|
||||
const env = makeEnv()
|
||||
await expect(
|
||||
setFeatureFlag(env as any, 'rum_enabled', {
|
||||
type: 'allowlist',
|
||||
users: [{ userId: 'user-1', email: 'one@example.com' }],
|
||||
})
|
||||
).rejects.toThrow('is a percentage flag')
|
||||
expect(env.FEATURE_FLAGS.put).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFeatureFlags (route handler)', () => {
|
||||
beforeEach(async () => {
|
||||
const { getAuth } = await import('./tla/getAuth')
|
||||
vi.mocked(getAuth).mockReset()
|
||||
})
|
||||
|
||||
it('returns evaluated flags with x-authenticated header for authenticated user', async () => {
|
||||
const { getAuth } = await import('./tla/getAuth')
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: 'user-abc' } as any)
|
||||
|
||||
const env = makeEnv({
|
||||
rum_enabled: JSON.stringify({ enabled: true, percentage: 100 }),
|
||||
})
|
||||
const response = await getFeatureFlags({} as any, env as any)
|
||||
const body: any = await response.json()
|
||||
|
||||
expect(response.headers.get('x-authenticated')).toBe('1')
|
||||
// percentage 100 includes every userId
|
||||
expect(body.rum_enabled.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('returns x-authenticated=0 for unauthenticated user', async () => {
|
||||
const { getAuth } = await import('./tla/getAuth')
|
||||
vi.mocked(getAuth).mockResolvedValue(null)
|
||||
|
||||
const env = makeEnv()
|
||||
const response = await getFeatureFlags({} as any, env as any)
|
||||
|
||||
expect(response.headers.get('x-authenticated')).toBe('0')
|
||||
})
|
||||
|
||||
it('returns false for percentage flags when not authenticated', async () => {
|
||||
const { getAuth } = await import('./tla/getAuth')
|
||||
vi.mocked(getAuth).mockResolvedValue(null)
|
||||
|
||||
const env = makeEnv({
|
||||
rum_enabled: JSON.stringify({ enabled: true, percentage: 100 }),
|
||||
})
|
||||
const response = await getFeatureFlags({} as any, env as any)
|
||||
const body: any = await response.json()
|
||||
|
||||
// Percentage flags require a userId
|
||||
expect(body.rum_enabled.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('forces legacy zero_enabled/zero_kill_switch flags on for old client bundles, even unauthenticated', async () => {
|
||||
const { getAuth } = await import('./tla/getAuth')
|
||||
vi.mocked(getAuth).mockResolvedValue(null)
|
||||
|
||||
const env = makeEnv()
|
||||
const response = await getFeatureFlags({} as any, env as any)
|
||||
const body: any = await response.json()
|
||||
|
||||
expect(body.zero_enabled.enabled).toBe(true)
|
||||
expect(body.zero_kill_switch.enabled).toBe(false)
|
||||
expect(body.rum_enabled).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllFeatureFlagValues', () => {
|
||||
it('returns raw flag values including percentage and description', async () => {
|
||||
const env = makeEnv({
|
||||
rum_enabled: JSON.stringify({ enabled: true, percentage: 30 }),
|
||||
})
|
||||
const flags = await getAllFeatureFlagValues(env as any)
|
||||
|
||||
expect(flags.rum_enabled).toMatchObject({
|
||||
type: 'percentage',
|
||||
enabled: true,
|
||||
percentage: 30,
|
||||
})
|
||||
expect(flags.rum_enabled.description).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns all flags even when KV is empty', async () => {
|
||||
const env = makeEnv()
|
||||
const flags = await getAllFeatureFlagValues(env as any)
|
||||
|
||||
expect(Object.keys(flags).sort()).toEqual([
|
||||
'commenting_enabled',
|
||||
'first_load_rum',
|
||||
'mcp_server_access',
|
||||
'rum_enabled',
|
||||
'version_chain',
|
||||
'version_chain_legacy_writes',
|
||||
])
|
||||
})
|
||||
})
|
||||
286
apps/dotcom/sync-worker/src/utils/featureFlags.ts
Normal file
286
apps/dotcom/sync-worker/src/utils/featureFlags.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
AllowlistEntry,
|
||||
EvaluatedFeatureFlag,
|
||||
FEATURE_FLAG_KEYS,
|
||||
FeatureFlagKey,
|
||||
FeatureFlagValue,
|
||||
} from '@tldraw/dotcom-shared'
|
||||
import { exhaustiveSwitchError } from '@tldraw/utils'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
import { getAuth } from './tla/getAuth'
|
||||
|
||||
function getFlagDefaults(env: Environment): Record<FeatureFlagKey, FeatureFlagValue> {
|
||||
return {
|
||||
rum_enabled: {
|
||||
type: 'percentage',
|
||||
percentage: 0,
|
||||
enabled: false,
|
||||
description: 'Real User Monitoring for editor performance metrics',
|
||||
},
|
||||
first_load_rum: {
|
||||
type: 'percentage',
|
||||
// Master toggle on, 0%: turning it on for a debugging session is one percentage change in
|
||||
// the admin panel, no deploy. Users with a @tldraw.com email send it regardless.
|
||||
percentage: 0,
|
||||
enabled: true,
|
||||
description:
|
||||
'Send the per-step first_load timing event (client marks + sync server echo) to PostHog. Users with a @tldraw.com email always send it, regardless of this flag',
|
||||
},
|
||||
commenting_enabled: {
|
||||
type: 'percentage',
|
||||
percentage: 0,
|
||||
enabled: false,
|
||||
description:
|
||||
'Commenting on files (tool, pins, threads, sidebar, notifications). Users with a @tldraw.com email always have it, regardless of this flag',
|
||||
},
|
||||
mcp_server_access: {
|
||||
type: 'allowlist',
|
||||
users: [],
|
||||
enabled: false,
|
||||
description:
|
||||
'Access to the board screenshot MCP server at /api/app/mcp. Off by default: the endpoint requires auth, so an unset flag denies everyone rather than leaving it open',
|
||||
},
|
||||
version_chain: {
|
||||
type: 'percentage',
|
||||
// Non-production environments exercise the new write path by default; production waits for
|
||||
// an explicit flip. Replaces the old VERSION_CHAIN_MODE wrangler vars.
|
||||
percentage: env.TLDRAW_ENV === 'production' ? 0 : 100,
|
||||
enabled: env.TLDRAW_ENV !== 'production',
|
||||
description:
|
||||
'Version cache entries written as segmented delta chains. Bucketed per ROOM, not per user: the sync worker passes the room R2 key as the id, and the per-user value browsers see is meaningless',
|
||||
},
|
||||
version_chain_legacy_writes: {
|
||||
type: 'boolean',
|
||||
enabled: true,
|
||||
description:
|
||||
'While a room is on version chains, also write legacy full copies (the dual-write bake). Evaluated per ROOM by the sync worker. Only consulted for rooms the version_chain rollout covers — rooms outside it always write legacy',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export { FEATURE_FLAG_KEYS } from '@tldraw/dotcom-shared'
|
||||
|
||||
/**
|
||||
* FNV-1a hash producing a deterministic bucket 0–99.
|
||||
* Hashes userId + flagName so a user gets independent buckets per flag.
|
||||
* https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function#FNV-1a_hash
|
||||
*/
|
||||
export function hashToPercentage(userId: string, flagName: string): number {
|
||||
const input = userId + flagName
|
||||
let hash = 2166136261
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return (hash >>> 0) % 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature flag value from KV store
|
||||
*/
|
||||
export async function getFeatureFlagValue(
|
||||
env: Environment,
|
||||
flag: FeatureFlagKey
|
||||
): Promise<FeatureFlagValue> {
|
||||
const defaults = getFlagDefaults(env)[flag]
|
||||
try {
|
||||
const value = await env.FEATURE_FLAGS.get(flag)
|
||||
if (!value) {
|
||||
return defaults
|
||||
}
|
||||
// The defaults table is the schema; KV holds only state. A stored `type` is therefore discarded
|
||||
// rather than spread over the default one: `{"type":"allowList"}` — a capital L, or any other
|
||||
// typo — would otherwise reach `evaluateFlagForUser` as a shape none of its arms recognise, and
|
||||
// the value it lands on decides who is let in.
|
||||
return { ...defaults, ...JSON.parse(value), type: defaults.type } as FeatureFlagValue
|
||||
} catch (e) {
|
||||
console.error(`Failed to get feature flag ${flag}:`, e)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The type a flag is. Read from the defaults table rather than from KV, which is what makes it a
|
||||
* schema: a caller can tell which fields apply to a flag without a KV round trip, and without a
|
||||
* stored value getting a say in the answer.
|
||||
*/
|
||||
export function getFeatureFlagType(
|
||||
env: Environment,
|
||||
flag: FeatureFlagKey
|
||||
): FeatureFlagValue['type'] {
|
||||
return getFlagDefaults(env)[flag].type
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a flag for a specific user. Percentage flags use a deterministic
|
||||
* hash of userId+flagName. Allowlist flags check the user against the named ids.
|
||||
* Boolean flags use the `enabled` field directly.
|
||||
*/
|
||||
export function evaluateFlagForUser(
|
||||
flag: FeatureFlagValue,
|
||||
flagName: string,
|
||||
userId: string | null
|
||||
): boolean {
|
||||
if (!flag.enabled) return false
|
||||
// Switched exhaustively rather than ending in a fall-through, so a fourth flag type is a compile
|
||||
// error here instead of a flag that quietly evaluates true for everyone. The type itself can only
|
||||
// be one the defaults table names — see getFeatureFlagValue.
|
||||
switch (flag.type) {
|
||||
case 'boolean':
|
||||
// `enabled` is the whole evaluation, and it was checked above.
|
||||
return true
|
||||
case 'percentage':
|
||||
if (!userId) return false
|
||||
return hashToPercentage(userId, flagName) < flag.percentage
|
||||
case 'allowlist':
|
||||
// An anonymous caller is never on a list of users. Stated rather than left to `some`, which
|
||||
// would also be false but only by accident of `null` matching nobody.
|
||||
if (!userId) return false
|
||||
// Missing or malformed `users` denies rather than admits: this is read from KV, where a
|
||||
// hand-edited value can arrive as anything, and the failure mode of the alternative is a flag
|
||||
// that silently opens to everyone.
|
||||
return (
|
||||
Array.isArray(flag.users) && flag.users.some((entry) => entry && entry.userId === userId)
|
||||
)
|
||||
default:
|
||||
exhaustiveSwitchError(flag)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a flag is on for one user, server-side. The counterpart to `getFeatureFlags` (which
|
||||
* evaluates every flag for a browser) for a route that gates itself on a single one.
|
||||
*/
|
||||
export async function isFeatureFlagEnabledForUser(
|
||||
env: Environment,
|
||||
flag: FeatureFlagKey,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
return evaluateFlagForUser(await getFeatureFlagValue(env, flag), flag, userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A change to one flag, discriminated by the type of flag it applies to.
|
||||
*
|
||||
* Discriminated rather than an optional-field bag, because the fields are not interchangeable: a bag
|
||||
* of `{ enabled?, percentage?, users? }` is matched by independent `if` guards that silently drop a
|
||||
* field belonging to another type — a `users` list sent for a percentage flag wrote nothing and still
|
||||
* reported success. Here a mismatch is a compile error at the call site, and a fourth flag type makes
|
||||
* the compiler name every place that has to learn about it.
|
||||
*/
|
||||
export type FeatureFlagUpdate =
|
||||
| { type: 'boolean'; enabled?: boolean }
|
||||
| { type: 'percentage'; enabled?: boolean; percentage?: number }
|
||||
| { type: 'allowlist'; enabled?: boolean; users?: AllowlistEntry[] }
|
||||
|
||||
/** Thrown when an update names a different type than the flag it addresses. */
|
||||
export class FeatureFlagTypeError extends Error {}
|
||||
|
||||
function expectFlagType<T extends FeatureFlagValue['type']>(
|
||||
flag: FeatureFlagKey,
|
||||
current: FeatureFlagValue,
|
||||
type: T
|
||||
): Extract<FeatureFlagValue, { type: T }> {
|
||||
if (current.type !== type) {
|
||||
throw new FeatureFlagTypeError(
|
||||
`"${flag}" is a ${current.type} flag; ${type} fields do not apply to it`
|
||||
)
|
||||
}
|
||||
return current as Extract<FeatureFlagValue, { type: T }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Set feature flag value in KV store. Admin only.
|
||||
*/
|
||||
export async function setFeatureFlag(
|
||||
env: Environment,
|
||||
flag: FeatureFlagKey,
|
||||
update: FeatureFlagUpdate
|
||||
): Promise<void> {
|
||||
const current = await getFeatureFlagValue(env, flag)
|
||||
const put = (value: FeatureFlagValue) => env.FEATURE_FLAGS.put(flag, JSON.stringify(value))
|
||||
|
||||
switch (update.type) {
|
||||
case 'boolean': {
|
||||
const value = expectFlagType(flag, current, 'boolean')
|
||||
return put({ ...value, enabled: update.enabled ?? value.enabled })
|
||||
}
|
||||
case 'percentage': {
|
||||
const value = expectFlagType(flag, current, 'percentage')
|
||||
return put({
|
||||
...value,
|
||||
enabled: update.enabled ?? value.enabled,
|
||||
percentage: update.percentage ?? value.percentage,
|
||||
})
|
||||
}
|
||||
case 'allowlist': {
|
||||
const value = expectFlagType(flag, current, 'allowlist')
|
||||
return put({
|
||||
...value,
|
||||
enabled: update.enabled ?? value.enabled,
|
||||
// Replaces the list rather than merging into it, so removing someone is a normal save and
|
||||
// not a separate operation the admin UI would have to model.
|
||||
users: update.users ?? value.users,
|
||||
})
|
||||
}
|
||||
default:
|
||||
exhaustiveSwitchError(update)
|
||||
}
|
||||
}
|
||||
|
||||
export { parseAllowlistEmails } from '@tldraw/dotcom-shared'
|
||||
|
||||
/**
|
||||
* Route handler: Get all feature flags evaluated for the requesting user.
|
||||
* Returns only `{ enabled }` per flag — no server internals like percentage.
|
||||
*/
|
||||
export async function getFeatureFlags(request: IRequest, env: Environment): Promise<Response> {
|
||||
const auth = await getAuth(request, env)
|
||||
const userId = auth?.userId ?? null
|
||||
|
||||
const flags: Record<string, EvaluatedFeatureFlag> = {}
|
||||
|
||||
await Promise.all(
|
||||
FEATURE_FLAG_KEYS.map(async (key) => {
|
||||
const raw = await getFeatureFlagValue(env, key)
|
||||
flags[key] = {
|
||||
enabled: evaluateFlagForUser(raw, key, userId),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Legacy client compat: bundles built before the polyfill removal still read
|
||||
// these flags to choose a sync path. Force them onto Zero. Remove once stale
|
||||
// bundles have aged out.
|
||||
flags.zero_enabled = { enabled: true }
|
||||
flags.zero_kill_switch = { enabled: false }
|
||||
|
||||
return new Response(JSON.stringify(flags), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-authenticated': userId ? '1' : '0',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Every flag's stored value, unevaluated — the admin view. Does NOT evaluate per-user: the stored
|
||||
* percentage, enabled and user list are returned as-is.
|
||||
*
|
||||
* Returns the record rather than a Response so the admin route can decorate it before answering —
|
||||
* see the allowlist label resolution there, which needs Postgres and has no business in here.
|
||||
*/
|
||||
export async function getAllFeatureFlagValues(
|
||||
env: Environment
|
||||
): Promise<Record<string, FeatureFlagValue>> {
|
||||
const flags: Record<string, FeatureFlagValue> = {}
|
||||
|
||||
await Promise.all(
|
||||
FEATURE_FLAG_KEYS.map(async (key) => {
|
||||
flags[key] = await getFeatureFlagValue(env, key)
|
||||
})
|
||||
)
|
||||
|
||||
return flags
|
||||
}
|
||||
46
apps/dotcom/sync-worker/src/utils/getPublicOrigin.test.ts
Normal file
46
apps/dotcom/sync-worker/src/utils/getPublicOrigin.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Environment } from '../types'
|
||||
import { getPublicOrigin } from './getPublicOrigin'
|
||||
|
||||
describe('getPublicOrigin', () => {
|
||||
function requestWithHeaders(headers: Record<string, string>) {
|
||||
return new Request('https://main-tldraw-multiplayer.workers.dev/app/social-preview/f/x/image', {
|
||||
headers,
|
||||
}) as any
|
||||
}
|
||||
|
||||
it('prefers the configured origin and ignores a spoofed forwarded host', () => {
|
||||
const origin = getPublicOrigin(
|
||||
requestWithHeaders({ 'x-forwarded-host': 'evil.com', 'x-forwarded-proto': 'https' }),
|
||||
{ MCP_SCREENSHOT_RENDER_ORIGIN: 'https://www.tldraw.com' } as Environment
|
||||
)
|
||||
expect(origin).toBe('https://www.tldraw.com')
|
||||
})
|
||||
|
||||
it('uses a trusted forwarded host when no origin is configured', () => {
|
||||
const origin = getPublicOrigin(
|
||||
requestWithHeaders({
|
||||
'x-forwarded-host': 'staging.tldraw.com',
|
||||
'x-forwarded-proto': 'https',
|
||||
}),
|
||||
{} as Environment
|
||||
)
|
||||
expect(origin).toBe('https://staging.tldraw.com')
|
||||
})
|
||||
|
||||
it('rejects an untrusted forwarded host and falls back to the request origin', () => {
|
||||
const origin = getPublicOrigin(
|
||||
requestWithHeaders({ 'x-forwarded-host': 'evil.com' }),
|
||||
{} as Environment
|
||||
)
|
||||
expect(origin).toBe('https://main-tldraw-multiplayer.workers.dev')
|
||||
})
|
||||
|
||||
it('takes the proxy-appended (rightmost) forwarded host, not a client-injected one', () => {
|
||||
const origin = getPublicOrigin(
|
||||
requestWithHeaders({ 'x-forwarded-host': 'evil.com, www.tldraw.com' }),
|
||||
{} as Environment
|
||||
)
|
||||
expect(origin).toBe('https://www.tldraw.com')
|
||||
})
|
||||
})
|
||||
31
apps/dotcom/sync-worker/src/utils/getPublicOrigin.ts
Normal file
31
apps/dotcom/sync-worker/src/utils/getPublicOrigin.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
|
||||
// The origin used to build the URLs we emit into crawler HTML (og:image, canonical) and 302
|
||||
// redirect `Location`s. We prefer the configured, trusted client origin so a spoofed
|
||||
// `x-forwarded-host` / `Host` can't steer those URLs at an attacker origin (open redirect + unfurl
|
||||
// spoofing). Only when no origin is configured do we fall back to a request-derived host, and only
|
||||
// if that host is a recognized tldraw/localhost origin.
|
||||
export function getPublicOrigin(request: IRequest, env: Environment) {
|
||||
if (env.MCP_SCREENSHOT_RENDER_ORIGIN) return env.MCP_SCREENSHOT_RENDER_ORIGIN
|
||||
|
||||
const forwardedHost = request.headers.get('x-forwarded-host')?.split(',').at(-1)?.trim()
|
||||
const host = forwardedHost ?? request.headers.get('host') ?? undefined
|
||||
const proto = request.headers.get('x-forwarded-proto')?.split(',').at(-1)?.trim() ?? 'https'
|
||||
if (host && isTrustedPublicHost(host)) {
|
||||
return `${proto}://${host}`
|
||||
}
|
||||
return new URL(request.url).origin
|
||||
}
|
||||
|
||||
// Hosts we're willing to emit into public URLs. Anything else (a spoofed header, a *.workers.dev
|
||||
// direct hit) falls through to the request's own origin rather than being trusted.
|
||||
function isTrustedPublicHost(host: string) {
|
||||
const hostname = host.split(':')[0].toLowerCase()
|
||||
return (
|
||||
hostname === 'tldraw.com' ||
|
||||
hostname.endsWith('.tldraw.com') ||
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1'
|
||||
)
|
||||
}
|
||||
10
apps/dotcom/sync-worker/src/utils/getSnapshotsTable.ts
Normal file
10
apps/dotcom/sync-worker/src/utils/getSnapshotsTable.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Environment } from '../types'
|
||||
|
||||
export function getSnapshotsTable(env: Environment) {
|
||||
if (env.TLDRAW_ENV === 'production') {
|
||||
return 'snapshots'
|
||||
} else if (env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'preview') {
|
||||
return 'snapshots_staging'
|
||||
}
|
||||
return 'snapshots_dev'
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user