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:
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 },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user